Files
pyramid/lib/features/chat/view/chat_header.dart
T
Bernd Steckmeister 31e4001fb0 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>
2026-07-03 11:03:31 +02:00

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';
}
}