Compare commits

...

2 Commits

Author SHA1 Message Date
d6132fabe1 Version 0.9.14: E-Mail-Feld in Stationsdialogen und Kundenvalidierung
- E-Mail-Feld in Abhol- und Zustellstationsdialogen hinzugefügt
- E-Mail-Pflichtfeld bei "Adresse speichern" mit Validierung
- Kundenvalidierung im Backend (E-Mail Pflicht und Formatprüfung)
- "Adresse speichern" wird bei Auswahl existierender Kunden deaktiviert
- Verbessertes Kunden-Matching über alle Felder inkl. E-Mail
- Übersetzung "Template" → "Vorlage" in messages_de.properties
2026-03-30 10:40:42 +02:00
2534d321cf refactor: remove obsolete test files and clean up manifest 2026-03-26 08:40:51 +01:00
15 changed files with 378 additions and 2495 deletions

View File

@@ -8,6 +8,7 @@
<!-- GPS Location permissions --> <!-- GPS Location permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<application <application
android:label="votianlt_app" android:label="votianlt_app"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -1,201 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/acknowledgment_message.dart';
void main() {
group('AcknowledgmentMessage', () {
group('fromJson', () {
test('parses all required fields correctly', () {
final json = {
'messageId': 'msg-123',
'status': 'RECEIVED',
'timestamp': '2024-01-15T10:30:00.000Z',
};
final ack = AcknowledgmentMessage.fromJson(json);
expect(ack.messageId, 'msg-123');
expect(ack.status, AcknowledgmentStatus.received);
expect(ack.timestamp, DateTime.utc(2024, 1, 15, 10, 30, 0));
expect(ack.errorMessage, isNull);
});
test('parses errorMessage when present', () {
final json = {
'messageId': 'msg-123',
'status': 'FAILED',
'timestamp': '2024-01-15T10:30:00.000Z',
'errorMessage': 'Connection timeout',
};
final ack = AcknowledgmentMessage.fromJson(json);
expect(ack.status, AcknowledgmentStatus.failed);
expect(ack.errorMessage, 'Connection timeout');
});
test('parses PROCESSED status', () {
final json = {
'messageId': 'msg-123',
'status': 'PROCESSED',
'timestamp': '2024-01-15T10:30:00.000Z',
};
final ack = AcknowledgmentMessage.fromJson(json);
expect(ack.status, AcknowledgmentStatus.processed);
});
});
group('toJson', () {
test('serializes all fields correctly', () {
final ack = AcknowledgmentMessage(
messageId: 'msg-123',
status: AcknowledgmentStatus.received,
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
);
final json = ack.toJson();
expect(json['messageId'], 'msg-123');
expect(json['status'], 'RECEIVED');
expect(json['timestamp'], '2024-01-15T10:30:00.000Z');
expect(json.containsKey('errorMessage'), false);
});
test('includes errorMessage when present', () {
final ack = AcknowledgmentMessage(
messageId: 'msg-123',
status: AcknowledgmentStatus.failed,
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
errorMessage: 'Processing error',
);
final json = ack.toJson();
expect(json['errorMessage'], 'Processing error');
});
});
group('fromJson/toJson roundtrip', () {
test('preserves all data through serialization', () {
final original = AcknowledgmentMessage(
messageId: 'roundtrip-msg',
status: AcknowledgmentStatus.processed,
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
);
final json = original.toJson();
final restored = AcknowledgmentMessage.fromJson(json);
expect(restored.messageId, original.messageId);
expect(restored.status, original.status);
expect(restored.timestamp, original.timestamp);
expect(restored.errorMessage, original.errorMessage);
});
test('preserves errorMessage through serialization', () {
final original = AcknowledgmentMessage(
messageId: 'error-msg',
status: AcknowledgmentStatus.failed,
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
errorMessage: 'Something went wrong',
);
final json = original.toJson();
final restored = AcknowledgmentMessage.fromJson(json);
expect(restored.errorMessage, 'Something went wrong');
});
});
group('toString', () {
test('returns readable representation', () {
final ack = AcknowledgmentMessage(
messageId: 'msg-123',
status: AcknowledgmentStatus.received,
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
);
final str = ack.toString();
expect(str, contains('msg-123'));
expect(str, contains('RECEIVED'));
});
});
});
group('AcknowledgmentStatus', () {
group('fromString', () {
test('parses RECEIVED', () {
expect(
AcknowledgmentStatus.fromString('RECEIVED'),
AcknowledgmentStatus.received,
);
});
test('parses PROCESSED', () {
expect(
AcknowledgmentStatus.fromString('PROCESSED'),
AcknowledgmentStatus.processed,
);
});
test('parses FAILED', () {
expect(
AcknowledgmentStatus.fromString('FAILED'),
AcknowledgmentStatus.failed,
);
});
test('handles lowercase input', () {
expect(
AcknowledgmentStatus.fromString('received'),
AcknowledgmentStatus.received,
);
expect(
AcknowledgmentStatus.fromString('processed'),
AcknowledgmentStatus.processed,
);
expect(
AcknowledgmentStatus.fromString('failed'),
AcknowledgmentStatus.failed,
);
});
test('defaults to received for unknown values', () {
expect(
AcknowledgmentStatus.fromString('UNKNOWN'),
AcknowledgmentStatus.received,
);
expect(
AcknowledgmentStatus.fromString(''),
AcknowledgmentStatus.received,
);
});
});
group('toString', () {
test('returns RECEIVED for received', () {
expect(AcknowledgmentStatus.received.toString(), 'RECEIVED');
});
test('returns PROCESSED for processed', () {
expect(AcknowledgmentStatus.processed.toString(), 'PROCESSED');
});
test('returns FAILED for failed', () {
expect(AcknowledgmentStatus.failed.toString(), 'FAILED');
});
});
group('fromString/toString roundtrip', () {
test('preserves status through conversion', () {
for (final status in AcknowledgmentStatus.values) {
final str = status.toString();
final restored = AcknowledgmentStatus.fromString(str);
expect(restored, status);
}
});
});
});
}

View File

