import 'package:flutter/material.dart'; import '../app/bookmarks_controller.dart'; import '../core/bookmarks.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'notice.dart'; // 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고). 관리자 화면은 bookmark_manager_page.dart (이 파일에서 나눔). // - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더) // - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자 // - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로 // 약속: // - 입력: [BuildContext] · 즐겨찾기 [BookmarksController] · 대상 [BookmarkNode] / 폴더 id · 열기 콜백. // - 출력: 바꾸기는 모두 [BookmarksController] 로 (삭제는 "실행 취소" 알림과 함께), 묻기 창은 고른 값 (취소하면 null). // - 오류: 던지지 않는다. 창이 닫힌 뒤 context 가 없어졌으면 아무것도 하지 않는다. /// 끌고 다니는 즐겨찾기 (id) class BookmarkDrag { final String id; const BookmarkDrag(this.id); } /// 폴더 고르기 목록 (깊이만큼 들여쓰기). [exclude] 와 그 안쪽 폴더는 뺀다 (폴더를 자기 안으로 옮길 수 없음) List> folderItems(BookmarksController bm, {String? exclude}) => [ for (final (f, depth) in bm.tree.folders()) if (exclude == null || (f.id != exclude && !bm.tree.isInside(f.id, exclude))) DropdownMenuItem( value: f.id, child: Padding( padding: EdgeInsets.only(left: depth * 14.0), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(depth == 0 ? Icons.folder_special_outlined : Icons.folder_outlined, size: 16, color: JjColors.amber), const SizedBox(width: 6), Text(f.title), ]), ), ), ]; /// 즐겨찾기 · 폴더 수정 창. [justAdded] 면 Chrome 처럼 "즐겨찾기 추가됨" 으로 보이고 [삭제] 가 "추가 취소". /// 이름 · 주소 · 저장할 폴더를 바꾸고, 그 자리에서 새 폴더를 만들어 넣을 수 있다. Future showBookmarkEditor(BuildContext context, BookmarksController bm, BookmarkNode n, {bool justAdded = false}) async { final r = await showDialog( context: context, builder: (_) => _BookmarkEditor(bm: bm, n: n, justAdded: justAdded), ); if (r == 'del' && context.mounted) { if (justAdded) { bm.remove(n.id); } else { removeBookmarkWithUndo(context, bm, n); } } } class _BookmarkEditor extends StatefulWidget { final BookmarksController bm; final BookmarkNode n; final bool justAdded; const _BookmarkEditor({required this.bm, required this.n, required this.justAdded}); @override State<_BookmarkEditor> createState() => _BookmarkEditorState(); } class _BookmarkEditorState extends State<_BookmarkEditor> { late final _title = TextEditingController(text: widget.n.title); late final _url = TextEditingController(text: widget.n.url ?? ''); late String _folder = widget.bm.tree.parentOf(widget.n.id)?.id ?? BookmarkTree.barId; @override void dispose() { _title.dispose(); _url.dispose(); super.dispose(); } Future _newFolder() async { final f = await showNewFolderDialog(context, widget.bm, _folder); if (f != null) setState(() => _folder = f.id); } void _save() { final bm = widget.bm; final n = widget.n; bm.update(n.id, title: _title.text, url: n.isFolder ? null : _url.text); if (bm.tree.parentOf(n.id)?.id != _folder) bm.moveInto(n.id, _folder); Navigator.pop(context, 'save'); } @override Widget build(BuildContext context) { final n = widget.n; return AlertDialog( title: Text(widget.justAdded ? tr('즐겨찾기 추가됨') : (n.isFolder ? tr('폴더 수정') : tr('즐겨찾기 수정'))), content: SizedBox( width: 440, child: Column(mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _title, autofocus: true, decoration: InputDecoration(labelText: tr('이름'), border: OutlineInputBorder()), onSubmitted: (_) => _save(), ), if (!n.isFolder) ...[ const SizedBox(height: 12), TextField( controller: _url, decoration: InputDecoration(labelText: tr('주소'), border: OutlineInputBorder())), ], const SizedBox(height: 12), Row(children: [ Expanded( child: DropdownButtonFormField( // 새 폴더를 만들면 그 폴더가 골라진 상태로 다시 그린다 key: ValueKey(_folder), initialValue: _folder, isExpanded: true, decoration: InputDecoration(labelText: tr('폴더'), border: OutlineInputBorder()), items: folderItems(widget.bm, exclude: n.isFolder ? n.id : null), onChanged: (v) => setState(() => _folder = v!), ), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: _newFolder, icon: const Icon(Icons.create_new_folder_outlined, size: 18), label: Text(tr('새 폴더')), ), ]), ]), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, 'del'), child: Text(widget.justAdded ? tr('추가 취소') : tr('삭제'), style: TextStyle(color: JjColors.danger)), ), if (!widget.justAdded) TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton(onPressed: _save, child: Text(widget.justAdded ? tr('완료') : tr('저장'))), ], ); } } /// 새 폴더 (폴더 [parentId] 안의 맨 뒤). 만든 폴더, 취소하면 null. Future showNewFolderDialog(BuildContext context, BookmarksController bm, String parentId) async { final name = await askText(context, tr('새 폴더'), tr('폴더 이름'), tr('새 폴더'), help: trf('"{0}" 안에 만듭니다', [bm.tree.find(parentId)?.title ?? ''])); if (name == null) return null; return bm.addFolder(name, parentId: parentId); } /// 새 즐겨찾기 (이름 · 주소 직접 입력) - 폴더 [parentId] 안에 Future showNewBookmarkDialog(BuildContext context, BookmarksController bm, String parentId, {String title = '', String url = ''}) async { final t = TextEditingController(text: title); final u = TextEditingController(text: url); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('즐겨찾기 추가')), content: SizedBox( width: 440, child: Column(mainAxisSize: MainAxisSize.min, children: [ TextField( controller: t, autofocus: true, decoration: InputDecoration(labelText: tr('이름'), border: OutlineInputBorder())), const SizedBox(height: 12), TextField( controller: u, decoration: InputDecoration(labelText: tr('주소'), hintText: 'https://…', border: OutlineInputBorder()), onSubmitted: (_) => Navigator.pop(ctx, true), ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: Text(trf('폴더: {0}', [bm.tree.find(parentId)?.title ?? '']), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('추가'))), ], ), ); if (ok == true) { var url = u.text.trim(); if (url.isNotEmpty) { if (!url.contains('://')) url = 'https://$url'; bm.addLink(t.text, url, parentId: parentId); } } await Future.delayed(const Duration(milliseconds: 300)); t.dispose(); u.dispose(); } /// 글 하나 묻기 (취소하면 null) Future askText(BuildContext context, String title, String label, String initial, {String? help}) async { final ctrl = TextEditingController(text: initial)..selection = TextSelection(baseOffset: 0, extentOffset: initial.length); final r = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(title), content: SizedBox( width: 380, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: ctrl, autofocus: true, decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), onSubmitted: (t) => Navigator.pop(ctx, t), ), if (help != null) ...[ const SizedBox(height: 6), Text(help, style: TextStyle(fontSize: 12, color: JjColors.textDim)), ], ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: Text(tr('확인'))), ], ), ); await Future.delayed(const Duration(milliseconds: 300)); // 닫히는 애니메이션 뒤 정리 ctrl.dispose(); return r; } /// 삭제 + "실행 취소" void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, BookmarkNode n) { if (!bm.remove(n.id)) return; // 되돌리기는 바로 보여야 하므로 앞서 떠 있던 알림은 치운다 showNotice(ScaffoldMessenger.of(context), clearOthers: true, text: n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title]), action: (tr('실행 취소'), bm.undoRemove)); } /// 즐겨찾기 오른쪽 클릭 메뉴 (Chrome 과 같은 항목). [n] 이 null 이면 빈 곳 (폴더 [folderId] 에 추가만). Future showBookmarkMenu( BuildContext context, { required BookmarksController bm, required Offset globalPosition, BookmarkNode? n, required String folderId, required void Function(String url) onOpen, void Function(String url)? onOpenExternal, String currentUrl = '', String currentTitle = '', VoidCallback? onOpenManager, }) async { // 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; final at = overlay.globalToLocal(globalPosition); // 폴더 위에서 연 메뉴: "추가" 는 그 폴더 안에 final addTo = n != null && n.isFolder ? n.id : folderId; PopupMenuItem item(String v, IconData icon, String label, {bool enabled = true}) => PopupMenuItem( value: v, enabled: enabled, height: 36, child: Row(children: [ Icon(icon, size: 18, color: enabled ? null : JjColors.textDim), const SizedBox(width: 10), Text(label, style: const TextStyle(fontSize: 13)), ]), ); final r = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy), items: [ if (n != null && !n.isFolder) ...[ item('open', Icons.open_in_browser, tr('열기')), if (onOpenExternal != null) item('ext', Icons.open_in_new, tr('외부 브라우저로 열기')), const PopupMenuDivider(), ], if (n != null) ...[ item('edit', Icons.edit_outlined, n.isFolder ? tr('이름 바꾸기 · 이동') : tr('수정 · 이동')), item('del', Icons.delete_outline, tr('삭제'), enabled: n.id != BookmarkTree.barId && n.id != BookmarkTree.otherId), const PopupMenuDivider(), ], item('addPage', Icons.bookmark_add_outlined, n != null && n.isFolder ? tr('이 폴더에 현재 페이지 추가') : tr('현재 페이지 추가'), enabled: currentUrl.startsWith('http')), item('addLink', Icons.add_link, tr('즐겨찾기 추가…')), item('addFolder', Icons.create_new_folder_outlined, n != null && n.isFolder ? tr('이 폴더에 새 폴더') : tr('새 폴더…')), if (onOpenManager != null) ...[ const PopupMenuDivider(), item('manager', Icons.bookmarks_outlined, tr('즐겨찾기 관리자')), ], ], ); if (r == null || !context.mounted) return; switch (r) { case 'open': onOpen(n!.url!); case 'ext': onOpenExternal?.call(n!.url!); case 'edit': await showBookmarkEditor(context, bm, n!); case 'del': removeBookmarkWithUndo(context, bm, n!); case 'addPage': bm.addLink(currentTitle.isEmpty ? currentUrl : currentTitle, currentUrl, parentId: addTo); case 'addLink': await showNewBookmarkDialog(context, bm, addTo); case 'addFolder': await showNewFolderDialog(context, bm, addTo); case 'manager': onOpenManager?.call(); } } /// 폴더 위에 즐겨찾기를 끌어다 놓을 수 있게 감싼다 (놓을 수 있으면 테두리 강조) class BookmarkFolderDrop extends StatelessWidget { final BookmarksController bm; final String folderId; final Widget child; const BookmarkFolderDrop({super.key, required this.bm, required this.folderId, required this.child}); @override Widget build(BuildContext context) => DragTarget( onWillAcceptWithDetails: (d) => bm.canMoveInto(d.data.id, folderId), onAcceptWithDetails: (d) => bm.moveInto(d.data.id, folderId), builder: (context, candidates, _) => DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all(color: candidates.isNotEmpty ? JjColors.accent : Colors.transparent, width: 2), color: candidates.isNotEmpty ? JjColors.accent.withValues(alpha: 0.12) : null, ), child: child, ), ); } /// 끌 수 있는 즐겨찾기 (끄는 동안 이름표가 따라온다). /// 터치 화면 (Android): 잠깐 누른 채 끌면 옮기기, 누른 채 움직이지 않고 떼면 [onMenu] (길게 누르기 메뉴) - 둘 다 되게 class BookmarkDraggable extends StatefulWidget { final BookmarkNode n; final Widget child; final void Function(Offset at)? onMenu; const BookmarkDraggable({super.key, required this.n, required this.child, this.onMenu}); @override State createState() => _BookmarkDraggableState(); } class _BookmarkDraggableState extends State { /// 눌렀던 곳 (끌는 동안 목록이 다시 그려져도 남도록 상태에) Offset? _down; BookmarkNode get n => widget.n; Widget get child => widget.child; Widget _feedback() => Material( color: JjColors.panelHigh, elevation: 4, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(n.isFolder ? Icons.folder : Icons.public, size: 16, color: n.isFolder ? JjColors.amber : null), const SizedBox(width: 6), Text(n.title, style: TextStyle(fontSize: 12, color: JjColors.text)), ]), ), ); @override Widget build(BuildContext context) { if (Theme.of(context).platform == TargetPlatform.android) { return Listener( onPointerDown: (e) => _down = e.position, child: LongPressDraggable( data: BookmarkDrag(n.id), delay: const Duration(milliseconds: 300), dragAnchorStrategy: pointerDragAnchorStrategy, feedback: _feedback(), childWhenDragging: Opacity(opacity: 0.4, child: child), // 움직이지 않고 뗐으면 끌기가 아니라 메뉴 onDragEnd: (d) { final at = _down; if (!d.wasAccepted && at != null && (d.offset - at).distance < 16) widget.onMenu?.call(at); }, child: child, ), ); } return Draggable( data: BookmarkDrag(n.id), dragAnchorStrategy: pointerDragAnchorStrategy, feedback: _feedback(), childWhenDragging: Opacity(opacity: 0.4, child: child), child: child, ); } }