나누기: bookmark_ui (783) 에서 즐겨찾기 관리자 화면을 bookmark_manager_page 모듈로 (→ 399 · 395)
- bookmark_ui.dart: 수정 · 새 폴더 · 새 즐겨찾기 창 · askText · 실행 취소 삭제 · 오른쪽 클릭 메뉴 · 끌기 조각 - bookmark_manager_page.dart (새): BookmarkManagerPage (위 조각을 부르기만) - 부르는 쪽 import 만 고침, 약속 시험 1개 (실제 BookmarksController · 시험 폴더) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_ui
parent
afba0dfede
commit
cac9a1b67a
|
|
@ -0,0 +1,395 @@
|
|||
// 즐겨찾기 관리자 화면 (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!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
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 'app_actions.dart';
|
||||
import 'theme.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'notice.dart';
|
||||
|
||||
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고):
|
||||
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고). 관리자 화면은 bookmark_manager_page.dart (이 파일에서 나눔).
|
||||
// - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더)
|
||||
// - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자
|
||||
// - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로
|
||||
// - 즐겨찾기 관리자 (전체 화면): 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 · HTML 내보내기 / 가져오기
|
||||
// 약속:
|
||||
// - 입력: [BuildContext] · 즐겨찾기 [BookmarksController] · 대상 [BookmarkNode] / 폴더 id · 열기 콜백.
|
||||
// - 출력: 바꾸기는 모두 [BookmarksController] 로 (삭제는 "실행 취소" 알림과 함께), 묻기 창은 고른 값 (취소하면 null).
|
||||
// - 오류: 던지지 않는다. 창이 닫힌 뒤 context 가 없어졌으면 아무것도 하지 않는다.
|
||||
|
||||
/// 끌고 다니는 즐겨찾기 (id)
|
||||
class BookmarkDrag {
|
||||
|
|
@ -402,382 +400,3 @@ class _BookmarkDraggableState extends State<BookmarkDraggable> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── 즐겨찾기 관리자 (Chrome 의 chrome://bookmarks 참고) ─────────
|
||||
|
||||
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!),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import '../platform/windows/com_guard.dart';
|
|||
import '../platform/windows/elevation.dart';
|
||||
import '../services/downloader.dart' show DownloadState;
|
||||
import 'app_actions.dart';
|
||||
import 'bookmark_manager_page.dart';
|
||||
import 'bookmark_ui.dart';
|
||||
import 'cef_setup.dart' show confirmCefWhenElevated;
|
||||
import 'confirm.dart';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/bookmarks.dart';
|
||||
import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/bookmark_ui.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
// bookmark_manager_page.dart 약속 시험: 실제 BookmarksController (시험 폴더의 파일) 로 입력 · 출력 · 없어진 폴더를 본다.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/bookmarks.dart';
|
||||
import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dirs.dart';
|
||||
|
||||
void main() {
|
||||
deleteTempDirsAtEnd();
|
||||
testWidgets('처음 폴더 · 주소 누르면 onOpen · 고른 폴더가 없어지면 즐겨찾기 표시줄로', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final bm = BookmarksController(p.join(tempDirUntilEnd('jj_bmm_').path, 'bookmarks.json'));
|
||||
final box = bm.addFolder('보관함', parentId: BookmarkTree.otherId);
|
||||
bm.addLink('예제', 'https://ex.example', parentId: box.id);
|
||||
final opened = <String>[];
|
||||
await tester.pumpWidget(MaterialApp(home: BookmarkManagerPage(bm: bm, onOpen: opened.add, initialFolderId: box.id)));
|
||||
expect(find.widgetWithText(ListTile, '예제'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(ListTile, '예제'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(opened, ['https://ex.example']);
|
||||
|
||||
bm.remove(box.id);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(ListTile, '예제'), findsNothing);
|
||||
expect(find.widgetWithText(ListTile, 'YouTube'), findsOneWidget); // 즐겨찾기 표시줄의 기본 내용
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue