// 즐겨찾기 관리자 화면 (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!), ), ), ]); } }