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/sync_queue_store.dartDart
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../storage/local_storage.dart';
enum SyncAction { create, update, finalize, delete }
class SyncOperation {
const SyncOperation({
required this.id,
required this.entityType,
required this.entityId,
required this.action,
required this.payload,
required this.createdAt,
this.tenantId,
this.idempotencyKey,
this.baseRevision = 0,
this.conflict = false,
this.attempts = 0,
this.lastError,
this.nextAttemptAt,
});
final String id;
final String entityType;
final String entityId;
final SyncAction action;
final Map<String, dynamic> payload;
final DateTime createdAt;
final String? tenantId;
final String? idempotencyKey;
final int baseRevision;
final bool conflict;
final int attempts;
final String? lastError;
final DateTime? nextAttemptAt;
bool isDue(DateTime now) =>
nextAttemptAt == null || !nextAttemptAt!.isAfter(now);
SyncOperation failed(String message, DateTime now) {
final nextAttempts = attempts + 1;
final delayMinutes = math.min(60, math.pow(2, nextAttempts).toInt());
return SyncOperation(
id: id,
entityType: entityType,
entityId: entityId,
action: action,
payload: payload,
createdAt: createdAt,
tenantId: tenantId,
idempotencyKey: idempotencyKey,
baseRevision: baseRevision,
conflict: conflict,
attempts: nextAttempts,
lastError: message,
nextAttemptAt: now.add(Duration(minutes: delayMinutes)),
);
}
Map<String, Object?> toJson() => {
'id': id,
'entityType': entityType,
'entityId': entityId,
'action': action.name,
'payload': payload,
'createdAt': createdAt.toIso8601String(),
'tenantId': tenantId,
'idempotencyKey': idempotencyKey,
'baseRevision': baseRevision,
'conflict': conflict,
'attempts': attempts,
'lastError': lastError,
'nextAttemptAt': nextAttemptAt?.toIso8601String(),
};
factory SyncOperation.fromJson(Map<String, dynamic> json) => SyncOperation(
id: json['id'] as String,
entityType: json['entityType'] as String,
entityId: json['entityId'] as String,
action: SyncAction.values.firstWhere(
(value) => value.name == json['action'],
orElse: () => SyncAction.update,
),
payload: Map<String, dynamic>.from(
json['payload'] as Map<String, dynamic>? ?? const {},
),
createdAt: DateTime.parse(json['createdAt'] as String),
tenantId: json['tenantId'] as String?,
idempotencyKey: json['idempotencyKey'] as String?,
baseRevision: json['baseRevision'] as int? ?? 0,
conflict: json['conflict'] as bool? ?? false,
attempts: json['attempts'] as int? ?? 0,
lastError: json['lastError'] as String?,
nextAttemptAt: json['nextAttemptAt'] == null
? null
: DateTime.parse(json['nextAttemptAt'] as String),
);
}
abstract interface class SyncTransport {
Future<void> send(SyncOperation operation);
}
class SyncQueueStore extends ChangeNotifier {
SyncQueueStore._();
static final instance = SyncQueueStore._();
static const _storageKey = 'machweis_sync_queue_v1';
static const _reportRevisionKey = 'machweis_report_revisions_v1';
final List<SyncOperation> _operations = [];
final Map<String, int> _reportRevisions = {};
final Set<String> _sendingReportOperations = {};
bool _isProcessing = false;
List<SyncOperation> get operations => List.unmodifiable(_operations);
int get pendingCount => _operations.length;
bool get isProcessing => _isProcessing;
Future<void> initialize() async {
final stored = await LocalStorage.instance.getString(_storageKey);
if (stored == null) return;
try {
final values = jsonDecode(stored) as List<dynamic>;
_operations
..clear()
..addAll(
values.map(
(value) => SyncOperation.fromJson(value as Map<String, dynamic>),
),
);
} on Object {
await LocalStorage.instance.remove(_storageKey);
}
final revisions = await LocalStorage.instance.getString(_reportRevisionKey);
if (revisions != null) {
try {
_reportRevisions
..clear()
..addAll(
(jsonDecode(revisions) as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as int),
),
);
} on Object {
await LocalStorage.instance.remove(_reportRevisionKey);
}
}
}
int reportRevision(String tenantId, String reportId) =>
_reportRevisions['$tenantId:$reportId'] ?? 0;
Future<void> enqueue({
required String entityType,
required String entityId,
required SyncAction action,
required Map<String, dynamic> payload,
String? tenantId,
int baseRevision = 0,
}) async {
final pendingCreateIndex = _operations.indexWhere(
(item) =>
item.entityType == entityType &&
item.entityId == entityId &&
item.tenantId == tenantId &&
!_sendingReportOperations.contains(item.id) &&
item.action == SyncAction.create,
);
if (action == SyncAction.delete && pendingCreateIndex != -1) {
_operations.removeWhere(
(item) =>
item.entityType == entityType &&
item.entityId == entityId &&
item.tenantId == tenantId,
);
} else if ((action == SyncAction.update || action == SyncAction.finalize) &&
pendingCreateIndex != -1) {
final pendingCreate = _operations[pendingCreateIndex];
_operations[pendingCreateIndex] = SyncOperation(
id: pendingCreate.id,
entityType: entityType,
entityId: entityId,
action: SyncAction.create,
payload: payload,
createdAt: pendingCreate.createdAt,
tenantId: tenantId ?? pendingCreate.tenantId,
idempotencyKey: pendingCreate.idempotencyKey ?? pendingCreate.id,
baseRevision: pendingCreate.baseRevision,
);
_operations.removeWhere(
(item) =>
item.entityType == entityType &&
item.entityId == entityId &&
item.tenantId == tenantId &&
item.id != pendingCreate.id,
);
} else {
_operations.removeWhere(
(item) =>
item.entityType == entityType &&
item.entityId == entityId &&
item.tenantId == tenantId &&
item.action == action,
);
final now = DateTime.now();
_operations.add(
SyncOperation(
id: '$entityType-$entityId-${action.name}-${now.microsecondsSinceEpoch}',
entityType: entityType,
entityId: entityId,
action: action,
payload: payload,
createdAt: now,
tenantId: tenantId,
idempotencyKey:
'$entityType-$entityId-${action.name}-${now.microsecondsSinceEpoch}',
baseRevision: baseRevision,
),
);
}
await _persistAndNotify();
}
Future<void> adoptLegacyReports(String tenantId) async {
var changed = false;
for (var index = 0; index < _operations.length; index++) {
final operation = _operations[index];
if (operation.entityType != 'report' || operation.tenantId != null) {
continue;
}
_operations[index] = SyncOperation(
id: operation.id,
entityType: operation.entityType,
entityId: operation.entityId,
action: operation.action,
payload: operation.payload,
createdAt: operation.createdAt,
tenantId: tenantId,
idempotencyKey: operation.id,
baseRevision: operation.action == SyncAction.create
? 0
: ((operation.payload['version'] as int? ?? 1) - 1),
attempts: operation.attempts,
lastError: operation.lastError,
nextAttemptAt: operation.nextAttemptAt,
);
changed = true;
}
if (changed) await _persistAndNotify();
}
List<SyncOperation> dueReports(
String tenantId,
DateTime now, {
bool force = false,
}) {
final due = <SyncOperation>[];
final selectedReports = <String>{};
final conflictedReports = _operations
.where(
(item) =>
item.entityType == 'report' &&
item.tenantId == tenantId &&
item.conflict,
)
.map((item) => item.entityId)
.toSet();
for (final item in _operations) {
if (item.entityType != 'report' ||
item.tenantId != tenantId ||
item.conflict ||
conflictedReports.contains(item.entityId) ||
selectedReports.contains(item.entityId) ||
(!force && !item.isDue(now))) {
continue;
}
selectedReports.add(item.entityId);
due.add(item);
}
return due;
}
void beginReportSend(Iterable<SyncOperation> operations) {
_sendingReportOperations.addAll(operations.map((item) => item.id));
}
void endReportSend(Iterable<SyncOperation> operations) {
_sendingReportOperations.removeAll(operations.map((item) => item.id));
}
int reportPendingCount(String tenantId) => _operations
.where(
(item) =>
item.entityType == 'report' &&
item.tenantId == tenantId &&
!item.conflict,
)
.length;
int reportConflictCount(String tenantId) => _operations
.where(
(item) =>
item.entityType == 'report' &&
item.tenantId == tenantId &&
item.conflict,
)
.length;
Future<void> applyReportResult(
SyncOperation operation,
Map<String, dynamic> result,
) async {
final index = _operations.indexWhere((item) => item.id == operation.id);
if (index == -1) return;
if (result['status'] == 'applied') {
final tenantId = operation.tenantId;
final revision = result['row_version'] as int?;
if (tenantId != null && revision != null) {
_reportRevisions['$tenantId:${operation.entityId}'] = revision;
for (
var successorIndex = 0;
successorIndex < _operations.length;
successorIndex++
) {
final successor = _operations[successorIndex];
if (successor.id == operation.id ||
successor.entityType != 'report' ||
successor.tenantId != tenantId ||
successor.entityId != operation.entityId) {
continue;
}
_operations[successorIndex] = SyncOperation(
id: successor.id,
entityType: successor.entityType,
entityId: successor.entityId,
action: successor.action == SyncAction.create
? SyncAction.update
: successor.action,
payload: successor.payload,
createdAt: successor.createdAt,
tenantId: successor.tenantId,
idempotencyKey: successor.idempotencyKey,
baseRevision: revision,
attempts: successor.attempts,
lastError: successor.lastError,
nextAttemptAt: successor.nextAttemptAt,
);
}
await LocalStorage.instance.setString(
_reportRevisionKey,
jsonEncode(_reportRevisions),
);
}
_operations.removeAt(index);
} else {
final message =
result['message'] as String? ??
'Server und Gerät enthalten unterschiedliche Berichtsversionen.';
_operations[index] = SyncOperation(
id: operation.id,
entityType: operation.entityType,
entityId: operation.entityId,
action: operation.action,
payload: operation.payload,
createdAt: operation.createdAt,
tenantId: operation.tenantId,
idempotencyKey: operation.idempotencyKey,
baseRevision: operation.baseRevision,
conflict: true,
attempts: operation.attempts,
lastError: message,
);
}
await _persistAndNotify();
}
Future<void> failReport(
SyncOperation operation,
String message,
DateTime now,
) async {
final index = _operations.indexWhere((item) => item.id == operation.id);
if (index == -1) return;
_operations[index] = operation.failed(message, now);
await _persistAndNotify();
}
Future<void> process(SyncTransport transport, {DateTime? now}) async {
if (_isProcessing) return;
_isProcessing = true;
notifyListeners();
final currentTime = now ?? DateTime.now();
try {
for (final operation in List<SyncOperation>.from(_operations)) {
if (!operation.isDue(currentTime)) continue;
try {
await transport.send(operation);
_operations.removeWhere((item) => item.id == operation.id);
} on Object catch (error) {
final index = _operations.indexWhere(
(item) => item.id == operation.id,
);
if (index != -1) {
_operations[index] = operation.failed(
error.toString(),
currentTime,
);
}
}
await _persistAndNotify();
}
} finally {
_isProcessing = false;
notifyListeners();
}
}
Future<void> retryNow(String operationId) async {
final index = _operations.indexWhere((item) => item.id == operationId);
if (index == -1) return;
final operation = _operations[index];
_operations[index] = SyncOperation(
id: operation.id,
entityType: operation.entityType,
entityId: operation.entityId,
action: operation.action,
payload: operation.payload,
createdAt: operation.createdAt,
attempts: operation.attempts,
lastError: operation.lastError,
);
await _persistAndNotify();
}
Future<void> _persistAndNotify() async {
await LocalStorage.instance.setString(
_storageKey,
jsonEncode(_operations.map((item) => item.toJson()).toList()),
);
notifyListeners();
}
}

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%