220 lines
7.6 KiB
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: 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: 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])),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|