Files
pyramid/lib/widgets/settings_modal.dart
T
Bernd SteckmeisterandClaude Opus 5.5 e90b954348 fix: Abmelden ohne verspaetete Sync-Antwort (SDK-Wettlauf) + Live-Regressionstest
logoutWithoutStraySync (lib/core/session_logout.dart): Sync-Schleife
anhalten, laufenden Long-Poll per Account-Daten wecken und auslaufen lassen,
erst dann abmelden - es ist keine Anfrage mit altem Token mehr unterwegs,
die per 401 ein zweites clear() ausloesen koennte. Genutzt an allen drei
Abmelde-Stellen (Einstellungen, Ueber, Konto geloescht) und im E2E-Live-Test.
Live gemessen: die verspaetete 401 tritt real auf, kam aber stets vor dem
Abschluss einer Neuanmeldung an - Risiko war klein, ist jetzt ausgeschlossen.
test/live_logout_race_test.dart in scripts/test.ps1 -Live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:39:37 +02:00

288 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/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),
};
}
}