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>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user