diff --git a/lib/features/chat/chat_composer.dart b/lib/features/chat/chat_composer.dart index a834014..6f708c3 100644 --- a/lib/features/chat/chat_composer.dart +++ b/lib/features/chat/chat_composer.dart @@ -303,8 +303,8 @@ class _ChatComposerState extends ConsumerState { final roomName = widget.room.getLocalizedDisplayname(); final placeholder = widget.room.isDirectChat - ? 'Message…' - : 'Message #$roomName'; + ? 'Nachricht…' + : 'Nachricht an #$roomName'; return SafeArea( child: Column( diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart index d94d106..3d668df 100644 --- a/lib/features/chat/view/chat_header.dart +++ b/lib/features/chat/view/chat_header.dart @@ -144,13 +144,13 @@ class _ChatHeader extends ConsumerWidget { PyrIconBtn( size: 28, icon: const Icon(Icons.search_rounded), - tooltip: 'Search', + tooltip: 'Suchen', onPressed: onOpenSearch, ), PyrIconBtn( size: 28, icon: const Icon(Icons.group_rounded), - tooltip: 'Members', + tooltip: 'Mitglieder', active: membersOpen, onPressed: onToggleMembers, ), @@ -540,13 +540,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { PyrIconBtn( size: 28, icon: const Icon(Icons.search_rounded), - tooltip: 'Search', + tooltip: 'Suchen', onPressed: widget.onOpenSearch, ), PyrIconBtn( size: 28, icon: const Icon(Icons.group_rounded), - tooltip: 'Members', + tooltip: 'Mitglieder', active: widget.membersOpen, onPressed: widget.onToggleMembers, ), diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart index 4f10baf..e7a9244 100644 --- a/lib/features/chat/view/chat_misc.dart +++ b/lib/features/chat/view/chat_misc.dart @@ -115,7 +115,7 @@ class NoChatSelected extends StatelessWidget { Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), const SizedBox(height: 16), Text( - 'Select a room to start chatting', + 'Wähle einen Chat aus', style: TextStyle(color: pt.fgMuted, fontSize: 16), ), const SizedBox(height: 8), diff --git a/lib/features/members/members_panel.dart b/lib/features/members/members_panel.dart index 872f48f..a63e14c 100644 --- a/lib/features/members/members_panel.dart +++ b/lib/features/members/members_panel.dart @@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget { Icon(Icons.group_rounded, size: 16, color: pt.fg), const SizedBox(width: 8), Text( - 'Members', + 'Mitglieder', style: TextStyle( color: pt.fg, fontSize: 14, diff --git a/lib/features/rooms/panel/rooms_header.dart b/lib/features/rooms/panel/rooms_header.dart index 0a7ef28..b06b4f4 100644 --- a/lib/features/rooms/panel/rooms_header.dart +++ b/lib/features/rooms/panel/rooms_header.dart @@ -29,7 +29,7 @@ class _Header extends ConsumerWidget { final name = isRealSpace ? (activeSpace?.getLocalizedDisplayname() ?? 'Space') - : (spaceId == 'dms' ? 'Chats' : 'Rooms'); + : (spaceId == 'dms' ? 'Chats' : 'Räume'); void handleMenu(_SpaceMenuItem item) { switch (item) { @@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget { onChanged: onChanged, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( - hintText: 'Filter rooms', + hintText: 'Chats durchsuchen', hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), filled: true, diff --git a/lib/features/rooms/panel/rooms_list.dart b/lib/features/rooms/panel/rooms_list.dart index 88b0d5d..3aee58e 100644 --- a/lib/features/rooms/panel/rooms_list.dart +++ b/lib/features/rooms/panel/rooms_list.dart @@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget { if (filtered.isEmpty && invites.isEmpty) { return Center( 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 isCollapsed = collapsed[sectionId] ?? false; @@ -513,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> { final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : >[]; 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; return MouseRegion( diff --git a/lib/features/rooms/panel/rooms_mobile_tile.dart b/lib/features/rooms/panel/rooms_mobile_tile.dart new file mode 100644 index 0000000..11add10 --- /dev/null +++ b/lib/features/rooms/panel/rooms_mobile_tile.dart @@ -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'; +} diff --git a/lib/features/rooms/panel/rooms_shared.dart b/lib/features/rooms/panel/rooms_shared.dart index 43d47c4..260badc 100644 --- a/lib/features/rooms/panel/rooms_shared.dart +++ b/lib/features/rooms/panel/rooms_shared.dart @@ -3,7 +3,8 @@ part of '../rooms_panel.dart'; class _DmAvatar extends ConsumerStatefulWidget { final Room room; 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 ConsumerState<_DmAvatar> createState() => _DmAvatarState(); @@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> { avatar = MxcAvatar( mxcUri: widget.room.avatar, client: client, - size: 22, - borderRadius: BorderRadius.circular(11), + size: widget.size, + borderRadius: BorderRadius.circular(widget.size / 2), placeholder: (_) => - _InitialCircle(initial: initial, color: color, size: 22), + _InitialCircle(initial: initial, color: color, size: widget.size), ); } else { - avatar = _InitialCircle(initial: initial, color: color, size: 22); + avatar = _InitialCircle(initial: initial, color: color, size: widget.size); } final dot = _dotColor(); @@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> { return Stack(clipBehavior: Clip.none, children: [ avatar, Positioned( - right: -2, - bottom: -2, + right: widget.size > 30 ? 0 : -2, + bottom: widget.size > 30 ? 0 : -2, child: Container( - width: 9, - height: 9, + width: widget.size > 30 ? 13 : 9, + height: widget.size > 30 ? 13 : 9, decoration: BoxDecoration( color: dot, shape: BoxShape.circle, @@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget { decoration: BoxDecoration(color: color, shape: BoxShape.circle), child: Center( child: Text(initial, - style: const TextStyle( + style: TextStyle( color: Colors.white, - fontSize: 10, + fontSize: size * 0.45, fontWeight: FontWeight.w600))), ); } diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index a7a1dc8..a6f3464 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -7,7 +7,9 @@ import 'package:matrix/matrix.dart'; // ignore: implementation_imports import 'package:matrix/src/utils/space_child.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/notification_content.dart' show notificationBody; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/rooms/rooms_provider.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/rooms/user_panel.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/hover_region.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_invite_item.dart'; part 'panel/rooms_shared.dart'; +part 'panel/rooms_mobile_tile.dart'; diff --git a/lib/features/spaces/spaces_rail.dart b/lib/features/spaces/spaces_rail.dart index 19d0eb7..0e4845d 100644 --- a/lib/features/spaces/spaces_rail.dart +++ b/lib/features/spaces/spaces_rail.dart @@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget { id: 'rooms', label: 'R', color: const Color(0xFFF59E0B), - name: 'Rooms', + name: 'Räume', active: activeSpaceId == 'rooms', unread: 0, pt: pt, @@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> { ? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05)) : Matrix4.identity(), child: Tooltip( - message: 'Direct Messages', + message: 'Direktnachrichten', waitDuration: const Duration(milliseconds: 400), preferBelow: false, child: Stack( diff --git a/lib/layout/app_shell.dart b/lib/layout/app_shell.dart index d81a5d5..4fd7837 100644 --- a/lib/layout/app_shell.dart +++ b/lib/layout/app_shell.dart @@ -592,7 +592,7 @@ class NoChatSelected extends StatelessWidget { Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), const SizedBox(height: 16), Text( - 'Select a chat to start messaging', + 'Wähle einen Chat aus', style: TextStyle(color: pt.fgMuted, fontSize: 15), ), ], diff --git a/lib/utils/date_labels.dart b/lib/utils/date_labels.dart new file mode 100644 index 0000000..356f351 --- /dev/null +++ b/lib/utils/date_labels.dart @@ -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')}'; +} diff --git a/test/date_labels_test.dart b/test/date_labels_test.dart new file mode 100644 index 0000000..065ffd6 --- /dev/null +++ b/test/date_labels_test.dart @@ -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', + ); + }); +}