@@ -1,883 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/cargo_item.dart';
import 'package:votianlt_app/models/task.dart';
import 'package:votianlt_app/models/tasks/confirmation_task.dart';
import 'package:votianlt_app/models/tasks/photo_task.dart';
import 'package:votianlt_app/models/tasks/signature_task.dart';
import 'package:votianlt_app/models/tasks/barcode_task.dart';
import 'package:votianlt_app/models/tasks/todolist_task.dart';
import 'package:votianlt_app/models/tasks/comment_task.dart';
import 'package:votianlt_app/models/tasks/generic_task.dart';
/// Test data based on job_json.md documentation from
/// https://www.appcreation.de/download/job_json.md
void main() {
// Complete job JSON according to documentation
final Map<String, dynamic> completeJobJson = {
'job': {
'id': {'timestamp': 1705312200, '\$oid': '65a4b5c8d4e5f6a7b8c9d0e1'},
'jobNumber': 'JOB-2024-001',
'status': 'ASSIGNED',
'createdAt': '2024-01-15T10:30:00.000Z',
'updatedAt': '2024-01-15T14:45:00.000Z',
'createdBy': 'admin@example.com',
'customerSelection': 'Kunde ABC GmbH',
'pickupCompany': 'Absender GmbH',
'pickupSalutation': 'Herr',
'pickupFirstName': 'Max',
'pickupLastName': 'Mustermann',
'pickupPhone': '+49 123 456789',
'pickupStreet': 'Hauptstraße',
'pickupHouseNumber': '42',
'pickupAddressAddition': 'Hinterhaus',
'pickupZip': '10115',
'pickupCity': 'Berlin',
'deliveryCompany': 'Empfänger AG',
'deliverySalutation': 'Frau',
'deliveryFirstName': 'Erika',
'deliveryLastName': 'Musterfrau',
'deliveryPhone': '+49 987 654321',
'deliveryStreet': 'Nebenstraße',
'deliveryHouseNumber': '7a',
'deliveryAddressAddition': null,
'deliveryZip': '80331',
'deliveryCity': 'München',
'digitalProcessing': true,
'appUser': 'driver@example.com',
'pickupDate': '2024-01-16',
'deliveryDate': '2024-01-17',
'remark': 'Bitte vorsichtig behandeln',
'price': 149.99,
'draft': false,
},
'cargoItems': [
{
'id': {'timestamp': 1705312201},
'jobId': {'timestamp': 1705312200},
'description': 'Palette mit Elektronik',
'quantity': 2,
'weightKg': 150.5,
'lengthMm': 1200.0,
'widthMm': 800.0,
'heightMm': 1000.0,
},
{
'id': {'timestamp': 1705312202},
'jobId': {'timestamp': 1705312200},
'description': 'Karton mit Dokumenten',
'quantity': 5,
'weightKg': 12.0,
'lengthMm': 400.0,
'widthMm': 300.0,
'heightMm': 200.0,
},
],
'tasks': [
{
'id': {'timestamp': 1705312210},
'jobId': {'timestamp': 1705312200},
'completed': false,
'completedAt': null,
'completedBy': null,
'taskOrder': 1,
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Abholung bestätigen',
},
},
{
'id': {'timestamp': 1705312211},
'jobId': {'timestamp': 1705312200},
'completed': true,
'completedAt': '2024-01-16T09:15:00.000Z',
'completedBy': 'driver@example.com',
'taskOrder': 2,
'taskSpecificData': {'taskType': 'SIGNATURE'},
},
{
'id': {'timestamp': 1705312212},
'jobId': {'timestamp': 1705312200},
'completed': false,
'completedAt': null,
'completedBy': null,
'taskOrder': 3,
'taskSpecificData': {
'taskType': 'PHOTO',
'minPhotoCount': 2,
'maxPhotoCount': 10,
},
},
{
'id': {'timestamp': 1705312213},
'jobId': {'timestamp': 1705312200},
'completed': false,
'completedAt': null,
'completedBy': null,
'taskOrder': 4,
'taskSpecificData': {
'taskType': 'BARCODE',
'minBarcodeCount': 1,
'maxBarcodeCount': 5,
},
},
{
'id': {'timestamp': 1705312214},
'jobId': {'timestamp': 1705312200},
'completed': false,
'completedAt': null,
'completedBy': null,
'taskOrder': 5,
'taskSpecificData': {
'taskType': 'TODOLIST',
'todoItems': [
'Ladung sichern',
'Dokumente prüfen',
'Unterschrift einholen',
],
},
},
{
'id': {'timestamp': 1705312215},
'jobId': {'timestamp': 1705312200},
'completed': false,
'completedAt': null,
'completedBy': null,
'taskOrder': 6,
'taskSpecificData': {
'taskType': 'COMMENT',
'commentText': '',
'required': true,
},
},
],
};
group('Job Parsing', () {
late Job job;
setUp(() {
job = Job.fromJson(completeJobJson);
});
test('parses job basic fields correctly', () {
expect(job.id, '1705312200');
expect(job.jobNumber, 'JOB-2024-001');
expect(job.status, 'ASSIGNED');
expect(job.createdBy, 'admin@example.com');
expect(job.customerSelection, 'Kunde ABC GmbH');
expect(job.appUser, 'driver@example.com');
expect(job.remark, 'Bitte vorsichtig behandeln');
});
test('parses pickup address correctly', () {
expect(job.pickupCompany, 'Absender GmbH');
expect(job.pickupSalutation, 'Herr');
expect(job.pickupFirstName, 'Max');
expect(job.pickupLastName, 'Mustermann');
expect(job.pickupPhone, '+49 123 456789');
expect(job.pickupStreet, 'Hauptstraße');
expect(job.pickupHouseNumber, '42');
expect(job.pickupAddressAddition, 'Hinterhaus');
expect(job.pickupZip, '10115');
expect(job.pickupCity, 'Berlin');
});
test('parses delivery address correctly', () {
expect(job.deliveryCompany, 'Empfänger AG');
expect(job.deliverySalutation, 'Frau');
expect(job.deliveryFirstName, 'Erika');
expect(job.deliveryLastName, 'Musterfrau');
expect(job.deliveryPhone, '+49 987 654321');
expect(job.deliveryStreet, 'Nebenstraße');
expect(job.deliveryHouseNumber, '7a');
expect(job.deliveryAddressAddition, '');
expect(job.deliveryZip, '80331');
expect(job.deliveryCity, 'München');
});
test('parses date strings correctly', () {
expect(job.pickupDate, '2024-01-16');
expect(job.deliveryDate, '2024-01-17');
});
test('parses DateTime from ISO string', () {
expect(job.createdAt, DateTime.utc(2024, 1, 15, 10, 30, 0));
expect(job.updatedAt, DateTime.utc(2024, 1, 15, 14, 45, 0));
});
test('parses DateTime from array format', () {
final jsonWithArrayDate = Map<String, dynamic>.from(completeJobJson);
final jobData = Map<String, dynamic>.from(jsonWithArrayDate['job']);
jobData['createdAt'] = [2024, 1, 15, 10, 30, 0, 0];
jobData['updatedAt'] = [2024, 1, 15, 14, 45, 0, 500000000];
jsonWithArrayDate['job'] = jobData;
final jobWithArrayDate = Job.fromJson(jsonWithArrayDate);
expect(jobWithArrayDate.createdAt, DateTime(2024, 1, 15, 10, 30, 0, 0));
expect(jobWithArrayDate.updatedAt.year, 2024);
expect(jobWithArrayDate.updatedAt.month, 1);
expect(jobWithArrayDate.updatedAt.day, 15);
});
test('parses price as double', () {
expect(job.price, 149.99);
});
test('parses boolean fields correctly', () {
expect(job.digitalProcessing, true);
expect(job.draft, false);
});
test('parses cargoItems array', () {
expect(job.cargoItems.length, 2);
});
test('parses tasks array', () {
expect(job.tasks.length, 6);
});
test('parses delivery stations and flattens station tasks', () {
final jsonWithStations = {
'job': {
'id': 'station-job-1',
'jobNumber': 'JOB-STATION-001',
'status': 'CREATED',
'deliveryCompany': 'Legacy Delivery',
'deliveryStreet': 'Legacy Street',
'deliveryHouseNumber': '1',
'deliveryZip': '12345',
'deliveryCity': 'Legacy City',
'deliveryCitiesDisplay': 'Boostedt -> Geesthacht',
'firstDeliveryCity': 'Boostedt',
'lastDeliveryCity': 'Geesthacht',
'deliveryStations': [
{
'stationOrder': 0,
'company': 'Volker Hinst',
'street': 'Vossbarg',
'houseNumber': '24',
'zip': '24893',
'city': 'Boostedt',
'tasks': [
{
'id': 'station-task-1',
'jobId': 'station-job-1',
'taskOrder': 0,
'description': 'Erste Station',
'displayName': 'Bestätigung',
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Blubb',
},
},
],
},
{
'stationOrder': 1,
'company': 'Timm GmbH',
'street': 'Gerhart-Hauptmann-Weg',
'houseNumber': '14',
'zip': '21502',
'city': 'Geesthacht',
'tasks': [
{
'id': 'station-task-2',
'jobId': 'station-job-1',
'taskOrder': 0,
'description': 'Zweite Station',
'displayName': 'Bestätigung',
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Blubb',
},
},
],
},
],
},
'cargoItems': <dynamic>[],
'tasks': [
{
'id': 'legacy-task',
'jobId': 'station-job-1',
'taskOrder': 0,
'description': 'Legacy',
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Legacy',
},
},
],
};
final jobWithStations = Job.fromJson(jsonWithStations);
expect(jobWithStations.deliveryStations.length, 2);
expect(jobWithStations.tasks.length, 2);
expect(jobWithStations.tasks[0].stationOrder, 0);
expect(jobWithStations.tasks[1].stationOrder, 1);
expect(
jobWithStations.deliveryStations[0].tasks.first.id,
'station-task-1',
);
expect(jobWithStations.deliveryCitiesDisplay, 'Boostedt -> Geesthacht');
});
test('extracts ID from Map object with timestamp', () {
expect(job.id, '1705312200');
});
test('extracts ID from Map object with \$oid fallback', () {
final jsonWithOidOnly = {
'job': {
'id': {'\$oid': '65a4b5c8d4e5f6a7b8c9d0e1'},
'jobNumber': 'JOB-TEST',
'status': 'CREATED',
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final jobWithOid = Job.fromJson(jsonWithOidOnly);
expect(jobWithOid.id, '65a4b5c8d4e5f6a7b8c9d0e1');
});
test('handles flat JSON structure (without nested job object)', () {
final flatJson = {
'id': 'flat-job-id-123',
'jobNumber': 'JOB-FLAT-001',
'status': 'CREATED',
'createdAt': '2024-01-15T10:30:00.000Z',
'updatedAt': '2024-01-15T10:30:00.000Z',
'createdBy': 'test@test.de',
'customerSelection': 'Test Kunde',
'pickupCompany': 'Test Firma',
'pickupFirstName': 'Test',
'pickupLastName': 'User',
'pickupPhone': '12345',
'pickupStreet': 'Teststr',
'pickupHouseNumber': '1',
'pickupAddressAddition': '',
'pickupZip': '12345',
'pickupCity': 'Teststadt',
'deliveryCompany': 'Ziel Firma',
'deliveryFirstName': 'Ziel',
'deliveryLastName': 'Person',
'deliveryPhone': '54321',
'deliveryStreet': 'Zielstr',
'deliveryHouseNumber': '2',
'deliveryAddressAddition': '',
'deliveryZip': '54321',
'deliveryCity': 'Zielstadt',
'digitalProcessing': false,
'appUser': 'user@test.de',
'pickupDate': '2024-01-20',
'deliveryDate': '2024-01-21',
'remark': '',
'price': 0.0,
'draft': true,
};
final flatJob = Job.fromJson(flatJson);
expect(flatJob.id, 'flat-job-id-123');
expect(flatJob.jobNumber, 'JOB-FLAT-001');
expect(flatJob.draft, true);
});
});
group('CargoItem Parsing', () {
late Job job;
setUp(() {
job = Job.fromJson(completeJobJson);
});
test('parses CargoItem fields correctly', () {
final cargoItem = job.cargoItems[0];
expect(cargoItem.id, '1705312201');
expect(cargoItem.jobId, '1705312200');
expect(cargoItem.description, 'Palette mit Elektronik');
expect(cargoItem.quantity, 2);
expect(cargoItem.weightKg, 150.5);
expect(cargoItem.lengthCm, 1200.0);
expect(cargoItem.widthCm, 800.0);
expect(cargoItem.heightCm, 1000.0);
});
test('parses multiple CargoItems', () {
expect(job.cargoItems.length, 2);
expect(job.cargoItems[0].description, 'Palette mit Elektronik');
expect(job.cargoItems[1].description, 'Karton mit Dokumenten');
});
test('extracts CargoItem ID from Map object', () {
expect(job.cargoItems[0].id, '1705312201');
expect(job.cargoItems[1].id, '1705312202');
});
test('handles CargoItem with simple string ID', () {
final cargoJson = {
'id': 'simple-string-id',
'jobId': 'simple-job-id',
'description': 'Test Item',
'quantity': 1,
'weightKg': 10.0,
'lengthMm': 100.0,
'widthMm': 100.0,
'heightMm': 100.0,
};
final cargoItem = CargoItem.fromJson(cargoJson);
expect(cargoItem.id, 'simple-string-id');
expect(cargoItem.jobId, 'simple-job-id');
});
});
group('Task Parsing', () {
late Job job;
setUp(() {
job = Job.fromJson(completeJobJson);
});
test('creates ConfirmationTask with buttonText', () {
final task = job.tasks[0];
expect(task, isA<ConfirmationTask>());
final confirmationTask = task as ConfirmationTask;
expect(confirmationTask.taskOrder, 1);
expect(confirmationTask.buttonText, 'Abholung bestätigen');
expect(confirmationTask.completed, false);
});
test('creates SignatureTask', () {
final task = job.tasks[1];
expect(task, isA<SignatureTask>());
expect(task.taskOrder, 2);
expect(task.completed, true);
expect(task.completedBy, 'driver@example.com');
});
test('creates PhotoTask with min/maxPhotoCount', () {
final task = job.tasks[2];
expect(task, isA<PhotoTask>());
final photoTask = task as PhotoTask;
expect(photoTask.taskOrder, 3);
expect(photoTask.minPhotoCount, 2);
expect(photoTask.maxPhotoCount, 10);
});
test('creates BarcodeTask with min/maxBarcodeCount', () {
final task = job.tasks[3];
expect(task, isA<BarcodeTask>());
final barcodeTask = task as BarcodeTask;
expect(barcodeTask.taskOrder, 4);
expect(barcodeTask.minBarcodeCount, 1);
expect(barcodeTask.maxBarcodeCount, 5);
});
test('creates TodoListTask with todoItems', () {
final task = job.tasks[4];
expect(task, isA<TodoListTask>());
final todoListTask = task as TodoListTask;
expect(todoListTask.taskOrder, 5);
expect(todoListTask.todoItems.length, 3);
expect(todoListTask.todoItems[0], 'Ladung sichern');
expect(todoListTask.todoItems[1], 'Dokumente prüfen');
expect(todoListTask.todoItems[2], 'Unterschrift einholen');
});
test('creates CommentTask with commentText and required', () {
final task = job.tasks[5];
expect(task, isA<CommentTask>());
final commentTask = task as CommentTask;
expect(commentTask.taskOrder, 6);
expect(commentTask.commentText, '');
expect(commentTask.required, true);
});
test('falls back to GenericTask for unknown task type', () {
final unknownTaskJson = {
'id': {'timestamp': 1705312299},
'jobId': {'timestamp': 1705312200},
'completed': false,
'taskOrder': 99,
'taskSpecificData': {'taskType': 'UNKNOWN_TYPE'},
};
final task = Task.fromJson(unknownTaskJson);
expect(task, isA<GenericTask>());
});
test('falls back to GenericTask when taskType is missing', () {
final noTypeTaskJson = {
'id': {'timestamp': 1705312298},
'jobId': {'timestamp': 1705312200},
'completed': false,
'taskOrder': 98,
'taskSpecificData': <String, dynamic>{},
};
final task = Task.fromJson(noTypeTaskJson);
expect(task, isA<GenericTask>());
});
test('parses completedAt from ISO string', () {
final task = job.tasks[1];
expect(task.completedAt, DateTime.utc(2024, 1, 16, 9, 15, 0));
});
test('parses completedAt from array format', () {
final taskJsonWithArrayDate = {
'id': {'timestamp': 1705312220},
'jobId': {'timestamp': 1705312200},
'completed': true,
'completedAt': [2024, 1, 16, 9, 15, 0, 0],
'completedBy': 'driver@example.com',
'taskOrder': 10,
'taskSpecificData': {'taskType': 'SIGNATURE'},
};
final task = Task.fromJson(taskJsonWithArrayDate);
expect(task.completedAt, DateTime(2024, 1, 16, 9, 15, 0, 0));
});
test('extracts task ID from Map object', () {
final task = job.tasks[0];
expect(task.id, '1705312210');
});
test('extracts task jobId from Map object', () {
final task = job.tasks[0];
expect(task.jobId, '1705312200');
});
});
group('Task Defaults', () {
test('ConfirmationTask uses default buttonText', () {
final taskJson = {
'id': 'task-1',
'jobId': 'job-1',
'taskOrder': 1,
'taskSpecificData': {'taskType': 'CONFIRMATION'},
};
final task = Task.fromJson(taskJson) as ConfirmationTask;
expect(task.buttonText, 'Bestätigen');
});
test('PhotoTask uses default min/max counts', () {
final taskJson = {
'id': 'task-2',
'jobId': 'job-1',
'taskOrder': 2,
'taskSpecificData': {'taskType': 'PHOTO'},
};
final task = Task.fromJson(taskJson) as PhotoTask;
expect(task.minPhotoCount, 1);
expect(task.maxPhotoCount, 5);
});
test('BarcodeTask uses default min/max counts', () {
final taskJson = {
'id': 'task-3',
'jobId': 'job-1',
'taskOrder': 3,
'taskSpecificData': {'taskType': 'BARCODE'},
};
final task = Task.fromJson(taskJson) as BarcodeTask;
expect(task.minBarcodeCount, 1);
expect(task.maxBarcodeCount, 10);
});
test('CommentTask uses default values', () {
final taskJson = {
'id': 'task-4',
'jobId': 'job-1',
'taskOrder': 4,
'taskSpecificData': {'taskType': 'COMMENT'},
};
final task = Task.fromJson(taskJson) as CommentTask;
expect(task.commentText, '');
expect(task.required, false);
});
test('TodoListTask handles empty todoItems', () {
final taskJson = {
'id': 'task-5',
'jobId': 'job-1',
'taskOrder': 5,
'taskSpecificData': {'taskType': 'TODOLIST'},
};
final task = Task.fromJson(taskJson) as TodoListTask;
expect(task.todoItems, isEmpty);
});
});
group('Edge Cases', () {
test('handles empty cargoItems array', () {
final jsonWithEmptyCargoItems = Map<String, dynamic>.from(
completeJobJson,
);
jsonWithEmptyCargoItems['cargoItems'] = <dynamic>[];
final job = Job.fromJson(jsonWithEmptyCargoItems);
expect(job.cargoItems, isEmpty);
});
test('handles empty tasks array', () {
final jsonWithEmptyTasks = Map<String, dynamic>.from(completeJobJson);
jsonWithEmptyTasks['tasks'] = <dynamic>[];
final job = Job.fromJson(jsonWithEmptyTasks);
expect(job.tasks, isEmpty);
});
test('handles missing optional fields with defaults', () {
final minimalJson = {
'job': {'jobNumber': 'JOB-MIN-001'},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(minimalJson);
expect(job.jobNumber, 'JOB-MIN-001');
expect(job.status, 'UNKNOWN');
expect(job.pickupCompany, '');
expect(job.deliveryCompany, '');
expect(job.digitalProcessing, false);
expect(job.price, 0.0);
expect(job.draft, false);
});
test('handles null values in optional fields', () {
final jsonWithNulls = {
'job': {
'id': 'null-test-id',
'jobNumber': 'JOB-NULL-001',
'status': 'CREATED',
'pickupSalutation': null,
'deliverySalutation': null,
'pickupAddressAddition': null,
'deliveryAddressAddition': null,
'remark': null,
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(jsonWithNulls);
expect(job.pickupSalutation, isNull);
expect(job.deliverySalutation, isNull);
expect(job.pickupAddressAddition, '');
expect(job.deliveryAddressAddition, '');
expect(job.remark, '');
});
test('generates ID from jobNumber when ID is missing', () {
final jsonWithoutId = {
'job': {'jobNumber': 'JOB-NOID-001'},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(jsonWithoutId);
expect(job.id, 'jobnum:JOB-NOID-001');
});
});
group('Roundtrip (fromJson -> toJson -> fromJson)', () {
test('Job preserves data through serialization', () {
final original = Job.fromJson(completeJobJson);
final json = original.toJson();
final restored = Job.fromJson(json);
expect(restored.id, original.id);
expect(restored.jobNumber, original.jobNumber);
expect(restored.status, original.status);
expect(restored.pickupCompany, original.pickupCompany);
expect(restored.pickupCity, original.pickupCity);
expect(restored.deliveryCompany, original.deliveryCompany);
expect(restored.deliveryCity, original.deliveryCity);
expect(restored.price, original.price);
expect(restored.digitalProcessing, original.digitalProcessing);
});
test('CargoItem preserves data through serialization', () {
final original = Job.fromJson(completeJobJson).cargoItems[0];
final json = original.toJson();
final restored = CargoItem.fromJson(json);
expect(restored.id, original.id);
expect(restored.jobId, original.jobId);
expect(restored.description, original.description);
expect(restored.quantity, original.quantity);
expect(restored.weightKg, original.weightKg);
expect(restored.lengthCm, original.lengthCm);
});
test('ConfirmationTask preserves data through serialization', () {
final original =
Job.fromJson(completeJobJson).tasks[0] as ConfirmationTask;
final json = original.toJson();
final restored = Task.fromJson(json) as ConfirmationTask;
expect(restored.id, original.id);
expect(restored.jobId, original.jobId);
expect(restored.buttonText, original.buttonText);
expect(restored.taskOrder, original.taskOrder);
});
test('PhotoTask preserves data through serialization', () {
final original = Job.fromJson(completeJobJson).tasks[2] as PhotoTask;
final json = original.toJson();
final restored = Task.fromJson(json) as PhotoTask;
expect(restored.minPhotoCount, original.minPhotoCount);
expect(restored.maxPhotoCount, original.maxPhotoCount);
});
test('TodoListTask preserves data through serialization', () {
final original = Job.fromJson(completeJobJson).tasks[4] as TodoListTask;
final json = original.toJson();
final restored = Task.fromJson(json) as TodoListTask;
expect(restored.todoItems, original.todoItems);
});
test('CommentTask preserves data through serialization', () {
final original = Job.fromJson(completeJobJson).tasks[5] as CommentTask;
final json = original.toJson();
final restored = Task.fromJson(json) as CommentTask;
expect(restored.commentText, original.commentText);
expect(restored.required, original.required);
});
});
group('Job Status', () {
test('statusDisplayText returns German text for known statuses', () {
final statuses = {
'CREATED': 'Erstellt',
'PENDING': 'Wartend',
'ASSIGNED': 'Zugewiesen',
'IN_PROGRESS': 'In Bearbeitung',
'STARTED': 'In Bearbeitung',
'COMPLETED': 'Abgeschlossen',
'DONE': 'Abgeschlossen',
'CANCELLED': 'Abgebrochen',
'FAILED': 'Fehlgeschlagen',
};
for (final entry in statuses.entries) {
final json = {
'job': {
'id': 'status-test',
'jobNumber': 'JOB-STATUS',
'status': entry.key,
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(json);
expect(
job.statusDisplayText,
entry.value,
reason: 'Status ${entry.key} should display as ${entry.value}',
);
}
});
test('statusColor returns correct color for statuses', () {
final statusColors = {
'CREATED': 'orange',
'ASSIGNED': 'orange',
'IN_PROGRESS': 'blue',
'COMPLETED': 'green',
'CANCELLED': 'red',
};
for (final entry in statusColors.entries) {
final json = {
'job': {
'id': 'color-test',
'jobNumber': 'JOB-COLOR',
'status': entry.key,
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(json);
expect(
job.statusColor,
entry.value,
reason: 'Status ${entry.key} should have color ${entry.value}',
);
}
});
});
group('Job normalized()', () {
test('trims string fields', () {
final json = {
'job': {
'id': 'normalize-test',
'jobNumber': ' JOB-TRIM ',
'status': ' ASSIGNED ',
'pickupCompany': ' Test Company ',
'pickupCity': ' Berlin ',
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(json).normalized();
expect(job.jobNumber, 'JOB-TRIM');
expect(job.status, 'ASSIGNED');
expect(job.pickupCompany, 'Test Company');
expect(job.pickupCity, 'Berlin');
});
test('converts null strings to empty strings', () {
final json = {
'job': {
'id': 'null-normalize-test',
'jobNumber': 'JOB-NULL',
'pickupSalutation': null,
},
'cargoItems': <dynamic>[],
'tasks': <dynamic>[],
};
final job = Job.fromJson(json).normalized();
expect(job.pickupSalutation, '');
});
});
}

View File

@@ -1,190 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/message_envelope.dart';
void main() {
group('MessageEnvelope', () {
group('fromJson', () {
test('parses all required fields correctly', () {
final json = {
'messageId': 'test-uuid-123',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user123/message',
'payload': {'key': 'value'},
};
final envelope = MessageEnvelope.fromJson(json);
expect(envelope.messageId, 'test-uuid-123');
expect(envelope.timestamp, DateTime.utc(2024, 1, 15, 10, 30, 0));
expect(envelope.topic, '/server/user123/message');
expect(envelope.payload, {'key': 'value'});
expect(envelope.requiresAck, true); // default
expect(envelope.retryCount, 0); // default
expect(envelope.expiresAt, isNull);
});
test('parses optional fields when present', () {
final json = {
'messageId': 'test-uuid-456',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user123/message',
'payload': {'data': 123},
'requiresAck': false,
'retryCount': 3,
'expiresAt': '2024-01-15T11:30:00.000Z',
};
final envelope = MessageEnvelope.fromJson(json);
expect(envelope.requiresAck, false);
expect(envelope.retryCount, 3);
expect(envelope.expiresAt, DateTime.utc(2024, 1, 15, 11, 30, 0));
});
test('handles list payload', () {
final json = {
'messageId': 'test-uuid-789',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user123/jobs',
'payload': [
{'id': '1'},
{'id': '2'}
],
};
final envelope = MessageEnvelope.fromJson(json);
expect(envelope.payload, isList);
expect(envelope.payload.length, 2);
});
test('handles null payload', () {
final json = {
'messageId': 'test-uuid-null',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user123/ping',
'payload': null,
};
final envelope = MessageEnvelope.fromJson(json);
expect(envelope.payload, isNull);
});
});
group('toJson', () {
test('serializes all fields correctly', () {
final envelope = MessageEnvelope(
messageId: 'test-uuid-123',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'key': 'value'},
requiresAck: true,
retryCount: 2,
expiresAt: DateTime.utc(2024, 1, 15, 11, 30, 0),
);
final json = envelope.toJson();
expect(json['messageId'], 'test-uuid-123');
expect(json['timestamp'], '2024-01-15T10:30:00.000Z');
expect(json['topic'], '/server/user123/message');
expect(json['payload'], {'key': 'value'});
expect(json['requiresAck'], true);
expect(json['retryCount'], 2);
expect(json['expiresAt'], '2024-01-15T11:30:00.000Z');
});
test('omits expiresAt when null', () {
final envelope = MessageEnvelope(
messageId: 'test-uuid-123',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'key': 'value'},
);
final json = envelope.toJson();
expect(json.containsKey('expiresAt'), false);
});
});
group('fromJson/toJson roundtrip', () {
test('preserves all data through serialization', () {
final original = MessageEnvelope(
messageId: 'roundtrip-uuid',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'nested': {'key': 'value'}, 'list': [1, 2, 3]},
requiresAck: false,
retryCount: 5,
expiresAt: DateTime.utc(2024, 1, 16, 10, 30, 0),
);
final json = original.toJson();
final restored = MessageEnvelope.fromJson(json);
expect(restored.messageId, original.messageId);
expect(restored.timestamp, original.timestamp);
expect(restored.topic, original.topic);
expect(restored.payload, original.payload);
expect(restored.requiresAck, original.requiresAck);
expect(restored.retryCount, original.retryCount);
expect(restored.expiresAt, original.expiresAt);
});
});
group('copyWith', () {
test('creates copy with updated messageId', () {
final original = MessageEnvelope(
messageId: 'original-id',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'key': 'value'},
);
final copy = original.copyWith(messageId: 'new-id');
expect(copy.messageId, 'new-id');
expect(copy.timestamp, original.timestamp);
expect(copy.topic, original.topic);
expect(copy.payload, original.payload);
});
test('creates copy with updated retryCount', () {
final original = MessageEnvelope(
messageId: 'test-id',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'key': 'value'},
retryCount: 0,
);
final copy = original.copyWith(retryCount: 3);
expect(copy.retryCount, 3);
expect(copy.messageId, original.messageId);
});
});
group('toString', () {
test('returns readable representation', () {
final envelope = MessageEnvelope(
messageId: 'test-id',
timestamp: DateTime.utc(2024, 1, 15, 10, 30, 0),
topic: '/server/user123/message',
payload: {'key': 'value'},
requiresAck: true,
retryCount: 2,
);
final str = envelope.toString();
expect(str, contains('test-id'));
expect(str, contains('/server/user123/message'));
expect(str, contains('requiresAck: true'));
expect(str, contains('retryCount: 2'));
});
});
});
}

View File

@@ -1,312 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/ack_tracker.dart';
void main() {
group('AckTracker', () {
late AckTracker tracker;
late List<Map<String, String>> retryCalls;
late List<Map<String, String>> timeoutCalls;
setUp(() {
retryCalls = [];
timeoutCalls = [];
tracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retryCalls.add({'topic': topic, 'payload': payload});
return true;
},
onTimeout: (messageId, topic) {
timeoutCalls.add({'messageId': messageId, 'topic': topic});
},
);
});
group('track', () {
test('adds message to pending', () {
tracker.track('msg-1', '/server/user/message', '{"data": "test"}');
expect(tracker.isPending('msg-1'), true);
expect(tracker.pendingCount, 1);
});
test('can track multiple messages', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.track('msg-3', '/topic3', 'payload3');
expect(tracker.pendingCount, 3);
expect(tracker.isPending('msg-1'), true);
expect(tracker.isPending('msg-2'), true);
expect(tracker.isPending('msg-3'), true);
});
test('stores correct topic and payload', () {
tracker.track('msg-1', '/server/user/message', '{"key": "value"}');
final pending = tracker.getPendingMessage('msg-1');
expect(pending, isNotNull);
expect(pending!.topic, '/server/user/message');
expect(pending.jsonPayload, '{"key": "value"}');
expect(pending.retryCount, 0);
});
test('overwrites existing message with same ID', () {
tracker.track('msg-1', '/old/topic', 'old payload');
tracker.track('msg-1', '/new/topic', 'new payload');
final pending = tracker.getPendingMessage('msg-1');
expect(pending!.topic, '/new/topic');
expect(pending.jsonPayload, 'new payload');
expect(tracker.pendingCount, 1);
});
});
group('acknowledge', () {
test('removes message from pending', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.isPending('msg-1'), true);
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
expect(tracker.pendingCount, 0);
});
test('does nothing for unknown message ID', () {
tracker.track('msg-1', '/topic', 'payload');
tracker.acknowledge('unknown-msg');
expect(tracker.pendingCount, 1);
expect(tracker.isPending('msg-1'), true);
});
test('only removes specified message', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), true);
expect(tracker.pendingCount, 1);
});
});
group('isPending', () {
test('returns true for tracked message', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.isPending('msg-1'), true);
});
test('returns false for untracked message', () {
expect(tracker.isPending('unknown'), false);
});
test('returns false after acknowledge', () {
tracker.track('msg-1', '/topic', 'payload');
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
});
});
group('pendingMessageIds', () {
test('returns empty list when no messages pending', () {
expect(tracker.pendingMessageIds, isEmpty);
});
test('returns all pending message IDs', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
final ids = tracker.pendingMessageIds;
expect(ids, containsAll(['msg-1', 'msg-2']));
expect(ids.length, 2);
});
test('returns unmodifiable list', () {
tracker.track('msg-1', '/topic', 'payload');
final ids = tracker.pendingMessageIds;
expect(() => ids.add('new-id'), throwsUnsupportedError);
});
});
group('processRetries', () {
test('increments retryCount on each call', () async {
tracker.track('msg-1', '/topic', 'payload');
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 1);
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 2);
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 3);
});
test('calls onRetry callback with correct parameters', () async {
tracker.track('msg-1', '/server/user/message', '{"data": "test"}');
await tracker.processRetries();
expect(retryCalls.length, 1);
expect(retryCalls[0]['topic'], '/server/user/message');
expect(retryCalls[0]['payload'], '{"data": "test"}');
});
test('calls onRetry for each pending message', () async {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
await tracker.processRetries();
expect(retryCalls.length, 2);
});
test('calls onTimeout after maxRetries exceeded', () async {
tracker.track('msg-1', '/timeout/topic', 'payload');
// Process until maxRetries reached
for (var i = 0; i < 4; i++) {
await tracker.processRetries();
}
expect(timeoutCalls, isEmpty);
expect(tracker.isPending('msg-1'), true);
// One more retry should trigger timeout
await tracker.processRetries();
expect(timeoutCalls.length, 1);
expect(timeoutCalls[0]['messageId'], 'msg-1');
expect(timeoutCalls[0]['topic'], '/timeout/topic');
});
test('removes message after timeout', () async {
tracker.track('msg-1', '/topic', 'payload');
// Process until timeout
for (var i = 0; i <= 4; i++) {
await tracker.processRetries();
}
expect(tracker.isPending('msg-1'), false);
expect(tracker.pendingCount, 0);
});
test('does not retry when isConnected is false', () async {
tracker.track('msg-1', '/topic', 'payload');
await tracker.processRetries(isConnected: false);
expect(retryCalls, isEmpty);
expect(tracker.getPendingMessage('msg-1')!.retryCount, 0);
});
test('still times out when disconnected after max retries', () async {
tracker.track('msg-1', '/topic', 'payload');
// Manually set retry count to max (simulating previous retries)
final pending = tracker.getPendingMessage('msg-1')!;
pending.retryCount = 4;
await tracker.processRetries(isConnected: false);
expect(timeoutCalls.length, 1);
expect(tracker.isPending('msg-1'), false);
});
test('does nothing when no messages pending', () async {
await tracker.processRetries();
expect(retryCalls, isEmpty);
expect(timeoutCalls, isEmpty);
});
});
group('clearAll', () {
test('removes all pending messages', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.clearAll();
expect(tracker.pendingCount, 0);
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), false);
});
});
group('clearForTopic', () {
test('removes only messages for matching topic', () {
tracker.track('msg-1', '/server/login', 'login1');
tracker.track('msg-2', '/server/login', 'login2');
tracker.track('msg-3', '/server/user/message', 'message');
tracker.clearForTopic('/server/login');
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), false);
expect(tracker.isPending('msg-3'), true);
expect(tracker.pendingCount, 1);
});
test('does nothing when no matching topic', () {
tracker.track('msg-1', '/server/user/message', 'payload');
tracker.clearForTopic('/server/login');
expect(tracker.pendingCount, 1);
expect(tracker.isPending('msg-1'), true);
});
});
group('without callbacks', () {
test('processRetries works without onRetry callback', () async {
final noCallbackTracker = AckTracker(maxRetries: 2);
noCallbackTracker.track('msg-1', '/topic', 'payload');
// Should not throw
await noCallbackTracker.processRetries();
expect(noCallbackTracker.getPendingMessage('msg-1')!.retryCount, 1);
});
test('processRetries works without onTimeout callback', () async {
final noCallbackTracker = AckTracker(maxRetries: 1);
noCallbackTracker.track('msg-1', '/topic', 'payload');
// Process until timeout
await noCallbackTracker.processRetries();
await noCallbackTracker.processRetries();
// Should be removed even without callback
expect(noCallbackTracker.isPending('msg-1'), false);
});
});
group('PendingMessage', () {
test('stores sentAt timestamp', () {
final before = DateTime.now();
tracker.track('msg-1', '/topic', 'payload');
final after = DateTime.now();
final pending = tracker.getPendingMessage('msg-1')!;
expect(pending.sentAt.isAfter(before) || pending.sentAt == before, true);
expect(pending.sentAt.isBefore(after) || pending.sentAt == after, true);
});
test('initializes retryCount to 0', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.getPendingMessage('msg-1')!.retryCount, 0);
});
});
});
}

