Files
buzz/mobile/lib/features/channels/message_content/token_pill.dart
klopez4212 bbcbd7ce13 Preview Tabler icons in the mobile app (#8109)
## Summary

Preview buzz-app's Tabler icon style in the mobile app, replacing
app-drawn Lucide glyphs across channels, messages, activity, search, and
settings. A shared `BuzzIcons` mapping uses the bundled Tabler 3.46.0
outline font, matching buzz-app's version, with its MIT license and
source documented.

Native UIKit controls retain SF Symbols. The former 300/500 navigation
glyphs use Tabler's standard outline; existing selection colors and
labels remain.

### Related issue

No matching Tabler mobile migration issue or PR found. Related native
iOS controls: #8055.

### Testing

- Pre-push mobile analysis, formatting, file-size checks, and the full
mobile test suite passed.
- 194 focused widget tests passed, including channels, home, search,
settings, and message content.
- Light and dark community-settings renders inspected.
- Signed iPhone Release build succeeded; installed and launched as Buzz
Tabler on a physical iPhone.
- Full `just ci` attempted; stopped during Rust clippy compilation
because the Mac ran low on disk space. Full repository validation is not
claimed.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-08 10:40:12 -04:00

139 lines
3.9 KiB
Dart

part of '../message_content.dart';
String? _channelNameForId(Map<String, String> channels, String channelId) {
for (final entry in channels.entries) {
if (entry.value == channelId) return entry.key;
}
return null;
}
class _ChannelLinkMd extends InlineMd {
final Map<String, String> channelNames;
final void Function(String channelId)? onChannelTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '#',
knownNames: channelNames.keys,
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
);
_ChannelLinkMd({required this.channelNames, this.onChannelTap});
@override
RegExp get exp => _exp;
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final channelId = channelNames[raw.substring(1).toLowerCase()];
final channelName = raw.substring(1);
final opensChannel = channelId != null && onChannelTap != null;
final child = _TokenPill(
icon: BuzzIcons.hash,
interactive: opensChannel,
semanticLabel: opensChannel
? 'Open channel $channelName'
: 'Channel $channelName',
text: channelName,
textStyle: config.style?.copyWith(fontWeight: FontWeight.w500),
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: opensChannel
? GestureDetector(onTap: () => onChannelTap!(channelId), child: child)
: child,
);
}
}
class _TokenPill extends StatelessWidget {
final IconData? icon;
final bool interactive;
final String? semanticLabel;
final String text;
final TextStyle? textStyle;
const _TokenPill({
super.key,
this.icon,
this.interactive = false,
this.semanticLabel,
required this.text,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final style =
textStyle?.copyWith(color: context.colors.primary) ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.primary);
final fontSize = style?.fontSize ?? 16;
final pill = Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: fontSize * 0.95, color: context.colors.primary),
const SizedBox(width: Grid.quarter + 1),
],
Text(text, style: style),
],
),
);
if (semanticLabel == null) return pill;
return Semantics(
label: semanticLabel,
button: interactive,
child: ExcludeSemantics(child: pill),
);
}
}
RegExp _buildPrefixPattern({
required String prefix,
required Iterable<String> knownNames,
required String genericTokenPattern,
}) {
final names =
knownNames
.map((name) => name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList()
..sort((a, b) => b.length.compareTo(a.length));
final escapedPrefix = RegExp.escape(prefix);
const leadingBoundary = r'(?<![\w./:-])';
const trailingBoundary = r'(?=$|[\s,;.!?:)\]}])';
if (names.isEmpty) {
return RegExp(
'$leadingBoundary$escapedPrefix(?:$genericTokenPattern)$trailingBoundary',
caseSensitive: false,
multiLine: true,
);
}
final knownAlternatives = names.map(RegExp.escape).join('|');
return RegExp(
'$leadingBoundary$escapedPrefix(?:(?:$knownAlternatives)$trailingBoundary|(?:$genericTokenPattern)$trailingBoundary)',
caseSensitive: false,
multiLine: true,
);
}