793 lines
23 KiB
Dart
793 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:stacked/stacked.dart';
|
|
|
|
import '../../about/external/data/Redis.dart';
|
|
import '../../about/internal/application/ServerProfile.dart';
|
|
import '../../about/internal/application/TextType.dart';
|
|
import '../../designs/text/Text.dart';
|
|
import '../../utils/Colors.dart';
|
|
import 'ConnectRedis.dart';
|
|
import 'Redis.dart';
|
|
import 'ViewRedis.dart';
|
|
|
|
class RedisState extends State<Redis> implements ConnectRedis {
|
|
static const _types = [
|
|
'all',
|
|
'string',
|
|
'list',
|
|
'set',
|
|
'hash',
|
|
'zset',
|
|
'stream',
|
|
];
|
|
|
|
ViewRedis? _model;
|
|
|
|
final _host = TextEditingController(text: '127.0.0.1');
|
|
final _port = TextEditingController(text: '6379');
|
|
final _password = TextEditingController();
|
|
final _pattern = TextEditingController(text: '*');
|
|
|
|
int _selectedDb = 0;
|
|
int _dbCount = 16;
|
|
Map<int, int> _dbCounts = {};
|
|
String _typeFilter = 'all';
|
|
|
|
List<String> _keys = [];
|
|
Map<String, String> _keyTypes = {};
|
|
int _total = 0;
|
|
bool _loading = true;
|
|
bool _detecting = false;
|
|
bool _connected = false;
|
|
String? _error;
|
|
|
|
String? _selectedKey;
|
|
RedisEntry? _entry;
|
|
bool _loadingValue = false;
|
|
bool _wide = false;
|
|
|
|
ServerProfile get _server => widget.server;
|
|
|
|
int get _portNumber => int.tryParse(_port.text.trim()) ?? 6379;
|
|
|
|
String get _hostText =>
|
|
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
|
|
|
@override
|
|
void dispose() {
|
|
_host.dispose();
|
|
_port.dispose();
|
|
_password.dispose();
|
|
_pattern.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ViewModelBuilder<ViewRedis>.reactive(
|
|
viewModelBuilder: () => ViewRedis(context, this),
|
|
onViewModelReady: _onReady,
|
|
builder: (context, viewModel, child) => Scaffold(
|
|
appBar: AppBar(
|
|
title: text(
|
|
'Redis · ${_server.name}',
|
|
20,
|
|
TextType.Bold,
|
|
color: context.jp.text,
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Refresh',
|
|
onPressed: _loading ? null : _onScan,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
_controls(),
|
|
if (_loading) const LinearProgressIndicator(minHeight: 3),
|
|
if (_error != null) _errorBar(),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, c) {
|
|
_wide = c.maxWidth >= 760;
|
|
if (!_wide) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
child: _keyList(),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(flex: 4, child: _keyList()),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
flex: 5,
|
|
child: _entry == null && !_loadingValue
|
|
? _placeholder()
|
|
: _valuePanel(_entry),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onReady(ViewRedis model) {
|
|
_model = model;
|
|
// onViewModelReady fires mid-build; a synchronous setState from the
|
|
// first load would dirty this State while it is still being built.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _initiate();
|
|
});
|
|
}
|
|
|
|
Future<void> _initiate() async {
|
|
_scan();
|
|
}
|
|
|
|
void _scan({bool triedDetect = false}) {
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
_model?.scan(
|
|
_server,
|
|
host: _hostText,
|
|
port: _portNumber,
|
|
password: _password.text,
|
|
database: _selectedDb,
|
|
pattern: _pattern.text,
|
|
typeFilter: _typeFilter,
|
|
triedDetect: triedDetect,
|
|
);
|
|
}
|
|
|
|
// ── named handlers ─────────────────────────────────────────────────────────
|
|
|
|
void _onScan() => _scan();
|
|
|
|
void _onPatternSubmitted(String _) => _scan();
|
|
|
|
void _onDetectPassword() {
|
|
setState(() => _detecting = true);
|
|
_model?.detectPassword(_server);
|
|
}
|
|
|
|
void _onSelectDatabase(int db) {
|
|
if (_selectedDb == db) return;
|
|
setState(() => _selectedDb = db);
|
|
_scan();
|
|
}
|
|
|
|
void _onSelectType(String type) {
|
|
if (_typeFilter == type) return;
|
|
setState(() => _typeFilter = type);
|
|
_scan();
|
|
}
|
|
|
|
void _onSelectKey(String key) {
|
|
setState(() {
|
|
_selectedKey = key;
|
|
_entry = null;
|
|
_loadingValue = true;
|
|
});
|
|
_model?.loadValue(
|
|
_server,
|
|
key,
|
|
host: _hostText,
|
|
port: _portNumber,
|
|
password: _password.text,
|
|
database: _selectedDb,
|
|
);
|
|
}
|
|
|
|
void _onCopyValue(RedisEntry entry) {
|
|
Clipboard.setData(ClipboardData(text: entry.value));
|
|
_notify('Value copied.');
|
|
}
|
|
|
|
void _notify(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
|
|
void _showValueSheet(RedisEntry entry) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: context.jp.surface,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) => Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
|
),
|
|
child: SizedBox(
|
|
height: MediaQuery.of(sheetContext).size.height * 0.7,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: _valuePanel(entry, inSheet: true),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── ConnectRedis ───────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
void onScanned(RedisScan scan) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_connected = true;
|
|
_dbCount = scan.databaseCount;
|
|
_dbCounts = scan.databaseCounts;
|
|
_total = scan.total;
|
|
_keys = scan.keys;
|
|
_keyTypes = scan.keyTypes;
|
|
_loading = false;
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void onScanFailed(String message) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_connected = false;
|
|
_loading = false;
|
|
_error = message;
|
|
_keys = [];
|
|
});
|
|
}
|
|
|
|
@override
|
|
void onPasswordDetected(String password) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_password.text = password;
|
|
_detecting = false;
|
|
});
|
|
_notify('Found the Redis password in redis.conf — connecting…');
|
|
// triedDetect stops a second lookup if this password is also rejected.
|
|
_scan(triedDetect: true);
|
|
}
|
|
|
|
@override
|
|
void onPasswordMissing() {
|
|
if (!mounted) return;
|
|
setState(() => _detecting = false);
|
|
_notify('No requirepass found in redis.conf.');
|
|
}
|
|
|
|
@override
|
|
void onValueLoaded(RedisEntry entry) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_entry = entry;
|
|
_loadingValue = false;
|
|
});
|
|
if (!_wide) _showValueSheet(entry);
|
|
}
|
|
|
|
// ── screen-only widgets ────────────────────────────────────────────────────
|
|
|
|
Widget _controls() {
|
|
final jp = context.jp;
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
decoration: BoxDecoration(
|
|
color: jp.surface,
|
|
border: Border(bottom: BorderSide(color: jp.border)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: TextField(
|
|
controller: _host,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Host',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
SizedBox(
|
|
width: 80,
|
|
child: TextField(
|
|
controller: _port,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Port',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _password,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Password (requirepass)',
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
OutlinedButton.icon(
|
|
onPressed: _detecting ? null : _onDetectPassword,
|
|
icon: _detecting
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.key, size: 18),
|
|
label: text('Detect', 13, TextType.Bold, color: jp.textDim),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _pattern,
|
|
onSubmitted: _onPatternSubmitted,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Key pattern',
|
|
hintText: 'user:* · *',
|
|
prefixIcon: Icon(Icons.search, size: 20),
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
FilledButton.icon(
|
|
onPressed: _loading ? null : _onScan,
|
|
icon: const Icon(Icons.travel_explore, size: 18),
|
|
label: text('Scan', 13, TextType.Bold, color: colorWhite),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_dbSelector(),
|
|
const SizedBox(height: 10),
|
|
_typeSelector(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _dbSelector() {
|
|
final jp = context.jp;
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_selectorLabel(Icons.storage, 'Database'),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: List.generate(_dbCount, (i) {
|
|
final selected = _selectedDb == i;
|
|
final count = _dbCounts[i] ?? 0;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(9),
|
|
onTap: _loading ? null : () => _onSelectDatabase(i),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 11,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? PeachTheme.seed.withValues(alpha: 0.14)
|
|
: jp.surface2,
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: selected
|
|
? PeachTheme.seed.withValues(alpha: 0.7)
|
|
: jp.border,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(
|
|
'db$i',
|
|
12.5,
|
|
selected ? TextType.Bold : TextType.Regular,
|
|
color: selected ? PeachTheme.seed : jp.text,
|
|
),
|
|
if (count > 0) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 5,
|
|
vertical: 1,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? PeachTheme.seed.withValues(alpha: 0.22)
|
|
: jp.border.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: text(
|
|
'$count',
|
|
10.5,
|
|
TextType.Bold,
|
|
color: selected ? PeachTheme.seed : jp.textFaint,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _typeSelector() {
|
|
final jp = context.jp;
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_selectorLabel(Icons.category_outlined, 'Type filter'),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: _types.map((type) {
|
|
final selected = _typeFilter == type;
|
|
final color = type == 'all' ? PeachTheme.slate : _typeColor(type);
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(9),
|
|
onTap: _loading ? null : () => _onSelectType(type),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 11,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? color.withValues(alpha: 0.14)
|
|
: jp.surface2,
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: selected
|
|
? color.withValues(alpha: 0.7)
|
|
: jp.border,
|
|
),
|
|
),
|
|
child: text(
|
|
type == 'all' ? 'all types' : type,
|
|
12.5,
|
|
selected ? TextType.Bold : TextType.Regular,
|
|
color: selected ? color : jp.text,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _selectorLabel(IconData icon, String label) => Row(
|
|
children: [
|
|
Icon(icon, size: 14, color: context.jp.textFaint),
|
|
const SizedBox(width: 6),
|
|
text(
|
|
label,
|
|
11,
|
|
TextType.Bold,
|
|
color: context.jp.textFaint,
|
|
letterSpacing: 0.4,
|
|
),
|
|
],
|
|
);
|
|
|
|
Color _typeColor(String type) {
|
|
switch (type) {
|
|
case 'string':
|
|
return PeachTheme.accent;
|
|
case 'list':
|
|
return PeachTheme.warn;
|
|
case 'set':
|
|
return PeachTheme.violet;
|
|
case 'hash':
|
|
return PeachTheme.seed;
|
|
case 'zset':
|
|
return PeachTheme.mint;
|
|
case 'stream':
|
|
return PeachTheme.pink;
|
|
default:
|
|
return PeachTheme.slate;
|
|
}
|
|
}
|
|
|
|
Widget _errorBar() => Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: PeachTheme.danger.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.error_outline, color: PeachTheme.danger),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _keyList() {
|
|
final jp = context.jp;
|
|
return Container(
|
|
decoration: PeachTheme.panel(context, radius: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.key, size: 16, color: PeachTheme.seed),
|
|
const SizedBox(width: 8),
|
|
text(
|
|
'KEYS',
|
|
12,
|
|
TextType.Bold,
|
|
color: jp.textDim,
|
|
letterSpacing: 1,
|
|
),
|
|
const SizedBox(width: 8),
|
|
text(
|
|
'${_keys.length}',
|
|
12,
|
|
TextType.Bold,
|
|
color: PeachTheme.seed,
|
|
),
|
|
if (_total > 0) ...[
|
|
const SizedBox(width: 4),
|
|
text(
|
|
_pattern.text.trim() == '*'
|
|
? 'of $_total'
|
|
: '($_total total)',
|
|
11.5,
|
|
TextType.Regular,
|
|
color: jp.textFaint,
|
|
),
|
|
],
|
|
if (_keys.length >= 100000) ...[
|
|
const SizedBox(width: 6),
|
|
text('(capped)', 11, TextType.Regular, color: jp.textFaint),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1, color: jp.border),
|
|
Expanded(
|
|
child: _keys.isEmpty
|
|
? Center(
|
|
child: text(
|
|
_loading
|
|
? 'Scanning…'
|
|
: (_connected ? 'No keys match.' : 'Not connected.'),
|
|
14,
|
|
TextType.Regular,
|
|
color: jp.textFaint,
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: _keys.length,
|
|
separatorBuilder: (_, i) => Divider(
|
|
height: 1,
|
|
color: jp.border,
|
|
indent: 12,
|
|
endIndent: 12,
|
|
),
|
|
itemBuilder: (_, i) => _keyRow(_keys[i]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _keyRow(String key) {
|
|
final jp = context.jp;
|
|
final selected = _wide && key == _selectedKey;
|
|
final type = _keyTypes[key];
|
|
return InkWell(
|
|
onTap: () => _onSelectKey(key),
|
|
child: Container(
|
|
color: selected ? PeachTheme.seed.withValues(alpha: 0.10) : null,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
|
child: Row(
|
|
children: [
|
|
if (type != null) ...[_typeBadge(type), const SizedBox(width: 9)],
|
|
Expanded(
|
|
child: text(
|
|
key,
|
|
13,
|
|
TextType.Regular,
|
|
color: selected ? PeachTheme.seed : jp.text,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, size: 18, color: jp.textFaint),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _typeBadge(String type) {
|
|
final color = _typeColor(type);
|
|
return Container(
|
|
width: 46,
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: color.withValues(alpha: 0.4)),
|
|
),
|
|
child: text(type, 9.5, TextType.Bold, color: color, letterSpacing: 0.2),
|
|
);
|
|
}
|
|
|
|
Widget _placeholder() {
|
|
final jp = context.jp;
|
|
return Container(
|
|
decoration: PeachTheme.panel(context, radius: 16),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.data_object, size: 40, color: jp.textFaint),
|
|
const SizedBox(height: 10),
|
|
text(
|
|
'Select a key to view its value',
|
|
14,
|
|
TextType.Regular,
|
|
color: jp.textDim,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _valuePanel(RedisEntry? entry, {bool inSheet = false}) {
|
|
final jp = context.jp;
|
|
if (_loadingValue) {
|
|
return Container(
|
|
decoration: inSheet ? null : PeachTheme.panel(context, radius: 16),
|
|
child: const Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (entry == null) return _placeholder();
|
|
|
|
final content = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: text(
|
|
entry.key,
|
|
14,
|
|
TextType.Bold,
|
|
color: jp.text,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Copy value',
|
|
onPressed: () => _onCopyValue(entry),
|
|
icon: const Icon(Icons.copy_all, size: 18),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
_tag(entry.type.toUpperCase(), PeachTheme.accent),
|
|
const SizedBox(width: 8),
|
|
_tag('TTL ${entry.ttlLabel}', jp.textDim),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: jp.consoleBg,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: ConsoleColors.frame),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: SelectableText(
|
|
entry.value.isEmpty ? '(empty)' : entry.value,
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12.5,
|
|
height: 1.4,
|
|
color: ConsoleColors.body,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (inSheet) return content;
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: PeachTheme.panel(context, radius: 16),
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
Widget _tag(String label, Color color) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: text(label, 11, TextType.Bold, color: color),
|
|
);
|
|
}
|