Files
Handwerks_app/screens/shell/main_shell_screen.dart
JUSN 9ddce354c0 Feature
ein paar feature aber datenbank macht probleme wenn man aufträge speichern möchge
2026-04-05 12:47:57 +02:00

1154 lines
39 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:appwrite/appwrite.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../appwrite_config.dart';
import '../../config/appwrite_rechnungen_setup.dart';
import '../../config/branding.dart';
import '../../utils/appwrite_error_message.dart';
import '../../legal/legal_content.dart';
import '../../models/auftrag.dart';
import '../../models/auftrag_list_sort.dart';
import '../../models/auftrag_status.dart';
import '../../models/dokument_typ.dart';
import '../../models/zahlungs_status.dart';
import '../../services/auftrag_repository.dart';
import '../../services/data_export_service.dart';
import '../../services/pdf_history_service.dart';
import '../../services/workflow_metrics_service.dart';
import '../../theme/app_theme.dart';
import '../auftrag/auftrag_bearbeiten_screen.dart';
import '../legal/legal_document_screen.dart';
/// Haupt-Navigation wie im Mockup: Start, Aufträge, PDFs, Profil.
class MainShellScreen extends StatefulWidget {
const MainShellScreen({super.key, this.onLoggedOut});
final VoidCallback? onLoggedOut;
@override
State<MainShellScreen> createState() => _MainShellScreenState();
}
class _MainShellScreenState extends State<MainShellScreen> {
final _repo = AuftragRepository();
final _account = Account(appwriteClient);
final _searchCtrl = TextEditingController();
int _tab = 0;
String? _name;
String? _email;
List<Auftrag> _list = [];
bool _loading = true;
String? _error;
AuftragListenSort _sort = AuftragListenSort.datumNeu;
List<PdfHistoryEntry> _pdfHistory = [];
String _appVersion = '';
WorkflowMetricsSnapshot? _metrics;
@override
void initState() {
super.initState();
_searchCtrl.addListener(() => setState(() {}));
_refresh();
_loadPdfHistory();
_loadMetrics();
PackageInfo.fromPlatform().then((p) {
if (mounted) {
setState(() => _appVersion = '${p.version} (${p.buildNumber})');
}
});
}
Future<void> _loadPdfHistory() async {
final h = await PdfHistoryService.load();
if (mounted) setState(() => _pdfHistory = h);
}
Future<void> _loadMetrics() async {
final m = await WorkflowMetricsService.load();
if (mounted) setState(() => _metrics = m);
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
List<Auftrag> get _filteredList {
final q = _searchCtrl.text.trim().toLowerCase();
if (q.isEmpty) return _list;
bool hit(String s) => s.toLowerCase().contains(q);
return _list.where((a) {
return hit(a.titel) ||
hit(a.kundenName) ||
hit(a.beschreibung) ||
hit(a.kundenAdresse) ||
hit(a.rechnungsnummer) ||
hit(a.dokumentTyp.labelDe) ||
hit(a.zahlungsStatus.labelDe);
}).toList();
}
Future<void> _refresh() async {
setState(() {
_loading = true;
_error = null;
});
try {
final u = await _account.get();
final list = await _repo.listAuftraege();
if (!mounted) return;
setState(() {
_name = u.name;
_email = u.email;
_list = list;
_loading = false;
});
await _loadPdfHistory();
await _loadMetrics();
} catch (e) {
if (!mounted) return;
setState(() {
_error = nachrichtFuerAppwriteDatenFehler(e);
_loading = false;
});
}
}
Future<void> _openEditor({String? auftragId}) async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => auftragId != null
? AuftragBearbeitenScreen(auftragId: auftragId)
: const AuftragBearbeitenScreen(),
),
);
if (mounted) await _refresh();
}
List<Auftrag> get _sortedAll => sortAuftraege(_list, _sort);
List<Auftrag> get _filteredSorted =>
sortAuftraege(_filteredList, _sort);
Widget _retryLoadSliver() {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Daten konnten nicht geladen werden.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade400),
),
const SizedBox(height: 8),
SelectableText(
_error ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF888888),
fontSize: 12,
height: 1.35,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _loading ? null : _refresh,
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
),
],
),
),
),
);
}
bool _istForderungsDokument(DokumentTyp t) =>
t == DokumentTyp.rechnung ||
t == DokumentTyp.abschlag ||
t == DokumentTyp.schlussrechnung ||
t == DokumentTyp.mahnung;
int get _offenePostenCount => _list.where((a) {
return _istForderungsDokument(a.dokumentTyp) &&
a.zahlungsStatus == ZahlungsStatus.offen;
}).length;
int get _ueberfaelligCount {
final heute = DateTime.now();
final tagHeute = DateTime(heute.year, heute.month, heute.day);
return _list.where((a) {
if (a.zahlungsStatus == ZahlungsStatus.bezahlt) return false;
final f = a.faelligAm;
if (f == null) return false;
final tf = DateTime(f.year, f.month, f.day);
return !tf.isAfter(tagHeute);
}).length;
}
Future<void> _exportData() async {
try {
final list = await _repo.listAuftraege();
final file = await DataExportService.exportAuftraegeToTempFile(list);
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path)],
subject: '$kAppDisplayName Datenexport',
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export vorbereitet.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export fehlgeschlagen: $e')),
);
}
}
}
Future<void> _exportCsv() async {
try {
final list = await _repo.listAuftraege();
final file = await DataExportService.exportAuftraegeCsvToTempFile(list);
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(file.path)],
subject: '$kAppDisplayName CSV-Export',
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('CSV-Export vorbereitet (Excel DE).')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('CSV-Export fehlgeschlagen: $e')),
);
}
}
}
Future<void> _openSupportMail() async {
final uri = Uri.parse(
'mailto:$kSupportEmail?subject=${Uri.encodeComponent('$kAppDisplayName Support')}',
);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Mail-App nicht erreichbar ($kSupportEmail)')),
);
}
}
String _greeting() {
final h = DateTime.now().hour;
if (h < 11) return 'Guten Morgen';
if (h < 18) return 'Guten Tag';
return 'Guten Abend';
}
({String label, Color color}) _statusBadge(Auftrag a) {
return (
label: a.status.labelDe,
color: a.status.badgeColor,
);
}
String _zeitZeile(Auftrag a) {
final c = a.createdAt ?? DateTime.now();
final n = DateTime.now();
final heute = DateTime(n.year, n.month, n.day);
final tag = DateTime(c.year, c.month, c.day);
final morgen = heute.add(const Duration(days: 1));
final zeit = DateFormat('HH:mm').format(c);
if (tag == heute) return 'Heute, $zeit Uhr';
if (tag == morgen) return 'Morgen, $zeit Uhr';
return DateFormat('dd.MM.yyyy, HH:mm').format(c);
}
int get _heuteCount {
final n = DateTime.now();
return _list.where((a) {
final c = a.createdAt;
return c != null &&
c.year == n.year &&
c.month == n.month &&
c.day == n.day;
}).length;
}
int get _offenCount =>
_list.where((a) => a.status == AuftragStatus.offen).length;
int get _erledigtCount =>
_list.where((a) => a.status == AuftragStatus.fertig).length;
@override
Widget build(BuildContext context) {
final name = _name?.trim().isNotEmpty == true ? _name!.trim() : 'Handwerker';
return Scaffold(
body: IndexedStack(
index: _tab,
children: [
_buildStartTab(context, name),
_buildAuftraegeTab(context),
_buildPdfsTab(context),
_buildProfilTab(context, name),
],
),
bottomNavigationBar: NavigationBar(
height: 72,
selectedIndex: _tab,
onDestinationSelected: (i) {
setState(() => _tab = i);
if (i == 2) _loadPdfHistory();
},
backgroundColor: AppTheme.card,
indicatorColor: AppTheme.accentCyan.withValues(alpha: 0.22),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home, color: AppTheme.accentCyan),
label: 'Start',
),
NavigationDestination(
icon: const Icon(Icons.assignment_outlined),
selectedIcon:
const Icon(Icons.assignment, color: AppTheme.accentCyan),
label: 'Aufträge',
),
NavigationDestination(
icon: const Icon(Icons.picture_as_pdf_outlined),
selectedIcon:
const Icon(Icons.picture_as_pdf, color: AppTheme.accentCyan),
label: 'PDFs',
),
NavigationDestination(
icon: const Icon(Icons.person_outline),
selectedIcon: const Icon(Icons.person, color: AppTheme.accentCyan),
label: 'Profil',
),
],
),
floatingActionButton: (_tab == 0 || _tab == 1)
? FloatingActionButton(
onPressed: _loading ? null : () => _openEditor(),
child: const Icon(Icons.add),
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
);
}
Widget _purpleAppBar({
required String title,
List<Widget>? actions,
}) {
return SliverAppBar(
pinned: true,
backgroundColor: AppTheme.headerPurple,
foregroundColor: Colors.white,
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20),
),
actions: actions,
);
}
Widget _buildStartTab(BuildContext context, String displayName) {
return RefreshIndicator(
color: AppTheme.accentCyan,
onRefresh: _refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
_purpleAppBar(
title: 'HandwerkPro',
actions: [
IconButton(
icon: const Icon(Icons.notifications_none),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Benachrichtigungen folgen.')),
);
},
),
IconButton(
icon: const Icon(Icons.account_circle_outlined),
onPressed: () => setState(() => _tab = 3),
),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text(
'${_greeting()}, $displayName 👷',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: _StatCard(
label: 'Offen',
value: _loading ? '' : '$_offenCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
label: 'Heute',
value: _loading ? '' : '$_heuteCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
label: 'Erledigt',
value: _loading ? '' : '$_erledigtCount',
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: Row(
children: [
Expanded(
child: _StatCard(
label: 'Offene Posten',
value: _loading ? '' : '$_offenePostenCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
label: 'Überfällig',
value: _loading ? '' : '$_ueberfaelligCount',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
label: 'PDFs (Woche)',
value: _metrics == null
? ''
: '${_metrics!.pdfThisCalendarWeek}',
),
),
],
),
),
),
if (_metrics != null && (_metrics!.pdfAllTime > 0 || _metrics!.lastPdfAt != null))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
child: Text(
_metrics!.lastPdfAt != null
? 'Zuletzt PDF: ${DateFormat('dd.MM.yyyy HH:mm').format(_metrics!.lastPdfAt!)} · '
'gesamt ${_metrics!.pdfAllTime}'
: 'PDFs gesamt: ${_metrics!.pdfAllTime}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 8, 8),
child: Row(
children: [
Text(
'Aufträge',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 4),
PopupMenuButton<AuftragListenSort>(
initialValue: _sort,
tooltip: 'Sortierung',
color: AppTheme.card,
onSelected: (v) => setState(() => _sort = v),
itemBuilder: (context) => AuftragListenSort.values
.map(
(s) => PopupMenuItem(
value: s,
child: Text(s.label),
),
)
.toList(),
child: Icon(
Icons.sort_rounded,
color: Colors.grey.shade500,
size: 22,
),
),
const Spacer(),
TextButton(
onPressed: () => setState(() => _tab = 1),
child: const Text(
'Alle anzeigen →',
style: TextStyle(color: AppTheme.accentCyan),
),
),
],
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null) _retryLoadSliver()
else if (_list.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Noch keine Aufträge.\nTippe auf + für einen neuen Auftrag.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: const Color(0xFF9E9E9E),
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
sliver: SliverList.separated(
itemCount: _sortedAll.length > 5 ? 5 : _sortedAll.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 10),
itemBuilder: (context, i) {
final a = _sortedAll[i];
final st = _statusBadge(a);
return _AuftragKarte(
titel: a.titel.isEmpty ? '(Ohne Titel)' : a.titel,
untertitel: _zeitZeile(a),
kunde: a.kundenName.isEmpty ? 'Kunde' : a.kundenName,
metaZeile:
'${a.dokumentTyp.labelDe} · ${a.zahlungsStatus.labelDe}',
statusLabel: st.label,
statusColor: st.color,
onTap: () => _openEditor(auftragId: a.id),
);
},
),
),
],
),
);
}
Widget _buildAuftraegeTab(BuildContext context) {
final filtered = _filteredSorted;
return RefreshIndicator(
color: AppTheme.accentCyan,
onRefresh: _refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
_purpleAppBar(title: 'Aufträge'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: TextField(
controller: _searchCtrl,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Suche Titel, Kunde, Adresse, Nr. …',
hintStyle: TextStyle(color: Colors.grey.shade600),
prefixIcon:
Icon(Icons.search, color: Colors.grey.shade500),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
color: Colors.grey.shade500,
onPressed: () {
_searchCtrl.clear();
setState(() {});
},
)
: null,
filled: true,
fillColor: AppTheme.card,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF404040)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppTheme.accentCyan,
width: 1.5,
),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
Text(
'Sortierung',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 13,
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<AuftragListenSort>(
key: ValueKey(_sort),
initialValue: _sort,
dropdownColor: AppTheme.card,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
filled: true,
fillColor: AppTheme.card,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide:
const BorderSide(color: Color(0xFF404040)),
),
),
items: AuftragListenSort.values
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(s.label),
),
)
.toList(),
onChanged: (v) {
if (v != null) setState(() => _sort = v);
},
),
),
],
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null)
_retryLoadSliver()
else if (_list.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Keine Aufträge',
style: TextStyle(color: Colors.grey.shade600),
),
),
)
else if (filtered.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Keine Treffer für die Suche.',
style: TextStyle(color: Colors.grey.shade600),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
sliver: SliverList.separated(
itemCount: filtered.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 10),
itemBuilder: (context, i) {
final a = filtered[i];
final st = _statusBadge(a);
return _AuftragKarte(
titel: a.titel.isEmpty ? '(Ohne Titel)' : a.titel,
untertitel: _zeitZeile(a),
kunde: a.kundenName.isEmpty ? 'Kunde' : a.kundenName,
metaZeile:
'${a.dokumentTyp.labelDe} · ${a.zahlungsStatus.labelDe}',
statusLabel: st.label,
statusColor: st.color,
onTap: () => _openEditor(auftragId: a.id),
);
},
),
),
],
),
);
}
Widget _buildPdfsTab(BuildContext context) {
return CustomScrollView(
slivers: [
_purpleAppBar(title: 'PDFs'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
'Zuletzt erzeugt oder geteilt nur Metadaten auf diesem Gerät, '
'keine PDF-Ablage in der Cloud.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
),
),
if (_pdfHistory.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.picture_as_pdf_outlined,
size: 56, color: Colors.grey.shade700),
const SizedBox(height: 16),
Text(
'Noch kein PDF über die App geteilt oder per E-Mail gesendet.\n\n'
'Im Auftrag: „PDF erstellen & senden“ oder „PDF per E-Mail senden“.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade500,
height: 1.4,
),
),
],
),
),
)
else ...[
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
final e = _pdfHistory[i];
return ListTile(
leading: Icon(Icons.picture_as_pdf,
color: AppTheme.accentCyan),
title: Text(
e.title,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
'${e.rechnungsnummer} · ${DateFormat('dd.MM.yyyy HH:mm').format(e.at)}',
style: TextStyle(color: Colors.grey.shade500),
),
);
},
childCount: _pdfHistory.length,
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: TextButton(
onPressed: () async {
await PdfHistoryService.clear();
await _loadPdfHistory();
},
child: const Text('Verlauf leeren'),
),
),
),
],
],
);
}
Widget _buildProfilTab(BuildContext context, String displayName) {
void openLegal(String title, String body, {bool disclaimer = false}) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentScreen(
title: title,
body: body,
showDisclaimer: disclaimer,
),
),
);
}
return CustomScrollView(
slivers: [
_purpleAppBar(title: 'Profil'),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: AppTheme.accentCyan.withValues(alpha: 0.2),
child: Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppTheme.accentCyan,
),
),
),
const SizedBox(height: 16),
Text(
displayName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (_email != null && _email!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
_email!,
style: TextStyle(color: Colors.grey.shade500),
),
],
const SizedBox(height: 8),
Text(
_appVersion.isEmpty ? 'Version …' : 'Version $_appVersion',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
const SizedBox(height: 16),
Card(
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
title: const Text('Appwrite-Verbindung'),
subtitle: Text(
'Endpoint, Project & Database-ID bei Fehler 404 hier prüfen',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SelectableText(
'Endpoint:\n$kAppwriteEndpoint\n\n'
'Project ID:\n$kAppwriteProjectId\n\n'
'Database ID (in der Console unter Databases '
'exakt so, oft ≠ Anzeigename):\n'
'$kAppwriteDatabaseId\n\n'
'Collection ID:\n$kAppwriteCollectionId\n\n'
'Bucket ID:\n$kAppwriteBucketId\n\n'
'IDs anpassen: lib/appwrite_local.dart '
'(kAppwriteDatabaseIdOverride usw.) oder z.B.\n'
'flutter run -d macos '
'--dart-define=APPWRITE_DATABASE_ID=deine_id\n\n'
'── Collection „$kAppwriteCollectionId“ anlegen ──\n\n'
'${appwriteRechnungenCollectionCheckliste()}',
style: TextStyle(
color: Colors.grey.shade400,
fontSize: 12,
height: 1.4,
),
),
),
],
),
),
),
const SizedBox(height: 24),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.article_outlined,
color: AppTheme.accentCyan),
title: const Text('Impressum'),
trailing: const Icon(Icons.chevron_right),
onTap: () => openLegal(
'Impressum',
LegalContent.impressum,
disclaimer: true,
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined,
color: AppTheme.accentCyan),
title: const Text('Datenschutz'),
trailing: const Icon(Icons.chevron_right),
onTap: () => openLegal(
'Datenschutz',
LegalContent.datenschutz,
disclaimer: true,
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.gavel_outlined,
color: AppTheme.accentCyan),
title: const Text('AGB'),
trailing: const Icon(Icons.chevron_right),
onTap: () => openLegal(
'AGB',
LegalContent.agb,
disclaimer: true,
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download_outlined,
color: AppTheme.accentCyan),
title: const Text('Aufträge exportieren (JSON)'),
subtitle: Text(
'Datei-Fotos & Unterschrift nur als IDs',
style: TextStyle(color: Colors.grey.shade600),
),
onTap: _exportData,
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.table_chart_outlined,
color: AppTheme.accentCyan),
title: const Text('CSV-Export (Excel / Steuerbüro)'),
subtitle: Text(
'Semikolon, UTF-8 grobe DATEV-Vorbereitung',
style: TextStyle(color: Colors.grey.shade600),
),
onTap: _exportCsv,
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.mail_outline,
color: AppTheme.accentCyan),
title: const Text('Support'),
subtitle: Text(kSupportEmail),
onTap: _openSupportMail,
),
],
),
),
const SizedBox(height: 12),
Text(
LegalContent.disclaimer,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 11,
height: 1.35,
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
try {
await _account.deleteSession(sessionId: 'current');
} catch (_) {}
widget.onLoggedOut?.call();
},
icon: const Icon(Icons.logout),
label: const Text('Abmelden'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Color(0xFF505050)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
),
),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
);
}
}
class _AuftragKarte extends StatelessWidget {
const _AuftragKarte({
required this.titel,
required this.untertitel,
required this.kunde,
required this.metaZeile,
required this.statusLabel,
required this.statusColor,
required this.onTap,
});
final String titel;
final String untertitel;
final String kunde;
final String metaZeile;
final String statusLabel;
final Color statusColor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppTheme.card,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titel,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
untertitel,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 6),
Text(
kunde,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade400,
),
),
const SizedBox(height: 4),
Text(
metaZeile,
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusLabel,
style: TextStyle(
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}