Align channel avatars and compact

This commit is contained in:
Janez T
2026-03-10 09:13:24 +01:00
parent 97addf7ba6
commit 15ac3e2798
4 changed files with 106 additions and 30 deletions

View File

@@ -679,7 +679,7 @@ class _ChannelActivityCard extends StatelessWidget {
} }
} }
return Container( return Container(
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
gradient: LinearGradient( gradient: LinearGradient(
@@ -706,12 +706,12 @@ class _ChannelActivityCard extends StatelessWidget {
onNavigateToMessages?.call(); onNavigateToMessages?.call();
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
ContactAvatar(contact: channel, radius: 24), ContactAvatar(contact: channel, radius: 20),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -721,9 +721,13 @@ class _ChannelActivityCard extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w800), ?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 15,
height: 1.05,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
if (participantNames.isNotEmpty) if (participantNames.isNotEmpty)
_ExpandableParticipantStack( _ExpandableParticipantStack(
names: participantNames, names: participantNames,
@@ -735,10 +739,10 @@ class _ChannelActivityCard extends StatelessWidget {
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant), ?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 6,
runSpacing: 8, runSpacing: 6,
children: [ children: [
_MetricChip( _MetricChip(
icon: Icons.forum_outlined, icon: Icons.forum_outlined,
@@ -790,18 +794,24 @@ class _ExpandableParticipantStack extends StatefulWidget {
class _ExpandableParticipantStackState class _ExpandableParticipantStackState
extends State<_ExpandableParticipantStack> { extends State<_ExpandableParticipantStack> {
bool _expanded = false; bool _expanded = false;
static const int _collapsedVisibleCount = 4;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasOverflow = widget.names.length > _collapsedVisibleCount;
final visibleNames = _expanded final visibleNames = _expanded
? widget.names ? widget.names
: widget.names.take(4).toList(); : widget.names.take(_collapsedVisibleCount).toList();
final spacing = _expanded ? 24.0 : 18.0; final overflowCount = _expanded
const avatarSize = 28.0; ? 0
final width = avatarSize + (visibleNames.length - 1) * spacing; : widget.names.length - visibleNames.length;
final spacing = _expanded ? 20.0 : 16.0;
const avatarSize = 24.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return GestureDetector( return GestureDetector(
onTap: widget.names.length > 4 onTap: hasOverflow
? () { ? () {
setState(() { setState(() {
_expanded = !_expanded; _expanded = !_expanded;
@@ -819,11 +829,18 @@ class _ExpandableParticipantStackState
for (var i = 0; i < visibleNames.length; i++) for (var i = 0; i < visibleNames.length; i++)
Positioned( Positioned(
left: i * spacing, left: i * spacing,
top: 0,
child: _ParticipantAvatar( child: _ParticipantAvatar(
name: visibleNames[i], name: visibleNames[i],
contact: widget.contactForName(visibleNames[i]), contact: widget.contactForName(visibleNames[i]),
), ),
), ),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _OverflowAvatar(count: overflowCount),
),
], ],
), ),
), ),
@@ -831,31 +848,66 @@ class _ExpandableParticipantStackState
} }
} }
class _OverflowAvatar extends StatelessWidget {
final int count;
const _OverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
'+$count',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
fontSize: 9,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget { class _ParticipantAvatar extends StatelessWidget {
final String name; final String name;
final Contact? contact; final Contact? contact;
static const double _size = 24;
const _ParticipantAvatar({required this.name, required this.contact}); const _ParticipantAvatar({required this.name, required this.contact});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (contact != null) { if (contact != null) {
return Container( final surfaceColor = Theme.of(context).colorScheme.surface;
decoration: BoxDecoration( return SizedBox(
shape: BoxShape.circle, width: _size,
border: Border.all( height: _size,
color: Theme.of(context).colorScheme.surface, child: DecoratedBox(
width: 2, decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: surfaceColor, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)),
), ),
), ),
child: ContactAvatar(contact: contact!, radius: 14),
); );
} }
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
width: 28, width: _size,
height: 28, height: _size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer, color: colorScheme.tertiaryContainer,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -867,6 +919,7 @@ class _ParticipantAvatar extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer, color: colorScheme.onTertiaryContainer,
fontSize: 9,
), ),
), ),
); );
@@ -888,7 +941,7 @@ class _MetricChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.72), color: colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -902,7 +955,7 @@ class _MetricChip extends StatelessWidget {
label, label,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(

View File

@@ -1,4 +1,6 @@
class AvatarLabelHelper { class AvatarLabelHelper {
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
static String buildLabel(String name) { static String buildLabel(String name) {
final trimmed = name.trim(); final trimmed = name.trim();
if (trimmed.isEmpty) return '?'; if (trimmed.isEmpty) return '?';
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
return '#${_take(hashBody, 2)}'.toUpperCase(); return '#${_take(hashBody, 2)}'.toUpperCase();
} }
final parts = trimmed final parts = _alnumChunks
.split(RegExp(r'[\s_-]+')) .allMatches(trimmed)
.map((match) => match.group(0)!)
.where((part) => part.isNotEmpty) .where((part) => part.isNotEmpty)
.toList(); .toList();
if (parts.isEmpty) {
return '?';
}
if (parts.length >= 2) { if (parts.length >= 2) {
final first = _take(parts[0], 1); final first = _take(parts[0], 1);
final second = _take(parts[1], 1); final second = _take(parts[1], 1);
return '$first$second'.toUpperCase(); return '$first$second'.toUpperCase();
} }
return _take(trimmed, 2).toUpperCase(); return _take(parts.first, 2).toUpperCase();
} }
static String _take(String value, int count) { static String _take(String value, int count) {

View File

@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
final foregroundColor = _getForegroundColor(backgroundColor); final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji; final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) { if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame( return _buildAvatarFrame(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)), child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
bool get _usesSquareShape => bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room; contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) { Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) { if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact); return TrailColorService.getTrailColor(contact);
} }

View File

@@ -22,5 +22,13 @@ void main() {
test('keeps non-hash labels at two characters', () { test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB'); expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
}); });
test('strips emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
});
test('strips leading emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
});
}); });
} }