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/profile/presentation/profile_screen.dartDart
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../../../app/theme.dart';
import '../../../core/ai/ai_settings_store.dart';
import '../../../core/auth/auth_store.dart';
import '../../../core/auth/industry_profiles.dart';
import '../../../core/sync/master_data_sync_card.dart';
import '../data/company_store.dart';
import '../../employees/presentation/employees_screen.dart';
import '../../employees/data/employee_store.dart';
import 'ai_settings_dialog.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, this.authStore});
final AuthStore? authStore;
Future<void> logout(BuildContext context, AuthStore auth) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abmelden?'),
content: const Text(
'Die Anmeldung wird sicher von diesem Gerät entfernt. Lokal gespeicherte Stammdaten bleiben erhalten.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Abmelden'),
),
],
),
);
if (confirmed == true) await auth.logout();
}
Future<void> editCompany(BuildContext context) async {
final value = await showDialog<CompanyProfile>(
context: context,
builder: (_) =>
_CompanyEditDialog(initial: CompanyStore.instance.profile),
);
if (value != null && context.mounted) {
try {
await CompanyStore.instance.save(value);
} on Object {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Die Firmendaten konnten nicht gespeichert werden. Bitte versuche es erneut.',
),
),
);
}
}
}
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: CompanyStore.instance,
builder: (context, _) {
final company = CompanyStore.instance.profile;
final auth = authStore ?? AuthStore.instance;
final canManageCompany =
auth.session == null ||
auth.session!.role == 'owner' ||
auth.session!.role == 'office';
final mobileAuth =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Firma & Profil',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
auth.session == null
? 'Lokale Unternehmensdaten'
: 'Angemeldet als ${auth.session!.email}',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
FilledButton.icon(
onPressed: canManageCompany
? () => editCompany(context)
: null,
icon: const Icon(Icons.edit_outlined),
label: const Text('Bearbeiten'),
),
],
),
const SizedBox(height: 28),
Card(
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.all(20),
leading: const CircleAvatar(
backgroundColor: AppColors.forest,
child: Icon(
Icons.business_rounded,
color: Colors.white,
),
),
title: Text(
company.name.isEmpty
? 'Firmendaten noch nicht hinterlegt'
: company.name,
style: const TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text(
company.owner.isEmpty
? 'Kein Ansprechpartner hinterlegt'
: company.owner,
),
),
const Divider(height: 1),
_InfoTile(
icon: Icons.mail_outline_rounded,
title: 'Kontakt',
value: [
company.email,
company.phone,
].where((value) => value.isNotEmpty).join(' · '),
),
const Divider(height: 1),
_InfoTile(
icon: Icons.location_on_outlined,
title: 'Firmenanschrift',
value: company.address,
),
const Divider(height: 1),
_InfoTile(
icon: Icons.receipt_long_outlined,
title: 'Steuernummer',
value: company.taxNumber,
),
const Divider(height: 1),
_InfoTile(
icon: Icons.notes_rounded,
title: 'Berichtshinweis',
value: company.reportFooter,
),
],
),
),
if (auth.session != null) ...[
const SizedBox(height: 20),
Card(
child: ListTile(
onTap: canManageCompany
? () => _editIndustryProfiles(context, auth)
: null,
contentPadding: const EdgeInsets.all(20),
leading: const Icon(
Icons.category_outlined,
color: AppColors.green,
),
title: const Text(
'Branchenprofile',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(
auth.session!.industryProfiles.isEmpty
? 'Keine Branchenprofile hinterlegt.'
: auth.session!.industryProfiles
.map((code) {
for (final item in industryProfileOptions) {
if (item.code == code) return item.label;
}
return code;
})
.join(' · '),
),
),
),
],
const SizedBox(height: 20),
if (!kIsWeb) ...[
const MasterDataSyncCard(),
const SizedBox(height: 20),
],
ListenableBuilder(
listenable: EmployeeStore.instance,
builder: (context, _) => Card(
child: ListTile(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const EmployeesScreen(),
),
),
contentPadding: const EdgeInsets.all(20),
leading: const Icon(
Icons.groups_outlined,
color: AppColors.green,
),
title: const Text(
'Mitarbeiter & Rollen',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(
'${EmployeeStore.instance.activeEmployees.length} aktive Mitarbeiter',
),
trailing: const Icon(Icons.chevron_right_rounded),
),
),
),
const SizedBox(height: 20),
ListenableBuilder(
listenable: AiSettingsStore.instance,
builder: (context, _) {
final settings = AiSettingsStore.instance.settings;
return Card(
child: ListTile(
onTap: () => showDialog<void>(
context: context,
builder: (_) => const AiSettingsDialog(),
),
contentPadding: const EdgeInsets.all(20),
leading: const Icon(
Icons.auto_awesome_outlined,
color: AppColors.green,
),
title: const Text(
'KI-Unterstützung',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(
settings.enabled
? '${settings.provider.label} · ${settings.activeModel}${settings.usesProxy ? ' · Proxy' : ''}'
: 'Deaktiviert · OpenAI oder Google Gemini einrichten',
),
trailing: const Icon(Icons.chevron_right_rounded),
),
);
},
),
const SizedBox(height: 20),
const Card(
child: ListTile(
contentPadding: EdgeInsets.all(20),
leading: Icon(
Icons.offline_bolt_outlined,
color: AppColors.green,
),
title: Text(
'Offline bereit',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text(
'Firmendaten, Kunden, Aufträge und Nachweise werden lokal gespeichert.',
),
),
),
if (mobileAuth && auth.isSignedIn) ...[
const SizedBox(height: 20),
Card(
child: ListTile(
onTap: () => logout(context, auth),
contentPadding: const EdgeInsets.all(20),
leading: const Icon(
Icons.logout_rounded,
color: Colors.red,
),
title: const Text(
'Abmelden',
style: TextStyle(fontWeight: FontWeight.w700),
),
subtitle: const Text(
'Anmeldedaten sicher von diesem Gerät entfernen',
),
),
),
],
],
),
),
),
);
},
);
Future<void> _editIndustryProfiles(
BuildContext context,
AuthStore auth,
) async {
final selected = {...auth.session!.industryProfiles};
final result = await showDialog<List<String>>(
context: context,
builder: (_) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Branchenprofile bearbeiten'),
content: SingleChildScrollView(
child: Column(
children: industryProfileOptions
.map(
(item) => CheckboxListTile(
value: selected.contains(item.code),
title: Text(item.label),
onChanged: (value) => setState(
() => value == true
? selected.add(item.code)
: selected.remove(item.code),
),
),
)
.toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: selected.isEmpty
? null
: () => Navigator.pop(context, selected.toList()),
child: const Text('Speichern'),
),
],
),
),
);
if (result != null) await auth.updateIndustryProfiles(result);
}
}
class _CompanyEditDialog extends StatefulWidget {
const _CompanyEditDialog({required this.initial});
final CompanyProfile initial;
@override
State<_CompanyEditDialog> createState() => _CompanyEditDialogState();
}
class _CompanyEditDialogState extends State<_CompanyEditDialog> {
late String name;
late String owner;
late String email;
late String phone;
late String address;
late String taxNumber;
late String footer;
@override
void initState() {
super.initState();
name = widget.initial.name;
owner = widget.initial.owner;
email = widget.initial.email;
phone = widget.initial.phone;
address = widget.initial.address;
taxNumber = widget.initial.taxNumber;
footer = widget.initial.reportFooter;
}
CompanyProfile get _value => CompanyProfile(
name: name.trim(),
owner: owner.trim(),
email: email.trim(),
phone: phone.trim(),
address: address.trim(),
taxNumber: taxNumber.trim(),
reportFooter: footer.trim(),
);
Widget _field(
String value,
String label,
IconData icon,
ValueChanged<String> onChanged, {
int lines = 1,
}) => TextFormField(
initialValue: value,
minLines: lines,
maxLines: lines,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
alignLabelWithHint: lines > 1,
border: const OutlineInputBorder(),
),
);
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('Firmendaten bearbeiten'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_field(name, 'Firmenname', Icons.business_outlined, (value) {
setState(() => name = value);
}),
const SizedBox(height: 12),
_field(
owner,
'Inhaber / Ansprechpartner',
Icons.person_outline_rounded,
(value) => owner = value,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _field(
email,
'E-Mail',
Icons.mail_outline_rounded,
(value) => email = value,
),
),
const SizedBox(width: 12),
Expanded(
child: _field(
phone,
'Telefon',
Icons.phone_outlined,
(value) => phone = value,
),
),
],
),
const SizedBox(height: 12),
_field(
address,
'Firmenanschrift',
Icons.location_on_outlined,
(value) => address = value,
lines: 2,
),
const SizedBox(height: 12),
_field(
taxNumber,
'Steuernummer (optional)',
Icons.receipt_long_outlined,
(value) => taxNumber = value,
),
const SizedBox(height: 12),
_field(
footer,
'Hinweis auf Berichten',
Icons.notes_rounded,
(value) => footer = value,
lines: 2,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: name.trim().isEmpty
? null
: () => Navigator.pop(context, _value),
child: const Text('Speichern'),
),
],
);
}
class _InfoTile extends StatelessWidget {
const _InfoTile({
required this.icon,
required this.title,
required this.value,
});
final IconData icon;
final String title;
final String value;
@override
Widget build(BuildContext context) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(value.isEmpty ? 'Noch nicht hinterlegt' : value),
);
}

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%