View File

@@ -1,350 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/message_handler.dart';
void main() {
group('MessageHandler', () {
late MessageHandler handler;
late List<String> ackCallbackCalls;
setUp(() {
ackCallbackCalls = [];
handler = MessageHandler(
maxProcessedIds: 100,
onAckRequired: (messageId) => ackCallbackCalls.add(messageId),
);
});
group('isEnvelopeMessage', () {
test('returns true for valid envelope with all required fields', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), true);
});
test('returns false when messageId is missing', () {
final data = {
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when timestamp is missing', () {
final data = {
'messageId': 'test-id',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when topic is missing', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when payload is missing', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false for non-map data', () {
expect(handler.isEnvelopeMessage('string'), false);
expect(handler.isEnvelopeMessage(123), false);
expect(handler.isEnvelopeMessage(['list']), false);
expect(handler.isEnvelopeMessage(null), false);
});
test('returns true even if payload is null', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': null,
};
expect(handler.isEnvelopeMessage(data), true);
});
});
group('unwrapEnvelope', () {
test('extracts payload from valid envelope', () {
final data = {
'messageId': 'test-id-123',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Hello'},
'requiresAck': true,
};
final result = handler.unwrapEnvelope(data);
expect(result, isNotNull);
expect(result!.payload, {'content': 'Hello'});
expect(result.messageId, 'test-id-123');
expect(result.requiresAck, true);
});
test('returns non-envelope data as-is', () {
final plainData = {'content': 'Hello', 'sender': 'user1'};
final result = handler.unwrapEnvelope(plainData);
expect(result, isNotNull);
expect(result!.payload, plainData);
expect(result.messageId, isNull);
expect(result.requiresAck, false);
});
test('returns null for duplicate messageId', () {
final data = {
'messageId': 'duplicate-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'First'},
'requiresAck': false,
};
// First call should succeed
final firstResult = handler.unwrapEnvelope(data);
expect(firstResult, isNotNull);
// Second call with same messageId should return null
final secondResult = handler.unwrapEnvelope(data);
expect(secondResult, isNull);
});
test('calls onAckRequired for duplicate when original required ACK', () {
final data = {
'messageId': 'ack-duplicate-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
'requiresAck': true,
};
// First call
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, isEmpty); // No ACK callback on first process
// Second call (duplicate) - should trigger ACK
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, ['ack-duplicate-id']);
});
test('does not call onAckRequired for duplicate when requiresAck is false', () {
final data = {
'messageId': 'no-ack-duplicate',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
'requiresAck': false,
};
handler.unwrapEnvelope(data);
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, isEmpty);
});
test('defaults requiresAck to true when not specified', () {
final data = {
'messageId': 'default-ack-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
// requiresAck not specified
};
final result = handler.unwrapEnvelope(data);
expect(result!.requiresAck, true);
});
test('handles list payload', () {
final data = {
'messageId': 'list-payload-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/jobs',
'payload': [
{'id': '1'},
{'id': '2'}
],
};
final result = handler.unwrapEnvelope(data);
expect(result!.payload, isList);
expect(result.payload.length, 2);
});
test('handles null payload', () {
final data = {
'messageId': 'null-payload-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/ping',
'payload': null,
};
final result = handler.unwrapEnvelope(data);
expect(result!.payload, isNull);
});
});
group('deduplication memory management', () {
test('respects maxProcessedIds limit', () {
final smallHandler = MessageHandler(maxProcessedIds: 3);
// Add 4 messages
for (var i = 1; i <= 4; i++) {
smallHandler.unwrapEnvelope({
'messageId': 'msg-$i',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
}
// Should only have 3 IDs tracked
expect(smallHandler.processedCount, 3);
// First message should have been evicted (FIFO)
expect(smallHandler.wasProcessed('msg-1'), false);
expect(smallHandler.wasProcessed('msg-2'), true);
expect(smallHandler.wasProcessed('msg-3'), true);
expect(smallHandler.wasProcessed('msg-4'), true);
});
test('allows reprocessing after eviction', () {
final smallHandler = MessageHandler(maxProcessedIds: 2);
// Process msg-1
final first = smallHandler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'first': true},
});
expect(first, isNotNull);
// Process msg-2 and msg-3 to evict msg-1
smallHandler.unwrapEnvelope({
'messageId': 'msg-2',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
smallHandler.unwrapEnvelope({
'messageId': 'msg-3',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
// msg-1 should be processable again
final reprocessed = smallHandler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'reprocessed': true},
});
expect(reprocessed, isNotNull);
expect(reprocessed!.payload, {'reprocessed': true});
});
});
group('wasProcessed', () {
test('returns true for processed message', () {
handler.unwrapEnvelope({
'messageId': 'processed-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
expect(handler.wasProcessed('processed-id'), true);
});
test('returns false for unprocessed message', () {
expect(handler.wasProcessed('unknown-id'), false);
});
});
group('clearProcessedIds', () {
test('removes all tracked message IDs', () {
handler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
handler.unwrapEnvelope({
'messageId': 'msg-2',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
expect(handler.processedCount, 2);
handler.clearProcessedIds();
expect(handler.processedCount, 0);
expect(handler.wasProcessed('msg-1'), false);
expect(handler.wasProcessed('msg-2'), false);
});
test('allows reprocessing cleared messages', () {
final data = {
'messageId': 'cleared-msg',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'value': 1},
};
handler.unwrapEnvelope(data);
handler.clearProcessedIds();
final result = handler.unwrapEnvelope(data);
expect(result, isNotNull);
});
});
group('without onAckRequired callback', () {
test('handles duplicate gracefully when no callback set', () {
final noCallbackHandler = MessageHandler();
final data = {
'messageId': 'no-callback-msg',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
'requiresAck': true,
};
noCallbackHandler.unwrapEnvelope(data);
// Should not throw when processing duplicate
expect(() => noCallbackHandler.unwrapEnvelope(data), returnsNormally);
});
});
});
}

