31e4001fb0
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf 33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze. Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt; ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen" abgehakt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
733 lines
26 KiB
Dart
733 lines
26 KiB
Dart
part of '../chat_view.dart';
|
|
|
|
class _ChatHeader extends ConsumerWidget {
|
|
final Room? room;
|
|
final String roomId;
|
|
final PyramidTheme pt;
|
|
final bool membersOpen;
|
|
final bool pinnedOpen;
|
|
final VoidCallback onToggleMembers;
|
|
final VoidCallback onOpenSearch;
|
|
final VoidCallback onTogglePinned;
|
|
|
|
const _ChatHeader({
|
|
required this.room,
|
|
required this.roomId,
|
|
required this.pt,
|
|
required this.membersOpen,
|
|
required this.pinnedOpen,
|
|
required this.onToggleMembers,
|
|
required this.onOpenSearch,
|
|
required this.onTogglePinned,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final name = room?.getLocalizedDisplayname() ?? roomId;
|
|
final isDm = room?.isDirectChat ?? false;
|
|
final isEncrypted = room?.encrypted ?? false;
|
|
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
|
final size = MediaQuery.sizeOf(context);
|
|
final isMobile = size.width < 900 || size.height < 500;
|
|
|
|
return Container(
|
|
height: 52,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: pt.bg1,
|
|
border: Border(bottom: BorderSide(color: pt.border)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Back button on mobile
|
|
if (isMobile) ...[
|
|
GestureDetector(
|
|
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
|
|
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
// Room icon / avatar
|
|
if (isDm && client != null) ...[
|
|
_DmHeaderAvatar(room: room, name: name, client: client),
|
|
] else if (!isDm) ...[
|
|
Icon(
|
|
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
|
size: 16,
|
|
color: pt.fgDim,
|
|
),
|
|
],
|
|
const SizedBox(width: 8),
|
|
// Name + presence for DMs
|
|
if (isDm)
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (room != null && client != null)
|
|
_PresenceLine(
|
|
userId: room!.directChatMatrixID,
|
|
client: client,
|
|
pt: pt,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
Text(
|
|
name,
|
|
style: TextStyle(
|
|
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(width: 12),
|
|
if (!isDm && topic.isNotEmpty) ...[
|
|
Container(width: 1, height: 18, color: pt.border),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
topic,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
] else if (!isDm)
|
|
const Spacer(),
|
|
// Right actions
|
|
Row(
|
|
children: [
|
|
if (isDm) ...[
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.phone_rounded),
|
|
tooltip: 'Voice call',
|
|
onPressed: () => _startCall(ref, room, cam: false),
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.videocam_rounded),
|
|
tooltip: 'Video call',
|
|
onPressed: () => _startCall(ref, room, cam: true),
|
|
),
|
|
if (size.width > 700)
|
|
Container(
|
|
width: 1,
|
|
height: 20,
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
color: pt.border,
|
|
),
|
|
],
|
|
if (size.width > 800) ...[
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.inbox_rounded),
|
|
tooltip: 'Threads',
|
|
onPressed: () {},
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.push_pin_rounded),
|
|
tooltip: 'Angepinnte Nachrichten',
|
|
active: pinnedOpen,
|
|
onPressed: onTogglePinned,
|
|
),
|
|
],
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.search_rounded),
|
|
tooltip: 'Search',
|
|
onPressed: onOpenSearch,
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.group_rounded),
|
|
tooltip: 'Members',
|
|
active: membersOpen,
|
|
onPressed: onToggleMembers,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _startCall(WidgetRef ref, Room? room, {required bool cam}) async {
|
|
if (room == null) return;
|
|
|
|
if (room.isDirectChat) {
|
|
// Use native Matrix VoIP for 1:1 DMs
|
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
|
await PyramidVoipManager.instance.startCall(room.id, video: cam);
|
|
return;
|
|
}
|
|
|
|
// Use LiveKit for group voice rooms
|
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
|
await ref.read(callStateProvider).startCall(
|
|
roomName: roomId,
|
|
roomDisplayName: room.getLocalizedDisplayname(),
|
|
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
|
audioOnly: !cam,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DmExpandingHeader extends ConsumerStatefulWidget {
|
|
final Room room;
|
|
final String roomId;
|
|
final PyramidTheme pt;
|
|
final bool expanded;
|
|
final VoidCallback onToggle;
|
|
final bool membersOpen;
|
|
final bool pinnedOpen;
|
|
final VoidCallback onToggleMembers;
|
|
final VoidCallback onOpenSearch;
|
|
final VoidCallback onTogglePinned;
|
|
|
|
const _DmExpandingHeader({
|
|
super.key,
|
|
required this.room,
|
|
required this.roomId,
|
|
required this.pt,
|
|
required this.expanded,
|
|
required this.onToggle,
|
|
required this.membersOpen,
|
|
required this.pinnedOpen,
|
|
required this.onToggleMembers,
|
|
required this.onOpenSearch,
|
|
required this.onTogglePinned,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<_DmExpandingHeader> createState() =>
|
|
_DmExpandingHeaderState();
|
|
}
|
|
|
|
class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|
static const _animDuration = Duration(milliseconds: 280);
|
|
static const _animCurve = Curves.easeOutCubic;
|
|
|
|
Profile? _profile;
|
|
String? _bannerMxcUri;
|
|
String? _statusMsg;
|
|
bool _online = false;
|
|
|
|
String? get _partnerId => widget.room.directChatMatrixID;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Direkt laden, damit das Banner beim ersten Aufklappen schon da ist.
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_DmExpandingHeader old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.room.id != widget.room.id) {
|
|
_profile = null;
|
|
_bannerMxcUri = null;
|
|
_statusMsg = null;
|
|
_online = false;
|
|
_load();
|
|
}
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final userId = _partnerId;
|
|
if (userId == null) return;
|
|
final client = widget.room.client;
|
|
// Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des
|
|
// VORHERIGEN Partners das Profil des aktuellen nicht überschreiben.
|
|
bool stillCurrent() => mounted && _partnerId == userId;
|
|
try {
|
|
final p = await client.getProfileFromUserId(userId);
|
|
if (stillCurrent()) setState(() => _profile = p);
|
|
} catch (_) {}
|
|
try {
|
|
final p = await client.fetchCurrentPresence(userId);
|
|
final last = p.lastActiveTimestamp;
|
|
final fresh = p.currentlyActive == true ||
|
|
(last != null &&
|
|
DateTime.now().difference(last) < const Duration(minutes: 5));
|
|
if (stillCurrent()) {
|
|
setState(() {
|
|
_online = p.presence == PresenceType.online && fresh;
|
|
_statusMsg = p.statusMsg;
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
// Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url).
|
|
try {
|
|
final apiUri = client.homeserver!.replace(
|
|
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}',
|
|
);
|
|
final resp = await client.httpClient.get(
|
|
apiUri,
|
|
headers: {'Authorization': 'Bearer ${client.accessToken}'},
|
|
);
|
|
if (resp.statusCode == 200) {
|
|
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
final url = data['io.pyramid.profile.banner_url'] as String?;
|
|
if (url != null && stillCurrent()) {
|
|
setState(() => _bannerMxcUri = url);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = widget.pt;
|
|
final expanded = widget.expanded;
|
|
final size = MediaQuery.sizeOf(context);
|
|
final isMobile = size.width < 900 || size.height < 500;
|
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
|
|
|
final userId = _partnerId ?? '';
|
|
final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId);
|
|
final name = _profile?.displayName ??
|
|
member.displayName ??
|
|
widget.room.getLocalizedDisplayname();
|
|
final avatarUrl =
|
|
_profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar;
|
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
|
|
|
// Im Querformat (wenig Höhe) kompakter aufklappen.
|
|
final expandedHeight = size.height < 500 ? 140.0 : 190.0;
|
|
final avatarSize = expanded
|
|
? (size.height < 500 ? 72.0 : 96.0)
|
|
: 32.0;
|
|
|
|
return AnimatedContainer(
|
|
duration: _animDuration,
|
|
curve: _animCurve,
|
|
height: expanded ? expandedHeight : 52,
|
|
decoration: BoxDecoration(
|
|
color: pt.bg1,
|
|
border: Border(bottom: BorderSide(color: pt.border)),
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Banner als Hintergrund — blendet beim Aufklappen ein.
|
|
if (_bannerMxcUri != null && client != null)
|
|
Positioned.fill(
|
|
child: AnimatedOpacity(
|
|
opacity: expanded ? 1 : 0,
|
|
duration: _animDuration,
|
|
curve: Curves.easeOut,
|
|
child: MxcImage(
|
|
mxcUri: _bannerMxcUri!,
|
|
client: client,
|
|
fit: BoxFit.cover,
|
|
placeholder: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
// Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup).
|
|
if (_bannerMxcUri != null)
|
|
Positioned.fill(
|
|
child: AnimatedOpacity(
|
|
opacity: expanded ? 1 : 0,
|
|
duration: _animDuration,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: [
|
|
pt.bg1,
|
|
pt.bg1.withAlpha(210),
|
|
pt.bg1.withAlpha(0),
|
|
],
|
|
stops: const [0.0, 0.32, 0.72],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
if (isMobile) ...[
|
|
GestureDetector(
|
|
onTap: () =>
|
|
ref.read(activeRoomIdProvider.notifier).state = null,
|
|
child:
|
|
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
// Klickbarer Profilbereich: Avatar + Name + Details
|
|
Expanded(
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onToggle,
|
|
child: Row(
|
|
children: [
|
|
// Avatar — skaliert mit (rund, wie im Mockup)
|
|
AnimatedContainer(
|
|
duration: _animDuration,
|
|
curve: _animCurve,
|
|
width: avatarSize,
|
|
height: avatarSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: pt.bg3,
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: avatarUrl != null && client != null
|
|
? MxcImage(
|
|
mxcUri: avatarUrl.toString(),
|
|
client: client,
|
|
fit: BoxFit.cover,
|
|
placeholder: _initialBox(initial, pt),
|
|
)
|
|
: _initialBox(initial, pt),
|
|
),
|
|
AnimatedContainer(
|
|
duration: _animDuration,
|
|
curve: _animCurve,
|
|
width: expanded ? 20 : 10,
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Name skaliert mit; Status blendet daneben ein
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Flexible(
|
|
child: AnimatedDefaultTextStyle(
|
|
duration: _animDuration,
|
|
curve: _animCurve,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: expanded ? 24 : 15,
|
|
fontWeight: expanded
|
|
? FontWeight.w700
|
|
: FontWeight.w600,
|
|
),
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
AnimatedOpacity(
|
|
opacity: expanded ? 1 : 0,
|
|
duration: _animDuration,
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.only(left: 8),
|
|
child: Text(
|
|
_online ? 'online' : 'offline',
|
|
style: TextStyle(
|
|
color: _online
|
|
? pt.online
|
|
: pt.fgMuted,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Eingeklappt: "zuletzt online vor…",
|
|
// aufgeklappt: Handle + Biographie.
|
|
AnimatedCrossFade(
|
|
duration: _animDuration,
|
|
sizeCurve: _animCurve,
|
|
crossFadeState: expanded
|
|
? CrossFadeState.showSecond
|
|
: CrossFadeState.showFirst,
|
|
firstChild: client != null
|
|
? _PresenceLine(
|
|
userId: _partnerId,
|
|
client: client,
|
|
pt: pt,
|
|
)
|
|
: const SizedBox.shrink(),
|
|
secondChild: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
userId,
|
|
style: TextStyle(
|
|
color: pt.fgMuted, fontSize: 13),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (_statusMsg != null &&
|
|
_statusMsg!.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_statusMsg!,
|
|
style: TextStyle(
|
|
color: pt.fgMuted,
|
|
fontSize: 13,
|
|
height: 1.35),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Aktionen — bleiben immer sichtbar
|
|
Row(
|
|
children: [
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.phone_rounded),
|
|
tooltip: 'Voice call',
|
|
onPressed: () => _startCall(cam: false),
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.videocam_rounded),
|
|
tooltip: 'Video call',
|
|
onPressed: () => _startCall(cam: true),
|
|
),
|
|
if (size.width > 700)
|
|
Container(
|
|
width: 1,
|
|
height: 20,
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
color: pt.border,
|
|
),
|
|
if (size.width > 800)
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.push_pin_rounded),
|
|
tooltip: 'Angepinnte Nachrichten',
|
|
active: widget.pinnedOpen,
|
|
onPressed: widget.onTogglePinned,
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.search_rounded),
|
|
tooltip: 'Search',
|
|
onPressed: widget.onOpenSearch,
|
|
),
|
|
PyrIconBtn(
|
|
size: 28,
|
|
icon: const Icon(Icons.group_rounded),
|
|
tooltip: 'Members',
|
|
active: widget.membersOpen,
|
|
onPressed: widget.onToggleMembers,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _initialBox(String initial, PyramidTheme pt) => Center(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: Text(
|
|
initial,
|
|
style: TextStyle(
|
|
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
void _startCall({required bool cam}) async {
|
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
|
await PyramidVoipManager.instance.startCall(widget.room.id, video: cam);
|
|
}
|
|
}
|
|
|
|
class _DmHeaderAvatar extends StatelessWidget {
|
|
final Room? room;
|
|
final String name;
|
|
final Client client;
|
|
|
|
const _DmHeaderAvatar({required this.room, required this.name, required this.client});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
|
const size = 32.0;
|
|
return MxcAvatar(
|
|
mxcUri: room?.avatar,
|
|
client: client,
|
|
size: size,
|
|
borderRadius: BorderRadius.circular(size / 2),
|
|
placeholder: (_) => _InitialCircle(initial: initial, size: size),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InitialCircle extends StatelessWidget {
|
|
final String initial;
|
|
final double size;
|
|
const _InitialCircle({required this.initial, required this.size});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const color = Color(0xFF06B6D4);
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: const BoxDecoration(color: color, shape: BoxShape.circle),
|
|
child: Center(
|
|
child: Text(initial,
|
|
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PresenceLine extends StatefulWidget {
|
|
final String? userId;
|
|
final Client client;
|
|
final PyramidTheme pt;
|
|
|
|
const _PresenceLine({required this.userId, required this.client, required this.pt});
|
|
|
|
@override
|
|
State<_PresenceLine> createState() => _PresenceLineState();
|
|
}
|
|
|
|
class _PresenceLineState extends State<_PresenceLine> {
|
|
CachedPresence? _presence;
|
|
StreamSubscription<CachedPresence>? _sub;
|
|
// Debounce timer: delay "online" updates 4s to filter out background-sync
|
|
// false positives (push notifications briefly wake the recipient's client).
|
|
Timer? _onlineDebounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load(widget.userId);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_PresenceLine oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.userId != widget.userId) {
|
|
_sub?.cancel();
|
|
_sub = null;
|
|
_onlineDebounce?.cancel();
|
|
setState(() => _presence = null);
|
|
_load(widget.userId);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sub?.cancel();
|
|
_onlineDebounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
// "online" only when the server's presence enum says online AND the last
|
|
// activity is recent. Using currentlyActive (the old logic) reported online
|
|
// long after the user left, because that flag lingers.
|
|
static bool _isOnline(CachedPresence p) {
|
|
if (p.presence != PresenceType.online) return false;
|
|
final last = p.lastActiveTimestamp;
|
|
return last == null || DateTime.now().difference(last).inSeconds < 90;
|
|
}
|
|
|
|
void _applyPresence(CachedPresence p) {
|
|
_onlineDebounce?.cancel();
|
|
if (_isOnline(p)) {
|
|
// Wait 4 seconds before committing "online" to filter single-sync spikes
|
|
// (a backgrounded client briefly reports online when a push wakes it).
|
|
_onlineDebounce = Timer(const Duration(seconds: 4), () {
|
|
if (mounted) setState(() => _presence = p);
|
|
});
|
|
} else {
|
|
if (mounted) setState(() => _presence = p);
|
|
}
|
|
}
|
|
|
|
Future<void> _load(String? userId) async {
|
|
if (userId == null) return;
|
|
// Never show our own presence in a DM header
|
|
if (userId == widget.client.userID) return;
|
|
try {
|
|
final p = await widget.client.fetchCurrentPresence(userId);
|
|
if (mounted) _applyPresence(p);
|
|
} catch (_) {}
|
|
_sub = widget.client.onPresenceChanged.stream
|
|
.where((p) => p.userid == userId)
|
|
.listen((p) => _applyPresence(p));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = _presence;
|
|
if (p == null) return const SizedBox.shrink();
|
|
|
|
String text;
|
|
Color color = widget.pt.fgDim;
|
|
if (_isOnline(p)) {
|
|
text = 'online';
|
|
color = const Color(0xFF3BA55D);
|
|
} else if (p.lastActiveTimestamp != null) {
|
|
text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}';
|
|
} else {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Text(
|
|
text,
|
|
style: TextStyle(color: color, fontSize: 11),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
);
|
|
}
|
|
|
|
String _relativeTime(DateTime t) {
|
|
final diff = DateTime.now().difference(t);
|
|
if (diff.inMinutes < 1) return 'gerade eben';
|
|
if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min';
|
|
if (diff.inHours < 24) return 'vor ${diff.inHours}h';
|
|
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
|
|
if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w';
|
|
return 'vor ${(diff.inDays / 365).floor()}j';
|
|
}
|
|
}
|