refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
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>
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user