View File

@@ -1,401 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/message_handler.dart';
import 'package:votianlt_app/services/ack_tracker.dart';
/// Integration tests simulating the full MQTT message flow
/// based on real app behavior from login through job loading.
void main() {
group('MQTT Integration Scenarios', () {
group('Login Flow', () {
test('complete login flow with message tracking and ACK handling', () async {
// === SETUP ===
final acksSent = <String>[];
final retriedMessages = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retriedMessages.add(topic);
return true;
},
onTimeout: (messageId, topic) {},
);
const appId = '410cea21-a3cf-47c3-97ad-b1c01f0bacbb';
const userId = '693fdcc757853e744d2ab0d5';
// === STEP 1: Send Login Request ===
// App sends login message with envelope
const loginMessageId = '5f4907de-684e-4381-a771-bd4d7ecdddbd';
const loginTopic = '/server/login';
final loginPayload = '''{
"messageId": "$loginMessageId",
"timestamp": "2026-01-13T11:13:10.275836",
"topic": "$loginTopic",
"payload": {
"email": "mail@svencarstensen.de",
"password": "secret"
},
"requiresAck": true,
"retryCount": 0
}''';
// Track the sent login message for ACK
ackTracker.track(loginMessageId, loginTopic, loginPayload);
expect(ackTracker.isPending(loginMessageId), true);
expect(ackTracker.pendingCount, 1);
// === STEP 2: Receive Auth Response ===
// Server sends auth response with envelope
const authResponseMessageId = '9d867b3a-fe87-40d7-80bf-56b031beb76d';
final authResponseEnvelope = {
'messageId': authResponseMessageId,
'timestamp': '2026-01-13T11:13:10.607327',
'topic': '/client/$appId/auth',
'payload': {'success': true, 'message': 'Anmeldung erfolgreich', 'token': null, 'userId': null, 'appUserId': userId},
'requiresAck': true,
'retryCount': 0,
};
// Unwrap and process the auth response
final authResult = messageHandler.unwrapEnvelope(authResponseEnvelope);
expect(authResult, isNotNull);
expect(authResult!.messageId, authResponseMessageId);
expect(authResult.requiresAck, true);
expect(authResult.payload['success'], true);
expect(authResult.payload['appUserId'], userId);
// Message was processed, now marked as seen
expect(messageHandler.wasProcessed(authResponseMessageId), true);
// === STEP 3: Auth Response acts as implicit ACK for login ===
// The auth response itself confirms the login was received
ackTracker.clearForTopic('/server/login');
expect(ackTracker.isPending(loginMessageId), false);
expect(ackTracker.pendingCount, 0);
// === STEP 4: Send ACK for auth response ===
// Simulate sending ACK (callback would be called after processing)
acksSent.add(authResponseMessageId);
expect(acksSent, contains(authResponseMessageId));
// === STEP 5: Verify duplicate auth response is ignored ===
final duplicateResult = messageHandler.unwrapEnvelope(authResponseEnvelope);
expect(duplicateResult, isNull); // Duplicate returns null
// But ACK is still triggered for duplicate (via callback)
expect(acksSent.where((id) => id == authResponseMessageId).length, 2);
});
test('login request retry when no ACK received', () async {
final retriedTopics = <String>[];
final timedOutMessages = <String>[];
final ackTracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retriedTopics.add(topic);
return true;
},
onTimeout: (messageId, topic) {
timedOutMessages.add(messageId);
},
);
const loginMessageId = 'login-no-ack-test';
const loginTopic = '/server/login';
// Track login message
ackTracker.track(loginMessageId, loginTopic, '{"test": true}');
// Simulate 4 retry cycles (5 seconds each in real app)
for (var i = 1; i <= 4; i++) {
await ackTracker.processRetries();
expect(retriedTopics.length, i);
expect(ackTracker.isPending(loginMessageId), true);
}
// 5th cycle should timeout
await ackTracker.processRetries();
expect(timedOutMessages, contains(loginMessageId));
expect(ackTracker.isPending(loginMessageId), false);
});
});
group('Job Loading Flow', () {
test('complete job loading flow with request, ACK, and response', () async {
// === SETUP ===
final acksSent = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(maxRetries: 4, onRetry: (topic, payload) async => true, onTimeout: (messageId, topic) {});
const userId = '693fdcc757853e744d2ab0d5';
// === STEP 1: Send Jobs Request ===
const jobsRequestMessageId = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
const jobsRequestTopic = '/server/$userId/jobs/assigned';
ackTracker.track(jobsRequestMessageId, jobsRequestTopic, '{"messageId": "$jobsRequestMessageId"}');
expect(ackTracker.isPending(jobsRequestMessageId), true);
// === STEP 2: Receive ACK for jobs request ===
// Server acknowledges our request
final jobsRequestAckEnvelope = {
'messageId': 'ack-envelope-id-1',
'timestamp': '2026-01-13T11:13:10.700000',
'topic': '/client/$userId/ack',
'payload': {'messageId': jobsRequestMessageId, 'status': 'RECEIVED', 'timestamp': '2026-01-13T11:13:10.700000', 'clientId': 'server'},
'requiresAck': false,
'retryCount': 0,
};
// Process ACK envelope
final ackResult = messageHandler.unwrapEnvelope(jobsRequestAckEnvelope);
expect(ackResult, isNotNull);
expect(ackResult!.requiresAck, false); // ACKs don't need ACKs
// Remove from pending based on ACK content
final ackPayload = ackResult.payload as Map<String, dynamic>;
final acknowledgedMessageId = ackPayload['messageId'] as String;
ackTracker.acknowledge(acknowledgedMessageId);
expect(ackTracker.isPending(jobsRequestMessageId), false);
// === STEP 3: Receive Jobs Response ===
const jobsResponseMessageId = 'c92ef207-65a5-4204-802c-99e097f833f5';
final jobsResponseEnvelope = {
'messageId': jobsResponseMessageId,
'timestamp': '2026-01-13T11:13:10.800000',
'topic': '/client/$userId/jobs',
'payload': [
{
'job': {'jobNumber': 'JOB20260106001', 'status': 'CREATED', 'id': '695ce8faf3fbbd0c2acfdb17', 'pickupCompany': 'cAPPacity GmbH', 'deliveryCompany': 'cAPPacity GmbH', 'pickupCity': 'Taarstedt', 'deliveryCity': 'Taarstedt'},
'cargoItems': [
{'description': 'Europalette', 'quantity': 1, 'id': '695ce8faf3fbbd0c2acfdb18'},
],
'tasks': [
{'taskType': 'CONFIRMATION', 'taskOrder': 0, 'completed': false, 'buttonText': 'TEST', 'displayName': 'Bestätigung', 'id': '695ce8faf3fbbd0c2acfdb19'},
],
},
],
'requiresAck': true,
'retryCount': 0,
};
// Process jobs response
final jobsResult = messageHandler.unwrapEnvelope(jobsResponseEnvelope);
expect(jobsResult, isNotNull);
expect(jobsResult!.messageId, jobsResponseMessageId);
expect(jobsResult.requiresAck, true);
expect(jobsResult.payload, isList);
expect((jobsResult.payload as List).length, 1);
// Verify job data
final jobData = (jobsResult.payload as List)[0] as Map<String, dynamic>;
expect(jobData['job']['jobNumber'], 'JOB20260106001');
expect(jobData['cargoItems'].length, 1);
expect(jobData['tasks'].length, 1);
// === STEP 4: Send ACK for jobs response ===
acksSent.add(jobsResponseMessageId);
expect(acksSent, contains(jobsResponseMessageId));
expect(messageHandler.wasProcessed(jobsResponseMessageId), true);
// === STEP 5: Verify duplicate jobs response is handled ===
final duplicateJobsResult = messageHandler.unwrapEnvelope(jobsResponseEnvelope);
expect(duplicateJobsResult, isNull);
// Duplicate triggers ACK callback
expect(acksSent.where((id) => id == jobsResponseMessageId).length, 2);
});
test('multiple concurrent job requests with independent ACK tracking', () async {
final ackTracker = AckTracker(maxRetries: 4);
const userId = '693fdcc757853e744d2ab0d5';
// Two JobsView instances send requests (as seen in log)
const request1Id = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
const request2Id = '936161ce-6535-4098-9e12-0e7bfb9393ed';
const topic = '/server/$userId/jobs/assigned';
ackTracker.track(request1Id, topic, '{}');
ackTracker.track(request2Id, topic, '{}');
expect(ackTracker.pendingCount, 2);
expect(ackTracker.isPending(request1Id), true);
expect(ackTracker.isPending(request2Id), true);
// Both ACKs arrive
ackTracker.acknowledge(request1Id);
expect(ackTracker.pendingCount, 1);
expect(ackTracker.isPending(request1Id), false);
expect(ackTracker.isPending(request2Id), true);
ackTracker.acknowledge(request2Id);
expect(ackTracker.pendingCount, 0);
});
test('ping messages without envelope are handled correctly', () {
final messageHandler = MessageHandler();
// Ping messages come without envelope wrapper
final pingData = {'type': 'ping', 'timestamp': '2026-01-13T11:13:15.000000'};
final result = messageHandler.unwrapEnvelope(pingData);
expect(result, isNotNull);
expect(result!.messageId, isNull); // No messageId for non-envelope
expect(result.requiresAck, false); // No ACK needed
expect(result.payload, pingData); // Returns data as-is
});
});
group('Message Deduplication Across Sessions', () {
test('prevents reprocessing of already-seen messages', () {
final processedPayloads = <String>[];
final acksSent = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
const messageId = 'duplicate-test-id';
final envelope = {
'messageId': messageId,
'timestamp': '2026-01-13T11:13:10.000000',
'topic': '/client/user/jobs',
'payload': {'jobNumber': 'JOB001'},
'requiresAck': true,
};
// First processing
final result1 = messageHandler.unwrapEnvelope(envelope);
if (result1 != null) {
processedPayloads.add(result1.payload['jobNumber']);
}
// Simulate server retry (same message arrives again)
final result2 = messageHandler.unwrapEnvelope(envelope);
if (result2 != null) {
processedPayloads.add(result2.payload['jobNumber']);
}
// Third arrival
final result3 = messageHandler.unwrapEnvelope(envelope);
if (result3 != null) {
processedPayloads.add(result3.payload['jobNumber']);
}
// Payload should only be processed once
expect(processedPayloads.length, 1);
expect(processedPayloads.first, 'JOB001');
// But ACK should be sent for each duplicate
expect(acksSent.length, 2); // Only duplicates trigger callback
});
});
group('Full Session Flow', () {
test('simulates complete app session from login to job receipt', () async {
// === SETUP ===
final processedMessages = <String, dynamic>{};
final acksSent = <String>[];
final pendingAcks = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(maxRetries: 4, onRetry: (topic, payload) async => true);
const appId = '410cea21-a3cf-47c3-97ad-b1c01f0bacbb';
const userId = '693fdcc757853e744d2ab0d5';
// === PHASE 1: LOGIN ===
const loginMsgId = '5f4907de-684e-4381-a771-bd4d7ecdddbd';
ackTracker.track(loginMsgId, '/server/login', '{}');
pendingAcks.add(loginMsgId);
expect(ackTracker.pendingCount, 1);
// Auth response arrives
const authMsgId = '9d867b3a-fe87-40d7-80bf-56b031beb76d';
final authResult = messageHandler.unwrapEnvelope({
'messageId': authMsgId,
'timestamp': '2026-01-13T11:13:10.607327',
'topic': '/client/$appId/auth',
'payload': {'success': true, 'appUserId': userId},
'requiresAck': true,
});
processedMessages['auth'] = authResult!.payload;
acksSent.add(authMsgId); // Send ACK for auth
// Login implicitly ACKed by auth response
ackTracker.clearForTopic('/server/login');
pendingAcks.remove(loginMsgId);
expect(ackTracker.pendingCount, 0);
expect(processedMessages['auth']['success'], true);
// === PHASE 2: REQUEST JOBS ===
const jobsReqMsgId = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
ackTracker.track(jobsReqMsgId, '/server/$userId/jobs/assigned', '{}');
pendingAcks.add(jobsReqMsgId);
expect(ackTracker.pendingCount, 1);
// ACK for jobs request arrives
ackTracker.acknowledge(jobsReqMsgId);
pendingAcks.remove(jobsReqMsgId);
expect(ackTracker.pendingCount, 0);
// === PHASE 3: RECEIVE JOBS ===
const jobsMsgId = 'c92ef207-65a5-4204-802c-99e097f833f5';
final jobsResult = messageHandler.unwrapEnvelope({
'messageId': jobsMsgId,
'timestamp': '2026-01-13T11:13:10.800000',
'topic': '/client/$userId/jobs',
'payload': [
{
'job': {'jobNumber': 'JOB20260106001', 'id': '695ce8faf3fbbd0c2acfdb17'},
'tasks': [
{'taskType': 'CONFIRMATION', 'id': '695ce8faf3fbbd0c2acfdb19'},
],
},
],
'requiresAck': true,
});
processedMessages['jobs'] = jobsResult!.payload;
acksSent.add(jobsMsgId); // Send ACK for jobs
// === VERIFY FINAL STATE ===
expect(processedMessages.length, 2);
expect(acksSent.length, 2);
expect(pendingAcks, isEmpty);
expect(ackTracker.pendingCount, 0);
// Verify processed data
expect(processedMessages['auth']['appUserId'], userId);
expect((processedMessages['jobs'] as List).length, 1);
expect((processedMessages['jobs'] as List)[0]['job']['jobNumber'], 'JOB20260106001');
// Verify deduplication state
expect(messageHandler.wasProcessed(authMsgId), true);
expect(messageHandler.wasProcessed(jobsMsgId), true);
expect(messageHandler.processedCount, 2);
});
});
});
}

