403 lines
16 KiB
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,
|
|
);
|
|
}
|
|
}
|