Files
pyramid/lib/widgets/settings_modal.dart
T
Bernd SteckmeisterandClaude Opus 5.5 441cc6ef54 fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07):
- Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache:
  Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ
  mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag
  verwies auf eine nicht mehr existierende Klasse. Neu: CallService
  (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst
  (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android
  16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau
  'Media projections require a foreground service ...'.
- Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv,
  Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit
  Berechtigung, sonst schmalere Variante statt Dienst-Abbruch.
- Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt
  Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste.
- LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog.
- Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück".
- Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose:
  das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:29 +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)
_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),
};
}
}