Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen, Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände, Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal). Sofort behoben: - S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab → automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still); leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse ohne Zurück am Handy). - S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC. - Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“; Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch. - Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt in der DM, robustere Schritte; test.ps1 räumt liegengebliebene Test-Prozesse weg; gemeinsame Hilfen integration_test/support/. Co-Authored-By: Claude Opus 5.5 <[email protected]>
836 lines
29 KiB
Dart
836 lines
29 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) ...[
|
||
PyrIconBtn(
|
||
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||
tooltip: 'Zurück',
|
||
onPressed: () =>
|
||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||
),
|
||
const SizedBox(width: 4),
|
||
],
|
||
// 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: 'Anrufen',
|
||
onPressed: () => _startCall(ref, room, cam: false),
|
||
),
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.videocam_rounded),
|
||
tooltip: 'Videoanruf',
|
||
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.push_pin_rounded),
|
||
tooltip: 'Angepinnte Nachrichten',
|
||
active: pinnedOpen,
|
||
onPressed: onTogglePinned,
|
||
),
|
||
],
|
||
if (isMobile)
|
||
_ChatOverflowMenu(
|
||
pt: pt,
|
||
onSearch: onOpenSearch,
|
||
onMembers: onToggleMembers,
|
||
)
|
||
else ...[
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.search_rounded),
|
||
tooltip: 'Suchen',
|
||
onPressed: onOpenSearch,
|
||
),
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.group_rounded),
|
||
tooltip: 'Mitglieder',
|
||
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 ref.read(callSignalingProvider).startCall(room.id, video: cam);
|
||
return;
|
||
}
|
||
|
||
// Use LiveKit for group voice rooms
|
||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||
await ref.read(voiceChannelProvider).startCall(
|
||
roomName: roomId,
|
||
roomDisplayName: room.getLocalizedDisplayname(),
|
||
identity: room.client.userID ?? '',
|
||
audioOnly: !cam,
|
||
// Für das Beitritts-Token (Server prüft den Matrix-Login).
|
||
matrixClient: room.client,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund
|
||
/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter
|
||
/// (Design nach Nutzer-Mockup, Twitter/Discord-artig).
|
||
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;
|
||
|
||
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
|
||
// der Name genau bis davor.
|
||
final _actionsKey = GlobalKey();
|
||
double _actionsWidth = 140;
|
||
|
||
void _measureActions() {
|
||
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (box == null || !box.hasSize) return;
|
||
final w = box.size.width;
|
||
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
|
||
setState(() => _actionsWidth = w);
|
||
}
|
||
}
|
||
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) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
|
||
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],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
|
||
// nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
|
||
// aufgeklappt wurde der Name zu „p…“ gekürzt).
|
||
AnimatedPositioned(
|
||
duration: _animDuration,
|
||
curve: _animCurve,
|
||
left: expanded ? 16 : (isMobile ? 54 : 16),
|
||
right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
|
||
top: expanded ? 44 : 0,
|
||
bottom: 0,
|
||
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, avatarSize),
|
||
)
|
||
: _initialBox(initial, pt, avatarSize),
|
||
),
|
||
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,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
|
||
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
|
||
if (isMobile)
|
||
Positioned(
|
||
left: 6,
|
||
top: 0,
|
||
height: 52,
|
||
child: Center(
|
||
child: PyrIconBtn(
|
||
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||
tooltip: 'Zurück',
|
||
onPressed: () =>
|
||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: isMobile ? 6 : 16,
|
||
top: 0,
|
||
height: 52,
|
||
child: KeyedSubtree(
|
||
key: _actionsKey,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.phone_rounded),
|
||
tooltip: 'Anrufen',
|
||
onPressed: () => _startCall(cam: false),
|
||
),
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.videocam_rounded),
|
||
tooltip: 'Videoanruf',
|
||
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,
|
||
),
|
||
if (isMobile)
|
||
_ChatOverflowMenu(
|
||
pt: pt,
|
||
onSearch: widget.onOpenSearch,
|
||
onMembers: widget.onToggleMembers,
|
||
)
|
||
else ...[
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.search_rounded),
|
||
tooltip: 'Suchen',
|
||
onPressed: widget.onOpenSearch,
|
||
),
|
||
PyrIconBtn(
|
||
size: 28,
|
||
icon: const Icon(Icons.group_rounded),
|
||
tooltip: 'Mitglieder',
|
||
active: widget.membersOpen,
|
||
onPressed: widget.onToggleMembers,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
|
||
// 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
|
||
Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
|
||
Center(
|
||
child: AnimatedDefaultTextStyle(
|
||
duration: _animDuration,
|
||
curve: _animCurve,
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: avatarSize * 0.42,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.0,
|
||
),
|
||
child: Text(
|
||
initial,
|
||
textHeightBehavior: const TextHeightBehavior(
|
||
applyHeightToFirstAscent: false,
|
||
applyHeightToLastDescent: false,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
void _startCall({required bool cam}) async {
|
||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||
await ref.read(callSignalingProvider).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';
|
||
}
|
||
}
|
||
|
||
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
|
||
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
|
||
class _ChatOverflowMenu extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final VoidCallback onSearch;
|
||
final VoidCallback onMembers;
|
||
|
||
const _ChatOverflowMenu({
|
||
required this.pt,
|
||
required this.onSearch,
|
||
required this.onMembers,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
PopupMenuItem<VoidCallback> item(
|
||
IconData icon, String label, VoidCallback action) =>
|
||
PopupMenuItem(
|
||
value: action,
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 20, color: pt.fgMuted),
|
||
const SizedBox(width: 12),
|
||
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
|
||
],
|
||
),
|
||
);
|
||
return PopupMenuButton<VoidCallback>(
|
||
tooltip: 'Mehr',
|
||
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
|
||
color: pt.bg2,
|
||
elevation: 6,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rBase),
|
||
side: BorderSide(color: pt.border),
|
||
),
|
||
onSelected: (action) => action(),
|
||
itemBuilder: (_) => [
|
||
item(Icons.search_rounded, 'Suchen', onSearch),
|
||
item(Icons.group_rounded, 'Mitglieder', onMembers),
|
||
],
|
||
);
|
||
}
|
||
}
|