View File

@@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/cargo_items_view.dart';
import 'package:votianlt_app/models/delivery_station.dart';
import 'package:votianlt_app/models/tasks/confirmation_task.dart';
void main() {
group('deliveryStationCardBackgroundColor', () {
DeliveryStation buildStation(List<ConfirmationTask> tasks) {
return DeliveryStation(
stationOrder: 0,
company: 'ACME',
salutation: null,
firstName: 'Max',
lastName: 'Mustermann',
phone: '12345',
street: 'Musterstrasse',
houseNumber: '1',
addressAddition: '',
zip: '12345',
city: 'Berlin',
deliveryDate: '2026-03-10',
deliveryTime: '10:00',
tasks: tasks,
);
}
ConfirmationTask buildTask(String id, {bool completed = false}) {
return ConfirmationTask(
id: id,
jobId: 'job-1',
buttonText: 'Bestaetigen',
completed: completed,
);
}
test('returns light green when all station tasks are completed', () {
final station = buildStation([
buildTask('task-1', completed: true),
buildTask('task-2', completed: true),
]);
final color = deliveryStationCardBackgroundColor(station, const {});
expect(color, Colors.green[50]);
});
test('returns null when only some tasks are completed', () {
final station = buildStation([
buildTask('task-1', completed: true),
buildTask('task-2'),
]);
final color = deliveryStationCardBackgroundColor(station, const {});
expect(color, isNull);
});
test('returns null when no tasks are completed', () {
final station = buildStation([buildTask('task-1'), buildTask('task-2')]);
final color = deliveryStationCardBackgroundColor(station, const {});
expect(color, isNull);
});
test('returns null when station has no tasks', () {
final station = buildStation(const []);
final color = deliveryStationCardBackgroundColor(station, const {});
expect(color, isNull);
});
test('prefers local task status over incomplete task payload', () {
final station = buildStation([buildTask('task-1'), buildTask('task-2')]);
final color = deliveryStationCardBackgroundColor(station, const {
'task-1': true,
'task-2': true,
});
expect(color, Colors.green[50]);
});
});
}

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.13</revision> <revision>0.9.14</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>

View File

