Files
pyramid/lib/features/spaces/space_admin_dialog.dart
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

2279 lines
75 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.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart' hide Visibility;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/mxc_image.dart';
const _kSpaceBannerEvent = 'io.pyramid.space.banner';
/// Ändert die Power-Levels auf Basis des aktuellen SERVER-Stands (nicht des
/// lokalen Caches!). Das SDK-`setPower` und ein lokal fehlendes
/// power_levels-Event haben sonst zur Folge, dass die users-Map des Servers
/// mit einem Minimal-Event überschrieben wird — und Admins sich selbst
/// aussperren (genau das ist hier bereits passiert).
Future<void> updatePowerLevelsSafely(
Room room,
void Function(Map<String, dynamic> content) mutate,
) async {
Map<String, dynamic> content;
try {
content = Map<String, dynamic>.from(
await room.client
.getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''),
);
} on MatrixException catch (e) {
if (e.errcode == 'M_NOT_FOUND') {
content = <String, dynamic>{};
} else {
rethrow;
}
}
final users = content['users'];
content['users'] =
users is Map ? Map<String, dynamic>.from(users) : <String, dynamic>{};
mutate(content);
// Selbst-Aussperr-Schutz: Nach der Änderung muss der eigene Account immer
// noch hoch genug stehen, um Power-Levels erneut zu ändern — sonst gäbe es
// kein Zurück mehr. Greift bei versehentlicher Selbst-Degradierung ODER
// wenn ein neu geschriebenes Event den eigenen Eintrag nicht enthält.
final myId = room.client.userID;
if (myId != null) {
int asInt(Object? v, int fallback) => v is int ? v : fallback;
final usersMap = content['users'] as Map<String, dynamic>;
final myLevel = asInt(usersMap[myId], asInt(content['users_default'], 0));
final events = content['events'];
final neededForPl = (events is Map && events['m.room.power_levels'] != null)
? asInt(events['m.room.power_levels'], 50)
: asInt(content['state_default'], 50);
if (myLevel < neededForPl) {
throw Exception(
'Abgebrochen, um eine Selbst-Aussperrung zu verhindern: '
'Diese Änderung würde dein Level auf $myLevel senken, '
'aber zum Verwalten der Berechtigungen sind $neededForPl nötig.',
);
}
}
await room.client
.setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, '', content);
}
// Power level labels
const _kPowerAdmin = 100;
const _kPowerMod = 50;
const _kPowerMember = 0;
String _powerLabel(int level) {
if (level >= _kPowerAdmin) return 'Admin';
if (level >= _kPowerMod) return 'Moderator';
return 'Mitglied';
}
Color _powerColor(int level, PyramidTheme pt) {
if (level >= _kPowerAdmin) return const Color(0xFFED4245);
if (level >= _kPowerMod) return const Color(0xFFF59E0B);
return pt.fgDim;
}
class SpaceAdminDialog extends ConsumerStatefulWidget {
final Room space;
const SpaceAdminDialog({super.key, required this.space});
@override
ConsumerState<SpaceAdminDialog> createState() => _SpaceAdminDialogState();
}
class _SpaceAdminDialogState extends ConsumerState<SpaceAdminDialog>
with SingleTickerProviderStateMixin {
late TabController _tabs;
@override
void initState() {
super.initState();
_tabs = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rLg)),
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680),
child: Column(
children: [
_DialogHeader(space: widget.space, pt: pt, tabs: _tabs),
Expanded(
child: TabBarView(
controller: _tabs,
children: [
_OverviewTab(space: widget.space),
_MembersTab(space: widget.space),
_PermissionsTab(space: widget.space),
_ChannelsTab(space: widget.space),
],
),
),
],
),
),
);
}
}
// ─── Header + TabBar ─────────────────────────────────────────────────────────
class _DialogHeader extends ConsumerWidget {
final Room space;
final PyramidTheme pt;
final TabController tabs;
const _DialogHeader({required this.space, required this.pt, required this.tabs});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = space.getLocalizedDisplayname();
final avatarUri = space.avatar;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
Widget avatar;
if (client != null && avatarUri != null) {
avatar = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 40,
borderRadius: BorderRadius.circular(pt.rBase),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 40),
);
} else {
avatar = _InitialAvatar(letter: letter, pt: pt, size: 40);
}
return Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 12),
child: Row(
children: [
avatar,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
Text('Space-Einstellungen',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
IconButton(
icon: Icon(Icons.close, color: pt.fgDim, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
TabBar(
controller: tabs,
labelColor: pt.accent,
unselectedLabelColor: pt.fgDim,
indicatorColor: pt.accent,
indicatorSize: TabBarIndicatorSize.label,
tabs: const [
Tab(text: 'Übersicht'),
Tab(text: 'Mitglieder'),
Tab(text: 'Berechtigungen'),
Tab(text: 'Kanäle'),
],
),
],
),
);
}
}
// ─── Overview Tab ─────────────────────────────────────────────────────────────
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))),
),
],
),
);
}
}
// ─── Members Tab ──────────────────────────────────────────────────────────────
class _MembersTab extends ConsumerStatefulWidget {
final Room space;
const _MembersTab({required this.space});
@override
ConsumerState<_MembersTab> createState() => _MembersTabState();
}
class _MembersTabState extends ConsumerState<_MembersTab> {
List<User>? _members;
bool _loading = true;
String? _error;
String _filter = '';
final _filterCtrl = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final members =
await widget.space.requestParticipants([Membership.join, Membership.invite]);
if (mounted) setState(() { _members = members; _loading = false; });
} catch (e) {
if (mounted) setState(() { _error = e.toString(); _loading = false; });
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
if (_loading) {
return Center(
child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2));
}
if (_error != null) {
return Center(
child: Text(_error!, style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final members = _members ?? [];
final filtered = _filter.isEmpty
? members
: members
.where((m) =>
(m.displayName ?? m.id)
.toLowerCase()
.contains(_filter.toLowerCase()) ||
m.id.toLowerCase().contains(_filter.toLowerCase()))
.toList()
..sort((a, b) => (b.powerLevel).compareTo(a.powerLevel));
final ownPower = widget.space.ownPowerLevel;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: _SearchField(
controller: _filterCtrl,
hint: 'Mitglieder suchen…',
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
),
if (ownPower >= _kPowerMod) ...[
const SizedBox(width: 8),
_InviteButton(space: widget.space),
],
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: filtered.length,
itemBuilder: (context, i) {
final member = filtered[i];
return _MemberTile(
member: member,
space: widget.space,
ownPower: ownPower,
onChanged: _load,
);
},
),
),
],
);
}
}
class _InviteButton extends ConsumerStatefulWidget {
final Room space;
const _InviteButton({required this.space});
@override
ConsumerState<_InviteButton> createState() => _InviteButtonState();
}
class _InviteButtonState extends ConsumerState<_InviteButton> {
bool _inviting = false;
String? _inviteError;
void _showInvite() {
final pt = PyramidTheme.of(context);
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) => AlertDialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
title: Text('Zu Space einladen',
style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Field(controller: ctrl, hint: '@user:server.org', pt: pt),
const SizedBox(height: 6),
Text('Lädt zu Space und allen Kanälen ein.',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
if (_inviteError != null) ...[
const SizedBox(height: 6),
Text(_inviteError!,
style: TextStyle(color: pt.danger, fontSize: 12)),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: pt.accent),
onPressed: _inviting
? null
: () async {
final userId = ctrl.text.trim();
if (userId.isEmpty) return;
setS(() { _inviting = true; _inviteError = null; });
try {
// 1. Invite to space itself
await widget.space.invite(userId);
// 2. Invite to every joined child room
final client = widget.space.client;
for (final child in widget.space.spaceChildren) {
final roomId = child.roomId;
if (roomId == null) continue;
final room = client.getRoomById(roomId);
if (room == null || room.membership == Membership.leave) continue;
try {
await room.invite(userId);
} catch (_) {}
}
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
setS(() { _inviteError = e.toString().split('\n').first; });
} finally {
setS(() => _inviting = false);
}
},
child: _inviting
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Einladen',
style: TextStyle(color: Colors.white)),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return IconButton(
icon: Icon(Icons.person_add_outlined, color: pt.accent, size: 20),
tooltip: 'Einladen',
onPressed: _showInvite,
);
}
}
class _MemberTile extends ConsumerStatefulWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberTile({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
ConsumerState<_MemberTile> createState() => _MemberTileState();
}
class _MemberTileState extends ConsumerState<_MemberTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final member = widget.member;
final power = member.powerLevel;
final name = member.displayName ?? member.id;
final client = ref.watch(matrixClientProvider).valueOrNull;
final avatarUri = member.avatarUrl;
final isMe = member.id == widget.space.client.userID;
final canManage = widget.ownPower > power && !isMe;
Widget avatar;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
if (client != null && avatarUri != null) {
avatar = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 32),
);
} else {
avatar = _InitialAvatar(letter: letter, pt: pt, size: 32);
}
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: _hovered ? pt.bg2 : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: avatar,
title: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
subtitle: Text(member.id,
style: TextStyle(color: pt.fgDim, fontSize: 11),
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: _powerColor(power, pt).withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: Text(_powerLabel(power),
style: TextStyle(
color: _powerColor(power, pt),
fontSize: 11,
fontWeight: FontWeight.w600)),
),
if (canManage) ...[
const SizedBox(width: 4),
_MemberActions(
member: member,
space: widget.space,
ownPower: widget.ownPower,
onChanged: widget.onChanged),
],
],
),
),
),
);
}
}
class _MemberActions extends ConsumerWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberActions({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
return PopupMenuButton<_MemberAction>(
icon: Icon(Icons.more_vert, color: pt.fgDim, size: 18),
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
itemBuilder: (_) => [
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerMod)
const PopupMenuItem(
value: _MemberAction.makeMod,
child: Text('Zum Moderator machen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel == _kPowerMod)
const PopupMenuItem(
value: _MemberAction.removeMod,
child: Text('Moderator-Rechte entziehen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerAdmin)
const PopupMenuItem(
value: _MemberAction.makeAdmin,
child: Text('Zum Admin machen'),
),
const PopupMenuItem(
value: _MemberAction.kick,
child: Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))),
),
const PopupMenuItem(
value: _MemberAction.ban,
child: Text('Bannen', style: TextStyle(color: Color(0xFFED4245))),
),
],
onSelected: (action) => _handleAction(context, action),
);
}
Future<void> _handleAction(BuildContext context, _MemberAction action) async {
switch (action) {
case _MemberAction.makeMod:
await _setPower(context, _kPowerMod);
case _MemberAction.removeMod:
await _setPower(context, _kPowerMember);
case _MemberAction.makeAdmin:
_confirmAdmin(context);
case _MemberAction.kick:
_confirmKick(context);
case _MemberAction.ban:
_confirmBan(context);
}
}
Future<void> _setPower(BuildContext context, int level) async {
try {
// NICHT space.setPower() — das merged nur mit dem lokalen Cache und
// kann die users-Map des Servers zerstören (Admin-Aussperrung).
await updatePowerLevelsSafely(space, (content) {
(content['users'] as Map<String, dynamic>)[member.id] = level;
});
onChanged();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())));
}
}
}
void _confirmAdmin(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Admin machen?'),
content: Text(
'${member.displayName ?? member.id} zum Admin befördern? '
'Admin-Rechte können nicht einfach entzogen werden.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _setPower(context, _kPowerAdmin);
},
child: const Text('Admin machen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
void _confirmKick(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied kicken?'),
content: Text('${member.displayName ?? member.id} aus dem Space entfernen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.kick(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Kicken',
style: TextStyle(color: Color(0xFFF59E0B))),
),
],
),
);
}
void _confirmBan(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied bannen?'),
content: Text(
'${member.displayName ?? member.id} dauerhaft aus dem Space ausschließen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.ban(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Bannen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
}
enum _MemberAction { makeMod, removeMod, makeAdmin, kick, ban }
// ─── Permissions Tab ──────────────────────────────────────────────────────────
class _PermissionsTab extends ConsumerStatefulWidget {
final Room space;
const _PermissionsTab({required this.space});
@override
ConsumerState<_PermissionsTab> createState() => _PermissionsTabState();
}
class _PermissionsTabState extends ConsumerState<_PermissionsTab> {
bool _saving = false;
String? _status;
// Current power level values (loaded from room state)
late Map<String, int> _levels;
static const _defaults = {
'events_default': 0,
'state_default': 50,
'invite': 0,
'kick': 50,
'ban': 50,
'redact': 50,
};
static const _labels = {
'events_default': 'Nachrichten senden',
'state_default': 'Raum-Einstellungen ändern',
'invite': 'Mitglieder einladen',
'kick': 'Mitglieder kicken',
'ban': 'Mitglieder bannen',
'redact': 'Nachrichten löschen',
};
@override
void initState() {
super.initState();
_load();
}
void _load() {
final content = widget.space
.getState(EventTypes.RoomPowerLevels)
?.content ??
{};
_levels = {
for (final key in _defaults.keys)
key: (content[key] as int?) ?? _defaults[key]!,
};
}
Future<void> _save() async {
setState(() { _saving = true; _status = null; });
try {
// Server-Stand holen und nur die geänderten Schlüssel mergen — der
// lokale Cache kann unvollständig sein und würde users/events wipen.
await updatePowerLevelsSafely(widget.space, (content) {
for (final entry in _levels.entries) {
content[entry.key] = entry.value;
}
});
setState(() => _status = 'Gespeichert');
} catch (e) {
final msg = e is MatrixException
? '${e.errcode}: ${e.errorMessage}'
: e.toString().split('\n').first;
setState(() => _status = 'Fehler: $msg');
} finally {
setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final ownPower = widget.space.ownPowerLevel;
final canEdit = ownPower >= _kPowerAdmin;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Mindestberechtigungen',
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 4),
Text(
'Lege fest, welche Stufe ein Mitglied mindestens haben muss, um diese Aktion auszuführen.',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
const SizedBox(height: 16),
...(_labels.entries.map((e) => _PermissionRow(
label: e.value,
value: _levels[e.key]!,
enabled: canEdit,
pt: pt,
onChanged: (v) => setState(() => _levels[e.key] = v),
))),
if (canEdit) ...[
const SizedBox(height: 20),
if (_status != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_status!,
style: TextStyle(
color: _status!.startsWith('F')
? const Color(0xFFED4245)
: const 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)),
),
),
],
],
),
);
}
}
class _PermissionRow extends StatelessWidget {
final String label;
final int value;
final bool enabled;
final PyramidTheme pt;
final ValueChanged<int> onChanged;
const _PermissionRow({
required this.label,
required this.value,
required this.enabled,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
child: Text(label,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
),
if (enabled)
DropdownButton<int>(
value: value,
dropdownColor: pt.bg2,
underline: const SizedBox.shrink(),
style: TextStyle(color: pt.fg, fontSize: 13),
items: const [
DropdownMenuItem(value: 0, child: Text('Mitglied (0)')),
DropdownMenuItem(value: 50, child: Text('Moderator (50)')),
DropdownMenuItem(value: 100, child: Text('Admin (100)')),
],
onChanged: (v) => v != null ? onChanged(v) : null,
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _powerColor(value, pt).withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: Text(_powerLabel(value),
style: TextStyle(
color: _powerColor(value, pt),
fontSize: 12,
fontWeight: FontWeight.w600)),
),
],
),
);
}
}
// ─── Channels Tab ─────────────────────────────────────────────────────────────
const _kSpaceChild = 'm.space.child';
const _kSpaceParent = 'm.space.parent';
const _kVoiceChannelType = 'io.pyramid.voice_channel';
class _ChannelsTab extends ConsumerStatefulWidget {
final Room space;
const _ChannelsTab({required this.space});
@override
ConsumerState<_ChannelsTab> createState() => _ChannelsTabState();
}
class _ChannelsTabState extends ConsumerState<_ChannelsTab> {
bool _busy = false;
List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) {
final childStates = widget.space.states[_kSpaceChild] ?? {};
final result = <({String roomId, Room? room, bool isVoice})>[];
for (final entry in childStates.entries) {
final content = entry.value.content;
if (content.isEmpty) continue;
final childRoomId = entry.key;
final room = client.getRoomById(childRoomId);
final createContent = room?.getState('m.room.create')?.content;
final isVoice = createContent?['type'] == _kVoiceChannelType;
result.add((roomId: childRoomId, room: room, isVoice: isVoice));
}
result.sort((a, b) {
final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId;
final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId;
return nameA.compareTo(nameB);
});
return result;
}
Future<void> _removeChild(String roomId) async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
setState(() => _busy = true);
try {
await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {});
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _addChild(String roomId) async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
final serverName = client.userID?.split(':').last ?? '';
try {
await client.setRoomStateWithKey(
widget.space.id,
_kSpaceChild,
roomId,
{'via': [serverName], 'suggested': false},
);
// Best-effort: set parent in child room
try {
await client.setRoomStateWithKey(
roomId,
_kSpaceParent,
widget.space.id,
{'via': [serverName], 'canonical': true},
);
} catch (_) {}
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _createAndAdd({
required String name,
required bool isVoice,
required Client client,
}) async {
setState(() => _busy = true);
try {
final serverName = client.userID?.split(':').last ?? '';
final roomId = await client.createRoom(
name: name,
visibility: Visibility.private,
preset: CreateRoomPreset.privateChat,
creationContent: isVoice ? {'type': _kVoiceChannelType} : null,
initialState: [
StateEvent(
type: 'm.room.join_rules',
content: {'join_rule': 'restricted', 'allow': [
{'type': 'm.room_membership', 'room_id': widget.space.id}
]},
),
],
);
await client.setRoomStateWithKey(
widget.space.id,
_kSpaceChild,
roomId,
{'via': [serverName], 'suggested': false},
);
try {
await client.setRoomStateWithKey(
roomId,
_kSpaceParent,
widget.space.id,
{'via': [serverName], 'canonical': true},
);
} catch (_) {}
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _showCreateDialog(BuildContext context, Client client) {
showDialog(
context: context,
builder: (_) => _CreateChannelDialog(
onConfirm: (name, isVoice) =>
_createAndAdd(name: name, isVoice: isVoice, client: client),
),
);
}
void _showAddExistingDialog(BuildContext context, Client client) {
final childStates = widget.space.states[_kSpaceChild] ?? {};
final existingIds = {
for (final e in childStates.entries)
if (e.value.content.isNotEmpty) e.key
};
final available = client.rooms
.where((r) => !r.isSpace && !existingIds.contains(r.id))
.toList()
..sort((a, b) => a.getLocalizedDisplayname()
.compareTo(b.getLocalizedDisplayname()));
showDialog(
context: context,
builder: (_) => _AddExistingDialog(
rooms: available,
client: client,
onConfirm: (roomId) => _addChild(roomId),
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final ownPower = widget.space.ownPowerLevel;
final canManage = ownPower >= _kPowerMod;
if (client == null) {
return Center(
child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2));
}
final children = _getChildren(client);
return Column(
children: [
if (canManage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Kanal erstellen'),
style: OutlinedButton.styleFrom(
foregroundColor: pt.accent,
side: BorderSide(color: pt.accent),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm)),
),
onPressed: _busy
? null
: () => _showCreateDialog(context, client),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.link, size: 16),
label: const Text('Raum hinzufügen'),
style: OutlinedButton.styleFrom(
foregroundColor: pt.fgDim,
side: BorderSide(color: pt.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm)),
),
onPressed: _busy
? null
: () => _showAddExistingDialog(context, client),
),
),
],
),
),
if (children.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, color: pt.fgDim, size: 40),
const SizedBox(height: 8),
Text('Noch keine Kanäle',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
],
),
),
)
else
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: children.length,
itemBuilder: (context, i) {
final child = children[i];
final name = child.room?.getLocalizedDisplayname() ??
child.roomId;
return _ChannelItem(
name: name,
isVoice: child.isVoice,
roomId: child.roomId,
pt: pt,
canRemove: canManage,
onRemove: () => _removeChild(child.roomId),
);
},
),
),
],
);
}
}
class _ChannelItem extends StatefulWidget {
final String name;
final bool isVoice;
final String roomId;
final PyramidTheme pt;
final bool canRemove;
final VoidCallback onRemove;
const _ChannelItem({
required this.name,
required this.isVoice,
required this.roomId,
required this.pt,
required this.canRemove,
required this.onRemove,
});
@override
State<_ChannelItem> createState() => _ChannelItemState();
}
class _ChannelItemState extends State<_ChannelItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: _hovered ? pt.bg2 : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
leading: Icon(
widget.isVoice
? Icons.volume_up_outlined
: Icons.tag,
color: pt.fgDim,
size: 18,
),
title: Text(
widget.name,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
widget.isVoice ? 'Sprachkanal' : 'Textkanal',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
trailing: widget.canRemove && _hovered
? IconButton(
icon: Icon(Icons.link_off,
color: const Color(0xFFED4245), size: 18),
tooltip: 'Aus Space entfernen',
onPressed: widget.onRemove,
)
: null,
),
),
);
}
}
class _CreateChannelDialog extends StatefulWidget {
final void Function(String name, bool isVoice) onConfirm;
const _CreateChannelDialog({required this.onConfirm});
@override
State<_CreateChannelDialog> createState() => _CreateChannelDialogState();
}
class _CreateChannelDialogState extends State<_CreateChannelDialog> {
final _ctrl = TextEditingController();
bool _isVoice = false;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return AlertDialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
title: Text('Neuen Kanal erstellen',
style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionLabel('Name', pt),
const SizedBox(height: 6),
_Field(controller: _ctrl, hint: 'kanal-name', pt: pt),
const SizedBox(height: 16),
_SectionLabel('Typ', pt),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _isVoice = false),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0,
border: Border.all(
color: !_isVoice ? pt.accent : pt.border),
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tag,
color: !_isVoice ? pt.accent : pt.fgDim,
size: 16),
const SizedBox(width: 6),
Text('Text',
style: TextStyle(
color: !_isVoice ? pt.accent : pt.fgDim,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _isVoice = true),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0,
border: Border.all(
color: _isVoice ? pt.accent : pt.border),
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.volume_up_outlined,
color: _isVoice ? pt.accent : pt.fgDim,
size: 16),
const SizedBox(width: 6),
Text('Sprache',
style: TextStyle(
color: _isVoice ? pt.accent : pt.fgDim,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: pt.accent),
onPressed: () {
final name = _ctrl.text.trim();
if (name.isEmpty) return;
Navigator.pop(context);
widget.onConfirm(name, _isVoice);
},
child: const Text('Erstellen',
style: TextStyle(color: Colors.white)),
),
],
);
}
}
class _AddExistingDialog extends StatefulWidget {
final List<Room> rooms;
final Client client;
final void Function(String roomId) onConfirm;
const _AddExistingDialog({
required this.rooms,
required this.client,
required this.onConfirm,
});
@override
State<_AddExistingDialog> createState() => _AddExistingDialogState();
}
class _AddExistingDialogState extends State<_AddExistingDialog> {
String _filter = '';
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final filtered = _filter.isEmpty
? widget.rooms
: widget.rooms
.where((r) => r
.getLocalizedDisplayname()
.toLowerCase()
.contains(_filter.toLowerCase()))
.toList();
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Raum hinzufügen',
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
),
IconButton(
icon: Icon(Icons.close, color: pt.fgDim, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 12),
_SearchField(
controller: _ctrl,
hint: 'Raum suchen…',
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
],
),
),
const Divider(height: 1),
if (filtered.isEmpty)
Expanded(
child: Center(
child: Text('Keine Räume gefunden',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
),
)
else
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
itemCount: filtered.length,
itemBuilder: (context, i) {
final room = filtered[i];
final name = room.getLocalizedDisplayname();
final letter =
name.isNotEmpty ? name[0].toUpperCase() : '?';
final avatarUri = room.avatar;
final createContent =
room.getState('m.room.create')?.content;
final isVoice =
createContent?['type'] == _kVoiceChannelType;
return ListTile(
dense: true,
leading: avatarUri != null
? MxcAvatar(
mxcUri: avatarUri,
client: widget.client,
size: 32,
borderRadius: BorderRadius.circular(pt.rSm),
placeholder: (_) => _InitialAvatar(
letter: letter, pt: pt, size: 32),
)
: _InitialAvatar(letter: letter, pt: pt, size: 32),
title: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
subtitle: Text(
isVoice ? 'Sprachkanal' : 'Textkanal',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
trailing:
Icon(isVoice ? Icons.volume_up_outlined : Icons.tag,
color: pt.fgDim, size: 16),
onTap: () {
Navigator.pop(context);
widget.onConfirm(room.id);
},
);
},
),
),
],
),
),
);
}
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _SectionLabel(this.text, this.pt);
@override
Widget build(BuildContext context) {
return Text(text,
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8));
}
}
class _Field extends StatelessWidget {
final TextEditingController controller;
final String hint;
final int maxLines;
final PyramidTheme pt;
const _Field({
required this.controller,
required this.hint,
this.maxLines = 1,
required this.pt,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLines: maxLines,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: pt.fgDim),
filled: true,
fillColor: pt.bg0,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent),
),
),
);
}
}
class _SearchField extends StatelessWidget {
final TextEditingController controller;
final String hint;
final PyramidTheme pt;
final ValueChanged<String> onChanged;
const _SearchField({
required this.controller,
required this.hint,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: pt.fgDim),
prefixIcon: Icon(Icons.search, color: pt.fgDim, size: 18),
filled: true,
fillColor: pt.bg0,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent),
),
),
);
}
}
// ─── Avatar Editor ───────────────────────────────────────────────────────────
class _AvatarEditor extends StatelessWidget {
final Room space;
final Uint8List? pendingAvatar;
final bool canEdit;
final VoidCallback onPick;
final VoidCallback onRemove;
final PyramidTheme pt;
final dynamic client;
const _AvatarEditor({
required this.space,
required this.pendingAvatar,
required this.canEdit,
required this.onPick,
required this.onRemove,
required this.pt,
required this.client,
});
@override
Widget build(BuildContext context) {
final letter = space.getLocalizedDisplayname().isNotEmpty
? space.getLocalizedDisplayname()[0].toUpperCase()
: '?';
final avatarUri = space.avatar;
final hasAvatar = pendingAvatar != null || avatarUri != null;
Widget avatarWidget;
if (pendingAvatar != null) {
avatarWidget = Image.memory(pendingAvatar!, fit: BoxFit.cover,
width: 64, height: 64);
} else if (avatarUri != null && client != null) {
avatarWidget = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 64,
borderRadius: BorderRadius.circular(32),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 64),
);
} else {
avatarWidget = _InitialAvatar(letter: letter, pt: pt, size: 64);
}
return Row(
children: [
GestureDetector(
onTap: canEdit ? onPick : null,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: avatarWidget,
),
if (canEdit)
Positioned(
right: -2,
bottom: -2,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 2),
),
child: const Icon(Icons.edit, size: 10, color: Colors.white),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
canEdit ? 'Klicken zum Ändern' : 'Serverbild',
style: TextStyle(color: pt.fg, fontSize: 13,
fontWeight: FontWeight.w500),
),
if (canEdit && hasAvatar)
GestureDetector(
onTap: onRemove,
child: Text('Entfernen',
style: TextStyle(
color: const Color(0xFFED4245), fontSize: 12)),
),
],
),
],
);
}
}
class _InitialAvatar extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double size;
const _InitialAvatar(
{required this.letter, required this.pt, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(size / 2),
),
child: Center(
child: Text(letter,
style: TextStyle(
color: pt.fg,
fontSize: size * 0.4,
fontWeight: FontWeight.w700)),
),
);
}
}