- 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]>
281 lines
8.5 KiB
Dart
281 lines
8.5 KiB
Dart
part of '../rooms_panel.dart';
|
|
|
|
class _DmAvatar extends ConsumerStatefulWidget {
|
|
final Room room;
|
|
final PyramidTheme pt;
|
|
final double size;
|
|
const _DmAvatar({required this.room, required this.pt, this.size = 22});
|
|
|
|
@override
|
|
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
|
|
}
|
|
|
|
class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
|
CachedPresence? _presence;
|
|
StreamSubscription<CachedPresence>? _sub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadPresence();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadPresence() async {
|
|
final client = ref.read(matrixClientProvider).valueOrNull;
|
|
if (client == null) return;
|
|
final userId = widget.room.directChatMatrixID;
|
|
if (userId == null) return;
|
|
try {
|
|
final p = await client.fetchCurrentPresence(userId);
|
|
if (mounted) setState(() => _presence = p);
|
|
} catch (_) {}
|
|
_sub = client.onPresenceChanged.stream
|
|
.where((p) => p.userid == userId)
|
|
.listen((p) {
|
|
if (mounted) setState(() => _presence = p);
|
|
});
|
|
}
|
|
|
|
Color? _dotColor() {
|
|
final p = _presence;
|
|
if (p == null) return null;
|
|
// Server-Presence kann veraltet sein (z.B. nie als offline gemeldet).
|
|
// Nur als online werten, wenn die letzte Aktivität wirklich frisch ist.
|
|
final last = p.lastActiveTimestamp;
|
|
final fresh = last != null &&
|
|
DateTime.now().difference(last) < const Duration(minutes: 5);
|
|
if (p.presence == PresenceType.online &&
|
|
(p.currentlyActive == true || fresh)) {
|
|
return PyramidColors.online;
|
|
}
|
|
if (p.presence == PresenceType.unavailable) return PyramidColors.away;
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final name = widget.room.getLocalizedDisplayname();
|
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
|
final color = _colorFromName(name);
|
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
|
|
|
Widget avatar;
|
|
if (client != null && widget.room.avatar != null) {
|
|
avatar = MxcAvatar(
|
|
mxcUri: widget.room.avatar,
|
|
client: client,
|
|
size: widget.size,
|
|
borderRadius: BorderRadius.circular(widget.size / 2),
|
|
placeholder: (_) =>
|
|
_InitialCircle(initial: initial, color: color, size: widget.size),
|
|
);
|
|
} else {
|
|
avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
|
|
}
|
|
|
|
final dot = _dotColor();
|
|
if (dot == null) return avatar;
|
|
return Stack(clipBehavior: Clip.none, children: [
|
|
avatar,
|
|
Positioned(
|
|
right: widget.size > 30 ? 0 : -2,
|
|
bottom: widget.size > 30 ? 0 : -2,
|
|
child: Container(
|
|
width: widget.size > 30 ? 13 : 9,
|
|
height: widget.size > 30 ? 13 : 9,
|
|
decoration: BoxDecoration(
|
|
color: dot,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: widget.pt.bg1, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
|
|
Color _colorFromName(String name) {
|
|
const colors = [
|
|
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
|
|
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
|
|
];
|
|
if (name.isEmpty) return colors[0];
|
|
return colors[name.codeUnitAt(0) % colors.length];
|
|
}
|
|
}
|
|
|
|
class _InitialCircle extends StatelessWidget {
|
|
final String initial;
|
|
final Color color;
|
|
final double size;
|
|
const _InitialCircle(
|
|
{required this.initial, required this.color, required this.size});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
child: Center(
|
|
child: Text(initial,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: size * 0.45,
|
|
fontWeight: FontWeight.w600))),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RoomMenuBtn extends StatefulWidget {
|
|
final PyramidTheme pt;
|
|
final VoidCallback onInvite;
|
|
final VoidCallback onSettings;
|
|
final VoidCallback onLeave;
|
|
const _RoomMenuBtn({required this.pt, required this.onInvite, required this.onSettings, required this.onLeave});
|
|
|
|
@override
|
|
State<_RoomMenuBtn> createState() => _RoomMenuBtnState();
|
|
}
|
|
|
|
class _RoomMenuBtnState extends State<_RoomMenuBtn> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopupMenuButton<String>(
|
|
color: widget.pt.bg2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
side: BorderSide(color: widget.pt.border)),
|
|
offset: const Offset(-100, 24),
|
|
tooltip: '',
|
|
onSelected: (v) {
|
|
if (v == 'invite') widget.onInvite();
|
|
if (v == 'settings') widget.onSettings();
|
|
if (v == 'leave') widget.onLeave();
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
value: 'invite',
|
|
height: 36,
|
|
child: Row(children: [
|
|
Icon(Icons.person_add_outlined, size: 14, color: widget.pt.fgMuted),
|
|
const SizedBox(width: 8),
|
|
Text('Nutzer einladen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
|
|
]),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'settings',
|
|
height: 36,
|
|
child: Row(children: [
|
|
Icon(Icons.settings_rounded, size: 14, color: widget.pt.fgMuted),
|
|
const SizedBox(width: 8),
|
|
Text('Einstellungen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
|
|
]),
|
|
),
|
|
PopupMenuDivider(height: 1),
|
|
PopupMenuItem(
|
|
value: 'leave',
|
|
height: 36,
|
|
child: Row(children: [
|
|
Icon(Icons.exit_to_app_rounded, size: 14, color: widget.pt.danger),
|
|
const SizedBox(width: 8),
|
|
Text('Raum verlassen', style: TextStyle(color: widget.pt.danger, fontSize: 13)),
|
|
]),
|
|
),
|
|
],
|
|
child: MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
color: _hovered ? widget.pt.bgActive : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Center(
|
|
child: Icon(Icons.more_horiz_rounded,
|
|
size: 12,
|
|
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HoverAction extends StatefulWidget {
|
|
final IconData icon;
|
|
final PyramidTheme pt;
|
|
final VoidCallback? onTap;
|
|
const _HoverAction({required this.icon, required this.pt, this.onTap});
|
|
|
|
@override
|
|
State<_HoverAction> createState() => _HoverActionState();
|
|
}
|
|
|
|
class _HoverActionState extends State<_HoverAction> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onTap,
|
|
child: MouseRegion(
|
|
cursor: widget.onTap != null
|
|
? SystemMouseCursors.click
|
|
: MouseCursor.defer,
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
color: _hovered ? widget.pt.bgActive : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Center(
|
|
child: Icon(widget.icon,
|
|
size: 12,
|
|
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UnreadBadge extends StatelessWidget {
|
|
final int count;
|
|
final bool isMention;
|
|
final PyramidTheme pt;
|
|
const _UnreadBadge(
|
|
{required this.count, required this.isMention, required this.pt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
|
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
|
decoration: BoxDecoration(
|
|
color: isMention ? pt.danger : pt.accent,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Center(
|
|
child: Text(count > 99 ? '99+' : '$count',
|
|
style: TextStyle(
|
|
color: isMention ? Colors.white : pt.accentFg,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700))),
|
|
);
|
|
}
|
|
}
|