mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
**Category:** improvement **User Impact:** Buzz channel, message, repository, pull request, and issue links now display recognizable context and navigate reliably in the mobile app. **Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on mobile, while channel and message links were not handled consistently across Markdown forms and startup states. **Solution:** Normalize eligible bare Buzz URLs without consuming Markdown syntax, render them as semantic icon-prefixed chips, and route channel/message targets through the mobile deep-link dispatcher while preserving authored Markdown labels as ordinary links. <details> <summary>File changes</summary> **mobile/lib/features/channels/deep_link_dispatcher.dart** Routes parsed channel and message links through the appropriate in-app navigation callbacks. **mobile/lib/features/channels/message_content.dart** Presents all bare Buzz permalinks as semantic icon chips and keeps authored labels as ordinary links. **mobile/lib/features/channels/message_content/link_normalizer.dart** Normalizes bare and autolinked Buzz URLs without consuming Markdown delimiters, code, or punctuation. **mobile/lib/shared/deeplink/deep_link.dart** Adds strict channel and project-entity parsing alongside message deep links. **mobile/lib/shared/deeplink/pending_deep_link_provider.dart** Preserves pending navigation until the mobile routing surface is ready. **mobile/test/features/channels/channel_detail_page_test.dart** Updates navigation integration coverage for icon-prefixed channel chips. **mobile/test/features/channels/deep_link_dispatcher_test.dart** Covers channel/message dispatch and missing-target behavior. **mobile/test/features/channels/message_content/link_normalizer_test.dart** Exercises Markdown-safe normalization across the full Buzz link suite. **mobile/test/features/channels/message_content_test.dart** Verifies chip labels, icons, semantics, authored-label opt-out, and navigation callbacks. **mobile/test/shared/deeplink/deep_link_test.dart** Covers strict parsing for channel, message, repository, pull-request, and issue links. </details> ## Reproduction steps 1. Run the mobile app and open a channel containing bare `buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and `buzz://issue` URLs. 2. Confirm each bare URL renders as one cohesive chip with a type icon, a useful name or shortened identifier, and no duplicated channel `#` character. 3. Add an authored Markdown link such as `[design discussion](buzz://issue?...)` and confirm the supplied label remains an ordinary link rather than becoming a chip. 4. Select channel and message links and confirm they navigate correctly from inline and autolinked forms. ## Screenshots / demos **iOS Simulator — channel, message, repository, pull request, and issue permalink chips** Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator.  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
596 lines
17 KiB
Dart
596 lines
17 KiB
Dart
part of '../compose_bar.dart';
|
|
|
|
enum _MarkdownStyle { codeBlock, inlineCode, bold, italic, strikethrough }
|
|
|
|
class _MarkdownRule {
|
|
final RegExp pattern;
|
|
final _MarkdownStyle style;
|
|
final bool parsesNestedFormatting;
|
|
|
|
_MarkdownRule(
|
|
String pattern,
|
|
this.style, {
|
|
this.parsesNestedFormatting = true,
|
|
}) : pattern = RegExp(pattern);
|
|
}
|
|
|
|
class _MarkdownEditingController extends TextEditingController {
|
|
final Set<String> _agentMentionNames = <String>{};
|
|
TextSpan? _cachedTextSpan;
|
|
String? _cachedText;
|
|
TextRange? _cachedComposingRange;
|
|
TextStyle? _cachedBaseStyle;
|
|
Color? _cachedOnSurface;
|
|
Color? _cachedSurface;
|
|
Map<String, String> _channelNames = const {};
|
|
|
|
void setChannelNames(Map<String, String> names) {
|
|
if (mapEquals(_channelNames, names)) return;
|
|
_channelNames = Map.unmodifiable(names);
|
|
_cachedTextSpan = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
static final _rules = [
|
|
_MarkdownRule(
|
|
r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```',
|
|
_MarkdownStyle.codeBlock,
|
|
parsesNestedFormatting: false,
|
|
),
|
|
_MarkdownRule(
|
|
r'`([^`\n]*?)`',
|
|
_MarkdownStyle.inlineCode,
|
|
parsesNestedFormatting: false,
|
|
),
|
|
_MarkdownRule(r'\*\*([^\n]*?)\*\*', _MarkdownStyle.bold),
|
|
_MarkdownRule(r'~~([^\n]*?)~~', _MarkdownStyle.strikethrough),
|
|
_MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic),
|
|
];
|
|
|
|
/// Updates the known agent labels which should render as agent mention
|
|
/// chips. The editor still stores the literal `@Name` text, matching the
|
|
/// markdown sent to the relay.
|
|
void setAgentMentionNames(Iterable<String> names) {
|
|
final next = {
|
|
for (final name in names)
|
|
if (name.trim().isNotEmpty) name.trim().toLowerCase(),
|
|
};
|
|
if (setEquals(_agentMentionNames, next)) return;
|
|
_agentMentionNames
|
|
..clear()
|
|
..addAll(next);
|
|
_cachedTextSpan = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
TextSpan buildTextSpan({
|
|
required BuildContext context,
|
|
TextStyle? style,
|
|
required bool withComposing,
|
|
}) {
|
|
final baseStyle = style ?? const TextStyle();
|
|
final composingRange =
|
|
withComposing && value.composing.isValid && !value.composing.isCollapsed
|
|
? value.composing
|
|
: TextRange.empty;
|
|
final colors = context.colors;
|
|
if (_cachedTextSpan case final cached?
|
|
when _cachedText == text &&
|
|
_cachedComposingRange == composingRange &&
|
|
_cachedBaseStyle == baseStyle &&
|
|
_cachedOnSurface == colors.onSurface &&
|
|
_cachedSurface == colors.surface) {
|
|
return cached;
|
|
}
|
|
final span = TextSpan(
|
|
style: baseStyle,
|
|
children: _buildMarkdownSpans(
|
|
context,
|
|
text,
|
|
baseStyle,
|
|
sourceOffset: 0,
|
|
composingRange: composingRange,
|
|
),
|
|
);
|
|
_cachedText = text;
|
|
_cachedComposingRange = composingRange;
|
|
_cachedBaseStyle = baseStyle;
|
|
_cachedOnSurface = colors.onSurface;
|
|
_cachedSurface = colors.surface;
|
|
_cachedTextSpan = span;
|
|
return span;
|
|
}
|
|
|
|
List<InlineSpan> _buildMarkdownSpans(
|
|
BuildContext context,
|
|
String source,
|
|
TextStyle inheritedStyle, {
|
|
required int sourceOffset,
|
|
required TextRange composingRange,
|
|
}) {
|
|
final spans = <InlineSpan>[];
|
|
var offset = 0;
|
|
|
|
while (offset < source.length) {
|
|
final tail = source.substring(offset);
|
|
_MarkdownRule? nextRule;
|
|
RegExpMatch? nextMatch;
|
|
|
|
for (final rule in _rules) {
|
|
final match = rule.pattern.firstMatch(tail);
|
|
if (match == null) continue;
|
|
if (nextMatch == null || match.start < nextMatch.start) {
|
|
nextRule = rule;
|
|
nextMatch = match;
|
|
}
|
|
}
|
|
|
|
if (nextRule == null || nextMatch == null) {
|
|
spans.addAll(
|
|
_buildTextSpans(
|
|
context,
|
|
source.substring(offset),
|
|
inheritedStyle,
|
|
sourceOffset + offset,
|
|
composingRange,
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
if (nextMatch.start > 0) {
|
|
spans.addAll(
|
|
_buildTextSpans(
|
|
context,
|
|
tail.substring(0, nextMatch.start),
|
|
inheritedStyle,
|
|
sourceOffset + offset,
|
|
composingRange,
|
|
),
|
|
);
|
|
}
|
|
|
|
final fullMatch = nextMatch.group(0)!;
|
|
final content = nextMatch.group(1)!;
|
|
final (contentStart, contentEnd) = _contentBounds(
|
|
fullMatch,
|
|
nextRule.style,
|
|
);
|
|
final contentStyle = _styleFor(context, inheritedStyle, nextRule.style);
|
|
final matchOffset = sourceOffset + offset + nextMatch.start;
|
|
|
|
spans.add(
|
|
TextSpan(
|
|
text: fullMatch.substring(0, contentStart),
|
|
style: _hiddenSyntaxStyle(inheritedStyle),
|
|
),
|
|
);
|
|
if (nextRule.parsesNestedFormatting) {
|
|
spans.addAll(
|
|
_buildMarkdownSpans(
|
|
context,
|
|
content,
|
|
contentStyle,
|
|
sourceOffset: matchOffset + contentStart,
|
|
composingRange: composingRange,
|
|
),
|
|
);
|
|
} else {
|
|
spans.addAll(
|
|
_buildTextSpans(
|
|
context,
|
|
content,
|
|
contentStyle,
|
|
matchOffset + contentStart,
|
|
composingRange,
|
|
renderAgentMentions: false,
|
|
),
|
|
);
|
|
}
|
|
spans.add(
|
|
TextSpan(
|
|
text: fullMatch.substring(contentEnd),
|
|
style: _hiddenSyntaxStyle(inheritedStyle),
|
|
),
|
|
);
|
|
|
|
offset += nextMatch.end;
|
|
}
|
|
|
|
return spans;
|
|
}
|
|
|
|
List<InlineSpan> _buildTextSpans(
|
|
BuildContext context,
|
|
String source,
|
|
TextStyle style,
|
|
int sourceOffset,
|
|
TextRange composingRange, {
|
|
bool renderAgentMentions = true,
|
|
}) {
|
|
List<InlineSpan> buildTextSegment(String text, TextStyle segmentStyle) =>
|
|
renderAgentMentions
|
|
? _buildAgentMentionSpans(context, text, segmentStyle)
|
|
: [TextSpan(text: text, style: segmentStyle)];
|
|
|
|
if (source.isEmpty) return const [];
|
|
final localStart = (composingRange.start - sourceOffset)
|
|
.clamp(0, source.length)
|
|
.toInt();
|
|
final localEnd = (composingRange.end - sourceOffset)
|
|
.clamp(0, source.length)
|
|
.toInt();
|
|
if (!composingRange.isValid ||
|
|
composingRange.isCollapsed ||
|
|
localStart >= localEnd) {
|
|
return buildTextSegment(source, style);
|
|
}
|
|
|
|
final composingDecorations = [
|
|
if (style.decoration != null && style.decoration != TextDecoration.none)
|
|
style.decoration!,
|
|
TextDecoration.underline,
|
|
];
|
|
final composingStyle = style.copyWith(
|
|
decoration: TextDecoration.combine(composingDecorations),
|
|
);
|
|
return [
|
|
if (localStart > 0)
|
|
...buildTextSegment(source.substring(0, localStart), style),
|
|
TextSpan(
|
|
text: source.substring(localStart, localEnd),
|
|
style: composingStyle,
|
|
),
|
|
if (localEnd < source.length)
|
|
...buildTextSegment(source.substring(localEnd), style),
|
|
];
|
|
}
|
|
|
|
List<InlineSpan> _buildAgentMentionSpans(
|
|
BuildContext context,
|
|
String source,
|
|
TextStyle style, {
|
|
bool renderLinks = true,
|
|
}) {
|
|
final tokenSpans = renderLinks
|
|
? _buildComposerTokenSpans(context, source, style)
|
|
: null;
|
|
if (tokenSpans != null) return tokenSpans;
|
|
if (_agentMentionNames.isEmpty) {
|
|
return [TextSpan(text: source, style: style)];
|
|
}
|
|
|
|
final escapedNames = _agentMentionNames.toList()
|
|
..sort((a, b) => b.length.compareTo(a.length));
|
|
final expression = RegExp(
|
|
r'(^|\s)@(' +
|
|
escapedNames.map(RegExp.escape).join('|') +
|
|
r')(?=\s|[,.!?:;)\]}*_]|$)',
|
|
caseSensitive: false,
|
|
multiLine: true,
|
|
);
|
|
final spans = <InlineSpan>[];
|
|
var offset = 0;
|
|
for (final match in expression.allMatches(source)) {
|
|
final prefix = match.group(1)!;
|
|
if (match.start > offset) {
|
|
spans.add(
|
|
TextSpan(text: source.substring(offset, match.start), style: style),
|
|
);
|
|
}
|
|
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));
|
|
|
|
final label = match.group(2)!;
|
|
spans.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: _ComposerAgentMentionChip(label: label, textStyle: style),
|
|
),
|
|
);
|
|
// The visual chip replaces the `@` placeholder. Keep the label as
|
|
// invisible source text so the text span still has one character per
|
|
// source character, preserving native cursor and deletion behavior.
|
|
spans.add(
|
|
TextSpan(
|
|
text: label,
|
|
semanticsLabel: '',
|
|
style: _hiddenMentionTextStyle(style),
|
|
),
|
|
);
|
|
offset = match.end;
|
|
}
|
|
if (offset < source.length) {
|
|
spans.add(TextSpan(text: source.substring(offset), style: style));
|
|
}
|
|
return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans;
|
|
}
|
|
|
|
List<InlineSpan>? _buildComposerTokenSpans(
|
|
BuildContext context,
|
|
String source,
|
|
TextStyle style,
|
|
) {
|
|
final expression = RegExp(
|
|
r'''buzz://(?:message\?|channel/|(?:repo|pr|issue)\?)[^\s<>"']+''',
|
|
caseSensitive: false,
|
|
);
|
|
final matches = expression.allMatches(source).toList();
|
|
if (matches.isEmpty) return null;
|
|
|
|
final spans = <InlineSpan>[];
|
|
var offset = 0;
|
|
for (final match in matches) {
|
|
if (match.start > offset) {
|
|
spans.addAll(
|
|
_buildAgentMentionSpans(
|
|
context,
|
|
source.substring(offset, match.start),
|
|
style,
|
|
renderLinks: false,
|
|
),
|
|
);
|
|
}
|
|
final raw = match.group(0)!;
|
|
final url = raw.replaceFirst(RegExp(r'[.,!?:;)\]}*]+$'), '');
|
|
final trailing = raw.substring(url.length);
|
|
final presentation = _composerLinkPresentation(url);
|
|
if (presentation == null) {
|
|
spans.add(TextSpan(text: raw, style: style));
|
|
} else {
|
|
spans.add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: _ComposerBuzzLinkChip(
|
|
icon: presentation.$1,
|
|
label: presentation.$2,
|
|
semanticLabel: presentation.$3,
|
|
textStyle: style,
|
|
),
|
|
),
|
|
);
|
|
// Preserve one source character per input character for native cursor
|
|
// movement while the visible atomic chip replaces the URL.
|
|
spans.add(
|
|
TextSpan(
|
|
text: url.substring(1),
|
|
semanticsLabel: '',
|
|
style: _hiddenMentionTextStyle(style),
|
|
),
|
|
);
|
|
if (trailing.isNotEmpty) {
|
|
spans.add(TextSpan(text: trailing, style: style));
|
|
}
|
|
}
|
|
offset = match.end;
|
|
}
|
|
if (offset < source.length) {
|
|
spans.addAll(
|
|
_buildAgentMentionSpans(
|
|
context,
|
|
source.substring(offset),
|
|
style,
|
|
renderLinks: false,
|
|
),
|
|
);
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
(IconData, String, String)? _composerLinkPresentation(String raw) {
|
|
final uri = Uri.tryParse(raw);
|
|
if (uri == null) return null;
|
|
final link = parseBuzzDeepLink(uri) ?? parseEntityDeepLink(uri);
|
|
return switch (link) {
|
|
ChannelDeepLink(:final channelId) => (
|
|
LucideIcons.hash,
|
|
_resolvedChannelName(channelId),
|
|
'Channel ${_resolvedChannelName(channelId)}',
|
|
),
|
|
MessageDeepLink(:final channelId, :final messageId) => (
|
|
LucideIcons.messageSquare,
|
|
'${_resolvedChannelName(channelId)} · ${messageId.substring(0, 8)}',
|
|
'Message ${messageId.substring(0, 8)} in channel ${_resolvedChannelName(channelId)}',
|
|
),
|
|
EntityDeepLink(:final type, :final repository, :final eventId) => (
|
|
switch (type) {
|
|
'repo' => LucideIcons.folderGit2,
|
|
'pr' => LucideIcons.gitPullRequest,
|
|
_ => LucideIcons.circleDot,
|
|
},
|
|
type == 'repo'
|
|
? repository
|
|
: '$repository · ${eventId!.substring(0, 8)}',
|
|
switch (type) {
|
|
'repo' => 'Repository $repository',
|
|
'pr' =>
|
|
'Pull request ${eventId!.substring(0, 8)} in repository $repository',
|
|
_ => 'Issue ${eventId!.substring(0, 8)} in repository $repository',
|
|
},
|
|
),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
String _resolvedChannelName(String channelId) {
|
|
for (final entry in _channelNames.entries) {
|
|
if (entry.value == channelId) return entry.key;
|
|
}
|
|
return channelId.substring(0, math.min(8, channelId.length));
|
|
}
|
|
|
|
TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) =>
|
|
inheritedStyle.copyWith(
|
|
color: Colors.transparent,
|
|
fontSize: 0.01,
|
|
height: 0.01,
|
|
letterSpacing: 0,
|
|
decoration: TextDecoration.none,
|
|
);
|
|
|
|
(int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) {
|
|
final delimiterLength = switch (markdownStyle) {
|
|
_MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2,
|
|
_MarkdownStyle.italic || _MarkdownStyle.inlineCode => 1,
|
|
_MarkdownStyle.codeBlock => 3,
|
|
};
|
|
if (markdownStyle != _MarkdownStyle.codeBlock) {
|
|
return (delimiterLength, fullMatch.length - delimiterLength);
|
|
}
|
|
|
|
final openingLength = fullMatch.startsWith('```\r\n')
|
|
? 5
|
|
: fullMatch.startsWith('```\n')
|
|
? 4
|
|
: delimiterLength;
|
|
final closingLength = fullMatch.endsWith('\r\n```')
|
|
? 5
|
|
: fullMatch.endsWith('\n```')
|
|
? 4
|
|
: delimiterLength;
|
|
return (openingLength, fullMatch.length - closingLength);
|
|
}
|
|
|
|
TextStyle _styleFor(
|
|
BuildContext context,
|
|
TextStyle inheritedStyle,
|
|
_MarkdownStyle markdownStyle,
|
|
) {
|
|
return switch (markdownStyle) {
|
|
_MarkdownStyle.bold => inheritedStyle.merge(
|
|
const TextStyle(fontWeight: FontWeight.w700),
|
|
),
|
|
_MarkdownStyle.italic => inheritedStyle.merge(
|
|
const TextStyle(fontStyle: FontStyle.italic),
|
|
),
|
|
_MarkdownStyle.strikethrough => inheritedStyle.merge(
|
|
const TextStyle(decoration: TextDecoration.lineThrough),
|
|
),
|
|
_MarkdownStyle.inlineCode ||
|
|
_MarkdownStyle.codeBlock => inheritedStyle.merge(
|
|
TextStyle(
|
|
fontFamily: 'GeistMono',
|
|
color: context.colors.onSurface,
|
|
backgroundColor: context.colors.surface,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
TextStyle _hiddenSyntaxStyle(TextStyle inheritedStyle) {
|
|
return inheritedStyle.copyWith(
|
|
color: Colors.transparent,
|
|
fontSize: 0.01,
|
|
height: 0.01,
|
|
letterSpacing: 0,
|
|
decoration: TextDecoration.none,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ComposerBuzzLinkChip extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final String semanticLabel;
|
|
final TextStyle textStyle;
|
|
|
|
const _ComposerBuzzLinkChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.semanticLabel,
|
|
required this.textStyle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final style = textStyle.copyWith(
|
|
color: context.colors.primary,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1,
|
|
);
|
|
final fontSize = style.fontSize ?? 16;
|
|
return Semantics(
|
|
label: semanticLabel,
|
|
excludeSemantics: true,
|
|
child: Container(
|
|
key: ValueKey('composer-buzz-link-chip:$label'),
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.primary.withValues(alpha: 0.16),
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
border: Border.all(
|
|
color: context.colors.primary.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: fontSize * 0.95, color: context.colors.primary),
|
|
const SizedBox(width: Grid.quarter + 1),
|
|
Text(label, style: style),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ComposerAgentMentionChip extends StatelessWidget {
|
|
final String label;
|
|
final TextStyle textStyle;
|
|
|
|
const _ComposerAgentMentionChip({
|
|
required this.label,
|
|
required this.textStyle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final style = textStyle.copyWith(
|
|
color: context.colors.primary,
|
|
fontWeight: FontWeight.w500,
|
|
height: 1,
|
|
);
|
|
final fontSize = style.fontSize ?? 16;
|
|
|
|
return Semantics(
|
|
label: 'Agent mention: $label',
|
|
excludeSemantics: true,
|
|
child: Container(
|
|
key: const ValueKey('composer-agent-mention-chip'),
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.half,
|
|
Grid.quarter + 1,
|
|
Grid.half,
|
|
Grid.quarter,
|
|
),
|
|
decoration: BoxDecoration(
|
|
// The composer surface is already tinted, so the body chip's
|
|
// low-opacity fill disappears here. Keep the same chip geometry
|
|
// while giving this editable token enough contrast to read as one.
|
|
color: context.colors.primary.withValues(alpha: 0.16),
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
border: Border.all(
|
|
color: context.colors.primary.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.bot,
|
|
size: fontSize * 0.95,
|
|
color: context.colors.primary,
|
|
),
|
|
const SizedBox(width: Grid.quarter),
|
|
Text(label, style: style),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|