feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht

- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips
  (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles
  'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server
- Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf
- Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte
  'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf
- Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst),
  Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer,
  Matrix-Namen/-Avatare statt LiveKit-IDs
- Tests: test/bubble_physics_test.dart

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:58:31 +02:00
co-authored by Claude Opus 5.5
parent 87b472a373
commit de24053167
24 changed files with 1550 additions and 314 deletions
+5 -10
View File
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Räume');
: 'Chats';
void handleMenu(_SpaceMenuItem item) {
switch (item) {
@@ -101,7 +101,7 @@ class _Header extends ConsumerWidget {
).then((confirmed) {
if (confirmed == true) {
space.leave().then((_) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
});
}
});
@@ -110,7 +110,7 @@ class _Header extends ConsumerWidget {
}
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
if (spaceId == 'dms' || spaceId == null) {
if (!isRealSpace) {
return [
PopupMenuItem(
value: _SpaceMenuItem.newDm,
@@ -119,15 +119,10 @@ class _Header extends ConsumerWidget {
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
PopupMenuItem(
value: _SpaceMenuItem.createRoom,
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
const PopupMenuDivider(),
PopupMenuItem(
value: _SpaceMenuItem.joinRoom,
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.discoverRooms,
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
+183 -120
View File
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
}
}
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
enum _ChatFilter { all, unread, people, groups }
final _chatFilterProvider =
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
class _RoomsList extends ConsumerWidget {
final List<Room> rooms;
final String filter;
final String? spaceId;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _RoomsList({
required this.rooms,
required this.filter,
required this.spaceId,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeRoomId = ref.watch(activeRoomIdProvider);
final isDms = spaceId == 'dms';
final channels = ref.watch(spaceChannelIdsProvider);
final chatFilter = ref.watch(_chatFilterProvider);
final query = filter.toLowerCase();
final invites = rooms
.where((r) =>
r.membership == Membership.invite &&
isDms == r.isDirectChat)
.toList();
final invites =
rooms.where((r) => r.membership == Membership.invite).toList();
final filtered = rooms.where((r) {
if (r.membership == Membership.invite) return false;
if (isDms != r.isDirectChat) return false;
if (filter.isEmpty) return true;
return r.getLocalizedDisplayname()
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
if (filtered.isEmpty && invites.isEmpty) {
return Center(
child:
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
bool matches(Room r) {
switch (chatFilter) {
case _ChatFilter.all:
break;
case _ChatFilter.unread:
if (!r.isUnread) return false;
case _ChatFilter.people:
if (!r.isDirectChat) return false;
case _ChatFilter.groups:
if (r.isDirectChat) return false;
}
if (query.isEmpty) return true;
return r.getLocalizedDisplayname().toLowerCase().contains(query);
}
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false;
final chats = rooms
.where((r) =>
r.membership == Membership.join && isChatListRoom(r, channels))
.toList();
final filtered = chats.where(matches).toList();
final unreadCount = chats.where((r) => r.isUnread).length;
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
_ChatFilterChips(
value: chatFilter,
unreadCount: unreadCount,
pt: pt,
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
),
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
child: _InviteItem(room: r, pt: pt))),
const SizedBox(height: 4),
],
_SectionHeader(
title: sectionTitle,
count: filtered.length,
isCollapsed: isCollapsed,
pt: pt,
onToggle: () => onToggleSection(sectionId),
onAddTap: isDms
? () => showDialog(
context: context, builder: (_) => const NewDmDialog())
: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
if (!isCollapsed)
if (filtered.isEmpty)
_ChatListEmpty(
filter: chatFilter,
searching: query.isNotEmpty,
hasInvites: invites.isNotEmpty,
pt: pt,
)
else
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
key: ValueKey('chat:${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
@@ -163,6 +169,136 @@ class _RoomsList extends ConsumerWidget {
}
}
class _ChatFilterChips extends StatelessWidget {
final _ChatFilter value;
final int unreadCount;
final PyramidTheme pt;
final ValueChanged<_ChatFilter> onChanged;
const _ChatFilterChips({
required this.value,
required this.unreadCount,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
Widget chip(_ChatFilter f, String label) {
final selected = value == f;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: Semantics(
button: true,
selected: selected,
child: GestureDetector(
onTap: () => onChanged(selected ? _ChatFilter.all : f),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected ? pt.accentSoft : pt.bg2,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? pt.accent.withAlpha(120) : pt.border),
),
child: Text(label,
style: TextStyle(
color: selected ? pt.accent : pt.fgMuted,
fontSize: 12.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
),
),
),
);
}
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
children: [
chip(_ChatFilter.all, 'Alle'),
chip(_ChatFilter.unread,
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
chip(_ChatFilter.people, 'Personen'),
chip(_ChatFilter.groups, 'Gruppen'),
],
),
);
}
}
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
class _ChatListEmpty extends StatelessWidget {
final _ChatFilter filter;
final bool searching;
final bool hasInvites;
final PyramidTheme pt;
const _ChatListEmpty({
required this.filter,
required this.searching,
required this.hasInvites,
required this.pt,
});
@override
Widget build(BuildContext context) {
if (hasInvites && filter == _ChatFilter.all && !searching) {
return const SizedBox.shrink();
}
final (IconData icon, String text) = searching
? (Icons.search_off_rounded, 'Nichts gefunden')
: switch (filter) {
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
_ChatFilter.people => (
Icons.person_outline_rounded,
'Noch keine Unterhaltungen'
),
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
_ChatFilter.all => (
Icons.chat_bubble_outline_rounded,
'Noch keine Chats'
),
};
final showAction = !searching && filter != _ChatFilter.unread;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
child: Column(
children: [
Icon(icon, size: 34, color: pt.fgDim),
const SizedBox(height: 10),
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
if (showAction) ...[
const SizedBox(height: 14),
FilledButton.tonalIcon(
style: FilledButton.styleFrom(
backgroundColor: pt.accentSoft,
foregroundColor: pt.accent,
),
icon: Icon(filter == _ChatFilter.groups
? Icons.group_add_rounded
: Icons.edit_rounded, size: 18),
label: Text(filter == _ChatFilter.groups
? 'Gruppe erstellen'
: 'Neue Unterhaltung'),
onPressed: () => showDialog(
context: context,
builder: (_) => filter == _ChatFilter.groups
? const CreateJoinDialog()
: const NewDmDialog(),
),
),
],
],
),
);
}
}
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
@@ -187,84 +323,6 @@ Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
);
}
class _SectionHeader extends StatefulWidget {
final String title;
final int count;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onToggle;
final VoidCallback? onAddTap;
const _SectionHeader({
required this.title,
required this.count,
required this.isCollapsed,
required this.pt,
required this.onToggle,
this.onAddTap,
});
@override
State<_SectionHeader> createState() => _SectionHeaderState();
}
class _SectionHeaderState extends State<_SectionHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(children: [
Expanded(
child: GestureDetector(
onTap: widget.onToggle,
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 14,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(widget.title.toUpperCase(),
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11)),
const Spacer(),
Text('${widget.count}',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
),
),
if (_hovered && widget.onAddTap != null) ...[
const SizedBox(width: 4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onAddTap,
child: SizedBox(
width: 18,
height: 18,
child: Center(
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
),
),
] else
const SizedBox(width: 22),
]),
),
);
}
}
class _RoomItem extends ConsumerStatefulWidget {
final Room room;
final bool isActive;
@@ -574,6 +632,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
children: [
if (isDm) ...[
_DmAvatar(room: room, pt: pt),
] else if (!isVoice &&
!ref
.watch(spaceChannelIdsProvider)
.contains(room.id)) ...[
_RoomTileAvatar(room: room, pt: pt, size: 22),
] else ...[
SizedBox(
width: 16,
+18 -11
View File
@@ -119,34 +119,41 @@ class _MobileRoomTile extends StatelessWidget {
}
}
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
class _RoomTileAvatar extends ConsumerWidget {
final Room room;
final PyramidTheme pt;
const _RoomTileAvatar({required this.room, required this.pt});
final double size;
const _RoomTileAvatar(
{required this.room, required this.pt, this.size = _kTileAvatar});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
final fallback = Container(
width: _kTileAvatar,
height: _kTileAvatar,
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
color: isChannel ? pt.bg3 : pt.accentSoft,
borderRadius: radius,
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
isChannel
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
: Icons.groups_rounded,
size: size * 0.5,
color: isChannel ? pt.fgMuted : pt.accent,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
size: size,
borderRadius: radius,
placeholder: (_) => fallback,
);
}
@@ -118,12 +118,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
data: (rooms) => _RoomsList(
rooms: rooms,
filter: _filter,
spaceId: activeSpaceId,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
),
),
),
@@ -40,7 +40,7 @@ class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
+1
View File
@@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
+19 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
@@ -12,19 +13,33 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
}
});
/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder
/// Synchronisation neu berechnet.
final spaceChannelIdsProvider = Provider<Set<String>>((ref) {
ref.watch(roomListProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
return client == null ? const {} : spaceChannelIds(client);
});
/// Ungelesene Unterhaltungen in „Chats“ (Direktnachrichten + Gruppen ohne
/// Server) – für das Abzeichen am Chats-Symbol.
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield _countDmUnread(client);
yield _countChatUnread(client);
await for (final _ in client.onSync.stream) {
yield _countDmUnread(client);
yield _countChatUnread(client);
}
});
int _countDmUnread(Client client) {
int _countChatUnread(Client client) {
final channels = spaceChannelIds(client);
return client.rooms
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
.where((r) =>
r.membership == Membership.join &&
isChatListRoom(r, channels) &&
r.notificationCount > 0)
.length;
}