@@ -46,6 +46,7 @@ public class DeliveryStationDialog extends Dialog {
private String firstName; private String firstName;
private String lastName; private String lastName;
private String phone; private String phone;
private String mail;
private String street; private String street;
private String houseNumber; private String houseNumber;
private String addressAddition; private String addressAddition;
@@ -112,6 +113,14 @@ public class DeliveryStationDialog extends Dialog {
this.phone = phone; this.phone = phone;
} }
public String getMail() {
return mail;
}
public void setMail(String mail) {
this.mail = mail;
}
public String getStreet() { public String getStreet() {
return street; return street;
} }
@@ -185,6 +194,7 @@ public class DeliveryStationDialog extends Dialog {
private final TextField firstName; private final TextField firstName;
private final TextField lastName; private final TextField lastName;
private final TextField phone; private final TextField phone;
private final TextField mail;
private final TextField street; private final TextField street;
private final TextField houseNumber; private final TextField houseNumber;
private final TextField addressAddition; private final TextField addressAddition;
@@ -258,6 +268,12 @@ public class DeliveryStationDialog extends Dialog {
phone.setWidthFull(); phone.setWidthFull();
formLayout.add(phone); formLayout.add(phone);
// E-Mail
mail = new TextField(translationHelper.getTranslation("customers.column.email"));
mail.setPlaceholder(translationHelper.getTranslation("customers.column.email"));
mail.setWidthFull();
formLayout.add(mail);
// Street + house number // Street + house number
street = new TextField(translationHelper.getTranslation("profile.street")); street = new TextField(translationHelper.getTranslation("profile.street"));
street.setPlaceholder(translationHelper.getTranslation("profile.street")); street.setPlaceholder(translationHelper.getTranslation("profile.street"));
@@ -307,12 +323,41 @@ public class DeliveryStationDialog extends Dialog {
// Clear error styling on value change for required fields and update tab // Clear error styling on value change for required fields and update tab
// indicators // indicators
firstName.addValueChangeListener(ev -> validateRequiredFields()); firstName.addValueChangeListener(ev -> {
lastName.addValueChangeListener(ev -> validateRequiredFields()); validateRequiredFields();
street.addValueChangeListener(ev -> validateRequiredFields()); updateSaveAddressState();
houseNumber.addValueChangeListener(ev -> validateRequiredFields()); });
zip.addValueChangeListener(ev -> validateRequiredFields()); lastName.addValueChangeListener(ev -> {
city.addValueChangeListener(ev -> validateRequiredFields()); validateRequiredFields();
updateSaveAddressState();
});
street.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
houseNumber.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
zip.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
city.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
salutation.addValueChangeListener(ev -> updateSaveAddressState());
phone.addValueChangeListener(ev -> updateSaveAddressState());
addressAddition.addValueChangeListener(ev -> updateSaveAddressState());
mail.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
saveAddress.addValueChangeListener(ev -> {
updateMailRequirement();
validateRequiredFields();
});
// TabSheet with address and tasks tabs // TabSheet with address and tasks tabs
TabSheet tabSheet = new TabSheet(); TabSheet tabSheet = new TabSheet();
@@ -453,6 +498,10 @@ public class DeliveryStationDialog extends Dialog {
lastName.setValue(data.getLastName()); lastName.setValue(data.getLastName());
if (data.getPhone() != null) if (data.getPhone() != null)
phone.setValue(data.getPhone()); phone.setValue(data.getPhone());
if (data.getMail() != null)
mail.setValue(data.getMail());
else
mail.clear();
if (data.getStreet() != null) if (data.getStreet() != null)
street.setValue(data.getStreet()); street.setValue(data.getStreet());
if (data.getHouseNumber() != null) if (data.getHouseNumber() != null)
@@ -464,7 +513,7 @@ public class DeliveryStationDialog extends Dialog {
if (data.getCity() != null) if (data.getCity() != null)
city.setValue(data.getCity()); city.setValue(data.getCity());
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress()); saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState(customerSelectedFromOptions); updateSaveAddressState();
// Load tasks into dialog state // Load tasks into dialog state
if (data.getTasks() != null && !data.getTasks().isEmpty()) { if (data.getTasks() != null && !data.getTasks().isEmpty()) {
@@ -482,6 +531,7 @@ public class DeliveryStationDialog extends Dialog {
} }
} }
} }
} }
private DeliveryData collectData() { private DeliveryData collectData() {
@@ -491,6 +541,7 @@ public class DeliveryStationDialog extends Dialog {
data.setFirstName(firstName.getValue()); data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue()); data.setLastName(lastName.getValue());
data.setPhone(phone.getValue()); data.setPhone(phone.getValue());
data.setMail(mail.getValue());
data.setStreet(street.getValue()); data.setStreet(street.getValue());
data.setHouseNumber(houseNumber.getValue()); data.setHouseNumber(houseNumber.getValue());
data.setAddressAddition(addressAddition.getValue()); data.setAddressAddition(addressAddition.getValue());
@@ -510,6 +561,7 @@ public class DeliveryStationDialog extends Dialog {
addressValid &= validateTextField(houseNumber); addressValid &= validateTextField(houseNumber);
addressValid &= validateTextField(zip); addressValid &= validateTextField(zip);
addressValid &= validateTextField(city); addressValid &= validateTextField(city);
addressValid &= validateMailField();
addressTabError.setVisible(!addressValid); addressTabError.setVisible(!addressValid);
// Tasks tab validation // Tasks tab validation
@@ -545,6 +597,17 @@ public class DeliveryStationDialog extends Dialog {
return !empty; return !empty;
} }
private boolean validateMailField() {
String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
}
private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) { private void applyErrorStyling(com.vaadin.flow.component.Component field, boolean error) {
if (error) { if (error) {
field.getElement().getStyle().set("--vaadin-input-field-background", "rgba(255, 0, 0, 0.1)"); field.getElement().getStyle().set("--vaadin-input-field-background", "rgba(255, 0, 0, 0.1)");
@@ -584,8 +647,8 @@ public class DeliveryStationDialog extends Dialog {
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
Customer customer = companyAddressOptions.get(event.getValue()); Customer customer = companyAddressOptions.get(event.getValue());
updateSaveAddressState(customer != null);
if (customer == null) { if (customer == null) {
updateSaveAddressState();
return; return;
} }
@@ -600,6 +663,8 @@ public class DeliveryStationDialog extends Dialog {
lastName.setValue(customer.getLastName()); lastName.setValue(customer.getLastName());
if (customer.getTelephone() != null) if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone()); phone.setValue(customer.getTelephone());
if (customer.getMail() != null)
mail.setValue(customer.getMail());
if (customer.getStreet() != null) if (customer.getStreet() != null)
street.setValue(customer.getStreet()); street.setValue(customer.getStreet());
if (customer.getHouseNumber() != null) if (customer.getHouseNumber() != null)
@@ -610,22 +675,32 @@ public class DeliveryStationDialog extends Dialog {
zip.setValue(customer.getZip()); zip.setValue(customer.getZip());
if (customer.getCity() != null) if (customer.getCity() != null)
city.setValue(customer.getCity()); city.setValue(customer.getCity());
updateSaveAddressState();
}); });
companyField.addCustomValueSetListener(event -> { companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail()); companyField.setValue(event.getDetail());
updateSaveAddressState(false); updateSaveAddressState();
}); });
} }
private void updateSaveAddressState(boolean customerSelectedFromOptions) { private void updateSaveAddressState() {
Customer selectedCustomer = companyAddressOptions.get(company.getValue());
boolean customerSelectedFromOptions = selectedCustomer != null && matchesCurrentCustomer(selectedCustomer);
if (customerSelectedFromOptions) { if (customerSelectedFromOptions) {
saveAddress.setValue(false); saveAddress.setValue(false);
saveAddress.setEnabled(false); saveAddress.setEnabled(false);
updateMailRequirement();
return; return;
} }
saveAddress.setEnabled(true); saveAddress.setEnabled(true);
updateMailRequirement();
}
private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
} }
private String buildCompanyAddressLabel(Customer customer) { private String buildCompanyAddressLabel(Customer customer) {
@@ -687,12 +762,32 @@ public class DeliveryStationDialog extends Dialog {
private boolean matchesCustomer(Customer customer, DeliveryData data) { private boolean matchesCustomer(Customer customer, DeliveryData data) {
return equalsNormalized(customer.getCompanyName(), data.getCompany()) return equalsNormalized(customer.getCompanyName(), data.getCompany())
&& equalsNormalized(customer.getTitle(), data.getSalutation())
&& equalsNormalized(customer.getFirstname(), data.getFirstName())
&& equalsNormalized(customer.getLastName(), data.getLastName())
&& equalsNormalized(customer.getTelephone(), data.getPhone())
&& equalsNormalized(customer.getMail(), data.getMail())
&& equalsNormalized(customer.getStreet(), data.getStreet()) && equalsNormalized(customer.getStreet(), data.getStreet())
&& equalsNormalized(customer.getAddressAddition(), data.getAddressAddition())
&& equalsNormalized(customer.getHouseNumber(), data.getHouseNumber()) && equalsNormalized(customer.getHouseNumber(), data.getHouseNumber())
&& equalsNormalized(customer.getZip(), data.getZip()) && equalsNormalized(customer.getZip(), data.getZip())
&& equalsNormalized(customer.getCity(), data.getCity()); && equalsNormalized(customer.getCity(), data.getCity());
} }
private boolean matchesCurrentCustomer(Customer customer) {
return equalsNormalized(customer.getCompanyName(), resolveCompanyValue(company.getValue()))
&& equalsNormalized(customer.getTitle(), salutation.getValue())
&& equalsNormalized(customer.getFirstname(), firstName.getValue())
&& equalsNormalized(customer.getLastName(), lastName.getValue())
&& equalsNormalized(customer.getTelephone(), phone.getValue())
&& equalsNormalized(customer.getMail(), mail.getValue())
&& equalsNormalized(customer.getStreet(), street.getValue())
&& equalsNormalized(customer.getAddressAddition(), addressAddition.getValue())
&& equalsNormalized(customer.getHouseNumber(), houseNumber.getValue())
&& equalsNormalized(customer.getZip(), zip.getValue())
&& equalsNormalized(customer.getCity(), city.getValue());
}
private boolean equalsNormalized(String left, String right) { private boolean equalsNormalized(String left, String right) {
String normalizedLeft = left != null ? left.trim() : ""; String normalizedLeft = left != null ? left.trim() : "";
String normalizedRight = right != null ? right.trim() : ""; String normalizedRight = right != null ? right.trim() : "";
@@ -756,8 +851,9 @@ public class DeliveryStationDialog extends Dialog {
tasksList.setPadding(false); tasksList.setPadding(false);
tasksList.setSpacing(true); tasksList.setSpacing(true);
// Add 1 example row // Add 1 example row, then append a signature task once on initial setup
createTaskRow(); createTaskRow();
ensureTrailingSignatureTask();
Button addTaskBtn = new Button(translationHelper.getTranslation("addjob.tasks.add"), new Icon(VaadinIcon.PLUS)); Button addTaskBtn = new Button(translationHelper.getTranslation("addjob.tasks.add"), new Icon(VaadinIcon.PLUS));
addTaskBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); addTaskBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -791,14 +887,7 @@ public class DeliveryStationDialog extends Dialog {
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> { deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
int idx = tasksList.getChildren().toList().indexOf(taskContainer);
if (idx >= 0 && idx < tasksState.size()) {
tasksState.remove(idx);
reorderTasksAfterDeletion();
}
tasksList.remove(taskContainer);
});
taskContainer.add(taskTypeCombo, configContainer); taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton); taskContainer.add(deleteXButton);
@@ -810,6 +899,9 @@ public class DeliveryStationDialog extends Dialog {
final BaseTask[] currentTask = { task }; final BaseTask[] currentTask = { task };
taskTypeCombo.setValue(TaskType.CONFIRMATION);
updateTaskConfiguration(configContainer, currentTask[0]);
taskTypeCombo.addValueChangeListener(ev -> { taskTypeCombo.addValueChangeListener(ev -> {
TaskType selectedType = ev.getValue(); TaskType selectedType = ev.getValue();
if (selectedType != null) { if (selectedType != null) {
@@ -856,11 +948,8 @@ public class DeliveryStationDialog extends Dialog {
} }
}); });
// Set initial configuration
taskTypeCombo.setValue(TaskType.CONFIRMATION);
updateTaskConfiguration(configContainer, currentTask[0]);
tasksList.add(taskContainer); tasksList.add(taskContainer);
updateTaskDeleteAvailability();
} }
private void createTaskRowFromTask(BaseTask task) { private void createTaskRowFromTask(BaseTask task) {
@@ -882,14 +971,7 @@ public class DeliveryStationDialog extends Dialog {
Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL));
deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClassName("dialog-floating-delete");
deleteXButton.addClickListener(e -> { deleteXButton.addClickListener(e -> removeTaskRow(taskContainer));
int idx = tasksList.getChildren().toList().indexOf(taskContainer);
if (idx >= 0 && idx < tasksState.size()) {
tasksState.remove(idx);
reorderTasksAfterDeletion();
}
tasksList.remove(taskContainer);
});
taskContainer.add(taskTypeCombo, configContainer); taskContainer.add(taskTypeCombo, configContainer);
taskContainer.add(deleteXButton); taskContainer.add(deleteXButton);
@@ -951,6 +1033,7 @@ public class DeliveryStationDialog extends Dialog {
updateTaskConfiguration(configContainer, task); updateTaskConfiguration(configContainer, task);
tasksList.add(taskContainer); tasksList.add(taskContainer);
updateTaskDeleteAvailability();
} }
private BaseTask createTaskByType(TaskType taskType) { private BaseTask createTaskByType(TaskType taskType) {
@@ -973,6 +1056,56 @@ public class DeliveryStationDialog extends Dialog {
} }
} }
private void removeTaskRow(VerticalLayout taskContainer) {
if (tasksList == null) {
return;
}
List<com.vaadin.flow.component.Component> taskRows = tasksList.getChildren().toList();
int idx = taskRows.indexOf(taskContainer);
if (idx < 0) {
return;
}
if (idx < tasksState.size()) {
tasksState.remove(idx);
}
tasksList.remove(taskContainer);
reorderTasksAfterDeletion();
updateTaskDeleteAvailability();
}
private void ensureTrailingSignatureTask() {
BaseTask lastTask = tasksState.isEmpty() ? null : tasksState.get(tasksState.size() - 1);
if (lastTask instanceof SignatureTask) {
return;
}
SignatureTask signatureTask = new SignatureTask();
signatureTask.setTaskOrder(tasksState.size());
tasksState.add(signatureTask);
if (tasksList != null) {
createTaskRowFromTask(signatureTask);
}
}
private void updateTaskDeleteAvailability() {
if (tasksList == null) {
return;
}
boolean deletable = tasksList.getChildren().count() > 1;
tasksList.getChildren()
.filter(VerticalLayout.class::isInstance)
.map(VerticalLayout.class::cast)
.forEach(taskContainer -> taskContainer.getChildren()
.filter(Button.class::isInstance)
.map(Button.class::cast)
.filter(button -> button.getClassNames().contains("dialog-floating-delete"))
.findFirst()
.ifPresent(button -> button.setEnabled(deletable)));
}
private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) { private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {
configContainer.removeAll(); configContainer.removeAll();
@@ -1315,16 +1448,22 @@ public class DeliveryStationDialog extends Dialog {
} }
private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) { private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) {
ConfirmDialog confirmDialog = new ConfirmDialog(); Dialog confirmDialog = DialogStylingHelper
confirmDialog.setHeader(translationHelper.getTranslation("addjob.tasks.template.load.title")); .createStyledDialog(translationHelper.getTranslation("addjob.tasks.template.load.title"), "560px");
confirmDialog.setText( confirmDialog.setCloseOnOutsideClick(false);
translationHelper.getTranslation("addjob.tasks.template.load.text", template.getTemplateName())); confirmDialog.addDialogCloseActionListener(e -> templateComboBox.clear());
confirmDialog.setCancelable(true);
confirmDialog.setCancelText(translationHelper.getTranslation("button.cancel"));
confirmDialog.setConfirmText(translationHelper.getTranslation("addjob.tasks.template.load.confirm"));
confirmDialog.setConfirmButtonTheme("primary");
confirmDialog.addConfirmListener(e -> { VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("420px");
dialogContent.add(new Span(
translationHelper.getTranslation("addjob.tasks.template.load.text", template.getTemplateName())));
Button cancelButton = new Button(translationHelper.getTranslation("button.cancel"), e -> {
templateComboBox.clear();
confirmDialog.close();
});
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmButton = new Button(translationHelper.getTranslation("addjob.tasks.template.load.confirm"), e -> {
tasksState.clear(); tasksState.clear();
tasksList.removeAll(); tasksList.removeAll();
@@ -1338,16 +1477,19 @@ public class DeliveryStationDialog extends Dialog {
} }
} }
ensureTrailingSignatureTask();
templateComboBox.clear(); templateComboBox.clear();
validateRequiredFields(); validateRequiredFields();
Notification.show( Notification.show(
translationHelper.getTranslation("addjob.tasks.template.loaded", template.getTemplateName()), 3000, translationHelper.getTranslation("addjob.tasks.template.loaded", template.getTemplateName()), 3000,
Notification.Position.BOTTOM_END); Notification.Position.BOTTOM_END);
confirmDialog.close();
}); });
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
confirmDialog.addCancelListener(e -> templateComboBox.clear()); confirmDialog.add(DialogStylingHelper.wrapContent(dialogContent));
confirmDialog.getFooter().add(cancelButton, confirmButton);
confirmDialog.open(); confirmDialog.open();
} }
} }

