A desktop app that parses Claude Code .jsonl session logs and provides a rich UI for exploring conversations, tool usage, subagents, and token consumption. Features include project browser with auto-discovery of ~/.claude/projects, conversation timeline with inline subagent expansion, agents overview, toolbelt chart, and token usage dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
707 lines
24 KiB
Dart
707 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../../models/agent_info.dart';
|
|
import '../../../models/content_block.dart';
|
|
import '../../../models/log_entry.dart';
|
|
import '../../../providers/session_provider.dart';
|
|
import '../../../theme/app_theme.dart';
|
|
import '../../../widgets/common/expandable_card.dart';
|
|
import '../../../widgets/common/json_tree_view.dart';
|
|
|
|
class AssistantMessageCard extends StatelessWidget {
|
|
final AssistantEntry entry;
|
|
final int index;
|
|
|
|
const AssistantMessageCard({
|
|
super.key,
|
|
required this.entry,
|
|
required this.index,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.assistantBg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: AppColors.assistant.withAlpha(40)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 22,
|
|
height: 22,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.assistant.withAlpha(40),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: const Icon(Icons.smart_toy,
|
|
size: 14, color: AppColors.assistant),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
entry.model ?? 'Assistant',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.assistant,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'#$index',
|
|
style: const TextStyle(
|
|
fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
const Spacer(),
|
|
if (entry.usage != null) ...[
|
|
_TokenBadge(
|
|
label: 'in',
|
|
value: entry.usage!.inputTokens,
|
|
color: AppColors.user,
|
|
),
|
|
const SizedBox(width: 6),
|
|
_TokenBadge(
|
|
label: 'out',
|
|
value: entry.usage!.outputTokens,
|
|
color: AppColors.assistant,
|
|
),
|
|
if (entry.usage!.cacheReadInputTokens > 0) ...[
|
|
const SizedBox(width: 6),
|
|
_TokenBadge(
|
|
label: 'cache',
|
|
value: entry.usage!.cacheReadInputTokens,
|
|
color: AppColors.tool,
|
|
),
|
|
],
|
|
const SizedBox(width: 12),
|
|
],
|
|
if (entry.timestamp != null)
|
|
Text(
|
|
_formatTime(entry.timestamp!),
|
|
style: const TextStyle(
|
|
fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Thinking blocks
|
|
if (entry.hasThinking)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 6),
|
|
child: ExpandableCard(
|
|
backgroundColor: AppColors.surfaceLight,
|
|
borderColor: AppColors.surfaceBorder,
|
|
header: Row(
|
|
children: [
|
|
const Icon(Icons.psychology,
|
|
size: 14, color: AppColors.agent),
|
|
const SizedBox(width: 6),
|
|
const Text(
|
|
'Thinking',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.agent,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
entry.thinkingBlocks.first.thinking.isEmpty
|
|
? '(encrypted)'
|
|
: '${entry.thinkingBlocks.first.thinking.length} chars',
|
|
style: const TextStyle(
|
|
fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
child: entry.thinkingBlocks.first.thinking.isEmpty
|
|
? const Text(
|
|
'Thinking content is encrypted and not available.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textMuted,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
)
|
|
: SelectableText(
|
|
entry.thinkingBlocks.first.thinking,
|
|
style: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Text blocks
|
|
for (final block in entry.textBlocks)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
|
child: MarkdownBody(
|
|
data: block.text,
|
|
selectable: true,
|
|
styleSheet: MarkdownStyleSheet(
|
|
p: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textPrimary,
|
|
height: 1.5,
|
|
),
|
|
code: const TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 12,
|
|
color: AppColors.tool,
|
|
backgroundColor: AppColors.surfaceLight,
|
|
),
|
|
codeblockDecoration: BoxDecoration(
|
|
color: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: AppColors.surfaceBorder),
|
|
),
|
|
codeblockPadding: const EdgeInsets.all(12),
|
|
h1: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary),
|
|
h2: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary),
|
|
h3: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary),
|
|
listBullet: const TextStyle(
|
|
fontSize: 13, color: AppColors.textSecondary),
|
|
blockquoteDecoration: BoxDecoration(
|
|
border: Border(
|
|
left: BorderSide(
|
|
color: AppColors.assistant.withAlpha(80), width: 3),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Tool use blocks
|
|
if (entry.hasToolUse)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
|
child: Column(
|
|
children: entry.toolUseBlocks
|
|
.map((block) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: _ToolUseCard(block: block),
|
|
))
|
|
.toList(),
|
|
),
|
|
),
|
|
|
|
// Raw JSON expandable
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
|
child: ExpandableCard(
|
|
backgroundColor: AppColors.surface,
|
|
borderColor: AppColors.surfaceBorder,
|
|
header: const Row(
|
|
children: [
|
|
Icon(Icons.data_object, size: 14, color: AppColors.textMuted),
|
|
SizedBox(width: 6),
|
|
Text(
|
|
'Raw JSON',
|
|
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
child: JsonTreeView(data: entry.raw),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatTime(DateTime dt) {
|
|
return '${dt.hour.toString().padLeft(2, '0')}:'
|
|
'${dt.minute.toString().padLeft(2, '0')}:'
|
|
'${dt.second.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|
|
|
|
// ─── Tool use card (handles both regular tools and Agent calls) ───
|
|
|
|
class _ToolUseCard extends StatelessWidget {
|
|
final ToolUseBlock block;
|
|
|
|
const _ToolUseCard({required this.block});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isAgent = block.isAgentCall;
|
|
final color = isAgent ? AppColors.agent : AppColors.tool;
|
|
final bgColor = isAgent ? AppColors.agentBg : AppColors.toolBg;
|
|
final hasResult = block.linkedResult != null;
|
|
final isError = block.linkedResult?.isError ?? false;
|
|
|
|
// Look up subagent info if this is an Agent call
|
|
AgentInfo? subagent;
|
|
if (isAgent) {
|
|
final session = context.read<SessionProvider>().session;
|
|
subagent = session?.findAgentForToolUse(block.id);
|
|
}
|
|
|
|
return ExpandableCard(
|
|
backgroundColor: bgColor,
|
|
borderColor: color.withAlpha(40),
|
|
header: Row(
|
|
children: [
|
|
Icon(
|
|
isAgent ? Icons.smart_toy_outlined : Icons.build_outlined,
|
|
size: 14,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
block.name,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
if (isAgent && block.subagentType != null) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(20),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
block.subagentType!,
|
|
style: TextStyle(fontSize: 10, color: color),
|
|
),
|
|
),
|
|
],
|
|
if (isAgent && block.agentDescription != null) ...[
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
block.agentDescription!,
|
|
style: const TextStyle(
|
|
fontSize: 11, color: AppColors.textSecondary),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
] else
|
|
const Spacer(),
|
|
// Subagent stats
|
|
if (subagent != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
'${subagent.messages.where((m) => m.type == 'assistant').length} turns',
|
|
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
if (subagent.toolsUsed.isNotEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
'${subagent.toolsUsed.length} tools',
|
|
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
if (hasResult)
|
|
Icon(
|
|
isError ? Icons.error_outline : Icons.check_circle_outline,
|
|
size: 14,
|
|
color: isError ? AppColors.error : AppColors.assistant,
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Arguments
|
|
const Text(
|
|
'ARGUMENTS',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textMuted,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: JsonTreeView(
|
|
data: block.input,
|
|
initiallyExpanded: true,
|
|
),
|
|
),
|
|
|
|
// Subagent conversation (inline nested timeline)
|
|
if (subagent != null) ...[
|
|
const SizedBox(height: 12),
|
|
_SubagentTimeline(agent: subagent),
|
|
],
|
|
|
|
// Result
|
|
if (hasResult) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
isError ? 'ERROR' : 'RESULT',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: isError ? AppColors.error : AppColors.textMuted,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(maxHeight: 400),
|
|
decoration: BoxDecoration(
|
|
color: isError ? AppColors.errorBg : AppColors.surface,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: isError
|
|
? Border.all(color: AppColors.error.withAlpha(40))
|
|
: null,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: SelectableText(
|
|
block.linkedResult!.textContent,
|
|
style: TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 12,
|
|
color: isError
|
|
? AppColors.error
|
|
: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
// Raw tool_use JSON
|
|
const SizedBox(height: 12),
|
|
ExpandableCard(
|
|
backgroundColor: AppColors.surface,
|
|
borderColor: AppColors.surfaceBorder,
|
|
header: const Row(
|
|
children: [
|
|
Icon(Icons.data_object, size: 12, color: AppColors.textMuted),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Raw Tool JSON',
|
|
style: TextStyle(fontSize: 10, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
child: JsonTreeView(data: block.raw),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Inline subagent conversation ────────────────────────────
|
|
|
|
class _SubagentTimeline extends StatelessWidget {
|
|
final AgentInfo agent;
|
|
|
|
const _SubagentTimeline({required this.agent});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Filter to user and assistant messages only
|
|
final messages = agent.messages
|
|
.where((m) => m.type == 'user' || m.type == 'assistant')
|
|
.toList();
|
|
|
|
if (messages.isEmpty) {
|
|
return const Text(
|
|
'No subagent conversation data available.',
|
|
style: TextStyle(fontSize: 12, color: AppColors.textMuted, fontStyle: FontStyle.italic),
|
|
);
|
|
}
|
|
|
|
return ExpandableCard(
|
|
backgroundColor: AppColors.agentBg,
|
|
borderColor: AppColors.agent.withAlpha(30),
|
|
header: Row(
|
|
children: [
|
|
const Icon(Icons.account_tree_outlined, size: 14, color: AppColors.agent),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Subagent Conversation (${messages.length} messages)',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.agent,
|
|
),
|
|
),
|
|
if (agent.model != null) ...[
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
agent.model!,
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
color: AppColors.textSecondary,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Toolbelt summary
|
|
if (agent.uniqueToolNames.isNotEmpty) ...[
|
|
Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children: agent.toolUsageCounts.entries.map((e) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.tool.withAlpha(15),
|
|
borderRadius: BorderRadius.circular(3),
|
|
border: Border.all(color: AppColors.tool.withAlpha(30)),
|
|
),
|
|
child: Text(
|
|
'${e.key} (${e.value})',
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
color: AppColors.tool,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
// Messages
|
|
for (int i = 0; i < messages.length; i++) ...[
|
|
_SubagentMessageTile(entry: messages[i], index: i),
|
|
if (i < messages.length - 1) const SizedBox(height: 4),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SubagentMessageTile extends StatelessWidget {
|
|
final LogEntry entry;
|
|
final int index;
|
|
|
|
const _SubagentMessageTile({required this.entry, required this.index});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (entry is UserEntry) {
|
|
return _buildUserTile(entry as UserEntry);
|
|
}
|
|
if (entry is AssistantEntry) {
|
|
return _buildAssistantTile(entry as AssistantEntry);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
Widget _buildUserTile(UserEntry user) {
|
|
if (user.isToolResult) return const SizedBox.shrink();
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.userBg,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: AppColors.user.withAlpha(25)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.person, size: 12, color: AppColors.user),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: SelectableText(
|
|
user.promptText,
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAssistantTile(AssistantEntry assistant) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: AppColors.surfaceBorder),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header row
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.smart_toy, size: 12, color: AppColors.assistant),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
assistant.model ?? 'assistant',
|
|
style: const TextStyle(fontSize: 10, color: AppColors.assistant, fontWeight: FontWeight.w600),
|
|
),
|
|
const Spacer(),
|
|
if (assistant.usage != null)
|
|
Text(
|
|
'in:${_fmt(assistant.usage!.inputTokens)} out:${_fmt(assistant.usage!.outputTokens)}',
|
|
style: const TextStyle(fontSize: 9, color: AppColors.textMuted, fontFamily: 'JetBrains Mono'),
|
|
),
|
|
],
|
|
),
|
|
// Text content
|
|
for (final block in assistant.textBlocks) ...[
|
|
const SizedBox(height: 6),
|
|
SelectableText(
|
|
block.text,
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4),
|
|
),
|
|
],
|
|
// Tool uses
|
|
for (final tool in assistant.toolUseBlocks) ...[
|
|
const SizedBox(height: 6),
|
|
ExpandableCard(
|
|
backgroundColor: AppColors.toolBg,
|
|
borderColor: AppColors.tool.withAlpha(30),
|
|
header: Row(
|
|
children: [
|
|
const Icon(Icons.build_outlined, size: 12, color: AppColors.tool),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
tool.name,
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.tool),
|
|
),
|
|
const Spacer(),
|
|
if (tool.linkedResult != null)
|
|
Icon(
|
|
tool.linkedResult!.isError ? Icons.error_outline : Icons.check_circle_outline,
|
|
size: 12,
|
|
color: tool.linkedResult!.isError ? AppColors.error : AppColors.assistant,
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
JsonTreeView(data: tool.input, initiallyExpanded: true),
|
|
if (tool.linkedResult != null) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
child: SingleChildScrollView(
|
|
child: SelectableText(
|
|
tool.linkedResult!.textContent,
|
|
style: TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 11,
|
|
color: tool.linkedResult!.isError ? AppColors.error : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _fmt(int v) {
|
|
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
|
|
return '$v';
|
|
}
|
|
}
|
|
|
|
// ─── Token badge ─────────────────────────────────────────────
|
|
|
|
class _TokenBadge extends StatelessWidget {
|
|
final String label;
|
|
final int value;
|
|
final Color color;
|
|
|
|
const _TokenBadge({
|
|
required this.label,
|
|
required this.value,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(15),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
'$label: ${_format(value)}',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: color.withAlpha(200),
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _format(int v) {
|
|
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
|
|
return '$v';
|
|
}
|
|
}
|