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.

test/widget_test.dartDart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:machweis/app/app.dart';
import 'package:machweis/features/dashboard/presentation/app_shell.dart';
import 'package:machweis/features/reports/presentation/create_report_screen.dart';
import 'package:machweis/features/reports/presentation/reports_screen.dart';
import 'package:machweis/features/reports/data/demo_report_store.dart';
import 'package:machweis/features/reports/domain/report_validator.dart';
import 'package:machweis/features/reports/domain/report_export.dart';
import 'package:machweis/features/customers/data/customer_store.dart';
import 'package:machweis/core/app_version.dart';
void main() {
test('Alte Nachweise werden in Positionsdaten migriert', () {
final report = DemoReport.fromJson({
'id': 'legacy',
'customer': 'Alt-Kunde',
'site': 'Alt-Baustelle',
'activity': 'Verteilung geprüft',
'material': 'Sicherung',
'quantity': '2',
'date': '2026-01-01T08:00:00.000',
'startHour': 8,
'startMinute': 0,
'endHour': 9,
'endMinute': 0,
});
expect(report.activityItems, ['Verteilung geprüft']);
expect(report.materialItems.single.name, 'Sicherung');
expect(report.materialItems.single.quantity, '2');
});
test('Vollständigkeitsprüfung erkennt offene Pflichtangaben', () {
final report = DemoReport(
id: 'incomplete',
customer: 'Kunde',
site: 'Baustelle',
activity: '',
material: 'Kabel',
quantity: '',
date: DateTime(2026),
start: const TimeOfDay(hour: 10, minute: 0),
end: const TimeOfDay(hour: 9, minute: 0),
materialItems: const [
MaterialEntry(name: 'Kabel', quantity: '', unit: 'm'),
],
additionalOrder: 'Zusätzliche Steckdose',
);
expect(
ReportValidator.issues(report),
containsAll([
'Kein beteiligter Mitarbeiter ausgewählt',
'Mindestens eine ausgeführte Leistung fehlt',
'Arbeitsende muss nach Arbeitsbeginn liegen',
'Menge für Material 1 fehlt',
'Bestätigung des Zusatzauftrags fehlt',
]),
);
});
test('CSV-Export enthält strukturierte Berichtsdaten', () {
final report = DemoReport(
id: 'export',
customer: 'Müller; GmbH',
site: 'Werkhalle 1',
activity: 'Verteiler geprüft',
material: 'Kabel',
quantity: '12',
date: DateTime(2026, 8, 29),
start: const TimeOfDay(hour: 7, minute: 30),
end: const TimeOfDay(hour: 9, minute: 15),
participantIds: const ['max'],
activityItems: const ['Verteiler geprüft'],
materialItems: const [
MaterialEntry(name: 'Kabel', quantity: '12', unit: 'm'),
],
isFinalized: true,
finalizedAt: DateTime(2026, 8, 29, 9, 16),
breakMinutes: 30,
);
final document = ReportDocument(
report: report,
companyName: 'Elektro GmbH',
companyAddress: 'Hauptstraße 1',
companyContact: 'info@example.de',
reportFooter: 'Danke',
participantNames: const ['Max Mustermann'],
);
const exporter = CsvReportExporter();
final csv = exporter.asText(document);
expect(csv, contains('"Müller; GmbH"'));
expect(csv, contains('"Verteiler geprüft"'));
expect(csv, contains('"Kabel";"12";"m"'));
expect(csv, contains('"Max Mustermann"'));
expect(csv, contains('"Pause";"30 Minuten"'));
expect(csv, contains('"Nettoarbeitszeit";"1:15 Stunden"'));
expect(exporter.fileName(document), 'MACHWEIS_2026-08-29_Werkhalle_1.csv');
});
testWidgets('Demo-Einstieg führt zum Dashboard', (tester) async {
await tester.pumpWidget(const MachweisApp());
expect(find.text('MACHWEIS'), findsOneWidget);
expect(find.text('Demo starten'), findsOneWidget);
await tester.tap(find.text('Demo starten'));
await tester.pumpAndSettle();
expect(find.text('Als Demo-Nutzer anmelden'), findsOneWidget);
await tester.tap(find.text('Als Demo-Nutzer anmelden'));
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget is Text &&
(widget.data?.startsWith('Guten ') == true ||
widget.data?.startsWith('Gute Nacht') == true),
),
findsOneWidget,
);
expect(find.text('Neuen Nachweis erstellen'), findsOneWidget);
});
testWidgets('Smartphone-Layout verwendet erreichbare Bottom-Navigation', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(const MaterialApp(home: AppShell()));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
expect(find.text(AppVersion.label), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Web-Layout verwendet dauerhafte Seitennavigation', (
tester,
) async {
tester.view.physicalSize = const Size(1440, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(const MaterialApp(home: AppShell()));
await tester.pumpAndSettle();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(find.text(AppVersion.label), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Nachweiserfassung bleibt auf schmalem Smartphone überlauffrei', (
tester,
) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(const MaterialApp(home: CreateReportScreen()));
await tester.enterText(
find.widgetWithText(TextField, 'Kunde'),
'Testkunde',
);
await tester.enterText(
find.widgetWithText(TextField, 'Baustelle / Auftrag'),
'Baustelle',
);
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
expect(find.text('Was wurde gemacht?'), findsOneWidget);
expect(find.text('Baustellennotizen mit KI strukturieren'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Kundensuche übernimmt gespeicherten Kunden', (tester) async {
const customer = Customer(
id: 'customer-search-test',
name: 'Suchkunde GmbH',
contact: '',
email: 'suchkunde@example.test',
phone: '',
address: 'Teststraße 1',
);
await CustomerStore.instance.add(customer);
addTearDown(() => CustomerStore.instance.remove(customer.id));
await tester.pumpWidget(const MaterialApp(home: CreateReportScreen()));
await tester.tap(find.text('Kunden suchen'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Name eingeben'),
'Suchkunde',
);
await tester.tap(find.text('Suchkunde GmbH'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'Kunde'), findsOneWidget);
expect(
tester
.widget<TextField>(find.widgetWithText(TextField, 'Kunde'))
.controller
?.text,
'Suchkunde GmbH',
);
});
testWidgets('Berichtsaktionen bleiben auf schmalem iPhone geordnet', (
tester,
) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final reports = ReportsScreen(onCreate: () {});
final report = DemoReport(
id: 'dialog-layout-test',
customer: 'Kunde',
site: 'Schmale Baustelle',
activity: 'Montage',
material: '',
quantity: '',
date: DateTime(2026, 9, 1),
start: const TimeOfDay(hour: 7, minute: 30),
end: const TimeOfDay(hour: 16, minute: 0),
activityItems: const ['Montage'],
);
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => FilledButton(
onPressed: () => reports.showReport(context, report),
child: const Text('Öffnen'),
),
),
),
);
await tester.tap(find.text('Öffnen'));
await tester.pumpAndSettle();
final sign = tester.getRect(find.text('Unterschreiben'));
final close = tester.getRect(find.text('Schließen'));
expect(sign.bottom, lessThanOrEqualTo(close.top));
expect(tester.takeException(), isNull);
});
testWidgets('Nachweis-Workflow prüft Pflichtfelder', (tester) async {
await tester.pumpWidget(const MachweisApp());
await tester.tap(find.text('Demo starten'));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Demo-Nutzer anmelden'));
await tester.pumpAndSettle();
await tester.tap(find.text('Nachweis starten'));
await tester.pumpAndSettle();
expect(find.text('Wo wurde gearbeitet?'), findsOneWidget);
await tester.tap(find.text('Weiter'));
await tester.pump();
expect(find.text('Bitte Kunde und Baustelle eintragen.'), findsOneWidget);
});
testWidgets('Nachweis wird in der Übersicht angezeigt', (tester) async {
await tester.pumpWidget(const MachweisApp());
await tester.tap(find.text('Demo starten'));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Demo-Nutzer anmelden'));
await tester.pumpAndSettle();
await tester.tap(find.text('Nachweis starten'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Kunde'),
'Testkunde',
);
await tester.enterText(
find.widgetWithText(TextField, 'Baustelle / Auftrag'),
'Testbaustelle',
);
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Leistung 1'),
'Leitung montiert',
);
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Entwurf speichern'));
await tester.pumpAndSettle();
expect(find.text('Testbaustelle'), findsOneWidget);
expect(find.text('Bereit'), findsOneWidget);
});
test('Abgeschlossene Nachweise sind unveränderlich', () async {
final store = DemoReportStore.instance;
final report = store.reports.firstWhere(
(item) => item.site == 'Testbaustelle',
);
await store.finalize(report.id, const [
[0, 0],
[1, 1],
]);
final finalized = store.reports.firstWhere((item) => item.id == report.id);
await store.update(
DemoReport(
id: finalized.id,
customer: finalized.customer,
site: 'Manipuliert',
activity: finalized.activity,
material: finalized.material,
quantity: finalized.quantity,
date: finalized.date,
start: finalized.start,
end: finalized.end,
),
);
await store.remove(finalized.id);
expect(
store.reports.firstWhere((item) => item.id == finalized.id).site,
'Testbaustelle',
);
expect(
store.reports.firstWhere((item) => item.id == finalized.id).isFinalized,
isTrue,
);
final history = store.historyFor(finalized.id);
expect(history.map((entry) => entry.action), [
ReportAuditAction.finalized,
ReportAuditAction.created,
]);
expect(history.first.version, 2);
expect(history.last.snapshot.isFinalized, isFalse);
expect(history.first.snapshot.isFinalized, isTrue);
});
test('Bearbeitungen erzeugen Versionen und Löschspur', () async {
final store = DemoReportStore.instance;
final initial = DemoReport(
id: 'audit-test',
customer: 'Kunde A',
site: 'Baustelle A',
activity: 'Montage',
material: '',
quantity: '',
date: DateTime(2026, 8, 29),
start: const TimeOfDay(hour: 8, minute: 0),
end: const TimeOfDay(hour: 9, minute: 0),
);
await store.add(initial);
await store.update(
DemoReport(
id: initial.id,
customer: initial.customer,
site: 'Baustelle B',
activity: initial.activity,
material: initial.material,
quantity: initial.quantity,
date: initial.date,
start: initial.start,
end: initial.end,
),
);
await store.remove(initial.id);
expect(store.reports.where((item) => item.id == initial.id), isEmpty);
final history = store.historyFor(initial.id);
expect(history.map((entry) => entry.action), [
ReportAuditAction.deleted,
ReportAuditAction.updated,
ReportAuditAction.created,
]);
expect(history[1].version, 2);
expect(history.last.snapshot.site, 'Baustelle A');
expect(history.first.snapshot.site, 'Baustelle B');
});
}

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%