Files
pyramid/lib/widgets/settings_modal.dart
T
Bernd SteckmeisterandClaude Opus 5.5 87b472a373 fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour
Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:35:40 +02:00

289 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
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 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart' as mcrypto;
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/notification_service.dart' show openBatteryOptimizationSettings;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show compute;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/encryption/utils/bootstrap.dart';
import 'package:matrix/encryption/utils/key_verification.dart' show KeyVerification, KeyVerificationEmoji, KeyVerificationState;
import 'package:matrix/matrix.dart';
import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pointycastle/export.dart' as pc;
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/session_logout.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/update_checker.dart';
import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mic_level_test.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
part 'settings/settings_shared.dart';
part 'settings/settings_profile.dart';
part 'settings/settings_notifications.dart';
part 'settings/settings_appearance.dart';
part 'settings/settings_voice.dart';
part 'settings/settings_keybinds.dart';
part 'settings/settings_privacy.dart';
part 'settings/settings_sessions.dart';
part 'settings/settings_verification.dart';
part 'settings/encryption/megolm_crypto.dart';
part 'settings/encryption/encryption_section.dart';
part 'settings/encryption/recovery_key_dialogs.dart';
part 'settings/encryption/fingerprint_passphrase.dart';
part 'settings/settings_account.dart';
part 'settings/settings_about.dart';
class SettingsModal extends ConsumerStatefulWidget {
final VoidCallback onClose;
const SettingsModal({super.key, required this.onClose});
@override
ConsumerState<SettingsModal> createState() => _SettingsModalState();
}
class _SettingsModalState extends ConsumerState<SettingsModal> {
// null = show the category list (mobile). On desktop it falls back to the
// first section so content is always visible.
String? _section;
// Categories shared by the desktop sidebar and the mobile list/detail.
static const List<(String, String, IconData)> _categories = [
('profile', 'Mein Profil', Icons.person_outline_rounded),
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
('voice', 'Sprache & Video', Icons.headphones_rounded),
('keys', 'Tastaturkürzel', Icons.keyboard_rounded),
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
('sessions', 'Sitzungen', Icons.devices_rounded),
('encryption', 'Verschlüsselung', Icons.shield_outlined),
('about', 'Über Pyramid', Icons.info_outline_rounded),
];
String _titleFor(String id) =>
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isDark = ref.watch(themeVariantProvider).isDark;
final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider);
final motion = ref.watch(motionProvider);
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 700;
return GestureDetector(
onTap: widget.onClose,
child: Container(
color: Colors.black.withAlpha(150),
child: Center(
child: GestureDetector(
onTap: () {},
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
child: Container(
constraints: isMobile
? null
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
width: isMobile ? size.width : 740,
height: isMobile ? size.height : 560,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
border: isMobile ? null : Border.all(color: pt.border),
boxShadow: isMobile
? null
: [
BoxShadow(
color: Colors.black.withAlpha(130),
blurRadius: 40,
offset: const Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
child: isMobile
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
),
),
),
),
),
);
}
// ── Desktop: sidebar + content side by side ──────────────────────────────
Widget _desktopLayout(PyramidTheme pt, bool isDark, int accentIdx,
double radius, double density, double motion) {
final active = _section ?? 'appearance';
return Row(
children: [
Container(
width: 200,
decoration: BoxDecoration(
color: pt.bg2,
border: Border(right: BorderSide(color: pt.border)),
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_NavSection('Account', pt),
_NavItem('profile', 'Mein Profil', Icons.person_outline_rounded, active, pt, () => setState(() => _section = 'profile')),
_NavItem('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded, active, pt, () => setState(() => _section = 'notifications')),
const SizedBox(height: 14),
_NavSection('App', pt),
_NavItem('appearance', 'Erscheinungsbild', Icons.palette_outlined, active, pt, () => setState(() => _section = 'appearance')),
_NavItem('voice', 'Sprache & Video', Icons.headphones_rounded, active, pt, () => setState(() => _section = 'voice')),
_NavItem('keys', 'Tastaturkürzel', Icons.keyboard_rounded, active, pt, () => setState(() => _section = 'keys')),
const SizedBox(height: 14),
_NavSection('Sicherheit', pt),
_NavItem('privacy', 'Datenschutz', Icons.lock_outline_rounded, active, pt, () => setState(() => _section = 'privacy')),
_NavItem('sessions', 'Sitzungen', Icons.devices_rounded, active, pt, () => setState(() => _section = 'sessions')),
_NavItem('encryption', 'Verschlüsselung', Icons.shield_outlined, active, pt, () => setState(() => _section = 'encryption')),
const SizedBox(height: 14),
_NavItem('about', 'Über Pyramid', Icons.info_outline_rounded, active, pt, () => setState(() => _section = 'about')),
const Divider(height: 24, indent: 12, endIndent: 12),
_LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose),
const SizedBox(height: 16),
],
),
),
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(28),
child: _buildContent(pt, isDark, accentIdx, radius, density, motion, active),
),
Positioned(
top: 8,
right: 8,
child: PyrIconBtn(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Schließen',
onPressed: widget.onClose,
),
),
],
),
),
],
);
}
// ── Mobile: full-width category list → detail with a back arrow ───────────
Widget _mobileLayout(PyramidTheme pt, bool isDark, int accentIdx,
double radius, double density, double motion) {
final section = _section;
if (section == null) {
// Category list
return Column(
children: [
_MobileHeader(title: 'Einstellungen', pt: pt, onClose: widget.onClose),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
for (final c in _categories.where((c) => c.$1 != 'keys'))
_MobileCategoryTile(
label: c.$2,
icon: c.$3,
pt: pt,
onTap: () => setState(() => _section = c.$1),
),
const Divider(height: 24, indent: 16, endIndent: 16),
_LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose),
const SizedBox(height: 16),
],
),
),
],
);
}
// Detail view with a back arrow
return Column(
children: [
_MobileHeader(
title: _titleFor(section),
pt: pt,
onBack: () => setState(() => _section = null),
onClose: widget.onClose,
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: _buildContent(pt, isDark, accentIdx, radius, density, motion, section),
),
),
],
);
}
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
double radius, double density, double motion, String section) {
final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final messageStyle = ref.watch(messageStyleProvider);
return switch (section) {
'appearance' => _AppearanceSection(
pt: pt,
variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx,
radius: radius,
density: density,
motion: motion,
ref: ref),
'profile' => _ProfileSection(pt: pt),
'notifications' => _NotificationsSection(pt: pt),
'voice' => _VoiceSection(pt: pt),
'keys' => _KeysSection(pt: pt),
'privacy' => _PrivacySection(pt: pt),
'sessions' => _SessionsSection(pt: pt),
'encryption' => _EncryptionSection(pt: pt, ref: ref),
'about' => _AboutSection(pt: pt),
_ => _AppearanceSection(
pt: pt,
variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx,
radius: radius,
density: density,
motion: motion,
ref: ref),
};
}
}