View File

@@ -54,6 +54,7 @@ public class PickupStationDialog extends Dialog {
private String firstName; private String firstName;
private String lastName; private String lastName;
private String phone; private String phone;
private String mail;
private String street; private String street;
private String houseNumber; private String houseNumber;
private String addressAddition; private String addressAddition;
@@ -125,6 +126,14 @@ public class PickupStationDialog extends Dialog {
this.phone = phone; this.phone = phone;
} }
public String getMail() {
return mail;
}
public void setMail(String mail) {
this.mail = mail;
}
public String getStreet() { public String getStreet() {
return street; return street;
} }
@@ -231,6 +240,7 @@ public class PickupStationDialog extends Dialog {
private final TextField firstName; private final TextField firstName;
private final TextField lastName; private final TextField lastName;
private final TextField phone; private final TextField phone;
private final TextField mail;
private final TextField street; private final TextField street;
private final TextField houseNumber; private final TextField houseNumber;
private final TextField addressAddition; private final TextField addressAddition;
@@ -239,6 +249,8 @@ public class PickupStationDialog extends Dialog {
private final Checkbox saveAddress; private final Checkbox saveAddress;
private final ComboBox<String> customerComboBox; private final ComboBox<String> customerComboBox;
private final Map<String, Customer> customerLabelMap = new LinkedHashMap<>();
private final Map<String, Customer> companyCustomerMap = new LinkedHashMap<>();
private DatePicker appointmentDatePicker; private DatePicker appointmentDatePicker;
private TimePicker appointmentTimePicker; private TimePicker appointmentTimePicker;
private Checkbox digitalProcessingCheckbox; private Checkbox digitalProcessingCheckbox;
@@ -278,7 +290,7 @@ public class PickupStationDialog extends Dialog {
customerComboBox.setRequiredIndicatorVisible(true); customerComboBox.setRequiredIndicatorVisible(true);
customerComboBox.setWidthFull(); customerComboBox.setWidthFull();
Map<String, Customer> customerLabelMap = new LinkedHashMap<>(); customerLabelMap.clear();
for (Customer c : customers) { for (Customer c : customers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank()) String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
? c.getCompanyName() + " | " ? c.getCompanyName() + " | "
@@ -330,6 +342,11 @@ public class PickupStationDialog extends Dialog {
phone.setPlaceholder(translationHelper.getTranslation("profile.phone")); phone.setPlaceholder(translationHelper.getTranslation("profile.phone"));
phone.setWidthFull(); phone.setWidthFull();
// E-Mail
mail = new TextField(translationHelper.getTranslation("customers.column.email"));
mail.setPlaceholder(translationHelper.getTranslation("customers.column.email"));
mail.setWidthFull();
// Street + house number // Street + house number
street = new TextField(translationHelper.getTranslation("profile.street")); street = new TextField(translationHelper.getTranslation("profile.street"));
street.setPlaceholder(translationHelper.getTranslation("profile.street")); street.setPlaceholder(translationHelper.getTranslation("profile.street"));
@@ -375,22 +392,54 @@ public class PickupStationDialog extends Dialog {
// Clear error styling on value change for required fields and update tab // Clear error styling on value change for required fields and update tab
// indicators // indicators
firstName.addValueChangeListener(ev -> validateRequiredFields()); firstName.addValueChangeListener(ev -> {
lastName.addValueChangeListener(ev -> validateRequiredFields()); validateRequiredFields();
street.addValueChangeListener(ev -> validateRequiredFields()); updateSaveAddressState();
houseNumber.addValueChangeListener(ev -> validateRequiredFields()); });
zip.addValueChangeListener(ev -> validateRequiredFields()); lastName.addValueChangeListener(ev -> {
city.addValueChangeListener(ev -> validateRequiredFields()); validateRequiredFields();
updateSaveAddressState();
});
street.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
houseNumber.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
zip.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
city.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
salutation.addValueChangeListener(ev -> updateSaveAddressState());
phone.addValueChangeListener(ev -> updateSaveAddressState());
addressAddition.addValueChangeListener(ev -> updateSaveAddressState());
mail.addValueChangeListener(ev -> {
validateRequiredFields();
updateSaveAddressState();
});
saveAddress.addValueChangeListener(ev -> {
updateMailRequirement();
validateRequiredFields();
});
// Customer selection fills address fields // Customer selection fills address fields
customerComboBox.addValueChangeListener(ev -> { customerComboBox.addValueChangeListener(ev -> {
String selected = ev.getValue(); String selected = ev.getValue();
if (selected == null) if (selected == null) {
updateSaveAddressState();
return; return;
}
Customer c = customerLabelMap.get(selected); Customer c = customerLabelMap.get(selected);
if (c == null) if (c == null) {
updateSaveAddressState();
return; return;
saveAddress.setValue(false); }
if (c.getCompanyName() != null) if (c.getCompanyName() != null)
company.setValue(c.getCompanyName()); company.setValue(c.getCompanyName());
else else
@@ -412,6 +461,10 @@ public class PickupStationDialog extends Dialog {
phone.setValue(c.getTelephone()); phone.setValue(c.getTelephone());
else else
phone.clear(); phone.clear();
if (c.getMail() != null)
mail.setValue(c.getMail());
else
mail.clear();
if (c.getStreet() != null) if (c.getStreet() != null)
street.setValue(c.getStreet()); street.setValue(c.getStreet());
else else
@@ -432,10 +485,12 @@ public class PickupStationDialog extends Dialog {
city.setValue(c.getCity()); city.setValue(c.getCity());
else else
city.clear(); city.clear();
updateSaveAddressState();
}); });
formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, streetLayout, addressAddition, formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, mail, streetLayout,
zipCityLayout, saveAddress); addressAddition, zipCityLayout, saveAddress);
// TabSheet with address, appointments, and cargo tabs // TabSheet with address, appointments, and cargo tabs
TabSheet tabSheet = new TabSheet(); TabSheet tabSheet = new TabSheet();
@@ -564,6 +619,11 @@ public class PickupStationDialog extends Dialog {
public void setData(PickupData data) { public void setData(PickupData data) {
if (data == null) if (data == null)
return; return;
if (data.getCustomerSelection() != null) {
customerComboBox.setValue(data.getCustomerSelection());
} else {
customerComboBox.clear();
}
if (data.getCompany() != null) if (data.getCompany() != null)
company.setValue(data.getCompany()); company.setValue(data.getCompany());
if (data.getSalutation() != null) if (data.getSalutation() != null)
@@ -574,6 +634,10 @@ public class PickupStationDialog extends Dialog {
lastName.setValue(data.getLastName()); lastName.setValue(data.getLastName());
if (data.getPhone() != null) if (data.getPhone() != null)
phone.setValue(data.getPhone()); phone.setValue(data.getPhone());
if (data.getMail() != null)
mail.setValue(data.getMail());
else
mail.clear();
if (data.getStreet() != null) if (data.getStreet() != null)
street.setValue(data.getStreet()); street.setValue(data.getStreet());
if (data.getHouseNumber() != null) if (data.getHouseNumber() != null)
@@ -584,11 +648,6 @@ public class PickupStationDialog extends Dialog {
zip.setValue(data.getZip()); zip.setValue(data.getZip());
if (data.getCity() != null) if (data.getCity() != null)
city.setValue(data.getCity()); city.setValue(data.getCity());
saveAddress.setValue(data.isSaveAddress());
if (data.getCustomerSelection() != null) {
customerComboBox.setValue(data.getCustomerSelection());
}
if (data.getAppointmentDate() != null && appointmentDatePicker != null) { if (data.getAppointmentDate() != null && appointmentDatePicker != null) {
appointmentDatePicker.setValue(data.getAppointmentDate()); appointmentDatePicker.setValue(data.getAppointmentDate());
} }
@@ -608,6 +667,9 @@ public class PickupStationDialog extends Dialog {
addCargoRowWithData(item); addCargoRowWithData(item);
} }
} }
saveAddress.setValue(data.isSaveAddress());
updateSaveAddressState();
} }
private PickupData collectData() { private PickupData collectData() {
@@ -617,6 +679,7 @@ public class PickupStationDialog extends Dialog {
data.setFirstName(firstName.getValue()); data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue()); data.setLastName(lastName.getValue());
data.setPhone(phone.getValue()); data.setPhone(phone.getValue());
data.setMail(mail.getValue());
data.setStreet(street.getValue()); data.setStreet(street.getValue());
data.setHouseNumber(houseNumber.getValue()); data.setHouseNumber(houseNumber.getValue());
data.setAddressAddition(addressAddition.getValue()); data.setAddressAddition(addressAddition.getValue());
@@ -649,6 +712,7 @@ public class PickupStationDialog extends Dialog {
addressValid &= validateTextField(houseNumber); addressValid &= validateTextField(houseNumber);
addressValid &= validateTextField(zip); addressValid &= validateTextField(zip);
addressValid &= validateTextField(city); addressValid &= validateTextField(city);
addressValid &= validateMailField();
if (addressTabError != null) { if (addressTabError != null) {
addressTabError.setVisible(!addressValid); addressTabError.setVisible(!addressValid);
} }
@@ -685,6 +749,17 @@ public class PickupStationDialog extends Dialog {
return !empty; return !empty;
} }
private boolean validateMailField() {
String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty();
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
boolean invalid = !empty && !normalizedValue.contains("@");
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
}
private boolean validateCargoItems() { private boolean validateCargoItems() {
boolean valid = true; boolean valid = true;
if (cargoList == null) if (cargoList == null)
@@ -737,16 +812,25 @@ public class PickupStationDialog extends Dialog {
List<String> companyNames = customers.stream().map(Customer::getCompanyName) List<String> companyNames = customers.stream().map(Customer::getCompanyName)
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); .filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
companyCustomerMap.clear();
for (Customer customer : customers) {
String companyName = normalizeValue(customer.getCompanyName());
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) {
continue;
}
companyCustomerMap.put(companyName, customer);
}
companyField.setItems(companyNames); companyField.setItems(companyNames);
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedCompany = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
updateSaveAddressState();
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream() Optional<Customer> matchingCustomer = customers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst(); .filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) { if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get(); Customer customer = matchingCustomer.get();
@@ -761,6 +845,8 @@ public class PickupStationDialog extends Dialog {
lastName.setValue(customer.getLastName()); lastName.setValue(customer.getLastName());
if (customer.getTelephone() != null) if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone()); phone.setValue(customer.getTelephone());
if (customer.getMail() != null)
mail.setValue(customer.getMail());
if (customer.getStreet() != null) if (customer.getStreet() != null)
street.setValue(customer.getStreet()); street.setValue(customer.getStreet());
if (customer.getHouseNumber() != null) if (customer.getHouseNumber() != null)
@@ -772,9 +858,57 @@ public class PickupStationDialog extends Dialog {
if (customer.getCity() != null) if (customer.getCity() != null)
city.setValue(customer.getCity()); city.setValue(customer.getCity());
} }
updateSaveAddressState();
}); });
companyField.addCustomValueSetListener(event -> companyField.setValue(event.getDetail())); companyField.addCustomValueSetListener(event -> {
companyField.setValue(event.getDetail());
updateSaveAddressState();
});
}
private void updateSaveAddressState() {
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue()));
boolean existingCustomerSelected = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean existingCompanySelected = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
if (existingCustomerSelected || existingCompanySelected) {
saveAddress.setValue(false);
saveAddress.setEnabled(false);
updateMailRequirement();
return;
}
saveAddress.setEnabled(true);
updateMailRequirement();
}
private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
}
private boolean matchesCustomer(Customer customer) {
return sameValue(company.getValue(), customer.getCompanyName())
&& sameValue(salutation.getValue(), customer.getTitle())
&& sameValue(firstName.getValue(), customer.getFirstname())
&& sameValue(lastName.getValue(), customer.getLastName())
&& sameValue(phone.getValue(), customer.getTelephone())
&& sameValue(mail.getValue(), customer.getMail())
&& sameValue(street.getValue(), customer.getStreet())
&& sameValue(houseNumber.getValue(), customer.getHouseNumber())
&& sameValue(addressAddition.getValue(), customer.getAddressAddition())
&& sameValue(zip.getValue(), customer.getZip())
&& sameValue(city.getValue(), customer.getCity());
}
private boolean sameValue(String left, String right) {
return normalizeValue(left).equals(normalizeValue(right));
}
private String normalizeValue(String value) {
return value == null ? "" : value.trim();
} }
// ============================================ // ============================================

