import 'dart:async'; import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../api/marianumconnect/auth/device_token_name.dart'; import '../../../api/marianumconnect/auth/token_storage.dart'; import '../../../api/marianumconnect/marianumconnect_endpoint.dart'; import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart'; import '../../../background/widget_background_task.dart'; import '../../../extensions/date_time.dart'; import '../../../model/account_data.dart'; import '../../../model/endpoint_data.dart'; import '../../../push/push_registration_store.dart'; import '../../../push/push_registration_type.dart'; import '../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/clipboard_helper.dart'; import '../../../widget/app_progress_indicator.dart'; import '../../../widget/async_action_button.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget_data/widget_sync.dart'; import 'widgets/push_status_sheet.dart'; typedef _Row = ({String label, String value}); typedef _Section = ({String title, List<_Row> rows}); /// Read-only snapshot of build, endpoint, session, push and widget state for /// bug reports. Secrets (passwords, bearer token) are never shown. class DiagnosticsPage extends StatefulWidget { const DiagnosticsPage({super.key}); @override State createState() => _DiagnosticsPageState(); } class _DiagnosticsPageState extends State { late Future> _sections = _collect(); Future> _collect() async { final settings = context.read().val(); final accountStatus = context.read().state.status; final account = AccountData(); const tokens = MarianumConnectTokenStorage(); final push = PushRegistrationStore(); Future safe(Future Function() read) async { try { final value = await read().timeout(const Duration(seconds: 5)); return switch (value) { null => '–', bool() => value ? 'ja' : 'nein', DateTime() => '${value.formatDateTime()} (${value.formatRelative()})', _ => '$value', }; } on Object catch (e) { return 'Fehler: $e'; } } Future> rows(Map Function()> reads) => Future.wait( reads.entries.map( (e) async => (label: e.key, value: await safe(e.value)), ), ); final info = await PackageInfo.fromPlatform(); return [ ( title: 'App', rows: await rows({ 'Version': () async => '${info.version} (${info.buildNumber})', 'Paket': () async => info.packageName, 'Build-Modus': () async => kReleaseMode ? 'Release' : kProfileMode ? 'Profile' : 'Debug', 'Plattform': () async => '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', 'Gerät': DeviceTokenName.resolve, 'Dart': () async => Platform.version.split(' ').first, }), ), ( title: 'Server', rows: await rows({ 'Marianum Connect': () async => MarianumConnectEndpoint.current(), 'Nextcloud': () async => 'https://${EndpointData().nextcloud().full()}', 'Notfall-Nachricht': () async => settings.devToolsSettings.resolveEmergencyNoticeUrl(), }), ), ( title: 'Sitzung', rows: await rows({ 'Status': () async => accountStatus.name, 'Benutzer': () async => account.isPopulated() ? account.getUsername() : null, 'Demo-Modus': () async => account.isDemo, 'Login Flow v2': () async => account.usesLoginFlow, 'App-Passwort (Dateien)': () async => account.hasAppPassword(), 'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(), 'Session-Epoche': () async => account.sessionEpoch, 'MC-Token-ID': tokens.readTokenId, 'MC-Token läuft ab': tokens.readExpiresAt, }), ), ( title: 'Push', rows: await rows({ 'Benachrichtigungen': () async => settings.notificationSettings.enabled, 'FCM-Token': FirebaseMessaging.instance.getToken, for (final type in PushRegistrationType.values) ...{ 'Registriert (${type.name})': () => push.isRegistered(type), 'Letzte Registrierung (${type.name})': () => push.lastRegistrationAt(type), 'Letzter Fehler (${type.name})': () => push.lastRegistrationError(type), }, }), ), ( title: 'Sonstiges', rows: await rows({ 'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve, 'Widget zuletzt abgerufen': WidgetSync.getFetchedAt, }), ), ]; } String _asText(List<_Section> sections) => [ for (final s in sections) ...[ '## ${s.title}', for (final r in s.rows) '${r.label}: ${r.value}', '', ], ].join('\n'); @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( title: const Text('Diagnose'), actions: [ IconButton( tooltip: 'Neu laden', icon: const Icon(Icons.refresh), onPressed: () => setState(() { _sections = _collect(); }), ), FutureBuilder( future: _sections, builder: (context, snapshot) => IconButton( tooltip: 'Alles kopieren', icon: const Icon(Icons.copy_all_outlined), onPressed: snapshot.hasData ? () => copyToClipboard(context, _asText(snapshot.data!)) : null, ), ), ], ), body: FutureBuilder( future: _sections, builder: (context, snapshot) { final sections = snapshot.data; if (sections == null) { return const Center(child: AppProgressIndicator.large()); } return ListView( children: [ for (final section in sections) ...[ ListTile( dense: true, title: Text( section.title, style: TextStyle( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), ), ), for (final row in section.rows) ListTile( dense: true, title: Text(row.label), subtitle: SelectableText(row.value), onLongPress: () => copyToClipboard(context, row.value), ), if (section.title == 'Push') ListTile( leading: const CenteredLeading( Icon(Icons.notifications_active_outlined), ), title: const Text('Push-Status prüfen'), trailing: const Icon(Icons.arrow_right), onTap: () => showPushStatusSheet(context), ), if (section.title == 'Sonstiges') AsyncListTile( leading: const CenteredLeading(Icon(Icons.widgets_outlined)), title: const Text('Widget jetzt aktualisieren'), closeOnSuccess: false, onPressed: WidgetBackgroundTask.requestImmediateRefresh, ), const Divider(), ], ], ); }, ), ); }