import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../core/sqlite_reader.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_title_bar.dart'; /// SQLite 로 볼 확장자 const sqliteExtensions = {'db', 'sqlite', 'sqlite3', 'db3'}; /// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 보기 (읽기 전용) - 원격 파일은 임시로 받아서 연다 Future openSqliteViewer(BuildContext context, AppController c, String path) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => SqliteViewerPage(c: c, path: path))); class SqliteViewerPage extends StatefulWidget { final AppController c; final String path; const SqliteViewerPage({super.key, required this.c, required this.path}); @override State createState() => _SqliteViewerPageState(); } class _SqliteViewerPageState extends State { static const _pageRows = 200; SqliteFile? _db; List _tables = const []; SqliteTable? _table; List _rows = const []; int? _count; bool _more = false, _busy = true; String? _error; @override void initState() { super.initState(); unawaited(_open()); } @override void dispose() { unawaited(_db?.close()); super.dispose(); } Future _open() async { try { final local = isDav(widget.path) ? await vLocalCopy(widget.path, await widget.c.services.storage.tempDirectory()) : widget.path; final db = await SqliteFile.open(local); if (!mounted) { await db.close(); return; } _db = db; _tables = await db.tables(); if (_tables.isNotEmpty) { await _pick(_tables.first); } else if (mounted) { setState(() => _busy = false); } } catch (e) { if (mounted) { setState(() { _busy = false; _error = e is SqliteFormatException ? tr('SQLite 파일이 아니거나 깨졌습니다') : trf('열 수 없습니다: {0}', [e]); }); } } } Future _pick(SqliteTable t) async { setState(() { _table = t; _rows = const []; _count = null; _busy = true; _error = null; }); final db = _db!; try { if (t.withoutRowid) { setState(() { _busy = false; _error = tr('WITHOUT ROWID 테이블은 아직 볼 수 없습니다'); }); return; } final rows = await db.rows(t, limit: _pageRows); final count = await db.count(t); if (!mounted || _table != t) return; setState(() { _rows = rows; _count = count; _more = rows.length < count; _busy = false; }); } catch (e) { if (mounted) { setState(() { _busy = false; _error = tr('SQLite 파일이 아니거나 깨졌습니다'); }); } } } Future _loadMore() async { final t = _table!; setState(() => _busy = true); final more = await _db!.rows(t, offset: _rows.length, limit: _pageRows); if (!mounted || _table != t) return; setState(() { _rows = [..._rows, ...more]; _more = _rows.length < (_count ?? 0); _busy = false; }); } String _cell(Object? v) => switch (v) { null => 'NULL', Uint8List b => trf('(BLOB {0}바이트)', [b.length]), String s => s.length > 120 ? '${s.substring(0, 120).replaceAll('\n', ' ')} …' : s.replaceAll('\n', ' '), _ => '$v', }; void _showCell(String column, Object? v) => showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(column), content: SelectableText( v is Uint8List ? v.take(256).map((x) => x.toRadixString(16).padLeft(2, '0')).join(' ') + (v.length > 256 ? ' …' : '') : '${v ?? 'NULL'}', style: const TextStyle(fontSize: 13), ), actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], ), ); @override Widget build(BuildContext context) { final t = _table; return Scaffold( appBar: jjTitleBar( context, title: vBasename(widget.path), ), body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Text(tr('읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (_tables.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(children: [ Expanded( child: DropdownButton( isExpanded: true, value: t, items: [for (final x in _tables) DropdownMenuItem(value: x, child: Text(x.name, overflow: TextOverflow.ellipsis))], onChanged: _busy ? null : (x) => _pick(x!), ), ), const SizedBox(width: 8), if (_count != null) Text(trf('{0}행', [_count!]), style: TextStyle(color: JjColors.textDim)), ]), ), if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: TextStyle(color: JjColors.danger))), if (_busy && _rows.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())), if (!_busy && _error == null && _tables.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('테이블이 없습니다'))), if (t != null && _rows.isNotEmpty) Expanded( child: Scrollbar( child: SingleChildScrollView( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( headingRowHeight: 36, dataRowMinHeight: 32, dataRowMaxHeight: 40, columns: [for (final col in t.displayColumns) DataColumn(label: Text(col, style: const TextStyle(fontWeight: FontWeight.w600)))], rows: [ for (final r in _rows) DataRow(cells: [ for (var i = 0; i < t.columns.length; i++) DataCell( ConstrainedBox( constraints: const BoxConstraints(maxWidth: 280), child: Text(_cell(i < r.values.length ? r.values[i] : null), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: (i < r.values.length ? r.values[i] : null) == null ? JjColors.textDim : null)), ), onTap: () => _showCell(t.displayColumns[i], i < r.values.length ? r.values[i] : null), ), ]), ], ), ), ), ), ), if (_more) Padding( padding: const EdgeInsets.all(8), child: OutlinedButton( onPressed: _busy ? null : _loadMore, child: Text(trf('더 보기 ({0} / {1})', [_rows.length, _count ?? 0])), ), ), ]), ); } }