From cac9a1b67abdfee55f51ad3f2d72418bdb53857a Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:36:22 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20bookmark=5Fui=20?= =?UTF-8?q?(783)=20=EC=97=90=EC=84=9C=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20bookmark=5Fmanager=5Fpage=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EB=A1=9C=20(=E2=86=92=20399=20=C2=B7=20395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bookmark_ui.dart: 수정 · 새 폴더 · 새 즐겨찾기 창 · askText · 실행 취소 삭제 · 오른쪽 클릭 메뉴 · 끌기 조각 - bookmark_manager_page.dart (새): BookmarkManagerPage (위 조각을 부르기만) - 부르는 쪽 import 만 고침, 약속 시험 1개 (실제 BookmarksController · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/ui/bookmark_manager_page.dart | 395 ++++++++++++++++++ lib/ui/bookmark_ui.dart | 391 +---------------- lib/ui/browser_page.dart | 1 + test/bookmark_folder_test.dart | 1 + test/bookmark_manager_page_contract_test.dart | 33 ++ 5 files changed, 435 insertions(+), 386 deletions(-) create mode 100644 lib/ui/bookmark_manager_page.dart create mode 100644 test/bookmark_manager_page_contract_test.dart diff --git a/lib/ui/bookmark_manager_page.dart b/lib/ui/bookmark_manager_page.dart new file mode 100644 index 0000000..0627bc7 --- /dev/null +++ b/lib/ui/bookmark_manager_page.dart @@ -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 createState() => _BookmarkManagerPageState(); +} + +class _BookmarkManagerPageState extends State { + late String _folder = widget.initialFolderId ?? BookmarkTree.barId; + final _search = TextEditingController(); + final Set _collapsed = {}; + + BookmarksController get bm => widget.bm; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + void _open(String url) { + widget.onOpen(url); + Navigator.maybePop(context); + } + + Future _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 _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 _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( + 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 = []; + 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!), + ), + ), + ]); + } +} diff --git a/lib/ui/bookmark_ui.dart b/lib/ui/bookmark_ui.dart index 41cdb6e..8dde1f5 100644 --- a/lib/ui/bookmark_ui.dart +++ b/lib/ui/bookmark_ui.dart @@ -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 { ); } } - -// ───────── 즐겨찾기 관리자 (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 createState() => _BookmarkManagerPageState(); -} - -class _BookmarkManagerPageState extends State { - late String _folder = widget.initialFolderId ?? BookmarkTree.barId; - final _search = TextEditingController(); - final Set _collapsed = {}; - - BookmarksController get bm => widget.bm; - - @override - void dispose() { - _search.dispose(); - super.dispose(); - } - - void _open(String url) { - widget.onOpen(url); - Navigator.maybePop(context); - } - - Future _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 _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 _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( - 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 = []; - 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!), - ), - ), - ]); - } -} - diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index b69a559..a121ca9 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -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'; diff --git a/test/bookmark_folder_test.dart b/test/bookmark_folder_test.dart index de08f3a..e39e3eb 100644 --- a/test/bookmark_folder_test.dart +++ b/test/bookmark_folder_test.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; diff --git a/test/bookmark_manager_page_contract_test.dart b/test/bookmark_manager_page_contract_test.dart new file mode 100644 index 0000000..409694a --- /dev/null +++ b/test/bookmark_manager_page_contract_test.dart @@ -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 = []; + 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); // 즐겨찾기 표시줄의 기본 내용 + }); +}