Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,792 @@
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),
);
}