- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten, Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs - CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste) - Sprachkanaele am Handy als Kachel mit 'wer ist drin' - Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt - Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz - UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit 5/7 Personen, untere Leiste; Version 0.1.42 Co-Authored-By: Claude Opus 5.5 <[email protected]>
275 lines
9.5 KiB
Dart
275 lines
9.5 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
// Immer im Baum, damit der Zähler beim Erscheinen und
|
|
// Hochzählen federnd aufploppen kann.
|
|
SpringBump(
|
|
value: unread,
|
|
child: unread > 0
|
|
? Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: _UnreadBadge(
|
|
count: unread, isMention: mention, pt: pt),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
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: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
color: isChannel ? pt.bg3 : pt.accentSoft,
|
|
borderRadius: radius,
|
|
),
|
|
child: Icon(
|
|
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: size,
|
|
borderRadius: radius,
|
|
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';
|
|
}
|
|
|
|
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
|
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
|
class _MobileVoiceTile extends StatelessWidget {
|
|
final Room room;
|
|
final bool inVoice;
|
|
final List<Map<String, dynamic>> participants;
|
|
final Client? client;
|
|
final PyramidTheme pt;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
|
|
const _MobileVoiceTile({
|
|
required this.room,
|
|
required this.inVoice,
|
|
required this.participants,
|
|
required this.client,
|
|
required this.pt,
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final green = pt.online;
|
|
final names = participants
|
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
|
.where((n) => n.isNotEmpty)
|
|
.toList();
|
|
final subtitle = inVoice
|
|
? 'Du bist drin · antippen zum Verlassen'
|
|
: participants.isEmpty
|
|
? 'Sprachkanal · antippen zum Beitreten'
|
|
: names.length <= 2
|
|
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
|
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
child: Container(
|
|
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: _kTileAvatar,
|
|
height: _kTileAvatar,
|
|
decoration: BoxDecoration(
|
|
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
|
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
|
),
|
|
child: Icon(
|
|
participants.isEmpty
|
|
? Icons.volume_up_rounded
|
|
: Icons.graphic_eq_rounded,
|
|
size: 22,
|
|
color: green,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(room.getLocalizedDisplayname(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 3),
|
|
Text(subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: participants.isEmpty && !inVoice
|
|
? pt.fgDim
|
|
: green,
|
|
fontSize: 13.5)),
|
|
],
|
|
),
|
|
),
|
|
if (participants.isNotEmpty && client != null) ...[
|
|
const SizedBox(width: 8),
|
|
CallAvatarStack(
|
|
participants: participants,
|
|
client: client!,
|
|
ringColor: pt.bg1,
|
|
size: 26,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|