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/reports_screen.dartDart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:printing/printing.dart';
import '../../../app/theme.dart';
import '../../../core/sync/sync_queue_store.dart';
import '../../../core/sync/report_sync.dart';
import '../../../core/sync/report_asset_sync.dart';
import '../../../core/reports/report_delivery_client.dart';
import '../../../core/auth/auth_store.dart';
import '../../../shared/widgets/empty_state.dart';
import '../../employees/data/employee_store.dart';
import '../../customers/data/customer_store.dart';
import '../../profile/data/company_store.dart';
import '../data/demo_report_store.dart';
import '../data/report_photo_store.dart';
import '../domain/report_validator.dart';
import '../domain/report_export.dart';
import '../domain/flutter_pdf_report_renderer.dart';
import 'create_report_screen.dart';
import 'signature_screen.dart';
class ReportsScreen extends StatelessWidget {
const ReportsScreen({super.key, required this.onCreate});
final VoidCallback onCreate;
Future<void> showReport(BuildContext context, DemoReport report) async {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(report.site),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
report.customer,
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'${report.start.format(context)} – ${report.end.format(context)}',
),
const SizedBox(height: 4),
Text(
'${report.breakMinutes} Min. Pause · ${_durationText(report.netMinutes)} netto',
),
const SizedBox(height: 6),
Text(
EmployeeStore.instance.employees
.where(
(employee) =>
report.participantIds.contains(employee.id),
)
.map((employee) => employee.name)
.join(', '),
style: const TextStyle(color: Colors.black54),
),
const Divider(height: 28),
const Text(
'Leistungen',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
for (
var index = 0;
index < report.activityItems.length;
index++
)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('${index + 1}. ${report.activityItems[index]}'),
),
if (report.materialItems.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Material',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
for (final item in report.materialItems)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${item.quantity.isEmpty ? '1' : item.quantity} ${item.unit} · ${item.name}',
),
),
],
if (report.additionalOrder.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Zusatzauftrag',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(report.additionalOrder),
const SizedBox(height: 4),
Text(
report.additionalOrderApproved
? 'Vom Kunden bestätigt'
: 'Bestätigung offen',
style: TextStyle(
color: report.additionalOrderApproved
? AppColors.green
: Colors.orange.shade800,
fontWeight: FontWeight.w700,
),
),
],
if (report.photoIds.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Fotodokumentation',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final photoId in report.photoIds)
if (ReportPhotoStore.instance.photoById(photoId)
case final photo?)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
photo.bytes,
width: 132,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(
width: 132,
height: 100,
child: ColoredBox(
color: AppColors.canvas,
child: Icon(Icons.broken_image_outlined),
),
),
),
),
],
),
],
if (!report.isFinalized) ...[
const SizedBox(height: 18),
_CompletenessPanel(issues: ReportValidator.issues(report)),
],
if (report.isFinalized) ...[
const SizedBox(height: 18),
const Row(
children: [
Icon(
Icons.lock_outline_rounded,
size: 18,
color: AppColors.green,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Vom Kunden unterschrieben und gegen Änderungen geschützt.',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
SignaturePreview(signature: report.signature),
],
const SizedBox(height: 18),
_AuditTrail(reportId: report.id),
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
actions: [
_ReportDialogActions(
report: report,
canWrite: _canWriteReports,
onDelete: () async {
final confirmed = await confirmDelete(dialogContext);
if (confirmed && dialogContext.mounted) {
await DemoReportStore.instance.remove(report.id);
if (dialogContext.mounted) Navigator.pop(dialogContext);
}
},
onEdit: () async {
Navigator.pop(dialogContext);
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => CreateReportScreen(report: report),
),
);
},
onSign: () async {
final issues = ReportValidator.issues(report);
if (issues.isNotEmpty) {
await showIncompleteDialog(dialogContext, issues);
return;
}
Navigator.pop(dialogContext);
final signature = await Navigator.of(context)
.push<List<List<double>>>(
MaterialPageRoute(
builder: (_) =>
SignatureScreen(customer: report.customer),
),
);
if (signature != null) {
await DemoReportStore.instance.finalize(report.id, signature);
}
},
onExportPdf: () => exportPdf(context, report),
onSendEmail: () => sendEmail(context, report),
onExportCsv: () => exportCsv(context, report),
onClose: () => Navigator.pop(dialogContext),
),
],
),
);
}
bool get _canWriteReports =>
!kIsWeb ||
const {
'owner',
'office',
'supervisor',
}.contains(AuthStore.instance.session?.role);
Future<void> exportCsv(BuildContext context, DemoReport report) async {
final document = _documentFor(report);
const exporter = CsvReportExporter();
await Clipboard.setData(ClipboardData(text: exporter.asText(document)));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${exporter.fileName(document)} wurde in die Zwischenablage kopiert.',
),
),
);
}
}
Future<void> exportPdf(BuildContext context, DemoReport report) async {
final document = _documentFor(report);
const renderer = FlutterPdfReportRenderer();
try {
final bytes = await renderer.export(document);
final recipients = CustomerStore.instance.customers
.where(
(customer) =>
customer.name.toLowerCase() == report.customer.toLowerCase() &&
customer.email.isNotEmpty,
)
.map((customer) => customer.email)
.toList();
await Printing.sharePdf(
bytes: Uint8List.fromList(bytes),
filename: renderer.fileName(document),
subject: 'Leistungsnachweis ${report.site}',
body:
'Guten Tag,\n\nim Anhang erhalten Sie den abgeschlossenen Leistungsnachweis für ${report.site}.\n\nMit freundlichen Grüßen\n${document.companyName}',
emails: recipients,
);
} on Object {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Das PDF konnte nicht erstellt oder geteilt werden.'),
),
);
}
}
}
Future<void> sendEmail(BuildContext context, DemoReport report) async {
try {
await ReportSyncController.instance.synchronize(force: true);
await ReportDeliveryClient().send(report.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Leistungsnachweis wurde per E-Mail versendet.'),
),
);
}
} on ReportDeliveryException catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
}
}
ReportDocument _documentFor(DemoReport report) {
final company = CompanyStore.instance.profile;
final employees = EmployeeStore.instance.employees;
return ReportDocument(
report: report,
companyName: company.name,
companyAddress: company.address,
companyContact: [
company.email,
company.phone,
].where((value) => value.isNotEmpty).join(' · '),
reportFooter: company.reportFooter,
participantNames: employees
.where((employee) => report.participantIds.contains(employee.id))
.map((employee) => employee.name)
.toList(),
photos: report.photoIds
.map(ReportPhotoStore.instance.photoById)
.whereType<ReportPhoto>()
.toList(),
);
}
String _durationText(int minutes) {
final hours = minutes ~/ 60;
final rest = minutes % 60;
return rest == 0 ? '$hours Std.' : '$hours Std. $rest Min.';
}
Future<bool> confirmDelete(BuildContext context) async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Entwurf löschen?'),
content: const Text('Dieser lokale Entwurf wird dauerhaft entfernt.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen'),
),
],
),
) ??
false;
Future<void> showIncompleteDialog(
BuildContext context,
List<String> issues,
) => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nachweis noch unvollständig'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Vor der Kundenunterschrift müssen diese Punkte geklärt werden:',
),
const SizedBox(height: 12),
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.warning_amber_rounded,
size: 19,
color: Colors.orange,
),
const SizedBox(width: 8),
Expanded(child: Text(issue)),
],
),
),
],
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Verstanden'),
),
],
),
);
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: Listenable.merge([
DemoReportStore.instance,
SyncQueueStore.instance,
ReportSyncController.instance,
ReportAssetSyncController.instance,
]),
builder: (context, _) {
final reports = DemoReportStore.instance.reports;
final canWrite = _canWriteReports;
if (reports.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (kIsWeb) const _WebReportControls(),
Expanded(
child: EmptyState(
icon: Icons.description_outlined,
title: 'Keine Nachweise gefunden',
description: kIsWeb
? 'Passe Suche oder Statusfilter an.'
: 'Entwürfe und abgeschlossene Nachweise findest du künftig hier.',
actionLabel: canWrite
? 'Nachweis erstellen'
: 'Aktualisieren',
onPressed: canWrite
? onCreate
: DemoReportStore.instance.initialize,
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nachweise',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'${reports.where((report) => !report.isFinalized).length} Entwürfe · ${reports.where((report) => report.isFinalized).length} abgeschlossen',
style: Theme.of(context).textTheme.bodyLarge,
),
if (ReportSyncController.instance.pendingCount > 0 ||
ReportSyncController.instance.conflictCount > 0 ||
ReportAssetSyncController.instance.pendingCount >
0) ...[
const SizedBox(height: 8),
ReportSyncNotice(
pendingCount:
ReportSyncController.instance.pendingCount,
conflictCount:
ReportSyncController.instance.conflictCount,
assetPendingCount: ReportAssetSyncController
.instance
.pendingCount,
),
],
],
),
),
if (canWrite)
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add_rounded),
label: const Text('Neu'),
),
],
),
if (kIsWeb) ...[
const SizedBox(height: 18),
const _WebReportControls(),
],
const SizedBox(height: 24),
for (final report in reports) ...[
_ReportCard(
report: report,
onTap: () => showReport(context, report),
),
const SizedBox(height: 12),
],
],
),
),
),
);
},
);
}
class _ReportCard extends StatelessWidget {
const _ReportCard({required this.report, required this.onTap});
final DemoReport report;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(20),
child: LayoutBuilder(
builder: (context, constraints) {
final status = _statusChip();
final details = Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
report.site,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
'${report.customer} · ${report.start.format(context)}–${report.end.format(context)}\n${report.activityItems.join(' · ')}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
);
final leading = const CircleAvatar(
backgroundColor: AppColors.canvas,
child: Icon(Icons.description_outlined, color: AppColors.green),
);
if (constraints.maxWidth < 520) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [leading, const SizedBox(width: 14), details]),
const SizedBox(height: 14),
Align(alignment: Alignment.centerLeft, child: status),
],
);
}
return Row(
children: [
leading,
const SizedBox(width: 14),
details,
const SizedBox(width: 14),
status,
],
);
},
),
),
),
);
Widget _statusChip() => Chip(
avatar: Icon(
report.isFinalized
? Icons.lock_outline_rounded
: ReportValidator.isComplete(report)
? Icons.check_circle_outline_rounded
: Icons.warning_amber_rounded,
size: 17,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
report.isFinalized
? 'Abgeschlossen'
: ReportValidator.isComplete(report)
? 'Bereit'
: 'Unvollständig',
),
Text(' · V${report.version}'),
],
),
);
}
class ReportSyncNotice extends StatelessWidget {
const ReportSyncNotice({
super.key,
required this.pendingCount,
required this.conflictCount,
this.assetPendingCount = 0,
});
final int pendingCount;
final int conflictCount;
final int assetPendingCount;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(Icons.cloud_off_outlined, size: 18),
),
const SizedBox(width: 6),
Expanded(
child: Text(
conflictCount > 0
? '$conflictCount Berichtskonflikt(e) benötigen eine Prüfung'
: assetPendingCount > 0 && pendingCount > 0
? '$pendingCount Berichts- und $assetPendingCount Dateiänderung(en) warten auf Synchronisation'
: assetPendingCount > 0
? '$assetPendingCount Datei(en) warten auf sichere Übertragung'
: '$pendingCount Berichtsänderungen warten auf Synchronisation',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
class _ReportDialogActions extends StatelessWidget {
const _ReportDialogActions({
required this.report,
required this.canWrite,
required this.onDelete,
required this.onEdit,
required this.onSign,
required this.onExportPdf,
required this.onSendEmail,
required this.onExportCsv,
required this.onClose,
});
final DemoReport report;
final bool canWrite;
final Future<void> Function() onDelete;
final Future<void> Function() onEdit;
final Future<void> Function() onSign;
final Future<void> Function() onExportPdf;
final Future<void> Function() onSendEmail;
final Future<void> Function() onExportCsv;
final VoidCallback onClose;
@override
Widget build(BuildContext context) => SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!report.isFinalized && canWrite) ...[
Row(
children: [
Expanded(
child: TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded),
label: const Text('Löschen'),
),
),
const SizedBox(width: 8),
Expanded(
child: TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
label: const Text('Bearbeiten'),
),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: onSign,
icon: const Icon(Icons.draw_outlined),
label: const Text('Unterschreiben'),
),
],
if (report.isFinalized) ...[
FilledButton.icon(
onPressed: onExportPdf,
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF teilen'),
),
const SizedBox(height: 8),
if (canWrite) ...[
OutlinedButton.icon(
onPressed: onSendEmail,
icon: const Icon(Icons.send_outlined),
label: const Text('Per E-Mail senden'),
),
const SizedBox(height: 8),
],
OutlinedButton.icon(
onPressed: onExportCsv,
icon: const Icon(Icons.table_view_outlined),
label: const Text('CSV kopieren'),
),
],
const SizedBox(height: 4),
TextButton(onPressed: onClose, child: const Text('Schließen')),
],
),
);
}
class _WebReportControls extends StatefulWidget {
const _WebReportControls();
@override
State<_WebReportControls> createState() => _WebReportControlsState();
}
class _WebReportControlsState extends State<_WebReportControls> {
final _search = TextEditingController();
String? _status;
bool _loading = false;
@override
void initState() {
super.initState();
_search.text = DemoReportStore.instance.webSearch;
_status = DemoReportStore.instance.webStatus;
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _load(int page) async {
setState(() => _loading = true);
try {
await DemoReportStore.instance.loadWebReports(
search: _search.text,
status: _status,
page: page,
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final store = DemoReportStore.instance;
final lastPage = store.webTotal == 0
? 1
: ((store.webTotal - 1) ~/ store.webLimit) + 1;
return LayoutBuilder(
builder: (context, constraints) {
final search = TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _load(1),
decoration: const InputDecoration(
labelText: 'Nachweise durchsuchen',
prefixIcon: Icon(Icons.search_rounded),
),
);
final status = DropdownButtonFormField<String?>(
initialValue: _status,
decoration: const InputDecoration(labelText: 'Status'),
items: const [
DropdownMenuItem(value: null, child: Text('Alle')),
DropdownMenuItem(value: 'draft', child: Text('Entwürfe')),
DropdownMenuItem(value: 'finalized', child: Text('Abgeschlossen')),
],
onChanged: _loading
? null
: (value) {
setState(() => _status = value);
_load(1);
},
);
return Column(
children: [
if (constraints.maxWidth < 620) ...[
search,
const SizedBox(height: 10),
status,
] else
Row(
children: [
Expanded(flex: 2, child: search),
const SizedBox(width: 12),
Expanded(child: status),
],
),
const SizedBox(height: 10),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _loading ? null : () => _load(1),
icon: _loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search_rounded),
label: const Text('Suchen'),
),
const Spacer(),
IconButton(
tooltip: 'Vorherige Seite',
onPressed: !_loading && store.webPage > 1
? () => _load(store.webPage - 1)
: null,
icon: const Icon(Icons.chevron_left_rounded),
),
Text(
'Seite ${store.webPage} von $lastPage · ${store.webTotal} Treffer',
),
IconButton(
tooltip: 'Nächste Seite',
onPressed: !_loading && store.webPage < lastPage
? () => _load(store.webPage + 1)
: null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
],
);
},
);
}
}
class _AuditTrail extends StatelessWidget {
const _AuditTrail({required this.reportId});
final String reportId;
@override
Widget build(BuildContext context) {
final entries = DemoReportStore.instance.historyFor(reportId);
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
leading: const Icon(Icons.history_rounded),
title: const Text(
'Prüfverlauf',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text('${entries.length} protokollierte Änderungen'),
children: [
for (final entry in entries)
ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 8),
leading: CircleAvatar(radius: 15, child: Text('${entry.version}')),
title: Text(entry.label),
subtitle: Text(_auditTimestamp(entry.timestamp)),
),
],
);
}
String _auditTimestamp(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${two(value.day)}.${two(value.month)}.${value.year} · ${two(value.hour)}:${two(value.minute)} Uhr';
}
}
class _CompletenessPanel extends StatelessWidget {
const _CompletenessPanel({required this.issues});
final List<String> issues;
@override
Widget build(BuildContext context) {
final complete = issues.isEmpty;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: complete ? const Color(0xFFEAF5EF) : const Color(0xFFFFF4E5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
complete
? Icons.check_circle_outline_rounded
: Icons.warning_amber_rounded,
color: complete ? AppColors.green : Colors.orange.shade800,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
complete
? 'Vollständig und bereit zur Unterschrift'
: '${issues.length} Prüfpunkte offen',
style: const TextStyle(fontWeight: FontWeight.w800),
),
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('• $issue'),
),
],
),
),
],
),
);
}
}

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%