feat: Handy-Chatliste als Messenger-Kachel (Vorschau, Uhrzeit, Ungelesen) + deutsche Beschriftungen

- rooms_mobile_tile.dart: auf dem Handy grosser Avatar, Name + Uhrzeit der
  letzten Nachricht, Vorschau (gleiche Texte wie Push: Medien-Labels,
  Antwort-Zitat entfernt; "Du:" bzw. Absender in Gruppen), Ungelesen-Zaehler.
  PC behaelt die kompakte Liste, Voice-Kanaele ihre Darstellung.
- utils/date_labels.dart: chatListTime (heute Uhrzeit, Gestern, Wochentag,
  Datum; sicher an Zeitumstellungs-Tagen) + 5 Tests.
- Sichtbare englische Texte eingedeutscht (Chats durchsuchen,
  Direktnachrichten, Raeume, Nachricht..., Mitglieder, Suchen, ...).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 17:21:33 +02:00
co-authored by Claude Opus 5.5
parent c5f10901e6
commit c38e9c18b0
13 changed files with 267 additions and 26 deletions
+2 -2
View File
@@ -303,8 +303,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final roomName = widget.room.getLocalizedDisplayname(); final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
? 'Message…' ? 'Nachricht…'
: 'Message #$roomName'; : 'Nachricht an #$roomName';
return SafeArea( return SafeArea(
child: Column( child: Column(
+4 -4
View File
@@ -144,13 +144,13 @@ class _ChatHeader extends ConsumerWidget {
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
tooltip: 'Search', tooltip: 'Suchen',
onPressed: onOpenSearch, onPressed: onOpenSearch,
), ),
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.group_rounded), icon: const Icon(Icons.group_rounded),
tooltip: 'Members', tooltip: 'Mitglieder',
active: membersOpen, active: membersOpen,
onPressed: onToggleMembers, onPressed: onToggleMembers,
), ),
@@ -540,13 +540,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
tooltip: 'Search', tooltip: 'Suchen',
onPressed: widget.onOpenSearch, onPressed: widget.onOpenSearch,
), ),
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.group_rounded), icon: const Icon(Icons.group_rounded),
tooltip: 'Members', tooltip: 'Mitglieder',
active: widget.membersOpen, active: widget.membersOpen,
onPressed: widget.onToggleMembers, onPressed: widget.onToggleMembers,
), ),
+1 -1
View File
@@ -115,7 +115,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a room to start chatting', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 16), style: TextStyle(color: pt.fgMuted, fontSize: 16),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+1 -1
View File
@@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget {
Icon(Icons.group_rounded, size: 16, color: pt.fg), Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Members', 'Mitglieder',
style: TextStyle( style: TextStyle(
color: pt.fg, color: pt.fg,
fontSize: 14, fontSize: 14,
+2 -2
View File
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space') ? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Rooms'); : (spaceId == 'dms' ? 'Chats' : 'Räume');
void handleMenu(_SpaceMenuItem item) { void handleMenu(_SpaceMenuItem item) {
switch (item) { switch (item) {
@@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget {
onChanged: onChanged, onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13), style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Filter rooms', hintText: 'Chats durchsuchen',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
filled: true, filled: true,
+14 -2
View File
@@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget {
if (filtered.isEmpty && invites.isEmpty) { if (filtered.isEmpty && invites.isEmpty) {
return Center( return Center(
child: child:
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13))); Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
} }
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms'; final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms'; final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false; final isCollapsed = collapsed[sectionId] ?? false;
@@ -513,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[]; final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
final client = ref.watch(matrixClientProvider).valueOrNull; final client = ref.watch(matrixClientProvider).valueOrNull;
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile(
room: room,
isActive: active,
pt: pt,
myUserId: client?.userID,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted; Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
return MouseRegion( return MouseRegion(
@@ -0,0 +1,159 @@
part of '../rooms_panel.dart';
// Chatlisten-Eintrag im Handy-Layout (Bernds Feedback 2026-10-07: die Handy-App
// soll sich wie ein gewohnter Messenger anfühlen): großer Avatar, Name mit
// Uhrzeit der letzten Nachricht, darunter die letzte Nachricht als Vorschau
// und der Ungelesen-Zähler. Am PC bleibt die kompakte Discord-Liste
// (_RoomItem); Voice-Kanäle behalten ihre eigene Darstellung.
const _kTileAvatar = 46.0;
class _MobileRoomTile extends StatelessWidget {
final Room room;
final bool isActive;
final PyramidTheme pt;
final String? myUserId;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _MobileRoomTile({
required this.room,
required this.isActive,
required this.pt,
required this.myUserId,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final name = room.getLocalizedDisplayname();
final unread = room.notificationCount;
final mention = room.highlightCount > 0;
final last = room.lastEvent;
final preview = _roomPreview(room, myUserId);
final time = last == null ? null : chatListTime(last.originServerTs);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Container(
color: isActive ? pt.accentSoft : Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
children: [
room.isDirectChat
? _DmAvatar(room: room, pt: pt, size: _kTileAvatar)
: _RoomTileAvatar(room: room, pt: pt),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (time != null) ...[
const SizedBox(width: 8),
Text(
time,
style: TextStyle(
color: unread > 0 ? pt.accent : pt.fgDim,
fontSize: 12,
fontWeight:
unread > 0 ? FontWeight.w600 : FontWeight.w400,
),
),
],
],
),
const SizedBox(height: 3),
Row(
children: [
Expanded(
child: Text(
preview ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: unread > 0 ? pt.fgMuted : pt.fgDim,
fontSize: 13.5,
),
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
_UnreadBadge(count: unread, isMention: mention, pt: pt),
],
],
),
],
),
),
],
),
),
);
}
}
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
class _RoomTileAvatar extends ConsumerWidget {
final Room room;
final PyramidTheme pt;
const _RoomTileAvatar({required this.room, required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final fallback = Container(
width: _kTileAvatar,
height: _kTileAvatar,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
placeholder: (_) => fallback,
);
}
}
/// Vorschau der letzten Nachricht: gleiche Texte wie in Benachrichtigungen
/// (Medien als „📷 Foto“ usw., Antwort-Zitat entfernt), in Gruppen mit
/// Absender, eigene Nachrichten mit „Du:“.
String? _roomPreview(Room room, String? myUserId) {
final e = room.lastEvent;
if (e == null) return null;
if (e.type == EventTypes.Encrypted) return '🔒 Verschlüsselte Nachricht';
final body = notificationBody(e);
if (body == null) return null;
if (e.senderId == myUserId) return 'Du: $body';
if (room.isDirectChat) return body;
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
}
+12 -11
View File
@@ -3,7 +3,8 @@ part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget { class _DmAvatar extends ConsumerStatefulWidget {
final Room room; final Room room;
final PyramidTheme pt; final PyramidTheme pt;
const _DmAvatar({required this.room, required this.pt}); final double size;
const _DmAvatar({required this.room, required this.pt, this.size = 22});
@override @override
ConsumerState<_DmAvatar> createState() => _DmAvatarState(); ConsumerState<_DmAvatar> createState() => _DmAvatarState();
@@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
avatar = MxcAvatar( avatar = MxcAvatar(
mxcUri: widget.room.avatar, mxcUri: widget.room.avatar,
client: client, client: client,
size: 22, size: widget.size,
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(widget.size / 2),
placeholder: (_) => placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: 22), _InitialCircle(initial: initial, color: color, size: widget.size),
); );
} else { } else {
avatar = _InitialCircle(initial: initial, color: color, size: 22); avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
} }
final dot = _dotColor(); final dot = _dotColor();
@@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
return Stack(clipBehavior: Clip.none, children: [ return Stack(clipBehavior: Clip.none, children: [
avatar, avatar,
Positioned( Positioned(
right: -2, right: widget.size > 30 ? 0 : -2,
bottom: -2, bottom: widget.size > 30 ? 0 : -2,
child: Container( child: Container(
width: 9, width: widget.size > 30 ? 13 : 9,
height: 9, height: widget.size > 30 ? 13 : 9,
decoration: BoxDecoration( decoration: BoxDecoration(
color: dot, color: dot,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget {
decoration: BoxDecoration(color: color, shape: BoxShape.circle), decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center( child: Center(
child: Text(initial, child: Text(initial,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: size * 0.45,
fontWeight: FontWeight.w600))), fontWeight: FontWeight.w600))),
); );
} }
+4
View File
@@ -7,7 +7,9 @@ import 'package:matrix/matrix.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:matrix/src/utils/space_child.dart'; import 'package:matrix/src/utils/space_child.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/call_ui/mini_call_widget.dart'; import 'package:pyramid/features/call_ui/mini_call_widget.dart';
@@ -15,6 +17,7 @@ import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart';
import 'package:pyramid/utils/date_labels.dart';
import 'package:pyramid/widgets/create_join_dialog.dart'; import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
@@ -31,3 +34,4 @@ part 'panel/rooms_list.dart';
part 'panel/rooms_voice.dart'; part 'panel/rooms_voice.dart';
part 'panel/rooms_invite_item.dart'; part 'panel/rooms_invite_item.dart';
part 'panel/rooms_shared.dart'; part 'panel/rooms_shared.dart';
part 'panel/rooms_mobile_tile.dart';
+2 -2
View File
@@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget {
id: 'rooms', id: 'rooms',
label: 'R', label: 'R',
color: const Color(0xFFF59E0B), color: const Color(0xFFF59E0B),
name: 'Rooms', name: 'Räume',
active: activeSpaceId == 'rooms', active: activeSpaceId == 'rooms',
unread: 0, unread: 0,
pt: pt, pt: pt,
@@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> {
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05)) ? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
: Matrix4.identity(), : Matrix4.identity(),
child: Tooltip( child: Tooltip(
message: 'Direct Messages', message: 'Direktnachrichten',
waitDuration: const Duration(milliseconds: 400), waitDuration: const Duration(milliseconds: 400),
preferBelow: false, preferBelow: false,
child: Stack( child: Stack(
+1 -1
View File
@@ -592,7 +592,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a chat to start messaging', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 15), style: TextStyle(color: pt.fgMuted, fontSize: 15),
), ),
], ],
+25
View File
@@ -0,0 +1,25 @@
/// Kurze Datums-/Zeitangaben für Listen (deutsch).
library;
/// Zeit in der Chatliste: heute „14:05“, gestern „Gestern“, diese Woche der
/// Wochentag, sonst das Datum.
String chatListTime(DateTime t, {DateTime? now}) {
final n = now ?? DateTime.now();
final today = DateTime(n.year, n.month, n.day);
final day = DateTime(t.year, t.month, t.day);
// Über Stunden runden: an Zeitumstellungs-Tagen hat ein Tag 23/25 Stunden,
// inDays würde dann „gestern“ als „heute“ zählen.
final diff = (today.difference(day).inHours / 24).round();
if (diff <= 0) {
return '${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}';
}
if (diff == 1) return 'Gestern';
if (diff < 7) {
const days = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
return days[t.weekday - 1];
}
return '${t.day.toString().padLeft(2, '0')}.'
'${t.month.toString().padLeft(2, '0')}.'
'${(t.year % 100).toString().padLeft(2, '0')}';
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/utils/date_labels.dart';
void main() {
// Mittwoch, 7. Oktober 2026, 15:00
final now = DateTime(2026, 10, 7, 15);
test('heute: Uhrzeit', () {
expect(chatListTime(DateTime(2026, 10, 7, 9, 5), now: now), '09:05');
});
test('gestern: „Gestern“ (auch kurz nach Mitternacht)', () {
expect(chatListTime(DateTime(2026, 10, 6, 23, 59), now: now), 'Gestern');
expect(
chatListTime(DateTime(2026, 10, 6, 22), now: DateTime(2026, 10, 7, 0, 5)),
'Gestern',
);
});
test('diese Woche: Wochentag', () {
expect(chatListTime(DateTime(2026, 10, 5, 12), now: now), 'Mo');
expect(chatListTime(DateTime(2026, 10, 1, 12), now: now), 'Do');
});
test('älter: Datum', () {
expect(chatListTime(DateTime(2026, 9, 30, 12), now: now), '30.09.26');
expect(chatListTime(DateTime(2025, 12, 24, 18), now: now), '24.12.25');
});
test('Zeitumstellung (25.10.2026): gestern bleibt gestern', () {
expect(
chatListTime(DateTime(2026, 10, 24, 20), now: DateTime(2026, 10, 25, 10)),
'Gestern',
);
expect(
chatListTime(DateTime(2026, 3, 28, 20), now: DateTime(2026, 3, 29, 10)),
'Gestern',
);
});
}