Files
pyramid/lib/widgets/settings/settings_sessions.dart
T
Bernd Steckmeister 5d772389d6 refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine
Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen.
Verifiziert per automatisiertem Blockvergleich gegen das Original und
flutter analyze (Baseline unverändert: 1 bekannter Hinweis).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:56:48 +02:00

469 lines
20 KiB
Dart

part of '../settings_modal.dart';
class _SessionsSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _SessionsSection({required this.pt});
@override
ConsumerState<_SessionsSection> createState() => _SessionsSectionState();
}
class _SessionsSectionState extends ConsumerState<_SessionsSection> {
List<DeviceKeys> _devices = [];
bool _loading = true;
String? _currentDeviceId;
final Set<String> _revoking = {};
bool _logoutAllLoading = false;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final client = await ref.read(matrixClientProvider.future);
_currentDeviceId = client.deviceID;
await client.updateUserDeviceKeys(additionalUsers: {client.userID!});
final keys = client.userDeviceKeys[client.userID];
if (mounted) {
setState(() {
_devices = keys?.deviceKeys.values.toList() ?? [];
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = e.toString().split('\n').first;
});
}
}
}
Future<void> _revokeDevice(String deviceId) async {
setState(() => _revoking.add(deviceId));
try {
final client = await ref.read(matrixClientProvider.future);
await client.deleteDevice(deviceId);
await _load();
} catch (e) {
if (mounted) {
setState(() {
_revoking.remove(deviceId);
_error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _renameCurrentDevice(BuildContext context) async {
final client = await ref.read(matrixClientProvider.future);
if (!context.mounted) return;
final currentName = _devices
.where((d) => d.deviceId == _currentDeviceId)
.map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '')
.firstOrNull ?? '';
final ctrl = TextEditingController(text: currentName);
final pt = widget.pt;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Gerät umbenennen',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: TextField(
controller: ctrl,
autofocus: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: 'Gerätename',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Umbenennen'),
),
],
),
);
ctrl.dispose();
if (confirmed != true || _currentDeviceId == null) return;
try {
await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim());
await _load();
} catch (e) {
if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}');
}
}
Future<void> _logoutAllOtherDevices(BuildContext context) async {
final pt = widget.pt;
final otherIds = _devices
.where((d) => d.deviceId != _currentDeviceId && d.deviceId != null)
.map((d) => d.deviceId!)
.toList();
if (otherIds.isEmpty) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
title: Text('Alle anderen Sitzungen abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Alle abmelden'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() { _logoutAllLoading = true; _error = null; });
final client = await ref.read(matrixClientProvider.future);
try {
await client.deleteDevices(otherIds);
} on MatrixException catch (e) {
if (e.requireAdditionalAuthentication && context.mounted) {
final password = await _askPassword(context, pt);
if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; }
try {
await client.deleteDevices(otherIds, auth: AuthenticationData(
type: AuthenticationTypes.password,
session: e.session,
additionalFields: {
'identifier': {'type': 'm.id.user', 'user': client.userID},
'password': password,
},
));
} catch (e2) {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; });
return;
}
} else {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
return;
}
} catch (e) {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
return;
}
await _load();
if (mounted) setState(() => _logoutAllLoading = false);
}
Future<String?> _askPassword(BuildContext context, PyramidTheme pt) {
final ctrl = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)),
content: TextField(
controller: ctrl,
autofocus: true,
obscureText: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: 'Passwort',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
onSubmitted: (_) => Navigator.pop(ctx, ctrl.text),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, ctrl.text),
child: const Text('Bestätigen'),
),
],
),
).then((v) { ctrl.dispose(); return v; });
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Sitzungen',
subtitle: 'Geräte, auf denen du angemeldet bist.',
pt: pt),
if (_loading)
const Center(
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
else if (_error != null)
_StatusCard(
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!)
else ...[
_SettingsGroup(
title: 'Aktive Sitzungen (${_devices.length})',
pt: pt,
children: _devices.isEmpty
? [
Padding(
padding: const EdgeInsets.all(16),
child: Text('Keine Sitzungen gefunden.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
)
]
: _devices.asMap().entries.map((entry) {
final i = entry.key;
final device = entry.value;
final isCurrent = device.deviceId == _currentDeviceId;
final isRevoking = _revoking.contains(device.deviceId);
final isVerified = device.verified;
final displayName = device.unsigned?['device_display_name'] as String? ??
device.deviceId ??
'Unbekanntes Gerät';
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isCurrent
? pt.accentSoft
: pt.bg3,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.devices_rounded,
size: 18,
color: isCurrent ? pt.accent : pt.fgMuted,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
displayName,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
if (isCurrent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(4),
),
child: Text('Dieses Gerät',
style: TextStyle(
color: pt.accent,
fontSize: 10,
fontWeight: FontWeight.w600)),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => _renameCurrentDevice(context),
child: Tooltip(
message: 'Gerät umbenennen',
child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim),
),
),
],
if (isVerified == true && !isCurrent)
Icon(Icons.verified_rounded,
size: 14, color: PyramidColors.success),
if (isVerified == false && !isCurrent)
Icon(Icons.warning_amber_rounded,
size: 14, color: pt.away),
],
),
const SizedBox(height: 2),
SelectableText(
device.deviceId ?? '',
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontFamily: 'monospace'),
),
],
),
),
if (!isCurrent) ...[
const SizedBox(width: 8),
if (isVerified == false)
GestureDetector(
onTap: () => _verifyDevice(context, device, displayName),
child: Tooltip(
message: 'Verifizieren',
child: Icon(Icons.verified_user_outlined,
size: 16, color: pt.accent),
),
),
const SizedBox(width: 6),
if (isRevoking)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.danger),
)
else
GestureDetector(
onTap: () => _confirmRevoke(context, device, displayName),
child: Tooltip(
message: 'Abmelden',
child: Icon(Icons.logout_rounded,
size: 16, color: pt.fgDim),
),
),
],
],
),
),
if (i < _devices.length - 1) _Divider(pt: pt),
],
);
}).toList(),
),
const SizedBox(height: 12),
Row(
children: [
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: pt.fgMuted,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 14),
label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)),
),
const Spacer(),
if (_logoutAllLoading)
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty)
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: pt.danger,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onPressed: () => _logoutAllOtherDevices(context),
icon: const Icon(Icons.logout_rounded, size: 14),
label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)),
),
],
),
],
],
),
);
}
Future<void> _verifyDevice(
BuildContext context, DeviceKeys device, String displayName) async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _SasVerificationDialog(
pt: widget.pt,
deviceName: displayName,
device: device,
),
);
await _load();
}
Future<void> _confirmRevoke(
BuildContext context, DeviceKeys device, String displayName) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) {
final pt = widget.pt;
return AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Gerät abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.',
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Abmelden'),
),
],
);
},
);
if (confirm == true && device.deviceId != null) {
await _revokeDevice(device.deviceId!);
}
}
}