View File

@@ -19,6 +19,8 @@ public class AddCustomerService {
} }
public void addCustomer(Customer customer) { public void addCustomer(Customer customer) {
validateCustomer(customer);
// Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session // Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser(); de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
customer.setCreatedBy(currentUser.getId()); customer.setCreatedBy(currentUser.getId());
@@ -26,4 +28,20 @@ public class AddCustomerService {
addCustomerRepository.save(customer); addCustomerRepository.save(customer);
} }
private void validateCustomer(Customer customer) {
if (customer == null) {
throw new IllegalArgumentException("Kunde darf nicht leer sein");
}
String mail = customer.getMail() != null ? customer.getMail().trim() : "";
if (mail.isEmpty()) {
throw new IllegalArgumentException("E-Mail-Adresse ist ein Pflichtfeld");
}
if (!mail.contains("@")) {
throw new IllegalArgumentException("Bitte geben Sie eine gültige E-Mail-Adresse ein");
}
customer.setMail(mail);
}
} }

View File

@@ -131,6 +131,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
private TextField pickupFirstName; private TextField pickupFirstName;
private TextField pickupLastName; private TextField pickupLastName;
private TextField pickupPhone; private TextField pickupPhone;
private String pickupMail;
private TextField pickupStreet; private TextField pickupStreet;
private TextField pickupHouseNumber; private TextField pickupHouseNumber;
private TextField pickupAddressAddition; private TextField pickupAddressAddition;
@@ -143,6 +144,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
private final List<StationTile> deliveryStationTilesList = new ArrayList<>(); private final List<StationTile> deliveryStationTilesList = new ArrayList<>();
private final List<DeliveryStation> deliveryStationsState = new ArrayList<>(); private final List<DeliveryStation> deliveryStationsState = new ArrayList<>();
private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>(); private final List<Boolean> deliveryStationsSaveAddress = new ArrayList<>();
private final List<String> deliveryStationsMailState = new ArrayList<>();
private final List<Div> deliveryStationSlotList = new ArrayList<>(); private final List<Div> deliveryStationSlotList = new ArrayList<>();
private final List<Span> deliveryStationDistanceChips = new ArrayList<>(); private final List<Span> deliveryStationDistanceChips = new ArrayList<>();
private Div stationsGridContainer; private Div stationsGridContainer;
@@ -720,6 +722,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Add empty state for this station // Add empty state for this station
deliveryStationsState.add(new DeliveryStation()); deliveryStationsState.add(new DeliveryStation());
deliveryStationsSaveAddress.add(true); deliveryStationsSaveAddress.add(true);
deliveryStationsMailState.add(null);
deliveryStationsValidatedByGoogle.add(false); deliveryStationsValidatedByGoogle.add(false);
int stationIndex = deliveryStationTilesList.size(); int stationIndex = deliveryStationTilesList.size();
@@ -766,6 +769,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryStationTilesList.remove(removeIdx); deliveryStationTilesList.remove(removeIdx);
deliveryStationsState.remove(removeIdx); deliveryStationsState.remove(removeIdx);
deliveryStationsSaveAddress.remove(removeIdx); deliveryStationsSaveAddress.remove(removeIdx);
deliveryStationsMailState.remove(removeIdx);
deliveryStationsValidatedByGoogle.remove(removeIdx); deliveryStationsValidatedByGoogle.remove(removeIdx);
deliveryStationTasksState.remove(removeIdx); deliveryStationTasksState.remove(removeIdx);
Div removedSlot = deliveryStationSlotList.remove(removeIdx); Div removedSlot = deliveryStationSlotList.remove(removeIdx);
@@ -854,6 +858,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupFirstName.setValue(data.getFirstName() != null ? data.getFirstName() : ""); pickupFirstName.setValue(data.getFirstName() != null ? data.getFirstName() : "");
pickupLastName.setValue(data.getLastName() != null ? data.getLastName() : ""); pickupLastName.setValue(data.getLastName() != null ? data.getLastName() : "");
pickupPhone.setValue(data.getPhone() != null ? data.getPhone() : ""); pickupPhone.setValue(data.getPhone() != null ? data.getPhone() : "");
pickupMail = trimToNull(data.getMail());
pickupStreet.setValue(data.getStreet() != null ? data.getStreet() : ""); pickupStreet.setValue(data.getStreet() != null ? data.getStreet() : "");
pickupHouseNumber.setValue(data.getHouseNumber() != null ? data.getHouseNumber() : ""); pickupHouseNumber.setValue(data.getHouseNumber() != null ? data.getHouseNumber() : "");
pickupAddressAddition.setValue(data.getAddressAddition() != null ? data.getAddressAddition() : ""); pickupAddressAddition.setValue(data.getAddressAddition() != null ? data.getAddressAddition() : "");
@@ -899,6 +904,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setFirstName(pickupFirstName.getValue()); currentData.setFirstName(pickupFirstName.getValue());
currentData.setLastName(pickupLastName.getValue()); currentData.setLastName(pickupLastName.getValue());
currentData.setPhone(pickupPhone.getValue()); currentData.setPhone(pickupPhone.getValue());
currentData.setMail(pickupMail);
currentData.setStreet(pickupStreet.getValue()); currentData.setStreet(pickupStreet.getValue());
currentData.setHouseNumber(pickupHouseNumber.getValue()); currentData.setHouseNumber(pickupHouseNumber.getValue());
currentData.setAddressAddition(pickupAddressAddition.getValue()); currentData.setAddressAddition(pickupAddressAddition.getValue());
@@ -1129,6 +1135,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
station.setCity(data.getCity()); station.setCity(data.getCity());
station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>()); station.setTasks(data.getTasks() != null ? new ArrayList<>(data.getTasks()) : new ArrayList<>());
deliveryStationsSaveAddress.set(idx, data.isSaveAddress()); deliveryStationsSaveAddress.set(idx, data.isSaveAddress());
deliveryStationsMailState.set(idx, trimToNull(data.getMail()));
// Store tasks for this delivery station // Store tasks for this delivery station
deliveryStationTasksState.put(idx, data.getTasks() != null ? data.getTasks() : new ArrayList<>()); deliveryStationTasksState.put(idx, data.getTasks() != null ? data.getTasks() : new ArrayList<>());
@@ -1166,6 +1173,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
currentData.setFirstName(station.getFirstName()); currentData.setFirstName(station.getFirstName());
currentData.setLastName(station.getLastName()); currentData.setLastName(station.getLastName());
currentData.setPhone(station.getPhone()); currentData.setPhone(station.getPhone());
currentData.setMail(actualIndex < deliveryStationsMailState.size() ? deliveryStationsMailState.get(actualIndex) : null);
currentData.setStreet(station.getStreet()); currentData.setStreet(station.getStreet());
currentData.setHouseNumber(station.getHouseNumber()); currentData.setHouseNumber(station.getHouseNumber());
currentData.setAddressAddition(station.getAddressAddition()); currentData.setAddressAddition(station.getAddressAddition());
@@ -1417,6 +1425,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupLastName.setValue(customer.getLastName()); pickupLastName.setValue(customer.getLastName());
if (customer.getTelephone() != null) if (customer.getTelephone() != null)
pickupPhone.setValue(customer.getTelephone()); pickupPhone.setValue(customer.getTelephone());
pickupMail = trimToNull(customer.getMail());
if (customer.getStreet() != null) if (customer.getStreet() != null)
pickupStreet.setValue(customer.getStreet()); pickupStreet.setValue(customer.getStreet());
if (customer.getHouseNumber() != null) if (customer.getHouseNumber() != null)
@@ -1443,6 +1452,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Reactivate save checkbox for custom values // Reactivate save checkbox for custom values
savePickupAddress.setValue(true); savePickupAddress.setValue(true);
pickupMail = null;
}); });
} }
@@ -1813,6 +1823,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
pickupCustomer.setFirstname(pickupFirstName.getValue()); pickupCustomer.setFirstname(pickupFirstName.getValue());
pickupCustomer.setLastName(pickupLastName.getValue()); pickupCustomer.setLastName(pickupLastName.getValue());
pickupCustomer.setTelephone(pickupPhone.getValue()); pickupCustomer.setTelephone(pickupPhone.getValue());
pickupCustomer.setMail(pickupMail);
pickupCustomer.setStreet(pickupStreet.getValue()); pickupCustomer.setStreet(pickupStreet.getValue());
pickupCustomer.setHouseNumber(pickupHouseNumber.getValue()); pickupCustomer.setHouseNumber(pickupHouseNumber.getValue());
pickupCustomer.setAddressAddition(pickupAddressAddition.getValue()); pickupCustomer.setAddressAddition(pickupAddressAddition.getValue());
@@ -1830,6 +1841,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
deliveryCustomer.setFirstname(ds.getFirstName()); deliveryCustomer.setFirstname(ds.getFirstName());
deliveryCustomer.setLastName(ds.getLastName()); deliveryCustomer.setLastName(ds.getLastName());
deliveryCustomer.setTelephone(ds.getPhone()); deliveryCustomer.setTelephone(ds.getPhone());
deliveryCustomer.setMail(i < deliveryStationsMailState.size() ? deliveryStationsMailState.get(i) : null);
deliveryCustomer.setStreet(ds.getStreet()); deliveryCustomer.setStreet(ds.getStreet());
deliveryCustomer.setHouseNumber(ds.getHouseNumber()); deliveryCustomer.setHouseNumber(ds.getHouseNumber());
deliveryCustomer.setAddressAddition(ds.getAddressAddition()); deliveryCustomer.setAddressAddition(ds.getAddressAddition());

View File

@@ -11,7 +11,6 @@ import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Language; import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.model.LocationPosition;
import de.assecutor.votianlt.model.Photo; import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Service; import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.Signature; import de.assecutor.votianlt.model.Signature;

View File

@@ -478,22 +478,22 @@ addjob.cargo.gridcart=Gitterwagen
addjob.cargo.parcel=Paket addjob.cargo.parcel=Paket
addjob.cargo.add=Fracht hinzufügen addjob.cargo.add=Fracht hinzufügen
addjob.tasks.title=Aufgaben addjob.tasks.title=Aufgaben
addjob.tasks.template.placeholder=Template auswählen addjob.tasks.template.placeholder=Vorlage auswählen
addjob.tasks.template.save.tooltip=Als Template speichern addjob.tasks.template.save.tooltip=Als Vorlage speichern
addjob.tasks.template.save.title=Template speichern addjob.tasks.template.save.title=Vorlage speichern
addjob.tasks.template.name=Template-Name addjob.tasks.template.name=Vorlagen-Name
addjob.tasks.template.name.placeholder=Name eingeben addjob.tasks.template.name.placeholder=Name eingeben
addjob.tasks.template.name.required=Name ist erforderlich addjob.tasks.template.name.required=Name ist erforderlich
addjob.tasks.template.saved=Template "{0}" gespeichert addjob.tasks.template.saved=Vorlage "{0}" gespeichert
addjob.tasks.template.save.error=Fehler beim Speichern: {0} addjob.tasks.template.save.error=Fehler beim Speichern: {0}
addjob.tasks.template.dialog.error=Fehler beim Öffnen des Dialogs: {0} addjob.tasks.template.dialog.error=Fehler beim Öffnen des Dialogs: {0}
addjob.tasks.template.no.tasks=Keine Aufgaben zum Speichern addjob.tasks.template.no.tasks=Keine Aufgaben zum Speichern
addjob.tasks.template.load.title=Template laden addjob.tasks.template.load.title=Vorlage laden
addjob.tasks.template.load.text=Möchten Sie das Template "{0}" laden? Diese Aktion ersetzt alle aktuellen Aufgaben. addjob.tasks.template.load.text=Möchten Sie die Vorlage "{0}" laden? Diese Aktion ersetzt alle aktuellen Aufgaben.
addjob.tasks.template.load.confirm=Laden addjob.tasks.template.load.confirm=Laden
addjob.tasks.template.loaded=Template "{0}" geladen addjob.tasks.template.loaded=Vorlage "{0}" geladen
addjob.tasks.template.load.error=Fehler beim Laden: {0} addjob.tasks.template.load.error=Fehler beim Laden: {0}
addjob.tasks.template.load.templates.error=Fehler beim Laden der Templates: {0} addjob.tasks.template.load.templates.error=Fehler beim Laden der Vorlagen: {0}
addjob.tasks.add=Aufgabe hinzufügen addjob.tasks.add=Aufgabe hinzufügen
addjob.tasks.tasktype=Aufgabentyp addjob.tasks.tasktype=Aufgabentyp
addjob.tasks.tasktype.placeholder=Typ wählen addjob.tasks.tasktype.placeholder=Typ wählen