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/master_data_sync_test.dartDart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:machweis/core/sync/master_data_sync.dart';
import 'package:machweis/core/sync/master_data_sync_card.dart';
import 'package:machweis/core/sync/master_data_sync_repository.dart';
const _operation = MasterDataOutboxOperation(
operationId: 'operation-1',
idempotencyKey: 'idempotency-1',
entityType: 'customers',
entityId: '10000000-0000-4000-8000-000000000001',
action: 'create',
baseRevision: 0,
payload: {'name': 'Kunde'},
attempts: 0,
);
class _Repository implements MasterDataSyncRepository {
_Repository({this.localOnly = false, this.lastError});
bool localOnly;
String? lastError;
InitialSyncDecision? decision = InitialSyncDecision.serverOnly;
int cursor = 0;
int pending = 1;
int conflicts = 0;
bool failed = false;
bool pulled = false;
List<MasterDataOutboxOperation> operations = const [_operation];
@override
Future<void> clearLastError(String tenantId) async {
lastError = null;
}
@override
Future<void> applyPull(
String tenantId,
List<Map<String, dynamic>> changes,
int nextCursor,
) async {
cursor = nextCursor;
pulled = true;
}
@override
Future<void> applyPushResults(List<Map<String, dynamic>> results) async {
if (results.any((item) => item['status'] != 'applied')) {
conflicts = 1;
} else {
pending = 0;
}
}
@override
Future<void> completeSync(String tenantId, DateTime now) async {
lastError = null;
}
@override
Future<int> conflictCount(String tenantId) async => conflicts;
@override
Future<List<MasterDataOutboxOperation>> dueOperations(
String tenantId,
DateTime now,
) async => operations;
@override
Future<void> failOperations(
List<MasterDataOutboxOperation> operations,
String message,
DateTime now,
) async => failed = true;
@override
Future<void> failSync(String tenantId, String message) async {
failed = true;
lastError = message;
}
@override
Future<bool> hasLocalOnlyData(String tenantId) async => localOnly;
@override
Future<int> pendingCount(String tenantId) async => pending;
@override
Future<void> retryNow(String tenantId) async {}
@override
Future<void> setInitialDecision(
String tenantId,
InitialSyncDecision value,
) async {
decision = value;
}
@override
Future<MasterDataSyncState> state(String tenantId) async =>
MasterDataSyncState(
cursor: cursor,
decision: decision,
lastError: lastError,
);
}
class _Api implements MasterDataSyncApi {
_Api({
this.conflict = false,
this.offline = false,
this.unauthorizedOnce = false,
});
final bool conflict;
final bool offline;
bool unauthorizedOnce;
int pushes = 0;
int pulls = 0;
@override
Future<SyncPullResponse> pull(
String accessToken, {
required int cursor,
}) async {
pulls++;
if (offline) throw const SyncClientException('offline');
return SyncPullResponse(
changes: [
{
'cursor': cursor + 1,
'entity': 'customers',
'entity_id': _operation.entityId,
'operation': 'upsert',
'row_version': 1,
'data': {'id': _operation.entityId, 'name': 'Serverkunde'},
},
],
cursor: cursor + 1,
hasMore: false,
);
}
@override
Future<SyncPushResponse> push(
String accessToken,
List<MasterDataOutboxOperation> operations,
) async {
pushes++;
if (unauthorizedOnce) {
unauthorizedOnce = false;
throw const SyncClientException('unauthorized', unauthorized: true);
}
if (offline) throw const SyncClientException('offline');
return SyncPushResponse([
{
'operation_id': operations.single.operationId,
'status': conflict ? 'rejected' : 'applied',
'code': conflict ? 'row_version_conflict' : 'ok',
'row_version': conflict ? null : 1,
},
]);
}
}
MasterDataSyncController _controller(
_Repository repository,
_Api api, {
Future<String> Function({bool forceRefresh})? tokens,
}) => MasterDataSyncController(
repository: repository,
api: api,
supported: true,
tenantProvider: () => 'tenant-a',
tokenProvider:
tokens ??
({bool forceRefresh = false}) async =>
forceRefresh ? 'refreshed' : 'access',
);
void main() {
test('Outbox-Operation behält Idempotenzschlüssel und Basisrevision', () {
expect(_operation.toApiJson()['operation_id'], 'operation-1');
expect(_operation.toApiJson()['idempotency_key'], 'idempotency-1');
expect(_operation.toApiJson()['base_revision'], 0);
});
test('Push und Pull aktualisieren Cursor und Sync-Status', () async {
final repository = _Repository();
final api = _Api();
final controller = _controller(repository, api);
await controller.initialize();
await controller.synchronize(now: DateTime(2026, 8, 30));
expect(api.pushes, 1);
expect(api.pulls, 1);
expect(repository.cursor, 1);
expect(repository.pulled, isTrue);
expect(controller.status, MasterDataSyncStatus.success);
});
test(
'Neue Sitzung entfernt alten Fehler ohne Sync-Daten zu ändern',
() async {
final repository = _Repository(lastError: 'alter Fehler')
..cursor = 17
..operations = const [_operation];
String? tenant;
final controller = MasterDataSyncController(
repository: repository,
api: _Api(),
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({bool forceRefresh = false}) async => 'access',
);
await controller.initialize();
expect(controller.status, MasterDataSyncStatus.unavailable);
tenant = 'tenant-a';
await controller.initialize();
expect(controller.message, isNull);
expect(repository.lastError, isNull);
expect(repository.cursor, 17);
expect(repository.operations, const [_operation]);
},
);
test('Neuer erfolgreicher Sync ersetzt einen alten Fehler', () async {
final repository = _Repository(lastError: 'alter Fehler');
final controller = _controller(repository, _Api());
await controller.initialize();
await controller.synchronize();
expect(repository.lastError, isNull);
expect(controller.status, MasterDataSyncStatus.success);
expect(controller.message, 'Stammdaten sind synchronisiert.');
});
test('Neuer echter Sync-Fehler wird sichtbar gespeichert', () async {
final repository = _Repository(lastError: 'alter Fehler');
final controller = _controller(repository, _Api(offline: true));
await controller.initialize();
await controller.synchronize();
expect(repository.lastError, 'offline');
expect(controller.status, MasterDataSyncStatus.offline);
expect(controller.message, 'offline');
});
test('401 erneuert das Access-Token genau einmal', () async {
final repository = _Repository();
final api = _Api(unauthorizedOnce: true);
var refreshes = 0;
final controller = _controller(
repository,
api,
tokens: ({bool forceRefresh = false}) async {
if (forceRefresh) refreshes++;
return forceRefresh ? 'refreshed' : 'access';
},
);
await controller.initialize();
await controller.synchronize();
expect(api.pushes, 2);
expect(refreshes, 1);
expect(controller.status, MasterDataSyncStatus.success);
});
test('Versionskonflikt wird sichtbar und nicht überschrieben', () async {
final repository = _Repository();
final controller = _controller(repository, _Api(conflict: true));
await controller.initialize();
await controller.synchronize();
expect(repository.conflicts, 1);
expect(controller.status, MasterDataSyncStatus.conflict);
expect(controller.message, contains('Konflikt'));
});
test('Offlinefall behält Outbox und markiert Retry', () async {
final repository = _Repository();
final controller = _controller(repository, _Api(offline: true));
await controller.initialize();
await controller.synchronize(now: DateTime(2026, 8, 30));
expect(repository.failed, isTrue);
expect(repository.pending, 1);
expect(controller.status, MasterDataSyncStatus.offline);
});
test('HTTP-Client akzeptiert 409 mit klaren Einzelresultaten', () async {
final client = HttpMasterDataSyncApi(
baseUrl: 'https://api.example.test',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/sync/push');
expect(request.headers['authorization'], 'Bearer access');
return Response(
jsonEncode({
'data': {
'results': [
{
'operation_id': 'operation-1',
'status': 'rejected',
'code': 'row_version_conflict',
},
],
},
}),
409,
);
}),
);
final response = await client.push('access', const [_operation]);
expect(response.results.single['code'], 'row_version_conflict');
});
test(
'Push und Pull wiederholen HTTP 401 mit dem jeweils erneuerten Token',
() async {
var requestIndex = 0;
final requests = <String>[];
final responses = <Response>[
Response(
jsonEncode({
'error': {'code': 'authentication_failed'},
}),
401,
),
Response(
jsonEncode({
'data': {
'results': [
{
'operation_id': _operation.operationId,
'status': 'applied',
'code': 'ok',
'row_version': 1,
},
],
},
}),
200,
),
Response(
jsonEncode({
'error': {'code': 'authentication_failed'},
}),
401,
),
Response(
jsonEncode({
'data': {'changes': [], 'cursor': 0, 'has_more': false},
}),
200,
),
];
final api = HttpMasterDataSyncApi(
baseUrl: 'https://api.example.test',
client: MockClient((request) async {
requests.add(
'${request.method} ${request.url.path} ${request.headers['authorization']}',
);
return responses[requestIndex++];
}),
);
final repository = _Repository();
var refreshes = 0;
final controller = MasterDataSyncController(
repository: repository,
api: api,
supported: true,
tenantProvider: () => 'tenant-a',
tokenProvider: ({bool forceRefresh = false}) async {
if (forceRefresh) refreshes++;
return forceRefresh ? 'refreshed-$refreshes' : 'access';
},
);
await controller.initialize();
await controller.synchronize();
expect(requestIndex, 4);
expect(refreshes, 2);
expect(requests, [
'POST /api/v1/sync/push Bearer access',
'POST /api/v1/sync/push Bearer refreshed-1',
'GET /api/v1/sync/pull Bearer refreshed-1',
'GET /api/v1/sync/pull Bearer refreshed-2',
]);
expect(controller.status, MasterDataSyncStatus.success);
},
);
testWidgets('Erstverbindung verlangt eine sichtbare Importentscheidung', (
tester,
) async {
final repository = _Repository(localOnly: true)..decision = null;
final controller = _controller(repository, _Api());
await controller.initialize();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: MasterDataSyncCard(controller: controller)),
),
);
expect(find.text('Alles synchronisieren'), findsOneWidget);
expect(find.textContaining('Entscheidung'), findsOneWidget);
await tester.tap(find.text('Alles synchronisieren'));
await tester.pumpAndSettle();
expect(find.text('Erste Synchronisation'), findsOneWidget);
expect(find.text('Nur Serverdaten ergänzen'), findsOneWidget);
expect(find.text('Lokale Daten importieren'), findsOneWidget);
});
}

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%