glanzlicht / MACHWEIS

Mach’s. Beleg’s. Rechne es ab. MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Verified secure showcase
180 Files 605 KB Public size 26 Redacted Dart · PHP · SQL · Markdown · XML · PLIST

Project overview

MACHWEIS is a verified code showcase with 180 visible project entries across 8 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MACHWEIS

Mach’s. Beleg’s. Rechne es ab.

Produktansicht

MACHWEIS Dashboard auf Laptop und Smartphone

MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Aktueller Funktionsumfang

  • lokale Firmen-, Kunden-, Auftrags- und Mitarbeiterdaten (SQLite auf Android/iOS, Browser-Speicher im Web)
  • mobile Stammdaten-Synchronisation für Kunden, Baustellen, Aufträge, Mitarbeiterprofile und Auftragszuweisungen über die serverseitigen Push-/Pull-Endpunkte
  • atomare lokale Änderungen mit Outbox, Cursor, Retry, sichtbaren Konflikten und einer ausdrücklichen Importentscheidung bei der ersten Verbindung
  • unveränderliche Berichtsversionen mit sichtbarem Prüfverlauf
  • persistente Offline-Synchronisationswarteschlange mit Retry-Strategie
  • lokale Fotodokumentation per Kamera, Galerie oder Web-Dateiauswahl
  • gebrandete PDF-Leistungsnachweise mit Unterschrift, Fotodokumentation und vorbereitetem Teilen per E-Mail
  • prüfpflichtiger KI-Assistent für Baustellennotizen mit OpenAI, Google Gemini oder eigenem Proxy
  • Rollen: Inhaber, Büro, Bauleiter und Monteur
  • strukturierte Arbeitszeit mit Leistungsdatum, Pause und Nettozeit
  • mehrere Leistungs- und Materialpositionen
  • Zusatzaufträge mit Kundenbestätigung
  • Vollständigkeitsprüfung vor Unterschrift
  • digitale Kundenunterschrift und unveränderlicher Abschluss
  • CSV-Export und vorbereitete PDF-Renderer-Schnittstelle
  • responsive Smartphone- und Webnavigation
  • aktuelle App-Version und Build-Nummer dauerhaft am unteren Rand der Navigation

Lokale Entwicklung

flutter pub get
flutter analyze
flutter test
flutter run -d chrome

Produktkontext und technische Leitplanken stehen in [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md). Architektur und Serververtrag der KI-Unterstützung beschreibt [docs/AI_INTEGRATION.md](docs/AI_INTEGRATION.md).

Die Stammdaten-Synchronisation ist ausschließlich für iOS und Android aktiviert. Sie verwendet das Access-Token der mobilen Anmeldung und erneuert es bei Bedarf über die bestehende Authentifizierung. Im Web bleibt die Synchronisation bewusst deaktiviert.

Backend-Grundgerüst

Das separate PHP-8.4-REST-Grundgerüst liegt in [backend/](backend/README.md). Es enthält in diesem Stand ausschließlich Infrastruktur und den Health-Endpunkt; Login, Fachlogik und KI sind noch nicht enthalten.

lib/features/reports/presentation/create_report_screen.dartDart
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),
],
],
),
),
],
),
),
);
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

backendmodule
libmodule
iosmodule
testmodule
androidmodule
docsmodule
webmodule
assetsmodule

Technology stack

DartPHPLOCKPBXPROJSQLMarkdownFlutter / Dart
Dart66.7%
PHP14.5%
LOCK4.0%
PBXPROJ3.8%
SQL3.1%
Markdown1.3%
XML0.9%
YAML0.9%