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 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 _dbCounts = {}; String _typeFilter = 'all'; List _keys = []; Map _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.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 _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), ); }