import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:printing/printing.dart';
import '../../../app/theme.dart';
import '../../../core/sync/sync_queue_store.dart';
import '../../../core/sync/report_sync.dart';
import '../../../core/sync/report_asset_sync.dart';
import '../../../core/reports/report_delivery_client.dart';
import '../../../core/auth/auth_store.dart';
import '../../../shared/widgets/empty_state.dart';
import '../../employees/data/employee_store.dart';
import '../../customers/data/customer_store.dart';
import '../../profile/data/company_store.dart';
import '../data/demo_report_store.dart';
import '../data/report_photo_store.dart';
import '../domain/report_validator.dart';
import '../domain/report_export.dart';
import '../domain/flutter_pdf_report_renderer.dart';
import 'create_report_screen.dart';
import 'signature_screen.dart';
class ReportsScreen extends StatelessWidget {
const ReportsScreen({super.key, required this.onCreate});
final VoidCallback onCreate;
Future<void> showReport(BuildContext context, DemoReport report) async {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(report.site),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
report.customer,
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'${report.start.format(context)} – ${report.end.format(context)}',
),
const SizedBox(height: 4),
Text(
'${report.breakMinutes} Min. Pause · ${_durationText(report.netMinutes)} netto',
),
const SizedBox(height: 6),
Text(
EmployeeStore.instance.employees
.where(
(employee) =>
report.participantIds.contains(employee.id),
)
.map((employee) => employee.name)
.join(', '),
style: const TextStyle(color: Colors.black54),
),
const Divider(height: 28),
const Text(
'Leistungen',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
for (
var index = 0;
index < report.activityItems.length;
index++
)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('${index + 1}. ${report.activityItems[index]}'),
),
if (report.materialItems.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Material',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
for (final item in report.materialItems)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${item.quantity.isEmpty ? '1' : item.quantity} ${item.unit} · ${item.name}',
),
),
],
if (report.additionalOrder.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Zusatzauftrag',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(report.additionalOrder),
const SizedBox(height: 4),
Text(
report.additionalOrderApproved
? 'Vom Kunden bestätigt'
: 'Bestätigung offen',
style: TextStyle(
color: report.additionalOrderApproved
? AppColors.green
: Colors.orange.shade800,
fontWeight: FontWeight.w700,
),
),
],
if (report.photoIds.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Fotodokumentation',
style: TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final photoId in report.photoIds)
if (ReportPhotoStore.instance.photoById(photoId)
case final photo?)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
photo.bytes,
width: 132,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(
width: 132,
height: 100,
child: ColoredBox(
color: AppColors.canvas,
child: Icon(Icons.broken_image_outlined),
),
),
),
),
],
),
],
if (!report.isFinalized) ...[
const SizedBox(height: 18),
_CompletenessPanel(issues: ReportValidator.issues(report)),
],
if (report.isFinalized) ...[
const SizedBox(height: 18),
const Row(
children: [
Icon(
Icons.lock_outline_rounded,
size: 18,
color: AppColors.green,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Vom Kunden unterschrieben und gegen Änderungen geschützt.',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
SignaturePreview(signature: report.signature),
],
const SizedBox(height: 18),
_AuditTrail(reportId: report.id),
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
actions: [
_ReportDialogActions(
report: report,
canWrite: _canWriteReports,
onDelete: () async {
final confirmed = await confirmDelete(dialogContext);
if (confirmed && dialogContext.mounted) {
await DemoReportStore.instance.remove(report.id);
if (dialogContext.mounted) Navigator.pop(dialogContext);
}
},
onEdit: () async {
Navigator.pop(dialogContext);
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => CreateReportScreen(report: report),
),
);
},
onSign: () async {
final issues = ReportValidator.issues(report);
if (issues.isNotEmpty) {
await showIncompleteDialog(dialogContext, issues);
return;
}
Navigator.pop(dialogContext);
final signature = await Navigator.of(context)
.push<List<List<double>>>(
MaterialPageRoute(
builder: (_) =>
SignatureScreen(customer: report.customer),
),
);
if (signature != null) {
await DemoReportStore.instance.finalize(report.id, signature);
}
},
onExportPdf: () => exportPdf(context, report),
onSendEmail: () => sendEmail(context, report),
onExportCsv: () => exportCsv(context, report),
onClose: () => Navigator.pop(dialogContext),
),
],
),
);
}
bool get _canWriteReports =>
!kIsWeb ||
const {
'owner',
'office',
'supervisor',
}.contains(AuthStore.instance.session?.role);
Future<void> exportCsv(BuildContext context, DemoReport report) async {
final document = _documentFor(report);
const exporter = CsvReportExporter();
await Clipboard.setData(ClipboardData(text: exporter.asText(document)));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${exporter.fileName(document)} wurde in die Zwischenablage kopiert.',
),
),
);
}
}
Future<void> exportPdf(BuildContext context, DemoReport report) async {
final document = _documentFor(report);
const renderer = FlutterPdfReportRenderer();
try {
final bytes = await renderer.export(document);
final recipients = CustomerStore.instance.customers
.where(
(customer) =>
customer.name.toLowerCase() == report.customer.toLowerCase() &&
customer.email.isNotEmpty,
)
.map((customer) => customer.email)
.toList();
await Printing.sharePdf(
bytes: Uint8List.fromList(bytes),
filename: renderer.fileName(document),
subject: 'Leistungsnachweis ${report.site}',
body:
'Guten Tag,\n\nim Anhang erhalten Sie den abgeschlossenen Leistungsnachweis für ${report.site}.\n\nMit freundlichen Grüßen\n${document.companyName}',
emails: recipients,
);
} on Object {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Das PDF konnte nicht erstellt oder geteilt werden.'),
),
);
}
}
}
Future<void> sendEmail(BuildContext context, DemoReport report) async {
try {
await ReportSyncController.instance.synchronize(force: true);
await ReportDeliveryClient().send(report.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Leistungsnachweis wurde per E-Mail versendet.'),
),
);
}
} on ReportDeliveryException catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
}
}
ReportDocument _documentFor(DemoReport report) {
final company = CompanyStore.instance.profile;
final employees = EmployeeStore.instance.employees;
return ReportDocument(
report: report,
companyName: company.name,
companyAddress: company.address,
companyContact: [
company.email,
company.phone,
].where((value) => value.isNotEmpty).join(' · '),
reportFooter: company.reportFooter,
participantNames: employees
.where((employee) => report.participantIds.contains(employee.id))
.map((employee) => employee.name)
.toList(),
photos: report.photoIds
.map(ReportPhotoStore.instance.photoById)
.whereType<ReportPhoto>()
.toList(),
);
}
String _durationText(int minutes) {
final hours = minutes ~/ 60;
final rest = minutes % 60;
return rest == 0 ? '$hours Std.' : '$hours Std. $rest Min.';
}
Future<bool> confirmDelete(BuildContext context) async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Entwurf löschen?'),
content: const Text('Dieser lokale Entwurf wird dauerhaft entfernt.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Löschen'),
),
],
),
) ??
false;
Future<void> showIncompleteDialog(
BuildContext context,
List<String> issues,
) => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nachweis noch unvollständig'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Vor der Kundenunterschrift müssen diese Punkte geklärt werden:',
),
const SizedBox(height: 12),
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.warning_amber_rounded,
size: 19,
color: Colors.orange,
),
const SizedBox(width: 8),
Expanded(child: Text(issue)),
],
),
),
],
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Verstanden'),
),
],
),
);
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: Listenable.merge([
DemoReportStore.instance,
SyncQueueStore.instance,
ReportSyncController.instance,
ReportAssetSyncController.instance,
]),
builder: (context, _) {
final reports = DemoReportStore.instance.reports;
final canWrite = _canWriteReports;
if (reports.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (kIsWeb) const _WebReportControls(),
Expanded(
child: EmptyState(
icon: Icons.description_outlined,
title: 'Keine Nachweise gefunden',
description: kIsWeb
? 'Passe Suche oder Statusfilter an.'
: 'Entwürfe und abgeschlossene Nachweise findest du künftig hier.',
actionLabel: canWrite
? 'Nachweis erstellen'
: 'Aktualisieren',
onPressed: canWrite
? onCreate
: DemoReportStore.instance.initialize,
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nachweise',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'${reports.where((report) => !report.isFinalized).length} Entwürfe · ${reports.where((report) => report.isFinalized).length} abgeschlossen',
style: Theme.of(context).textTheme.bodyLarge,
),
if (ReportSyncController.instance.pendingCount > 0 ||
ReportSyncController.instance.conflictCount > 0 ||
ReportAssetSyncController.instance.pendingCount >
0) ...[
const SizedBox(height: 8),
ReportSyncNotice(
pendingCount:
ReportSyncController.instance.pendingCount,
conflictCount:
ReportSyncController.instance.conflictCount,
assetPendingCount: ReportAssetSyncController
.instance
.pendingCount,
),
],
],
),
),
if (canWrite)
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add_rounded),
label: const Text('Neu'),
),
],
),
if (kIsWeb) ...[
const SizedBox(height: 18),
const _WebReportControls(),
],
const SizedBox(height: 24),
for (final report in reports) ...[
_ReportCard(
report: report,
onTap: () => showReport(context, report),
),
const SizedBox(height: 12),
],
],
),
),
),
);
},
);
}
class _ReportCard extends StatelessWidget {
const _ReportCard({required this.report, required this.onTap});
final DemoReport report;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(20),
child: LayoutBuilder(
builder: (context, constraints) {
final status = _statusChip();
final details = Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
report.site,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
'${report.customer} · ${report.start.format(context)}–${report.end.format(context)}\n${report.activityItems.join(' · ')}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
);
final leading = const CircleAvatar(
backgroundColor: AppColors.canvas,
child: Icon(Icons.description_outlined, color: AppColors.green),
);
if (constraints.maxWidth < 520) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [leading, const SizedBox(width: 14), details]),
const SizedBox(height: 14),
Align(alignment: Alignment.centerLeft, child: status),
],
);
}
return Row(
children: [
leading,
const SizedBox(width: 14),
details,
const SizedBox(width: 14),
status,
],
);
},
),
),
),
);
Widget _statusChip() => Chip(
avatar: Icon(
report.isFinalized
? Icons.lock_outline_rounded
: ReportValidator.isComplete(report)
? Icons.check_circle_outline_rounded
: Icons.warning_amber_rounded,
size: 17,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
report.isFinalized
? 'Abgeschlossen'
: ReportValidator.isComplete(report)
? 'Bereit'
: 'Unvollständig',
),
Text(' · V${report.version}'),
],
),
);
}
class ReportSyncNotice extends StatelessWidget {
const ReportSyncNotice({
super.key,
required this.pendingCount,
required this.conflictCount,
this.assetPendingCount = 0,
});
final int pendingCount;
final int conflictCount;
final int assetPendingCount;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(Icons.cloud_off_outlined, size: 18),
),
const SizedBox(width: 6),
Expanded(
child: Text(
conflictCount > 0
? '$conflictCount Berichtskonflikt(e) benötigen eine Prüfung'
: assetPendingCount > 0 && pendingCount > 0
? '$pendingCount Berichts- und $assetPendingCount Dateiänderung(en) warten auf Synchronisation'
: assetPendingCount > 0
? '$assetPendingCount Datei(en) warten auf sichere Übertragung'
: '$pendingCount Berichtsänderungen warten auf Synchronisation',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
class _ReportDialogActions extends StatelessWidget {
const _ReportDialogActions({
required this.report,
required this.canWrite,
required this.onDelete,
required this.onEdit,
required this.onSign,
required this.onExportPdf,
required this.onSendEmail,
required this.onExportCsv,
required this.onClose,
});
final DemoReport report;
final bool canWrite;
final Future<void> Function() onDelete;
final Future<void> Function() onEdit;
final Future<void> Function() onSign;
final Future<void> Function() onExportPdf;
final Future<void> Function() onSendEmail;
final Future<void> Function() onExportCsv;
final VoidCallback onClose;
@override
Widget build(BuildContext context) => SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!report.isFinalized && canWrite) ...[
Row(
children: [
Expanded(
child: TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded),
label: const Text('Löschen'),
),
),
const SizedBox(width: 8),
Expanded(
child: TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined),
label: const Text('Bearbeiten'),
),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: onSign,
icon: const Icon(Icons.draw_outlined),
label: const Text('Unterschreiben'),
),
],
if (report.isFinalized) ...[
FilledButton.icon(
onPressed: onExportPdf,
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF teilen'),
),
const SizedBox(height: 8),
if (canWrite) ...[
OutlinedButton.icon(
onPressed: onSendEmail,
icon: const Icon(Icons.send_outlined),
label: const Text('Per E-Mail senden'),
),
const SizedBox(height: 8),
],
OutlinedButton.icon(
onPressed: onExportCsv,
icon: const Icon(Icons.table_view_outlined),
label: const Text('CSV kopieren'),
),
],
const SizedBox(height: 4),
TextButton(onPressed: onClose, child: const Text('Schließen')),
],
),
);
}
class _WebReportControls extends StatefulWidget {
const _WebReportControls();
@override
State<_WebReportControls> createState() => _WebReportControlsState();
}
class _WebReportControlsState extends State<_WebReportControls> {
final _search = TextEditingController();
String? _status;
bool _loading = false;
@override
void initState() {
super.initState();
_search.text = DemoReportStore.instance.webSearch;
_status = DemoReportStore.instance.webStatus;
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _load(int page) async {
setState(() => _loading = true);
try {
await DemoReportStore.instance.loadWebReports(
search: _search.text,
status: _status,
page: page,
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final store = DemoReportStore.instance;
final lastPage = store.webTotal == 0
? 1
: ((store.webTotal - 1) ~/ store.webLimit) + 1;
return LayoutBuilder(
builder: (context, constraints) {
final search = TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _load(1),
decoration: const InputDecoration(
labelText: 'Nachweise durchsuchen',
prefixIcon: Icon(Icons.search_rounded),
),
);
final status = DropdownButtonFormField<String?>(
initialValue: _status,
decoration: const InputDecoration(labelText: 'Status'),
items: const [
DropdownMenuItem(value: null, child: Text('Alle')),
DropdownMenuItem(value: 'draft', child: Text('Entwürfe')),
DropdownMenuItem(value: 'finalized', child: Text('Abgeschlossen')),
],
onChanged: _loading
? null
: (value) {
setState(() => _status = value);
_load(1);
},
);
return Column(
children: [
if (constraints.maxWidth < 620) ...[
search,
const SizedBox(height: 10),
status,
] else
Row(
children: [
Expanded(flex: 2, child: search),
const SizedBox(width: 12),
Expanded(child: status),
],
),
const SizedBox(height: 10),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _loading ? null : () => _load(1),
icon: _loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search_rounded),
label: const Text('Suchen'),
),
const Spacer(),
IconButton(
tooltip: 'Vorherige Seite',
onPressed: !_loading && store.webPage > 1
? () => _load(store.webPage - 1)
: null,
icon: const Icon(Icons.chevron_left_rounded),
),
Text(
'Seite ${store.webPage} von $lastPage · ${store.webTotal} Treffer',
),
IconButton(
tooltip: 'Nächste Seite',
onPressed: !_loading && store.webPage < lastPage
? () => _load(store.webPage + 1)
: null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
],
);
},
);
}
}
class _AuditTrail extends StatelessWidget {
const _AuditTrail({required this.reportId});
final String reportId;
@override
Widget build(BuildContext context) {
final entries = DemoReportStore.instance.historyFor(reportId);
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
leading: const Icon(Icons.history_rounded),
title: const Text(
'Prüfverlauf',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text('${entries.length} protokollierte Änderungen'),
children: [
for (final entry in entries)
ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 8),
leading: CircleAvatar(radius: 15, child: Text('${entry.version}')),
title: Text(entry.label),
subtitle: Text(_auditTimestamp(entry.timestamp)),
),
],
);
}
String _auditTimestamp(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${two(value.day)}.${two(value.month)}.${value.year} · ${two(value.hour)}:${two(value.minute)} Uhr';
}
}
class _CompletenessPanel extends StatelessWidget {
const _CompletenessPanel({required this.issues});
final List<String> issues;
@override
Widget build(BuildContext context) {
final complete = issues.isEmpty;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: complete ? const Color(0xFFEAF5EF) : const Color(0xFFFFF4E5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
complete
? Icons.check_circle_outline_rounded
: Icons.warning_amber_rounded,
color: complete ? AppColors.green : Colors.orange.shade800,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
complete
? 'Vollständig und bereit zur Unterschrift'
: '${issues.length} Prüfpunkte offen',
style: const TextStyle(fontWeight: FontWeight.w800),
),
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('• $issue'),
),
],
),
),
],
),
);
}
}
