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:
co-authored by
Claude Opus 5.5
parent
c5f10901e6
commit
c38e9c18b0
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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')}';
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user