JJ_MKVMaker/lib/ui/bookmark_manager_page.dart

396 lines
16 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 즐겨찾기 관리자 화면 (Chrome 의 chrome://bookmarks 참고) - bookmark_ui.dart 에서 나눔.
// 약속:
// - 입력: 즐겨찾기 [BookmarksController] · 열기 콜백 ([onOpen] · [onOpenExternal]) · 지금 페이지 주소 · 제목 · 처음 열 폴더.
// - 출력: 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 결과. 바꾸기 (추가 · 수정 · 삭제 · 옮기기 · 순서) 는 모두 [BookmarksController] 로,
// HTML 내보내기 · 가져오기 · 다른 브라우저에서 가져오기 결과는 알림 한 줄.
// - 오류: 가져올 것이 없거나 파일을 찾지 못하면 알림 글로만 (던지지 않음). 고른 폴더가 없어지면 즐겨찾기 줄로 돌아간다.
// 대화 상자 · 메뉴 · 끌기 조각은 bookmark_ui.dart 의 것을 부른다.
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../app/bookmarks_controller.dart';
import '../core/bookmarks.dart';
import '../l10n/tr.dart';
import 'app_actions.dart';
import 'bookmark_ui.dart';
import 'theme.dart';
class BookmarkManagerPage extends StatefulWidget {
final BookmarksController bm;
final void Function(String url) onOpen;
final void Function(String url)? onOpenExternal;
final String currentUrl;
final String currentTitle;
final String? initialFolderId;
const BookmarkManagerPage({
super.key,
required this.bm,
required this.onOpen,
this.onOpenExternal,
this.currentUrl = '',
this.currentTitle = '',
this.initialFolderId,
});
@override
State<BookmarkManagerPage> createState() => _BookmarkManagerPageState();
}
class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
late String _folder = widget.initialFolderId ?? BookmarkTree.barId;
final _search = TextEditingController();
final Set<String> _collapsed = {};
BookmarksController get bm => widget.bm;
@override
void dispose() {
_search.dispose();
super.dispose();
}
void _open(String url) {
widget.onOpen(url);
Navigator.maybePop(context);
}
Future<void> _export() async {
final uri = await FilePicker.saveFile(
dialogTitle: tr('즐겨찾기 내보내기 (HTML)'),
fileName: tr('JJ_MKVMaker_즐겨찾기.html'),
bytes: Uint8List.fromList(utf8.encode(bm.tree.toNetscapeHtml())),
type: FileType.custom,
allowedExtensions: const ['html'],
);
if (uri == null) return;
final file = uri.scheme == 'file' ? uri.toFilePath() : uri.toString();
await bm.exportHtml(file);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('내보냈습니다: {0}', [file]))));
}
}
Future<void> _importHtml() async {
final r = await FilePicker.pickFiles(
dialogTitle: tr('즐겨찾기 HTML 가져오기'), type: FileType.custom, allowedExtensions: const ['html', 'htm']);
final path = r.isEmpty ? null : r.single.path;
if (path == null) return;
final n = await bm.importHtml(path);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(n > 0 ? trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]) : tr('가져올 즐겨찾기가 없습니다.'))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
Future<void> _importBrowser(String b) async {
final n = await bm.importFrom(b);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n < 0 ? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.') : trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]))));
if (n > 0) setState(() => _folder = BookmarkTree.otherId);
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: bm,
builder: (context, _) {
if (bm.tree.find(_folder) == null) _folder = BookmarkTree.barId;
final q = _search.text.trim();
return Scaffold(
body: Column(children: [
_topBar(),
const Divider(height: 1),
Expanded(
child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SizedBox(width: 280, child: _tree()),
const VerticalDivider(width: 1),
Expanded(child: q.isEmpty ? _contents() : _results(q)),
]),
),
]),
);
},
);
Widget _topBar() => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(children: [
Icon(Icons.bookmarks_outlined, color: JjColors.accent),
const SizedBox(width: 8),
if (!isCompact(context)) ...[
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 24),
],
Expanded(
child: TextField(
controller: _search,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: tr('즐겨찾기 검색 (이름 · 주소)'),
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _search.text.isEmpty
? null
: IconButton(
tooltip: tr('검색 지우기'),
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(_search.clear)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
tooltip: tr('관리 메뉴'),
icon: const Icon(Icons.more_vert),
itemBuilder: (_) => [
PopupMenuItem(value: 'link', child: Text(tr('새 즐겨찾기 추가'))),
PopupMenuItem(value: 'folder', child: Text(tr('새 폴더 추가'))),
PopupMenuDivider(),
PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))),
PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))),
PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))),
PopupMenuItem(value: 'html', child: Text(tr('HTML 파일에서 가져오기'))),
PopupMenuDivider(),
PopupMenuItem(value: 'export', child: Text(tr('HTML 파일로 내보내기'))),
],
onSelected: (v) async {
switch (v) {
case 'link':
await showNewBookmarkDialog(context, bm, _folder, title: widget.currentTitle, url: widget.currentUrl);
case 'folder':
await showNewFolderDialog(context, bm, _folder);
case 'chrome' || 'edge' || 'whale':
await _importBrowser(v);
case 'html':
await _importHtml();
case 'export':
await _export();
}
},
),
]),
);
// 왼쪽: 폴더 나무 (누르면 오른쪽에 내용, 즐겨찾기를 끌어다 놓으면 그 폴더로)
Widget _tree() {
final rows = <Widget>[];
void walk(BookmarkNode f, int depth) {
final subs = f.children!.where((c) => c.isFolder).toList();
final open = !_collapsed.contains(f.id);
final selected = f.id == _folder;
void folderMenu(Offset at) => showBookmarkMenu(context,
bm: bm,
globalPosition: at,
n: f,
folderId: f.id,
onOpen: _open,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
final row = BookmarkFolderDrop(
bm: bm,
folderId: f.id,
// 터치 화면: 길게 누르면 오른쪽 클릭과 같은 메뉴
child: GestureDetector(
onLongPressStart: depth == 0 ? (d) => folderMenu(d.globalPosition) : null,
child: InkWell(
onTap: () => setState(() {
_folder = f.id;
_search.clear();
}),
onSecondaryTapDown: (d) => folderMenu(d.globalPosition),
child: Container(
color: selected ? JjColors.accent.withValues(alpha: 0.16) : null,
padding: EdgeInsets.only(left: 6 + depth * 16.0, right: 8, top: 6, bottom: 6),
child: Row(children: [
SizedBox(
width: 22,
child: subs.isEmpty
? null
: InkWell(
onTap: () => setState(() => open ? _collapsed.add(f.id) : _collapsed.remove(f.id)),
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18),
),
),
Icon(depth == 0 ? Icons.folder_special : (selected ? Icons.folder_open : Icons.folder),
size: 18, color: JjColors.amber),
const SizedBox(width: 8),
Expanded(child: Text(f.title, maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
),
),
),
);
rows.add(depth == 0 ? row : BookmarkDraggable(n: f, onMenu: folderMenu, child: row));
if (open) {
for (final s in subs) {
walk(s, depth + 1);
}
}
}
for (final r in bm.tree.roots) {
walk(r, 0);
}
return Material(color: JjColors.panel, child: ListView(children: rows));
}
// 오른쪽: 고른 폴더의 내용 (끌어서 순서 바꾸기, 폴더 위로 끌면 그 안으로, 오른쪽 클릭 메뉴)
Widget _contents() {
final folder = bm.tree.find(_folder)!;
final items = folder.children!;
final path = bm.tree.pathTo(folder.id);
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 6),
child: Row(children: [
Expanded(
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, f) in path.indexed) ...[
if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim),
BookmarkFolderDrop(
bm: bm,
folderId: f.id,
child: InkWell(
onTap: () => setState(() => _folder = f.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text(f.title,
style: TextStyle(
fontSize: 14,
fontWeight: f.id == folder.id ? FontWeight.w600 : FontWeight.normal,
color: f.id == folder.id ? JjColors.text : JjColors.accent)),
),
),
),
],
]),
),
OutlinedButton.icon(
onPressed: () => showNewBookmarkDialog(context, bm, folder.id,
title: widget.currentTitle, url: widget.currentUrl),
icon: const Icon(Icons.add_link, size: 18),
label: Text(tr('즐겨찾기 추가')),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showNewFolderDialog(context, bm, folder.id),
icon: const Icon(Icons.create_new_folder_outlined, size: 18),
label: Text(tr('새 폴더')),
),
]),
),
const Divider(height: 1),
Expanded(
child: items.isEmpty
? Center(
child: Text(tr('비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.'),
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)))
: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: items.length,
onReorderItem: (o, n) => bm.reorder(folder.id, o, n),
itemBuilder: (_, i) => _row(items[i], i, folder),
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(tr('왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴'),
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
]);
}
Widget _row(BookmarkNode n, int i, BookmarkNode folder) {
final tile = ListTile(
dense: true,
leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener(
index: i,
child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim),
),
const SizedBox(width: 6),
// 아이콘을 끌면 다른 폴더로 옮기기
BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, folder, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
]),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(n.isFolder ? trf('{0}개 · 주소 {1}개', [n.children!.length, bm.tree.countLinks(n)]) : n.url!,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder ? setState(() => _folder = n.id) : _open(n.url!),
trailing: IconButton(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, size: 18),
onPressed: () {
final box = context.findRenderObject() as RenderBox?;
final pos = box == null ? Offset.zero : box.localToGlobal(box.size.topRight(Offset.zero));
_menu(n, folder, pos);
},
),
);
final row = GestureDetector(
onSecondaryTapDown: (d) => _menu(n, folder, d.globalPosition),
child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile,
);
return KeyedSubtree(key: ValueKey(n.id), child: row);
}
void _menu(BookmarkNode n, BookmarkNode folder, Offset pos) => showBookmarkMenu(context,
bm: bm,
globalPosition: pos,
n: n,
folderId: folder.id,
onOpen: _open,
onOpenExternal: widget.onOpenExternal,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
// 검색 결과 (위치 = 들어 있는 폴더)
Widget _results(String q) {
final found = bm.tree.search(q);
if (found.isEmpty) return Center(child: Text(tr('찾는 즐겨찾기가 없습니다.'), style: TextStyle(color: JjColors.textDim)));
return ListView(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Text(trf('"{0}" 검색 결과 {1}개', [q, found.length]), style: const TextStyle(fontWeight: FontWeight.w600)),
),
for (final n in found)
GestureDetector(
onSecondaryTapDown: (d) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, d.globalPosition),
child: ListTile(
dense: true,
leading: BookmarkDraggable(
n: n,
onMenu: (at) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, at),
child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${bm.tree.pathTo(n.id).reversed.skip(1).toList().reversed.map((f) => f.title).join(' › ')}'
'${n.isFolder ? '' : ' · ${n.url}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder
? setState(() {
_folder = n.id;
_search.clear();
})
: _open(n.url!),
),
),
]);
}
}