From 5a909f0e0d87059b6e0ea87119e9a0c78b899743 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Sun, 19 Apr 2026 20:07:20 +0200 Subject: [PATCH] feat: rooms list with real Matrix data, MXC avatars and unread badges --- lib/features/rooms/room_list_item.dart | 148 +++++++++++++++++++++++++ lib/features/rooms/rooms_page.dart | 51 ++++++++- lib/features/rooms/rooms_provider.dart | 25 +++++ lib/widgets/mxc_image.dart | 53 +++++++++ 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 lib/features/rooms/room_list_item.dart create mode 100644 lib/features/rooms/rooms_provider.dart create mode 100644 lib/widgets/mxc_image.dart diff --git a/lib/features/rooms/room_list_item.dart b/lib/features/rooms/room_list_item.dart new file mode 100644 index 0000000..7ae29a9 --- /dev/null +++ b/lib/features/rooms/room_list_item.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/widgets/mxc_image.dart'; + +class RoomListItem extends StatelessWidget { + final Room room; + final VoidCallback onTap; + + const RoomListItem({super.key, required this.room, required this.onTap}); + + @override + Widget build(BuildContext context) { + final unread = room.notificationCount; + final lastEvent = room.lastEvent; + final theme = Theme.of(context); + + return ListTile( + onTap: onTap, + leading: _RoomAvatar(room: room), + title: Text( + room.getLocalizedDisplayname(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: unread > 0 + ? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold) + : theme.textTheme.bodyLarge, + ), + subtitle: lastEvent != null + ? Text( + _previewText(lastEvent), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ) + : null, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (lastEvent != null) + Text( + _formatTime(lastEvent.originServerTs), + style: theme.textTheme.labelSmall?.copyWith( + color: unread > 0 + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + if (unread > 0) ...[ + const SizedBox(height: 4), + _UnreadBadge(count: unread), + ], + ], + ), + ); + } + + String _previewText(Event event) { + if (event.type == EventTypes.Message) { + final body = event.body; + if (body.isNotEmpty) return body; + } + return event.type; + } + + String _formatTime(DateTime time) { + final now = DateTime.now(); + if (time.day == now.day && + time.month == now.month && + time.year == now.year) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } + return '${time.day.toString().padLeft(2, '0')}.${time.month.toString().padLeft(2, '0')}'; + } +} + +class _RoomAvatar extends StatelessWidget { + final Room room; + const _RoomAvatar({required this.room}); + + @override + Widget build(BuildContext context) { + final avatar = room.avatar; + final name = room.getLocalizedDisplayname(); + final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final color = _colorForName(name, Theme.of(context).colorScheme); + + return CircleAvatar( + radius: 24, + backgroundColor: color, + child: ClipOval( + child: avatar != null + ? MxcImage( + mxcUri: avatar, + width: 48, + height: 48, + placeholder: (_) => Text( + initials, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ) + : Text( + initials, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ); + } + + Color _colorForName(String name, ColorScheme scheme) { + final colors = [ + scheme.primary, + scheme.secondary, + scheme.tertiary, + Colors.teal, + Colors.indigo, + Colors.orange, + ]; + final hash = name.codeUnits.fold(0, (a, b) => a + b); + return colors[hash % colors.length]; + } +} + +class _UnreadBadge extends StatelessWidget { + final int count; + const _UnreadBadge({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/features/rooms/rooms_page.dart b/lib/features/rooms/rooms_page.dart index 98ba2d6..399ab95 100644 --- a/lib/features/rooms/rooms_page.dart +++ b/lib/features/rooms/rooms_page.dart @@ -1,12 +1,55 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pyramid/features/rooms/room_list_item.dart'; +import 'package:pyramid/features/rooms/rooms_provider.dart'; -class RoomsPage extends StatelessWidget { +class RoomsPage extends ConsumerWidget { const RoomsPage({super.key}); @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text('Rooms')), + Widget build(BuildContext context, WidgetRef ref) { + final roomsAsync = ref.watch(roomListProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Pyramid'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.go('/settings'), + ), + ], + ), + body: roomsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (rooms) { + if (rooms.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 64), + SizedBox(height: 16), + Text('Noch keine Räume'), + ], + ), + ); + } + return ListView.separated( + itemCount: rooms.length, + separatorBuilder: (context, i) => const Divider(height: 1, indent: 72), + itemBuilder: (context, i) { + final room = rooms[i]; + return RoomListItem( + room: room, + onTap: () => context.go('/chat/${Uri.encodeComponent(room.id)}'), + ); + }, + ); + }, + ), ); } } diff --git a/lib/features/rooms/rooms_provider.dart b/lib/features/rooms/rooms_provider.dart new file mode 100644 index 0000000..d4a5ccf --- /dev/null +++ b/lib/features/rooms/rooms_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; + +final roomListProvider = StreamProvider>((ref) async* { + final client = await ref.watch(matrixClientProvider.future); + + yield _sortedRooms(client); + + await for (final _ in client.onSync.stream) { + yield _sortedRooms(client); + } +}); + +List _sortedRooms(Client client) { + final rooms = client.rooms + .where((r) => !r.isSpace) + .toList() + ..sort((a, b) { + final aTime = a.lastEvent?.originServerTs ?? DateTime(0); + final bTime = b.lastEvent?.originServerTs ?? DateTime(0); + return bTime.compareTo(aTime); + }); + return rooms; +} diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart new file mode 100644 index 0000000..ef0a585 --- /dev/null +++ b/lib/widgets/mxc_image.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; + +class MxcImage extends ConsumerWidget { + final Uri? mxcUri; + final double? width; + final double? height; + final Widget Function(BuildContext)? placeholder; + + const MxcImage({ + super.key, + required this.mxcUri, + this.width, + this.height, + this.placeholder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final uri = mxcUri; + + if (uri == null) return _fallback(context); + + return clientAsync.when( + loading: () => _fallback(context), + error: (e, st) => _fallback(context), + data: (client) => FutureBuilder( + future: uri.getThumbnailUri( + client, + width: width != null ? (width! * 2).toInt() : 64, + height: height != null ? (height! * 2).toInt() : 64, + method: ThumbnailMethod.crop, + ), + builder: (context, snap) { + if (!snap.hasData) return _fallback(context); + return Image.network( + snap.data!.toString(), + width: width, + height: height, + fit: BoxFit.cover, + errorBuilder: (ctx, err, st) => _fallback(context), + ); + }, + ), + ); + } + + Widget _fallback(BuildContext context) => + placeholder?.call(context) ?? SizedBox(width: width, height: height); +}