JJ_MKVMaker/lib/ui/bookmark_ui.dart

403 lines
16 KiB
Dart

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<DropdownMenuItem<String>> 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<void> showBookmarkEditor(BuildContext context, BookmarksController bm, BookmarkNode n,
{bool justAdded = false}) async {
final r = await showDialog<String>(
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<void> _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<String>(
// 새 폴더를 만들면 그 폴더가 골라진 상태로 다시 그린다
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<BookmarkNode?> 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<void> 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<bool>(
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<void>.delayed(const Duration(milliseconds: 300));
t.dispose();
u.dispose();
}
/// 글 하나 묻기 (취소하면 null)
Future<String?> 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<String>(
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<void>.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<void> 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<String> 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<String>(
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<BookmarkDrag>(
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<BookmarkDraggable> createState() => _BookmarkDraggableState();
}
class _BookmarkDraggableState extends State<BookmarkDraggable> {
/// 눌렀던 곳 (끌는 동안 목록이 다시 그려져도 남도록 상태에)
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<BookmarkDrag>(
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<BookmarkDrag>(
data: BookmarkDrag(n.id),
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: _feedback(),
childWhenDragging: Opacity(opacity: 0.4, child: child),
child: child,
);
}
}