import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import '../../../app/theme.dart';
import '../../../core/identifiers/uuid.dart';
import '../../../core/ai/ai_report_assistant.dart';
import '../../../core/ai/ai_settings_store.dart';
import '../../../core/sync/report_asset_sync.dart';
import '../../jobs/data/job_store.dart';
import '../../customers/data/customer_store.dart';
import '../../employees/data/employee_store.dart';
import '../data/demo_report_store.dart';
import '../data/report_photo_store.dart';
class _PendingPhoto {
const _PendingPhoto({
required this.id,
required this.fileName,
required this.mimeType,
required this.bytes,
});
final String id;
final String fileName;
final String mimeType;
final Uint8List bytes;
}
class _PendingFile {
const _PendingFile({
required this.id,
required this.fileName,
required this.bytes,
});
final String id;
final String fileName;
final Uint8List bytes;
}
class CreateReportScreen extends StatefulWidget {
const CreateReportScreen({super.key, this.report});
final DemoReport? report;
@override
State<CreateReportScreen> createState() => _CreateReportScreenState();
}
class _CreateReportScreenState extends State<CreateReportScreen> {
final customer = TextEditingController();
final site = TextEditingController();
late final List<TextEditingController> activities;
late final List<TextEditingController> materials;
late final List<TextEditingController> quantities;
late final List<TextEditingController> units;
final additionalOrder = TextEditingController();
final aiNotes = TextEditingController();
bool additionalOrderApproved = false;
int step = 0;
TimeOfDay start = const TimeOfDay(hour: 7, minute: 30);
TimeOfDay end = const TimeOfDay(hour: 16, minute: 0);
DateTime reportDate = DateTime.now();
int breakMinutes = 30;
String? selectedJobId;
String? selectedCustomerId;
String? selectedSiteId;
final Set<String> participantIds = {'demo-max'};
late final String reportId;
final List<String> photoIds = [];
final List<String> fileIds = [];
final List<String> fileNames = [];
final Set<String> removedPhotoIds = {};
final Set<String> removedFileIds = {};
final List<_PendingPhoto> pendingPhotos = [];
final List<_PendingFile> pendingFiles = [];
bool aiLoading = false;
@override
void initState() {
super.initState();
final report = widget.report;
reportId = report?.id ?? newUuid();
activities = (report?.activityItems.isNotEmpty ?? false)
? report!.activityItems
.map((value) => TextEditingController(text: value))
.toList()
: [TextEditingController(text: report?.activity)];
materials = (report?.materialItems.isNotEmpty ?? false)
? report!.materialItems
.map((item) => TextEditingController(text: item.name))
.toList()
: [TextEditingController(text: report?.material)];
quantities = (report?.materialItems.isNotEmpty ?? false)
? report!.materialItems
.map((item) => TextEditingController(text: item.quantity))
.toList()
: [TextEditingController(text: report?.quantity)];
units = (report?.materialItems.isNotEmpty ?? false)
? report!.materialItems
.map((item) => TextEditingController(text: item.unit))
.toList()
: [TextEditingController(text: 'Stk.')];
if (report != null) {
customer.text = report.customer;
site.text = report.site;
selectedJobId = report.jobId;
selectedCustomerId = report.customerId;
selectedSiteId = report.siteId;
start = report.start;
end = report.end;
reportDate = report.date;
breakMinutes = report.breakMinutes;
participantIds
..clear()
..addAll(report.participantIds);
additionalOrder.text = report.additionalOrder;
additionalOrderApproved = report.additionalOrderApproved;
photoIds.addAll(report.photoIds);
fileIds.addAll(report.fileIds);
fileNames.addAll(report.fileNames);
}
}
@override
void dispose() {
for (final controller in [
customer,
site,
...activities,
...materials,
...quantities,
...units,
additionalOrder,
aiNotes,
]) {
controller.dispose();
}
super.dispose();
}
void next() {
if (step == 0 &&
(customer.text.trim().isEmpty || site.text.trim().isEmpty)) {
return message('Bitte Kunde und Baustelle eintragen.');
}
if (step == 1 &&
activities.every((controller) => controller.text.trim().isEmpty)) {
return message('Bitte mindestens eine ausgeführte Leistung eintragen.');
}
setState(() => step++);
}
void message(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
Future<void> searchCustomer() async {
final selected = await showDialog<Customer>(
context: context,
builder: (context) {
var query = '';
return StatefulBuilder(
builder: (context, setDialogState) {
final customers = CustomerStore.instance.customers
.where(
(item) => item.name.toLowerCase().contains(
query.trim().toLowerCase(),
),
)
.toList();
return AlertDialog(
title: const Text('Kunden suchen'),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name eingeben',
prefixIcon: Icon(Icons.search_rounded),
border: OutlineInputBorder(),
),
onChanged: (value) => setDialogState(() => query = value),
),
const SizedBox(height: 12),
if (customers.isEmpty)
const Align(
alignment: Alignment.centerLeft,
child: Text('Keine passenden Kunden gefunden.'),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: customers.length,
itemBuilder: (context, index) {
final customer = customers[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.person_outline_rounded),
title: Text(customer.name),
subtitle: Text(
[customer.phone, customer.email]
.where((value) => value.isNotEmpty)
.join(' · '),
),
onTap: () => Navigator.pop(context, customer),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
],
);
},
);
},
);
if (!mounted || selected == null) return;
setState(() {
customer.text = selected.name;
selectedCustomerId = selected.id;
selectedJobId = null;
selectedSiteId = null;
});
}
Future<void> save() async {
for (final photoId in removedPhotoIds) {
await ReportPhotoStore.instance.remove(photoId);
}
for (final photo in pendingPhotos) {
await ReportPhotoStore.instance.add(
ReportPhoto(
id: photo.id,
reportId: reportId,
fileName: photo.fileName,
mimeType: photo.mimeType,
bytes: photo.bytes,
createdAt: DateTime.now(),
),
);
}
for (final file in pendingFiles) {
await ReportAssetSyncController.instance.enqueueFile(
assetId: file.id,
reportId: reportId,
fileName: file.fileName,
bytes: file.bytes,
);
}
for (final fileId in removedFileIds) {
await ReportAssetSyncController.instance.enqueueDelete(
assetId: fileId,
reportId: reportId,
);
}
final report = DemoReport(
id: reportId,
customer: customer.text.trim(),
site: site.text.trim(),
activity: activities
.map((controller) => controller.text.trim())
.where((value) => value.isNotEmpty)
.join('\n'),
material: materials.first.text.trim(),
quantity: quantities.first.text.trim(),
date: reportDate,
start: start,
end: end,
isFinalized: widget.report?.isFinalized ?? false,
finalizedAt: widget.report?.finalizedAt,
signature: widget.report?.signature ?? const [],
participantIds: participantIds.toList(),
activityItems: activities
.map((controller) => controller.text.trim())
.where((value) => value.isNotEmpty)
.toList(),
materialItems: [
for (var index = 0; index < materials.length; index++)
if (materials[index].text.trim().isNotEmpty)
MaterialEntry(
name: materials[index].text.trim(),
quantity: quantities[index].text.trim(),
unit: units[index].text.trim().isEmpty
? 'Stk.'
: units[index].text.trim(),
),
],
additionalOrder: additionalOrder.text.trim(),
additionalOrderApproved: additionalOrderApproved,
breakMinutes: breakMinutes,
photoIds: [...photoIds, ...pendingPhotos.map((photo) => photo.id)],
fileIds: [...fileIds, ...pendingFiles.map((file) => file.id)],
fileNames: [...fileNames, ...pendingFiles.map((file) => file.fileName)],
customerId: selectedCustomerId,
siteId: selectedSiteId,
jobId: selectedJobId,
);
if (widget.report == null) {
await DemoReportStore.instance.add(report);
} else {
await DemoReportStore.instance.update(report);
}
if (pendingFiles.isNotEmpty || removedFileIds.isNotEmpty) {
await ReportAssetSyncController.instance.synchronize(force: true);
}
if (!mounted) return;
Navigator.pop(context, true);
}
Future<void> pickPhoto(ImageSource source) async {
if (photoIds.length + pendingPhotos.length >=
ReportPhotoStore.maxPhotosPerReport) {
message('Pro Nachweis sind höchstens 8 Fotos möglich.');
return;
}
try {
final file = await ImagePicker().pickImage(
source: source,
imageQuality: 72,
maxWidth: 1800,
requestFullMetadata: false,
);
if (file == null) return;
final bytes = await file.readAsBytes();
if (bytes.lengthInBytes > ReportPhotoStore.maxPhotoBytes) {
message('Das Foto ist größer als 6 MB. Bitte kleiner aufnehmen.');
return;
}
final mimeType = (file.mimeType ?? 'image/jpeg').toLowerCase();
if (!const {'image/jpeg', 'image/png', 'image/webp'}.contains(mimeType)) {
message('Bitte ein JPEG-, PNG- oder WebP-Foto auswählen.');
return;
}
setState(() {
pendingPhotos.add(
_PendingPhoto(
id: newUuid(),
fileName: file.name,
mimeType: mimeType,
bytes: bytes,
),
);
});
} on Object {
message('Das Foto konnte nicht geladen werden.');
}
}
Future<void> pickPdf() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: true,
);
final file = result == null || result.files.isEmpty
? null
: result.files.single;
final bytes = file?.bytes;
if (file == null || bytes == null) return;
if (bytes.lengthInBytes > 10 * 1024 * 1024) {
message('Die PDF-Datei ist größer als 10 MB.');
return;
}
if (bytes.length < 4 ||
bytes[0] != 0x25 ||
bytes[1] != 0x50 ||
bytes[2] != 0x44 ||
bytes[3] != 0x46) {
message('Bitte eine gültige PDF-Datei auswählen.');
return;
}
setState(
() => pendingFiles.add(
_PendingFile(id: newUuid(), fileName: file.name, bytes: bytes),
),
);
} on Object {
message('Die PDF-Datei konnte nicht geladen werden.');
}
}
Future<void> structureWithAi() async {
if (!AiSettingsStore.instance.settings.enabled) {
message('KI-Unterstützung zuerst unter Firma & Profil einrichten.');
return;
}
if (aiNotes.text.trim().isEmpty) {
message('Bitte zuerst Baustellennotizen eingeben.');
return;
}
setState(() => aiLoading = true);
try {
final suggestion = await RemoteAiReportAssistant().suggest(
AiReportRequest(
notes: aiNotes.text.trim(),
customer: customer.text.trim(),
site: site.text.trim(),
),
);
if (!mounted) return;
final accepted = await _confirmAiSuggestion(suggestion);
if (accepted == true && mounted) _applyAiSuggestion(suggestion);
} on AiAssistantException catch (error) {
if (mounted) message(error.message);
} on Object {
if (mounted) message('Die KI-Anfrage ist fehlgeschlagen.');
} finally {
if (mounted) setState(() => aiLoading = false);
}
}
Future<bool?> _confirmAiSuggestion(
AiReportSuggestion suggestion,
) => showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('KI-Vorschlag prüfen'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sicherheitseinschätzung: ${(suggestion.confidence * 100).round()} %',
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 14),
const Text(
'Leistungen',
style: TextStyle(fontWeight: FontWeight.w800),
),
for (final item in suggestion.activities) Text('• $item'),
if (suggestion.materials.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'Material',
style: TextStyle(fontWeight: FontWeight.w800),
),
for (final item in suggestion.materials)
Text('• ${item.quantity} ${item.unit} · ${item.name}'),
],
if (suggestion.startTime != null ||
suggestion.endTime != null) ...[
const SizedBox(height: 12),
Text(
'Zeit: ${suggestion.startTime ?? '?'} – ${suggestion.endTime ?? '?'} · Pause ${suggestion.breakMinutes ?? '?'} Min.',
),
],
if (suggestion.openQuestions.isNotEmpty) ...[
const SizedBox(height: 14),
const Text(
'Vor dem Speichern noch klären',
style: TextStyle(fontWeight: FontWeight.w800),
),
for (final question in suggestion.openQuestions)
Text('• $question'),
],
const SizedBox(height: 14),
const Text(
'Bitte alle Angaben kontrollieren. Der Vorschlag ersetzt keine fachliche Prüfung.',
style: TextStyle(color: Colors.black54),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Verwerfen'),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, true),
icon: const Icon(Icons.check_rounded),
label: const Text('Übernehmen & bearbeiten'),
),
],
),
);
void _applyAiSuggestion(AiReportSuggestion suggestion) {
for (final controller in activities) {
controller.dispose();
}
activities
..clear()
..addAll(
(suggestion.activities.isEmpty ? const [''] : suggestion.activities)
.map((value) => TextEditingController(text: value)),
);
for (final controller in [...materials, ...quantities, ...units]) {
controller.dispose();
}
materials
..clear()
..addAll(
(suggestion.materials.isEmpty
? const [
AiMaterialSuggestion(name: '', quantity: '', unit: 'Stk.'),
]
: suggestion.materials)
.map((value) => TextEditingController(text: value.name)),
);
quantities
..clear()
..addAll(
(suggestion.materials.isEmpty
? const [
AiMaterialSuggestion(name: '', quantity: '', unit: 'Stk.'),
]
: suggestion.materials)
.map((value) => TextEditingController(text: value.quantity)),
);
units
..clear()
..addAll(
(suggestion.materials.isEmpty
? const [
AiMaterialSuggestion(name: '', quantity: '', unit: 'Stk.'),
]
: suggestion.materials)
.map((value) => TextEditingController(text: value.unit)),
);
additionalOrder.text = suggestion.additionalOrder;
final parsedStart = _parseTime(suggestion.startTime);
final parsedEnd = _parseTime(suggestion.endTime);
setState(() {
if (parsedStart != null) start = parsedStart;
if (parsedEnd != null) end = parsedEnd;
if (suggestion.breakMinutes case final value?) {
if (const [0, 15, 30, 45, 60, 90].contains(value)) {
breakMinutes = value;
}
}
});
}
TimeOfDay? _parseTime(String? value) {
final parts = value?.split(':');
if (parts == null || parts.length != 2) return null;
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null || hour > 23 || minute > 59) {
return null;
}
return TimeOfDay(hour: hour, minute: minute);
}
Future<void> pickTime(bool isStart) async {
final selected = await showTimePicker(
context: context,
initialTime: isStart ? start : end,
helpText: isStart ? 'Arbeitsbeginn wählen' : 'Arbeitsende wählen',
);
if (selected != null) {
setState(() => isStart ? start = selected : end = selected);
}
}
Future<void> pickDate() async {
final selected = await showDatePicker(
context: context,
initialDate: reportDate,
firstDate: DateTime(DateTime.now().year - 2),
lastDate: DateTime(DateTime.now().year + 1),
helpText: 'Leistungsdatum wählen',
);
if (selected != null) {
setState(() => reportDate = selected);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
widget.report == null ? 'Neuer Nachweis' : 'Entwurf bearbeiten',
),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
),
body: SafeArea(
child: Column(
children: [
LinearProgressIndicator(
value: (step + 1) / 3,
minHeight: 4,
color: AppColors.green,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: step == 0
? orderStep()
: step == 1
? workStep()
: reviewStep(),
),
),
),
),
),
Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(24, 14, 24, 18),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Row(
children: [
if (step > 0) ...[
OutlinedButton(
onPressed: () => setState(() => step--),
child: const Text('Zurück'),
),
const SizedBox(width: 12),
],
Expanded(
child: FilledButton.icon(
onPressed: step == 2 ? save : next,
icon: Icon(
step == 2
? Icons.save_outlined
: Icons.arrow_forward_rounded,
),
label: Text(step == 2 ? 'Entwurf speichern' : 'Weiter'),
),
),
],
),
),
),
),
],
),
),
);
Widget heading(String label, String title, String description) => Column(
key: ValueKey(label),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(title, style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(description, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 28),
],
);
Widget orderStep() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(
'SCHRITT 1 VON 3',
'Wo wurde gearbeitet?',
'Ordne den Nachweis einem Kunden und einer Baustelle zu.',
),
if (JobStore.instance.jobs.isNotEmpty) ...[
DropdownButtonFormField<String>(
initialValue: selectedJobId,
decoration: const InputDecoration(
labelText: 'Vorhandenen Auftrag wählen',
prefixIcon: Icon(Icons.work_outline_rounded),
border: OutlineInputBorder(),
),
items: JobStore.instance.jobs
.map(
(job) => DropdownMenuItem(
value: job.id,
child: Text('${job.number} · ${job.site}'),
),
)
.toList(),
onChanged: (id) {
final job = JobStore.instance.jobs.firstWhere(
(item) => item.id == id,
);
setState(() {
selectedJobId = id;
selectedCustomerId = job.customerId;
selectedSiteId = job.siteId;
customer.text = job.customer;
site.text = job.site;
});
},
),
const SizedBox(height: 18),
],
field(
customer,
'Kunde',
'z. B. Müller GmbH',
Icons.person_outline_rounded,
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: CustomerStore.instance.customers.isEmpty
? null
: searchCustomer,
icon: const Icon(Icons.search_rounded),
label: const Text('Kunden suchen'),
),
),
const SizedBox(height: 18),
field(
site,
'Baustelle / Auftrag',
'z. B. Umbau Werkhalle',
Icons.location_on_outlined,
),
const SizedBox(height: 18),
InkWell(
onTap: pickDate,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Leistungsdatum',
prefixIcon: Icon(Icons.calendar_today_outlined),
border: OutlineInputBorder(),
),
child: Text(
MaterialLocalizations.of(context).formatMediumDate(reportDate),
),
),
),
],
);
Widget workStep() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(
'SCHRITT 2 VON 3',
'Was wurde gemacht?',
'Halte Arbeitszeit, Leistung und verwendetes Material fest.',
),
Card(
color: const Color(0xFFEAF5EF),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Row(
children: [
Icon(Icons.auto_awesome_rounded, color: AppColors.green),
SizedBox(width: 8),
Expanded(
child: Text(
'Baustellennotizen mit KI strukturieren',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
],
),
const SizedBox(height: 8),
const Text(
'Schreibe oder füge deine rohen Notizen ein. Die KI schlägt Positionen vor – übernommen wird erst nach deiner Bestätigung.',
),
const SizedBox(height: 12),
TextField(
controller: aiNotes,
minLines: 3,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Rohe Baustellennotizen',
hintText:
'z. B. 8 bis 12 Uhr, 30 m Kabel gezogen, Verteiler geprüft …',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: aiLoading ? null : structureWithAi,
icon: aiLoading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high_rounded),
label: Text(
aiLoading ? 'KI strukturiert …' : 'Vorschlag erstellen',
),
),
],
),
),
),
const SizedBox(height: 18),
Row(
children: [
Expanded(child: timeField('Beginn', start, () => pickTime(true))),
const SizedBox(width: 14),
Expanded(child: timeField('Ende', end, () => pickTime(false))),
],
),
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
final pauseField = DropdownButtonFormField<int>(
initialValue: breakMinutes,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Pause',
prefixIcon: Icon(Icons.free_breakfast_outlined),
border: OutlineInputBorder(),
),
items: const [0, 15, 30, 45, 60, 90]
.map(
(minutes) => DropdownMenuItem(
value: minutes,
child: Text('$minutes Minuten'),
),
)
.toList(),
onChanged: (value) => setState(() => breakMinutes = value ?? 0),
);
final netField = InputDecorator(
decoration: const InputDecoration(
labelText: 'Nettoarbeitszeit',
prefixIcon: Icon(Icons.timelapse_rounded),
border: OutlineInputBorder(),
),
child: Text(_durationText(_netMinutes)),
);
if (constraints.maxWidth < 560) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [pauseField, const SizedBox(height: 12), netField],
);
}
return Row(
children: [
Expanded(child: pauseField),
const SizedBox(width: 14),
Expanded(child: netField),
],
);
},
),
const SizedBox(height: 18),
Text(
'Beteiligte Mitarbeiter',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: EmployeeStore.instance.activeEmployees
.map(
(employee) => FilterChip(
selected: participantIds.contains(employee.id),
label: Text(employee.name),
avatar: const Icon(Icons.person_outline_rounded, size: 18),
onSelected: (selected) => setState(
() => selected
? participantIds.add(employee.id)
: participantIds.remove(employee.id),
),
),
)
.toList(),
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: Text(
'Ausgeführte Leistungen',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
TextButton.icon(
onPressed: () =>
setState(() => activities.add(TextEditingController())),
icon: const Icon(Icons.add_rounded),
label: const Text('Position'),
),
],
),
const SizedBox(height: 10),
for (var index = 0; index < activities.length; index++) ...[
_activityEditor(index),
if (index < activities.length - 1) const SizedBox(height: 12),
],
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: Text(
'Material (optional)',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
TextButton.icon(
onPressed: () => setState(() {
materials.add(TextEditingController());
quantities.add(TextEditingController());
units.add(TextEditingController(text: 'Stk.'));
}),
icon: const Icon(Icons.add_rounded),
label: const Text('Material'),
),
],
),
const SizedBox(height: 10),
for (var index = 0; index < materials.length; index++) ...[
_materialEditor(index),
if (index < materials.length - 1) const SizedBox(height: 12),
],
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: Text(
'Fotodokumentation (optional)',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
PopupMenuButton<ImageSource>(
tooltip: 'Foto hinzufügen',
onSelected: pickPhoto,
itemBuilder: (context) => const [
PopupMenuItem(
value: ImageSource.camera,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.photo_camera_outlined),
title: Text('Kamera'),
),
),
PopupMenuItem(
value: ImageSource.gallery,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.photo_library_outlined),
title: Text('Galerie / Datei'),
),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_a_photo_outlined, size: 20),
SizedBox(width: 6),
Text('Foto'),
],
),
),
),
],
),
const SizedBox(height: 10),
if (photoIds.isEmpty && pendingPhotos.isEmpty)
Text(
'Bis zu 8 Fotos, jeweils maximal 6 MB.',
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final photoId in photoIds)
if (ReportPhotoStore.instance.photoById(photoId)
case final photo?)
_photoTile(
photo.bytes,
() => setState(() {
photoIds.remove(photoId);
removedPhotoIds.add(photoId);
}),
),
for (final photo in pendingPhotos)
_photoTile(
photo.bytes,
() => setState(() => pendingPhotos.remove(photo)),
),
],
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: Text(
'PDF-Dateien (optional)',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
TextButton.icon(
onPressed: pickPdf,
icon: const Icon(Icons.attach_file_rounded),
label: const Text('PDF anhängen'),
),
],
),
if (fileIds.isEmpty && pendingFiles.isEmpty)
Text(
'PDF-Dateien bis maximal 10 MB werden sicher übertragen.',
style: Theme.of(context).textTheme.bodySmall,
)
else
Column(
children: [
for (var index = 0; index < fileIds.length; index++)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.picture_as_pdf_outlined),
title: Text(
index < fileNames.length ? fileNames[index] : 'PDF-Datei',
),
trailing: IconButton(
tooltip: 'PDF entfernen',
onPressed: () => setState(() {
removedFileIds.add(fileIds[index]);
fileIds.removeAt(index);
if (index < fileNames.length) fileNames.removeAt(index);
}),
icon: const Icon(Icons.delete_outline_rounded),
),
),
for (final file in pendingFiles)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.picture_as_pdf_outlined),
title: Text(file.fileName),
subtitle: Text(
'${(file.bytes.lengthInBytes / 1024).ceil()} KB',
),
trailing: IconButton(
tooltip: 'PDF entfernen',
onPressed: () => setState(() => pendingFiles.remove(file)),
icon: const Icon(Icons.delete_outline_rounded),
),
),
],
),
const SizedBox(height: 22),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Zusatzauftrag / Nachtrag',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: const Text(
'Der Kunde hat vor Ort zusätzliche Arbeiten beauftragt.',
),
value: additionalOrder.text.isNotEmpty,
onChanged: (enabled) => setState(() {
if (!enabled) {
additionalOrder.clear();
additionalOrderApproved = false;
} else {
additionalOrder.text = 'Zusätzlich beauftragt: ';
}
}),
),
if (additionalOrder.text.isNotEmpty) ...[
const SizedBox(height: 10),
TextField(
controller: additionalOrder,
minLines: 2,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Beschreibung des Zusatzauftrags',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: additionalOrderApproved,
onChanged: (value) =>
setState(() => additionalOrderApproved = value ?? false),
title: const Text('Kunde hat den Zusatzauftrag bestätigt'),
controlAffinity: ListTileControlAffinity.leading,
),
],
],
);
Widget reviewStep() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(
'SCHRITT 3 VON 3',
'Alles vollständig?',
'Prüfe die Angaben. Sie werden zunächst als lokaler Demo-Entwurf gespeichert. Nach der Kundenunterschrift kannst du das fertige PDF über „PDF teilen“ exportieren.',
),
review(Icons.location_on_outlined, site.text, [
customer.text,
MaterialLocalizations.of(context).formatMediumDate(reportDate),
]),
const SizedBox(height: 14),
review(
Icons.schedule_rounded,
'${start.format(context)} – ${end.format(context)} · $breakMinutes Min. Pause · ${_durationText(_netMinutes)} netto',
[
EmployeeStore.instance.employees
.where((employee) => participantIds.contains(employee.id))
.map((employee) => '${employee.name} · ${employee.role.label}')
.join(', '),
],
),
const SizedBox(height: 14),
review(Icons.handyman_outlined, 'Ausgeführte Leistungen', [
for (var index = 0; index < activities.length; index++)
if (activities[index].text.trim().isNotEmpty)
'${index + 1}. ${activities[index].text.trim()}',
]),
if (materials.any((controller) => controller.text.trim().isNotEmpty)) ...[
const SizedBox(height: 14),
review(Icons.inventory_2_outlined, 'Material', [
for (var index = 0; index < materials.length; index++)
if (materials[index].text.trim().isNotEmpty)
'${quantities[index].text.trim().isEmpty ? '1' : quantities[index].text.trim()} ${units[index].text.trim().isEmpty ? 'Stk.' : units[index].text.trim()} · ${materials[index].text.trim()}',
]),
],
if (additionalOrder.text.trim().isNotEmpty) ...[
const SizedBox(height: 14),
review(Icons.add_task_rounded, 'Zusatzauftrag', [
additionalOrder.text.trim(),
additionalOrderApproved
? 'Vom Kunden bestätigt'
: 'Bestätigung noch offen',
]),
],
if (photoIds.isNotEmpty || pendingPhotos.isNotEmpty) ...[
const SizedBox(height: 14),
review(Icons.photo_library_outlined, 'Fotodokumentation', [
'${photoIds.length + pendingPhotos.length} Fotos zugeordnet',
]),
],
if (fileIds.isNotEmpty || pendingFiles.isNotEmpty) ...[
const SizedBox(height: 14),
review(Icons.picture_as_pdf_outlined, 'PDF-Dateien', [
'${fileIds.length + pendingFiles.length} PDF-Datei(en) zugeordnet',
]),
],
],
);
Widget _photoTile(Uint8List bytes, VoidCallback onRemove) => Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
bytes,
width: 112,
height: 92,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(
width: 112,
height: 92,
child: ColoredBox(
color: AppColors.canvas,
child: Icon(Icons.broken_image_outlined),
),
),
),
),
Positioned(
top: 4,
right: 4,
child: IconButton.filled(
visualDensity: VisualDensity.compact,
tooltip: 'Foto entfernen',
onPressed: onRemove,
icon: const Icon(Icons.close_rounded, size: 18),
),
),
],
);
Widget _activityEditor(int index) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: activities[index],
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
labelText: 'Leistung ${index + 1}',
hintText: 'Was wurde konkret ausgeführt?',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
),
),
),
if (activities.length > 1)
IconButton(
tooltip: 'Position entfernen',
onPressed: () => setState(() => activities.removeAt(index).dispose()),
icon: const Icon(Icons.close_rounded),
),
],
);
Widget _materialEditor(int index) => LayoutBuilder(
builder: (context, constraints) {
final materialField = field(
materials[index],
'Material ${index + 1}',
'z. B. NYM-J 3×1,5',
Icons.inventory_2_outlined,
);
final quantityField = TextField(
controller: quantities[index],
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Menge',
border: OutlineInputBorder(),
),
);
final unitField = TextField(
controller: units[index],
decoration: const InputDecoration(
labelText: 'Einheit',
border: OutlineInputBorder(),
),
);
final removeButton = IconButton(
tooltip: 'Material entfernen',
onPressed: () => setState(() {
materials.removeAt(index).dispose();
quantities.removeAt(index).dispose();
units.removeAt(index).dispose();
}),
icon: const Icon(Icons.close_rounded),
);
if (constraints.maxWidth < 560) {
return Column(
children: [
Row(
children: [
Expanded(child: materialField),
if (materials.length > 1) removeButton,
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: quantityField),
const SizedBox(width: 10),
Expanded(child: unitField),
],
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: materialField),
const SizedBox(width: 10),
Expanded(child: quantityField),
const SizedBox(width: 10),
Expanded(child: unitField),
if (materials.length > 1) removeButton,
],
);
},
);
Widget field(
TextEditingController controller,
String label,
String hint,
IconData icon,
) => TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
int get _netMinutes =>
(end.hour * 60 + end.minute) -
(start.hour * 60 + start.minute) -
breakMinutes;
String _durationText(int minutes) {
if (minutes <= 0) return 'Ungültig';
final hours = minutes ~/ 60;
final rest = minutes % 60;
return rest == 0 ? '$hours Std.' : '$hours Std. $rest Min.';
}
Widget timeField(String label, TimeOfDay value, VoidCallback onTap) =>
InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: const Icon(Icons.schedule_rounded),
border: const OutlineInputBorder(),
),
child: Text(value.format(context)),
),
);
Widget review(IconData icon, String title, List<String> lines) => Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: AppColors.canvas,
child: Icon(icon, color: AppColors.green),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
for (final line in lines) ...[
const SizedBox(height: 5),
Text(line),
],
],
),
),
],
),
),
);
}
