Files
pyramid/lib/features/spaces/admin/space_admin_overview.dart
T
Bernd Steckmeister 0c7d359076 refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt
Gleiche Technik wie bei den vorigen drei Umbauten: reine Verschiebung per
Dart part/part-of, keine Logikänderung. space_admin_dialog.dart schrumpft
von 2278 auf 19 Zeilen (nur noch Bibliothekskopf). Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:01:58 +02:00

573 lines
20 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.
part of '../space_admin_dialog.dart';
class _OverviewTab extends ConsumerStatefulWidget {
final Room space;
const _OverviewTab({required this.space});
@override
ConsumerState<_OverviewTab> createState() => _OverviewTabState();
}
class _OverviewTabState extends ConsumerState<_OverviewTab> {
late TextEditingController _nameCtrl;
late TextEditingController _topicCtrl;
bool _saving = false;
String? _error;
String? _success;
Uint8List? _pendingBanner;
String? _bannerMxcUri;
Uint8List? _pendingAvatar;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(
text: widget.space.getLocalizedDisplayname());
_topicCtrl = TextEditingController(text: widget.space.topic);
// Prefer local override (set on last save) over room state event
final overrides = ref.read(spaceBannerOverrideProvider);
_bannerMxcUri = overrides.containsKey(widget.space.id)
? overrides[widget.space.id]
: widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?;
}
@override
void dispose() {
_nameCtrl.dispose();
_topicCtrl.dispose();
super.dispose();
}
Future<void> _pickBanner() async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
final cropped = await showDialog<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
);
if (cropped == null || !mounted) return;
setState(() => _pendingBanner = cropped);
}
Future<void> _pickAvatar() async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
setState(() => _pendingAvatar = raw);
}
Future<void> _removeAvatar() async {
setState(() => _pendingAvatar = null);
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, EventTypes.RoomAvatar, '', {});
} catch (_) {}
}
Future<void> _removeBanner() async {
setState(() { _pendingBanner = null; _bannerMxcUri = null; });
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, _kSpaceBannerEvent, '', {});
} catch (_) {}
}
Future<void> _save() async {
setState(() { _saving = true; _error = null; _success = null; });
// Welcher Schritt gerade läuft — für präzise Fehlermeldungen statt eines
// pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam).
var step = 'Speichern';
try {
final client = widget.space.client;
final newName = _nameCtrl.text.trim();
final newTopic = _topicCtrl.text.trim();
if (newName.isNotEmpty &&
newName != widget.space.getLocalizedDisplayname()) {
step = 'Name ändern';
await widget.space.setName(newName);
}
if (newTopic != widget.space.topic) {
step = 'Beschreibung ändern';
await widget.space.setDescription(newTopic);
}
if (_pendingAvatar != null) {
step = 'Avatar hochladen';
final uri = await client.uploadContent(
_pendingAvatar!,
filename: 'space_avatar.jpg',
contentType: 'image/jpeg',
);
step = 'Avatar setzen';
await _setStateWithRepair(
client,
EventTypes.RoomAvatar,
{'url': uri.toString()},
);
setState(() => _pendingAvatar = null);
}
if (_pendingBanner != null) {
step = 'Banner hochladen';
final uri = await client.uploadContent(
_pendingBanner!,
filename: 'space_banner.jpg',
contentType: 'image/jpeg',
);
step = 'Banner setzen';
await _setStateWithRepair(
client,
_kSpaceBannerEvent,
{'url': uri.toString()},
);
final uriStr = uri.toString();
// Update local override immediately so header updates before next sync
ref.read(spaceBannerOverrideProvider.notifier).update(
(m) => {...m, widget.space.id: uriStr},
);
setState(() {
_bannerMxcUri = uriStr;
_pendingBanner = null;
});
}
setState(() { _success = 'Gespeichert'; });
} catch (e) {
// Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung) —
// nicht pauschal auf "Admin-Level erforderlich" mappen.
final raw = e is MatrixException
? '${e.errcode}: ${e.errorMessage}'
: e.toString().split('\n').first;
var detail = '';
if (e is MatrixException && e.errcode == 'M_FORBIDDEN') {
detail = '\n${_permDiagnostics()}';
// Zusätzlich die SERVER-Sicht der Power-Levels holen — die lokale
// Kopie kann von der des Servers abweichen.
try {
final pl = await widget.space.client.getRoomStateWithKey(
widget.space.id, EventTypes.RoomPowerLevels, '');
detail +=
'\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}';
} on MatrixException catch (se) {
detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)';
} catch (_) {}
}
setState(() {
_error = '$step fehlgeschlagen $raw$detail';
});
} finally {
setState(() { _saving = false; });
}
}
/// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE
/// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann
/// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels
/// mit uns als Admin angelegt und der Vorgang wiederholt.
Future<void> _setStateWithRepair(
Client client,
String type,
Map<String, Object?> content,
) async {
try {
await client.setRoomStateWithKey(widget.space.id, type, '', content);
} on MatrixException catch (e) {
if (e.errcode != 'M_FORBIDDEN') rethrow;
final repaired = await _ensurePowerLevels(client);
if (!repaired) rethrow;
await client.setRoomStateWithKey(widget.space.id, type, '', content);
}
}
/// Legt Standard-Power-Levels an, falls der Raum keine hat.
/// Gibt true zurück, wenn das Event neu angelegt wurde.
Future<bool> _ensurePowerLevels(Client client) async {
try {
await client.getRoomStateWithKey(
widget.space.id, EventTypes.RoomPowerLevels, '');
return false; // existiert bereits — Ablehnung hat andere Ursache
} catch (_) {
// fehlt (M_NOT_FOUND) → anlegen
}
try {
await client.setRoomStateWithKey(
widget.space.id,
EventTypes.RoomPowerLevels,
'',
{
'users': {client.userID!: 100},
'users_default': 0,
'events': const <String, Object?>{},
'events_default': 0,
'state_default': 50,
'ban': 50,
'kick': 50,
'redact': 50,
'invite': 0,
'notifications': {'room': 50},
},
);
return true;
} catch (_) {
return false;
}
}
/// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der
/// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level).
String _permDiagnostics() {
try {
final space = widget.space;
final create = space.getState(EventTypes.RoomCreate);
final version = create?.content['room_version'] ?? '1';
final creator = create?.senderId ?? '?';
final own = space.ownPowerLevel;
final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent);
final hasPl = space.getState(EventTypes.RoomPowerLevels) != null;
return 'Diagnose: dein Level $own, benötigt $needed, '
'Raumversion $version, Creator $creator'
'${hasPl ? '' : ', kein power_levels-Event vorhanden!'}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
// SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und
// state_default) statt hartcodiertem Admin-Level 100.
final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) ||
widget.space.ownPowerLevel >= _kPowerAdmin;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Banner ────────────────────────────────────────────────────────
_SectionLabel('Banner', pt),
const SizedBox(height: 6),
GestureDetector(
onTap: canEdit ? _pickBanner : null,
child: Stack(
children: [
AspectRatio(
aspectRatio: 2.4,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: _pendingBanner != null
? Image.memory(_pendingBanner!,
fit: BoxFit.cover, width: double.infinity)
: (_bannerMxcUri != null && client != null
? MxcImage(
mxcUri: _bannerMxcUri!,
client: client,
fit: BoxFit.cover,
width: double.infinity,
placeholder: Container(color: pt.bg0),
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 24, color: pt.fgDim),
const SizedBox(height: 4),
Text('Banner hinzufügen',
style: TextStyle(
color: pt.fgDim, fontSize: 12)),
],
),
)),
), // Container
), // AspectRatio
if (canEdit)
Positioned(
right: 6,
bottom: 6,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_bannerMxcUri != null || _pendingBanner != null)
GestureDetector(
onTap: _removeBanner,
child: Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFED4245).withAlpha(200),
borderRadius: BorderRadius.circular(5),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_outline,
size: 11, color: Colors.white),
SizedBox(width: 3),
Text('Entfernen',
style: TextStyle(
color: Colors.white, fontSize: 11)),
],
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: pt.bg0.withAlpha(200),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded,
size: 11, color: pt.fgMuted),
const SizedBox(width: 3),
Text('Banner ändern',
style: TextStyle(
color: pt.fgMuted, fontSize: 11)),
],
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// ── Avatar ───────────────────────────────────────────────────────
_SectionLabel('Serverbild', pt),
const SizedBox(height: 8),
_AvatarEditor(
space: widget.space,
pendingAvatar: _pendingAvatar,
canEdit: canEdit,
onPick: _pickAvatar,
onRemove: _removeAvatar,
pt: pt,
client: client,
),
const SizedBox(height: 16),
// ── Name / Topic ─────────────────────────────────────────────────
_SectionLabel('Name', pt),
const SizedBox(height: 6),
_Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt),
const SizedBox(height: 16),
_SectionLabel('Beschreibung', pt),
const SizedBox(height: 6),
_Field(
controller: _topicCtrl,
hint: 'Worum geht es in diesem Space?',
maxLines: 4,
pt: pt),
const SizedBox(height: 8),
_SpaceVisibilityToggle(space: widget.space),
const SizedBox(height: 20),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!,
style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)),
),
if (_success != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_success!,
style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
),
child: _saving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.bg0))
: const Text('Speichern',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 24),
_DangerZone(space: widget.space),
],
),
);
}
}
class _SpaceVisibilityToggle extends ConsumerStatefulWidget {
final Room space;
const _SpaceVisibilityToggle({required this.space});
@override
ConsumerState<_SpaceVisibilityToggle> createState() =>
_SpaceVisibilityToggleState();
}
class _SpaceVisibilityToggleState
extends ConsumerState<_SpaceVisibilityToggle> {
bool? _isPublic;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final joinRules = widget.space.joinRules;
setState(() {
_isPublic = joinRules == JoinRules.public;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _toggle(bool value) async {
setState(() => _isPublic = value);
try {
await widget.space.setJoinRules(
value ? JoinRules.public : JoinRules.invite);
} catch (_) {
setState(() => _isPublic = !value);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
if (_loading) return const SizedBox.shrink();
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Öffentlicher Space',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600)),
Text('Jeder kann diesem Space beitreten',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Switch(
value: _isPublic ?? false,
onChanged: _toggle,
activeThumbColor: pt.accent,
),
],
);
}
}
class _DangerZone extends ConsumerWidget {
final Room space;
const _DangerZone({required this.space});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
return Container(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFED4245).withAlpha(80)),
borderRadius: BorderRadius.circular(pt.rBase),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gefahrenzone',
style: TextStyle(
color: const Color(0xFFED4245),
fontSize: 13,
fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Space verlassen',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600)),
Text('Du verlässt diesen Space',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
OutlinedButton(
onPressed: () => _confirmLeave(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFED4245),
side: const BorderSide(color: Color(0xFFED4245)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('Verlassen'),
),
],
),
],
),
);
}
void _confirmLeave(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Space verlassen?'),
content: Text(
'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(context);
await space.leave();
},
child: const Text('Verlassen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
}