25ed765a03
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
2279 lines
75 KiB
Dart
2279 lines
75 KiB
Dart
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)),
|
||
),
|
||
);
|
||
}
|
||
}
|