5d772389d6
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>
469 lines
20 KiB
Dart
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!);
|
|
}
|
|
}
|
|
}
|