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]>
288 lines
12 KiB
Dart
288 lines
12 KiB
Dart
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),
|
||
};
|
||
}
|
||
}
|