9dc11757e2
Entfernt tatsächlich toten Code, den flutter analyze als Warning markiert hatte: unbenutzte Felder/Parameter (_saved, _hovered wird jetzt für Hover- Feedback genutzt, destructive-Flag ohne Aufrufer, size-Parameter ohne Override), unerreichbaren dead_code (Room.topic/Space.topic sind laut SDK nie null, client.database ist nie null) sowie Legacy-Funktionen aus der alten Push-Architektur (_showNotif/_fetchTitle, ersetzt durch natives PushService.kt) und einen leeren Noise-Suppression-Stub ohne Aufrufer. Keine funktionale Verhaltensänderung an aktiven Code-Pfaden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1047 lines
39 KiB
Dart
1047 lines
39 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart' hide Visibility;
|
|
import 'package:flutter/services.dart';
|
|
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<PublicRoomsChunk> _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, activeColor: 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 PublicRoomsChunk 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)),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|