JJ_MKVMaker/lib/ui/sqlite_viewer.dart

220 lines
7.6 KiB
Dart

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';
/// SQLite 로 볼 확장자
const sqliteExtensions = {'db', 'sqlite', 'sqlite3', 'db3'};
/// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 보기 (읽기 전용) - 원격 파일은 임시로 받아서 연다
Future<void> openSqliteViewer(BuildContext context, AppController c, String path) =>
Navigator.of(context).push(MaterialPageRoute<void>(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<SqliteViewerPage> createState() => _SqliteViewerPageState();
}
class _SqliteViewerPageState extends State<SqliteViewerPage> {
static const _pageRows = 200;
SqliteFile? _db;
List<SqliteTable> _tables = const [];
SqliteTable? _table;
List<SqliteRow> _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<void> _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<void> _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<void> _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<void>(
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: AppBar(
title: Text(vBasename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis),
),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Text(tr('읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (_tables.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: [
Expanded(
child: DropdownButton<SqliteTable>(
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: const TextStyle(color: JjColors.textDim)),
]),
),
if (_error != null)
Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const 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])),
),
),
]),
);
}
}