Files
pyramid/lib/widgets/profile_popover.dart
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

443 lines
17 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
class ProfilePopover extends StatefulWidget {
final String userId;
final Room room;
final Offset globalPos;
final VoidCallback onClose;
const ProfilePopover({
super.key,
required this.userId,
required this.room,
required this.globalPos,
required this.onClose,
});
@override
State<ProfilePopover> createState() => _ProfilePopoverState();
}
class _ProfilePopoverState extends State<ProfilePopover> {
Profile? _profile;
PresenceType? _presence;
String? _bannerMxcUri;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final p = await widget.room.client.getProfileFromUserId(widget.userId);
if (mounted) setState(() => _profile = p);
} catch (_) {}
try {
final p = await widget.room.client.fetchCurrentPresence(widget.userId);
// Veraltete "online"-Presence herabstufen, wenn die letzte Aktivität
// nicht frisch ist (Server meldet sonst dauerhaft online).
final last = p.lastActiveTimestamp;
final fresh = p.currentlyActive == true ||
(last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5));
final effective = (p.presence == PresenceType.online && !fresh)
? PresenceType.offline
: p.presence;
if (mounted) setState(() => _presence = effective);
} catch (_) {}
// Load banner from Matrix profile custom field (works for any user)
try {
final client = widget.room.client;
final apiUri = client.homeserver!.replace(
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(widget.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 && mounted) setState(() => _bannerMxcUri = url);
}
} catch (_) {}
// Legacy fallback: account data (only own user)
if (_bannerMxcUri == null && widget.userId == widget.room.client.userID) {
try {
final bannerData =
widget.room.client.accountData['com.pyramid.profile.banner'];
final url = bannerData?.content['url'] as String?;
if (url != null && mounted) setState(() => _bannerMxcUri = url);
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final member = widget.room.unsafeGetUserFromMemoryOrFallback(widget.userId);
final displayName = _profile?.displayName ?? member.displayName ?? widget.userId.split(':').first.replaceAll('@', '');
final avatarUrl = _profile?.avatarUrl ?? member.avatarUrl;
final handle = widget.userId;
final initials = displayName.isNotEmpty ? displayName[0].toUpperCase() : '?';
final color = _colorForName(displayName);
final powerLevel = widget.room.getPowerLevelByUserId(widget.userId);
final joinDate = _formatJoinDate(widget.room, widget.userId);
const popoverW = 300.0;
const popoverH = 380.0;
final left = (widget.globalPos.dx + 16).clamp(8.0, size.width - popoverW - 8);
final top = (widget.globalPos.dy - 80).clamp(8.0, size.height - popoverH - 8);
return Positioned(
left: left,
top: top,
child: Material(
color: Colors.transparent,
child: Container(
width: popoverW,
decoration: BoxDecoration(
color: PyramidColors.bg0,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: PyramidColors.border),
boxShadow: const [
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner — real image for own profile, color gradient for others
SizedBox(
height: 64,
child: _bannerMxcUri != null
? MxcImage(
mxcUri: _bannerMxcUri!,
client: widget.room.client,
fit: BoxFit.cover,
width: double.infinity,
height: 64,
placeholder: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.6),
color.withValues(alpha: 0.3)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
)
: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color.withValues(alpha: 0.6),
color.withValues(alpha: 0.3)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar row
Transform.translate(
offset: const Offset(0, -28),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: PyramidColors.bg0, width: 3),
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null
? MxcImage(
mxcUri: avatarUrl.toString(),
client: widget.room.client,
width: 56,
height: 56,
placeholder: Center(
child: Text(initials,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700)),
),
)
: Center(
child: Text(initials,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700))),
),
Positioned(
right: -2,
bottom: -2,
child: _PresenceDot(presence: _presence, size: 14),
),
],
),
],
),
),
Transform.translate(
offset: const Offset(0, -20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
style: const TextStyle(
color: PyramidColors.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(handle,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
const SizedBox(height: 12),
// Action buttons
Row(
children: [
_ProfileBtn(
label: 'Nachricht',
icon: Icons.send_outlined,
primary: true,
onTap: () {
widget.onClose();
// TODO: open DM
},
),
const SizedBox(width: 8),
_ProfileBtn(
label: 'Anrufen',
icon: Icons.call_outlined,
onTap: () {},
),
const SizedBox(width: 8),
_ProfileBtn(
icon: Icons.more_vert,
onTap: () {},
),
],
),
if (powerLevel > 0) ...[
const SizedBox(height: 14),
const Text('Rollen',
style: TextStyle(
color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_RoleBadge(
label: powerLevel >= 100 ? 'Admin' : 'Moderator',
color: powerLevel >= 100
? const Color(0xFFF59E0B)
: const Color(0xFF06B6D4),
),
],
),
],
if (joinDate != null) ...[
const SizedBox(height: 14),
const Text('Mitglied seit',
style: TextStyle(
color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 4),
Text(joinDate,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
],
],
),
),
],
),
),
],
),
),
),
),
);
}
String? _formatJoinDate(Room room, String userId) {
try {
final member = room.unsafeGetUserFromMemoryOrFallback(userId);
// User joined if membership is join
if (member.membership != Membership.join) return null;
// No timestamp available from StrippedStateEvent; show server domain hint
final server = userId.contains(':') ? userId.split(':').last : null;
return server != null ? 'via $server' : null;
} catch (_) {
return null;
}
}
Color _colorForName(String name) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D),
Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6),
Color(0xFFF43F5E),
];
final hash = name.codeUnits.fold(0, (a, b) => a + b);
return colors[hash % colors.length];
}
}
class _PresenceDot extends StatelessWidget {
final PresenceType? presence;
final double size;
const _PresenceDot({required this.presence, required this.size});
@override
Widget build(BuildContext context) {
final color = switch (presence) {
PresenceType.online => const Color(0xFF23A55A),
PresenceType.unavailable => const Color(0xFFF0B232),
_ => const Color(0xFF7C7C8A),
};
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: PyramidColors.bg0, width: 2),
),
);
}
}
class _ProfileBtn extends StatefulWidget {
final String? label;
final IconData icon;
final bool primary;
final VoidCallback onTap;
const _ProfileBtn({
required this.icon,
required this.onTap,
this.label,
this.primary = false,
});
@override
State<_ProfileBtn> createState() => _ProfileBtnState();
}
class _ProfileBtnState extends State<_ProfileBtn> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final hasLabel = widget.label != null;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: EdgeInsets.symmetric(
horizontal: hasLabel ? 12 : 9, vertical: 7),
decoration: BoxDecoration(
color: widget.primary
? (_hover
? PyramidTheme.of(context).accent.withAlpha(200)
: PyramidTheme.of(context).accent)
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.primary ? Colors.transparent : PyramidColors.border,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon,
size: 14,
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg),
if (hasLabel) ...[
const SizedBox(width: 6),
Text(widget.label!,
style: TextStyle(
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
],
],
),
),
),
);
}
}
class _RoleBadge extends StatelessWidget {
final String label;
final Color color;
const _RoleBadge({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6, height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text(label,
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w500)),
],
),
);
}
}