Files
pyramid/lib/widgets/create_join_dialog.dart
T
Bernd Steckmeister 057ac1e4ea style: verbleibende flutter-analyze Deprecations beheben
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen
Flutter-3.44-Äquivalente ersetzt.
2026-07-03 09:40:10 +02:00

1048 lines
39 KiB
Dart

import 'dart:async';
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/mxc_image.dart';
enum CreateJoinTab { create, join, discover }
enum _CreateType { room, voice, space }
class CreateJoinDialog extends ConsumerStatefulWidget {
final CreateJoinTab initialTab;
const CreateJoinDialog({super.key, this.initialTab = CreateJoinTab.create});
@override
ConsumerState<CreateJoinDialog> createState() => _CreateJoinDialogState();
}
class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
late CreateJoinTab _tab;
_CreateType _createType = _CreateType.room;
// Create
final _nameCtrl = TextEditingController();
final _topicCtrl = TextEditingController();
final _aliasCtrl = TextEditingController();
bool _isPublic = false;
bool _isEncrypted = true;
bool _creating = false;
String? _createError;
// Join
final _joinCtrl = TextEditingController();
bool _joining = false;
String? _joinError;
// Discover
final _discoverServerCtrl = TextEditingController();
final _discoverSearchCtrl = TextEditingController();
List<PublishedRoomsChunk> _publicRooms = [];
String? _nextBatch;
bool _discoverLoading = false;
bool _discoverLoadingMore = false;
String? _discoverError;
Timer? _searchDebounce;
final Set<String> _joiningRooms = {};
@override
void initState() {
super.initState();
_tab = widget.initialTab;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_tab == CreateJoinTab.discover) _discover();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_topicCtrl.dispose();
_aliasCtrl.dispose();
_joinCtrl.dispose();
_discoverServerCtrl.dispose();
_discoverSearchCtrl.dispose();
_searchDebounce?.cancel();
super.dispose();
}
Future<void> _create() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
setState(() => _createError = 'Bitte einen Namen eingeben.');
return;
}
setState(() { _creating = true; _createError = null; });
try {
final client = await ref.read(matrixClientProvider.future);
String roomId;
if (_createType == _CreateType.space) {
roomId = await client.createSpace(
name: name,
topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(),
visibility: _isPublic ? Visibility.public : Visibility.private,
spaceAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(),
);
} else if (_createType == _CreateType.voice) {
roomId = await client.createRoom(
name: name,
topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(),
visibility: Visibility.private,
preset: CreateRoomPreset.privateChat,
creationContent: {'type': 'io.pyramid.voice_channel'},
);
// Voice-Presence (io.pyramid.voice.presence) muss von JEDEM Mitglied
// schreibbar sein (state_default ist 50, normale Mitglieder haben 0) —
// sonst sieht man nur den Raum-Ersteller im Kanal. Nachträglich setzen,
// weil Continuwuity power_level_content_override beim createRoom
// ignoriert.
try {
final plRaw = await client.getRoomStateWithKey(
roomId, EventTypes.RoomPowerLevels, '');
final pl = Map<String, dynamic>.from(plRaw);
final events =
Map<String, dynamic>.from(pl['events'] as Map? ?? {});
events['io.pyramid.voice.presence'] = 0;
pl['events'] = events;
await client.setRoomStateWithKey(
roomId, EventTypes.RoomPowerLevels, '', pl);
} catch (_) {
// Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins.
}
} else {
roomId = await client.createRoom(
name: name,
topic: _topicCtrl.text.trim().isEmpty ? null : _topicCtrl.text.trim(),
visibility: _isPublic ? Visibility.public : Visibility.private,
roomAliasName: _aliasCtrl.text.trim().isEmpty ? null : _aliasCtrl.text.trim(),
preset: _isPublic ? CreateRoomPreset.publicChat : CreateRoomPreset.privateChat,
initialState: _isEncrypted && !_isPublic
? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)]
: null,
);
}
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
Navigator.of(context).pop();
}
} on MatrixException catch (e) {
if (mounted) setState(() { _creating = false; _createError = e.errorMessage; });
} catch (e) {
if (mounted) setState(() { _creating = false; _createError = e.toString().split('\n').first; });
}
}
Future<void> _join() async {
final addr = _joinCtrl.text.trim();
if (addr.isEmpty) {
setState(() => _joinError = 'Bitte eine Raum-Adresse eingeben.');
return;
}
setState(() { _joining = true; _joinError = null; });
try {
final client = await ref.read(matrixClientProvider.future);
final roomId = await client.joinRoom(addr);
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
Navigator.of(context).pop();
}
} on MatrixException catch (e) {
if (mounted) setState(() { _joining = false; _joinError = e.errorMessage; });
} catch (e) {
if (mounted) setState(() { _joining = false; _joinError = e.toString().split('\n').first; });
}
}
Future<void> _discover({bool loadMore = false}) async {
if (loadMore) {
if (_nextBatch == null) return;
setState(() => _discoverLoadingMore = true);
} else {
setState(() { _discoverLoading = true; _discoverError = null; });
}
try {
final client = await ref.read(matrixClientProvider.future);
final server = _discoverServerCtrl.text.trim().isEmpty ? null : _discoverServerCtrl.text.trim();
final search = _discoverSearchCtrl.text.trim();
final res = await client.queryPublicRooms(
server: server,
limit: 30,
since: loadMore ? _nextBatch : null,
filter: search.isEmpty ? null : PublicRoomQueryFilter(genericSearchTerm: search),
);
if (mounted) {
setState(() {
if (loadMore) {
_publicRooms.addAll(res.chunk);
} else {
_publicRooms = res.chunk;
}
_nextBatch = res.nextBatch;
_discoverLoading = false;
_discoverLoadingMore = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_discoverLoading = false;
_discoverLoadingMore = false;
_discoverError = e.toString().split('\n').first;
});
}
}
}
void _onSearchChanged(String _) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 500), _discover);
}
Future<void> _joinPublicRoom(String roomId, List<String>? via) async {
setState(() => _joiningRooms.add(roomId));
try {
final client = await ref.read(matrixClientProvider.future);
final id = await client.joinRoom(roomId, via: via);
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
Navigator.of(context).pop();
}
} catch (_) {
if (mounted) setState(() => _joiningRooms.remove(roomId));
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final isNarrow = size.width < 600;
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isNarrow ? size.width * 0.97 : 580,
maxHeight: size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTabs(pt),
Divider(color: pt.border, height: 1),
Flexible(child: _buildBody(pt)),
],
),
),
);
}
Widget _buildTabs(PyramidTheme pt) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
child: Row(
children: [
Expanded(
child: Row(
children: [
_Tab(label: 'Erstellen', icon: Icons.add_rounded, active: _tab == CreateJoinTab.create,
pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.create)),
const SizedBox(width: 4),
_Tab(label: 'Beitreten', icon: Icons.login_rounded, active: _tab == CreateJoinTab.join,
pt: pt, onTap: () => setState(() => _tab = CreateJoinTab.join)),
const SizedBox(width: 4),
_Tab(label: 'Entdecken', icon: Icons.explore_rounded, active: _tab == CreateJoinTab.discover,
pt: pt, onTap: () {
setState(() => _tab = CreateJoinTab.discover);
if (_publicRooms.isEmpty) _discover();
}),
],
),
),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
],
),
);
}
Widget _buildBody(PyramidTheme pt) {
return switch (_tab) {
CreateJoinTab.create => _buildCreate(pt),
CreateJoinTab.join => _buildJoin(pt),
CreateJoinTab.discover => _buildDiscover(pt),
};
}
Widget _buildCreate(PyramidTheme pt) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Type toggle
Row(children: [
_TypeToggle(
label: 'Raum',
icon: Icons.tag_rounded,
active: _createType == _CreateType.room,
pt: pt,
onTap: () => setState(() { _createType = _CreateType.room; _isPublic = false; }),
),
const SizedBox(width: 8),
_TypeToggle(
label: 'Voice',
icon: Icons.mic_rounded,
active: _createType == _CreateType.voice,
pt: pt,
onTap: () => setState(() { _createType = _CreateType.voice; _isPublic = false; }),
),
const SizedBox(width: 8),
_TypeToggle(
label: 'Space',
icon: Icons.workspaces_rounded,
active: _createType == _CreateType.space,
pt: pt,
onTap: () => setState(() => _createType = _CreateType.space),
),
]),
const SizedBox(height: 16),
_Field(ctrl: _nameCtrl, label: 'Name *', pt: pt, autofocus: true),
const SizedBox(height: 10),
_Field(ctrl: _topicCtrl, label: 'Thema / Beschreibung (optional)', pt: pt, maxLines: 2),
const SizedBox(height: 14),
// Options
if (_createType == _CreateType.voice)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(Icons.info_outline, size: 14, color: pt.fgDim),
const SizedBox(width: 6),
Expanded(
child: Text(
'Voice-Kanäle sind privat. Mitglieder können beitreten und direkt sprechen.',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
],
),
),
if (_createType != _CreateType.voice) ...[
_OptionRow(
label: 'Öffentlich',
desc: 'Im Verzeichnis sichtbar, jeder kann beitreten',
pt: pt,
value: _isPublic,
onChanged: (v) => setState(() => _isPublic = v),
),
if (_createType == _CreateType.room) ...[
const SizedBox(height: 8),
_OptionRow(
label: 'Verschlüsselt',
desc: 'Ende-zu-Ende-Verschlüsselung aktivieren',
pt: pt,
value: _isEncrypted,
onChanged: (v) => setState(() => _isEncrypted = v),
),
],
if (_isPublic) ...[
const SizedBox(height: 10),
_Field(
ctrl: _aliasCtrl,
label: 'Alias (optional)',
pt: pt,
hint: 'mein-raum → #mein-raum:server',
),
],
],
if (_createError != null) ...[
const SizedBox(height: 10),
_ErrorCard(text: _createError!, pt: pt),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _creating ? null : _create,
child: _creating
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
: Text(_createType == _CreateType.space ? 'Space erstellen' : 'Raum erstellen'),
),
),
],
),
);
}
Widget _buildJoin(PyramidTheme pt) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Raum-Adresse oder ID eingeben',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5)),
const SizedBox(height: 12),
_Field(
ctrl: _joinCtrl,
label: 'Adresse oder ID',
hint: '#kanal:server.de oder !abc123:server.de',
pt: pt,
autofocus: true,
onSubmitted: (_) => _join(),
),
const SizedBox(height: 6),
Text('Tipp: Matrix-Räume, Spaces und Gruppen werden alle unterstützt.',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
if (_joinError != null) ...[
const SizedBox(height: 10),
_ErrorCard(text: _joinError!, pt: pt),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: _joining ? null : _join,
child: _joining
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
: const Text('Beitreten'),
),
),
],
),
);
}
Widget _buildDiscover(PyramidTheme pt) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(children: [
Expanded(
child: _Field(
ctrl: _discoverSearchCtrl,
label: 'Suchen',
pt: pt,
onChanged: _onSearchChanged,
prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
),
),
const SizedBox(width: 8),
SizedBox(
width: 140,
child: _Field(
ctrl: _discoverServerCtrl,
label: 'Server',
hint: 'matrix.org',
pt: pt,
onSubmitted: (_) => _discover(),
),
),
]),
),
Divider(color: pt.border, height: 1),
Flexible(
child: _discoverLoading
? const Center(child: CircularProgressIndicator.adaptive())
: _discoverError != null
? Center(child: _ErrorCard(text: _discoverError!, pt: pt))
: _publicRooms.isEmpty
? Center(child: Text('Keine Räume gefunden.', style: TextStyle(color: pt.fgMuted, fontSize: 13)))
: ListView.builder(
itemCount: _publicRooms.length + (_nextBatch != null ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == _publicRooms.length) {
return Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: _discoverLoadingMore
? const CircularProgressIndicator.adaptive()
: TextButton(
onPressed: () => _discover(loadMore: true),
child: Text('Mehr laden', style: TextStyle(color: pt.accent)),
),
),
);
}
final room = _publicRooms[i];
return _PublicRoomItem(
room: room,
pt: pt,
isJoining: _joiningRooms.contains(room.roomId),
onJoin: () => _joinPublicRoom(room.roomId, null),
);
},
),
),
],
);
}
}
// ── Space / Room editor dialog ─────────────────────────────────────────────────
class SpaceEditDialog extends ConsumerStatefulWidget {
final Room space;
const SpaceEditDialog({super.key, required this.space});
@override
ConsumerState<SpaceEditDialog> createState() => _SpaceEditDialogState();
}
class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
late TextEditingController _nameCtrl;
late TextEditingController _topicCtrl;
bool _saving = false;
String? _error;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname());
_topicCtrl = TextEditingController(text: widget.space.topic);
}
@override
void dispose() {
_nameCtrl.dispose();
_topicCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() { _saving = true; _error = null; });
try {
final name = _nameCtrl.text.trim();
final topic = _topicCtrl.text.trim();
if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) {
await widget.space.setName(name);
}
if (topic != widget.space.topic) {
await widget.space.setDescription(topic);
}
if (mounted) setState(() { _saving = false; });
await Future.delayed(const Duration(seconds: 1));
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; });
}
}
Future<void> _leave() async {
final pt = PyramidTheme.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
title: Text('Space verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text('Du verlässt "${widget.space.getLocalizedDisplayname()}".',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
Navigator.of(context).pop(true);
}
} catch (e) {
if (mounted) setState(() => _error = e.toString().split('\n').first);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.workspaces_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Expanded(child: Text('Space bearbeiten',
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
]),
const SizedBox(height: 16),
_Field(ctrl: _nameCtrl, label: 'Name', pt: pt),
const SizedBox(height: 10),
_Field(ctrl: _topicCtrl, label: 'Beschreibung', pt: pt, maxLines: 2),
if (_error != null) ...[
const SizedBox(height: 10),
_ErrorCard(text: _error!, pt: pt),
],
const SizedBox(height: 16),
Row(children: [
Expanded(child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)),
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: _leave,
child: const Text('Verlassen'),
)),
const SizedBox(width: 10),
Expanded(child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
: const Text('Speichern'),
)),
]),
],
),
),
),
);
}
}
// ── Room settings dialog ───────────────────────────────────────────────────────
class RoomSettingsDialog extends ConsumerStatefulWidget {
final Room room;
const RoomSettingsDialog({super.key, required this.room});
@override
ConsumerState<RoomSettingsDialog> createState() => _RoomSettingsDialogState();
}
class _RoomSettingsDialogState extends ConsumerState<RoomSettingsDialog> {
late TextEditingController _nameCtrl;
late TextEditingController _topicCtrl;
bool _saving = false;
String? _error;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname());
_topicCtrl = TextEditingController(text: widget.room.topic);
}
@override
void dispose() {
_nameCtrl.dispose();
_topicCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() { _saving = true; _error = null; });
try {
if (_nameCtrl.text.trim() != widget.room.getLocalizedDisplayname()) {
await widget.room.setName(_nameCtrl.text.trim());
}
final newTopic = _topicCtrl.text.trim();
if (newTopic != widget.room.topic) {
await widget.room.setDescription(newTopic);
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; });
}
}
Future<void> _leaveRoom() async {
final pt = PyramidTheme.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
title: Text('Raum verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text('"${widget.room.getLocalizedDisplayname()}" verlassen.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final roomId = widget.room.id;
await widget.room.leave();
if (mounted) {
final current = ref.read(activeRoomIdProvider);
if (current == roomId) ref.read(activeRoomIdProvider.notifier).state = null;
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) setState(() => _error = e.toString().split('\n').first);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final canEdit = widget.room.canSendEvent(EventTypes.RoomName);
return Dialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.settings_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Expanded(child: Text('Raum-Einstellungen',
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600))),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
]),
const SizedBox(height: 6),
SelectableText(widget.room.id,
style: TextStyle(color: pt.fgDim, fontSize: 11, fontFamily: 'monospace')),
const SizedBox(height: 16),
if (canEdit) ...[
_Field(ctrl: _nameCtrl, label: 'Name', pt: pt),
const SizedBox(height: 10),
_Field(ctrl: _topicCtrl, label: 'Thema', pt: pt, maxLines: 2),
] else
Text('Du hast keine Berechtigung, diesen Raum zu bearbeiten.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
if (_error != null) ...[
const SizedBox(height: 10),
_ErrorCard(text: _error!, pt: pt),
],
const SizedBox(height: 16),
Row(children: [
Expanded(child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(80)),
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: _leaveRoom,
child: const Text('Verlassen'),
)),
if (canEdit) ...[
const SizedBox(width: 10),
Expanded(child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
: const Text('Speichern'),
)),
],
]),
],
),
),
),
);
}
}
// ── Helper widgets ─────────────────────────────────────────────────────────────
class _Tab extends StatefulWidget {
final String label;
final IconData icon;
final bool active;
final PyramidTheme pt;
final VoidCallback onTap;
const _Tab({required this.label, required this.icon, required this.active, required this.pt, required this.onTap});
@override
State<_Tab> createState() => _TabState();
}
class _TabState extends State<_Tab> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: widget.active ? pt.accentSoft : (_hovered ? pt.bgHover : Colors.transparent),
borderRadius: BorderRadius.circular(6),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(widget.icon, size: 14, color: widget.active ? pt.accent : pt.fgMuted),
const SizedBox(width: 6),
Text(widget.label, style: TextStyle(
color: widget.active ? pt.fg : pt.fgMuted,
fontSize: 13, fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400,
)),
]),
),
),
);
}
}
class _TypeToggle extends StatelessWidget {
final String label;
final IconData icon;
final bool active;
final PyramidTheme pt;
final VoidCallback onTap;
const _TypeToggle({required this.label, required this.icon, required this.active, required this.pt, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: active ? pt.accent : pt.border),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 14, color: active ? pt.accentFg : pt.fgMuted),
const SizedBox(width: 6),
Text(label, style: TextStyle(
color: active ? pt.accentFg : pt.fgMuted,
fontSize: 13, fontWeight: FontWeight.w500,
)),
]),
),
);
}
}
class _OptionRow extends StatelessWidget {
final String label;
final String desc;
final bool value;
final ValueChanged<bool> onChanged;
final PyramidTheme pt;
const _OptionRow({required this.label, required this.desc, required this.value, required this.onChanged, required this.pt});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
Text(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)),
])),
Switch(value: value, onChanged: onChanged, activeThumbColor: pt.accent),
]),
);
}
}
class _Field extends StatelessWidget {
final TextEditingController ctrl;
final String label;
final String? hint;
final PyramidTheme pt;
final bool autofocus;
final int maxLines;
final Widget? prefixIcon;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
const _Field({
required this.ctrl, required this.label, required this.pt,
this.hint, this.autofocus = false, this.maxLines = 1,
this.prefixIcon, this.onChanged, this.onSubmitted,
});
@override
Widget build(BuildContext context) => TextField(
controller: ctrl,
autofocus: autofocus,
maxLines: maxLines,
style: TextStyle(color: pt.fg, fontSize: 13),
onChanged: onChanged,
onSubmitted: onSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
prefixIcon: prefixIcon,
filled: true, fillColor: pt.bg3,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
);
}
class _ErrorCard extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _ErrorCard({required this.text, required this.pt});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: pt.danger.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.danger.withAlpha(60)),
),
child: Row(children: [
Icon(Icons.error_outline_rounded, size: 14, color: pt.danger),
const SizedBox(width: 8),
Expanded(child: Text(text, style: TextStyle(color: pt.danger, fontSize: 12))),
]),
);
}
class _PublicRoomItem extends ConsumerWidget {
final PublishedRoomsChunk room;
final PyramidTheme pt;
final bool isJoining;
final VoidCallback onJoin;
const _PublicRoomItem({required this.room, required this.pt, required this.isJoining, required this.onJoin});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = room.name ?? room.canonicalAlias ?? room.roomId;
final avatarUrl = room.avatarUrl;
final alreadyJoined = client?.getRoomById(room.roomId) != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(children: [
// Avatar
Container(
width: 36, height: 36,
decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(8)),
child: (client != null && avatarUrl != null)
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: MxcImage(mxcUri: avatarUrl.toString(), client: client, fit: BoxFit.cover),
)
: Center(child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w700),
)),
),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
if (room.topic != null)
Text(room.topic!, style: TextStyle(color: pt.fgDim, fontSize: 11),
overflow: TextOverflow.ellipsis, maxLines: 1),
Text('${room.numJoinedMembers} Mitglieder',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
])),
const SizedBox(width: 8),
if (alreadyJoined)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(6)),
child: Text('Beigetreten', style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600)),
)
else if (isJoining)
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
else
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent, foregroundColor: pt.accentFg,
elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
onPressed: onJoin,
child: const Text('Beitreten', style: TextStyle(fontSize: 12)),
),
]),
);
}
}