mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Align channel avatars and compact
This commit is contained in:
@@ -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;
|
||||||
|
return SizedBox(
|
||||||
|
width: _size,
|
||||||
|
height: _size,
|
||||||
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(color: surfaceColor, width: 2),
|
||||||
color: Theme.of(context).colorScheme.surface,
|
),
|
||||||
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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user