1154 lines
39 KiB
Dart
1154 lines
39 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|