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/core/sync/master_data_sync.dartDart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../auth/auth_store.dart';
import 'master_data_sync_repository.dart';
class SyncClientException implements Exception {
const SyncClientException(this.message, {this.unauthorized = false});
final String message;
final bool unauthorized;
@override
String toString() => message;
}
class SyncPushResponse {
const SyncPushResponse(this.results);
final List<Map<String, dynamic>> results;
}
class SyncPullResponse {
const SyncPullResponse({
required this.changes,
required this.cursor,
required this.hasMore,
});
final List<Map<String, dynamic>> changes;
final int cursor;
final bool hasMore;
}
abstract interface class MasterDataSyncApi {
Future<SyncPushResponse> push(
String accessToken,
List<MasterDataOutboxOperation> operations,
);
Future<SyncPullResponse> pull(String accessToken, {required int cursor});
}
class HttpMasterDataSyncApi implements MasterDataSyncApi {
HttpMasterDataSyncApi({http.Client? client, String? baseUrl})
: _client = client ?? http.Client(),
baseUrl =
(baseUrl ??
const String.fromEnvironment(
'MACHWEIS_API_BASE_URL',
defaultValue: 'https://machweisapi.haselhuhn-media.com',
))
.replaceFirst(RegExp(r'/$'), '');
final http.Client _client;
final String baseUrl;
@override
Future<SyncPushResponse> push(
String accessToken,
List<MasterDataOutboxOperation> operations,
) async {
if (kIsWeb) {
throw const SyncClientException('Web-Synchronisation ist deaktiviert.');
}
try {
final response = await _client.post(
Uri.parse('$baseUrl/api/v1/sync/push'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
'Idempotency-Key': operations
.map((item) => item.idempotencyKey)
.join('.'),
},
body: jsonEncode({
'operations': operations.map((item) => item.toApiJson()).toList(),
}),
);
_debugResponse('POST /api/v1/sync/push', response);
final data = _responseData(response);
final results = data['results'] as List<dynamic>? ?? const [];
if (kDebugMode) {
for (final item in results.whereType<Map>()) {
if (item['status'] == 'rejected') {
debugPrint(
'[MACHWEIS Sync] Push abgelehnt: ${item['entity']}/${item['action'] ?? 'operation'} (${item['code'] ?? 'unknown'})',
);
}
}
}
return SyncPushResponse(
results.map((item) => Map<String, dynamic>.from(item as Map)).toList(),
);
} on http.ClientException {
throw const SyncClientException(
'Offline – Synchronisation wird später erneut versucht.',
);
}
}
@override
Future<SyncPullResponse> pull(
String accessToken, {
required int cursor,
}) async {
if (kIsWeb) {
throw const SyncClientException('Web-Synchronisation ist deaktiviert.');
}
try {
final response = await _client.get(
Uri.parse('$baseUrl/api/v1/sync/pull?cursor=$cursor&limit=100'),
headers: {'Authorization': 'Bearer $accessToken'},
);
_debugResponse('GET /api/v1/sync/pull', response);
final data = _responseData(response);
return SyncPullResponse(
changes: (data['changes'] as List<dynamic>? ?? const [])
.map((item) => Map<String, dynamic>.from(item as Map))
.toList(),
cursor: data['cursor'] as int? ?? cursor,
hasMore: data['has_more'] as bool? ?? false,
);
} on http.ClientException {
throw const SyncClientException(
'Offline – Synchronisation wird später erneut versucht.',
);
}
}
Map<String, dynamic> _responseData(http.Response response) {
final body = response.body.isEmpty
? const <String, dynamic>{}
: jsonDecode(response.body) as Map<String, dynamic>;
if (response.statusCode == 401) {
throw const SyncClientException(
'Anmeldung muss erneuert werden.',
unauthorized: true,
);
}
if (response.statusCode >= 500) {
throw const SyncClientException(
'Der Sync-Server ist vorübergehend nicht erreichbar.',
);
}
if (response.statusCode != 200 &&
response.statusCode != 403 &&
response.statusCode != 409 &&
response.statusCode != 422) {
throw const SyncClientException('Unerwartete Antwort des Sync-Servers.');
}
return Map<String, dynamic>.from(
body['data'] as Map<String, dynamic>? ?? const {},
);
}
void _debugResponse(String request, http.Response response) {
if (kDebugMode) {
debugPrint('[MACHWEIS Sync] $request: HTTP ${response.statusCode}');
}
}
}
enum MasterDataSyncStatus {
unavailable,
needsDecision,
idle,
syncing,
success,
offline,
conflict,
error,
}
typedef SyncAccessTokenProvider = Future<String> Function({bool forceRefresh});
typedef SyncTenantProvider = String? Function();
class MasterDataSyncController extends ChangeNotifier {
MasterDataSyncController({
MasterDataSyncRepository? repository,
MasterDataSyncApi? api,
SyncAccessTokenProvider? tokenProvider,
SyncTenantProvider? tenantProvider,
bool? supported,
}) : _repository = repository ?? SqliteMasterDataSyncRepository(),
_api = api ?? HttpMasterDataSyncApi(),
_tokenProvider = tokenProvider ?? AuthStore.instance.accessToken,
_tenantProvider =
tenantProvider ?? (() => AuthStore.instance.session?.tenantId),
_supported =
supported ??
(!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS));
static final instance = MasterDataSyncController();
final MasterDataSyncRepository _repository;
final MasterDataSyncApi _api;
final SyncAccessTokenProvider _tokenProvider;
final SyncTenantProvider _tenantProvider;
final bool _supported;
MasterDataSyncStatus status = MasterDataSyncStatus.unavailable;
int pendingCount = 0;
int conflictCount = 0;
DateTime? lastSyncAt;
String? message;
String? conflictMessage;
String? _tenantId;
Future<void> Function()? onDataChanged;
bool get requiresInitialDecision =>
status == MasterDataSyncStatus.needsDecision;
bool get isSyncing => status == MasterDataSyncStatus.syncing;
Future<void> initialize() async {
final tenant = _tenantProvider();
if (!_supported || tenant == null) {
status = MasterDataSyncStatus.unavailable;
notifyListeners();
return;
}
_tenantId = tenant;
await _repository.clearLastError(tenant);
final state = await _repository.state(tenant);
lastSyncAt = state.lastSyncAt;
message = state.lastError;
if (state.decision == null) {
if (await _repository.hasLocalOnlyData(tenant)) {
status = MasterDataSyncStatus.needsDecision;
} else {
await _repository.setInitialDecision(
tenant,
InitialSyncDecision.serverOnly,
);
status = MasterDataSyncStatus.idle;
}
} else {
status = MasterDataSyncStatus.idle;
}
await _refreshCounts();
notifyListeners();
}
Future<void> chooseInitialSync(InitialSyncDecision decision) async {
final tenant = _tenantId;
if (tenant == null) return;
await _repository.setInitialDecision(tenant, decision);
status = MasterDataSyncStatus.idle;
message = decision == InitialSyncDecision.importLocal
? 'Lokale Stammdaten wurden zum Import vorgemerkt.'
: 'Serverdaten werden ergänzt; lokale Altdaten bleiben unangetastet.';
await _refreshCounts();
notifyListeners();
}
Future<void> synchronize({DateTime? now}) async {
final tenant = _tenantId;
if (!_supported || tenant == null || requiresInitialDecision || isSyncing) {
return;
}
final startedAt = now ?? DateTime.now();
status = MasterDataSyncStatus.syncing;
message = 'Stammdaten werden synchronisiert …';
notifyListeners();
var operations = <MasterDataOutboxOperation>[];
try {
await _repository.clearLastError(tenant);
await _repository.retryNow(tenant);
operations = await _repository.dueOperations(tenant, startedAt);
var token = await _tokenProvider();
if (operations.isNotEmpty) {
final pushed = await _withRefresh(
token,
(value) => _api.push(value, operations),
);
token = pushed.$1;
await _repository.applyPushResults(pushed.$2.results);
}
var state = await _repository.state(tenant);
var hasMore = true;
while (hasMore) {
final pulled = await _withRefresh(
token,
(value) => _api.pull(value, cursor: state.cursor),
);
token = pulled.$1;
await _repository.applyPull(
tenant,
pulled.$2.changes,
pulled.$2.cursor,
);
hasMore = pulled.$2.hasMore;
state = await _repository.state(tenant);
}
await _repository.completeSync(tenant, startedAt);
await _refreshCounts();
conflictMessage =
conflictCount > 0 && _repository is SqliteMasterDataSyncRepository
? await (_repository).latestConflictMessage(tenant)
: null;
lastSyncAt = startedAt;
status = conflictCount > 0
? MasterDataSyncStatus.conflict
: MasterDataSyncStatus.success;
message = conflictCount > 0
? '$conflictCount Konflikt(e) benötigen eine Prüfung${conflictMessage == null ? '.' : ': $conflictMessage'}'
: 'Stammdaten sind synchronisiert.';
await onDataChanged?.call();
} on SyncClientException catch (error) {
if (operations.isNotEmpty) {
await _repository.failOperations(operations, error.message, startedAt);
}
await _repository.failSync(tenant, error.message);
await _refreshCounts();
status = error.unauthorized
? MasterDataSyncStatus.error
: MasterDataSyncStatus.offline;
message = error.message;
} on Object {
const error = 'Synchronisation konnte nicht abgeschlossen werden.';
await _repository.failSync(tenant, error);
await _refreshCounts();
status = MasterDataSyncStatus.error;
message = error;
} finally {
notifyListeners();
}
}
Future<void> discardConflicts() async {
final tenant = _tenantId;
if (tenant == null || _repository is! SqliteMasterDataSyncRepository) {
return;
}
await (_repository).discardConflicts(tenant);
await _refreshCounts();
conflictMessage = null;
message =
'Lokale Konfliktänderungen wurden verworfen; die Serverdaten bleiben unverändert.';
status = MasterDataSyncStatus.idle;
notifyListeners();
}
Future<(String, T)> _withRefresh<T>(
String token,
Future<T> Function(String token) action,
) async {
try {
return (token, await action(token));
} on SyncClientException catch (error) {
if (!error.unauthorized) rethrow;
if (kDebugMode) {
debugPrint(
'[MACHWEIS Sync] HTTP 401: Access-Token wird erneuert und der Request einmal wiederholt.',
);
}
final refreshed = await _tokenProvider(forceRefresh: true);
return (refreshed, await action(refreshed));
}
}
Future<void> _refreshCounts() async {
final tenant = _tenantId;
if (tenant == null) return;
pendingCount = await _repository.pendingCount(tenant);
conflictCount = await _repository.conflictCount(tenant);
}
}

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%