From 2a59a2aa013c057f8400a9a72eb1bfd879ff33cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=B0=94=E4=BF=8A?= Date: Wed, 28 Jan 2026 09:28:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=B7=A6=E6=BB=91=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/Notesmaster/.project | 28 ++ src/Notesmaster/app/.classpath | 6 + ...stView迁移至RecyclerView实施方案.md | 57 --- .../java/net/micode/notes/MainActivity.java | 16 + .../micode/notes/data/NotesRepository.java | 186 ++++++- .../notes/ui/FolderOperationDialogs.java | 164 ++++++ .../net/micode/notes/ui/NoteInfoAdapter.java | 255 ++++++++-- .../micode/notes/ui/NotesListActivity.java | 183 ++++++- .../net/micode/notes/ui/SidebarFragment.java | 84 +++- .../net/micode/notes/ui/SwipeDetector.java | 155 ++++++ .../net/micode/notes/ui/SwipeMenuLayout.java | 472 ++++++++++++++++++ .../notes/viewmodel/NotesListViewModel.java | 115 +++++ .../src/main/res/anim/swipe_menu_close.xml | 10 + .../app/src/main/res/anim/swipe_menu_open.xml | 10 + .../main/res/layout/dialog_folder_delete.xml | 16 + .../main/res/layout/dialog_folder_name.xml | 17 + .../src/main/res/layout/note_item_swipe.xml | 133 +++++ .../src/main/res/layout/swipe_menu_note.xml | 52 ++ .../src/main/res/layout/swipe_menu_trash.xml | 30 ++ .../src/main/res/menu/folder_context_menu.xml | 14 + .../src/main/res/values-zh-rCN/strings.xml | 29 ++ .../src/main/res/values-zh-rTW/strings.xml | 41 +- .../app/src/main/res/values/colors_swipe.xml | 10 + .../app/src/main/res/values/strings.xml | 66 +-- 25 files changed, 2005 insertions(+), 145 deletions(-) create mode 100644 src/Notesmaster/.project create mode 100644 src/Notesmaster/app/.classpath delete mode 100644 src/Notesmaster/app/.trae/documents/ListView迁移至RecyclerView实施方案.md create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/FolderOperationDialogs.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeDetector.java create mode 100644 src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeMenuLayout.java create mode 100644 src/Notesmaster/app/src/main/res/anim/swipe_menu_close.xml create mode 100644 src/Notesmaster/app/src/main/res/anim/swipe_menu_open.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/dialog_folder_delete.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/dialog_folder_name.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/note_item_swipe.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/swipe_menu_note.xml create mode 100644 src/Notesmaster/app/src/main/res/layout/swipe_menu_trash.xml create mode 100644 src/Notesmaster/app/src/main/res/menu/folder_context_menu.xml create mode 100644 src/Notesmaster/app/src/main/res/values/colors_swipe.xml diff --git a/.gitignore b/.gitignore index 75b1fd5..b90a8f7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ AGENTS.md .opencode/ opencode.json .opencode.backup +log diff --git a/src/Notesmaster/.project b/src/Notesmaster/.project new file mode 100644 index 0000000..ca0f2b5 --- /dev/null +++ b/src/Notesmaster/.project @@ -0,0 +1,28 @@ + + + Notes-master-Notesmaster + Project Notesmaster created by Buildship. + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + + + + 1769218588724 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/src/Notesmaster/app/.classpath b/src/Notesmaster/app/.classpath new file mode 100644 index 0000000..0a3280e --- /dev/null +++ b/src/Notesmaster/app/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Notesmaster/app/.trae/documents/ListView迁移至RecyclerView实施方案.md b/src/Notesmaster/app/.trae/documents/ListView迁移至RecyclerView实施方案.md deleted file mode 100644 index d76ef2b..0000000 --- a/src/Notesmaster/app/.trae/documents/ListView迁移至RecyclerView实施方案.md +++ /dev/null @@ -1,57 +0,0 @@ -# ListView迁移至RecyclerView实施方案 - -## 实施概述 - -将小米便签的ListView组件迁移至RecyclerView,实现性能提升、功能增强和代码可维护性改进。 - -## 主要工作内容 - -### 1. 依赖配置 - -* 修改`build.gradle.kts`,添加RecyclerView和CursorAdapter依赖 - -### 2. 核心类创建 - -* 创建`NoteViewHolder.java`:实现ViewHolder模式 - -* 创建`NoteItemDecoration.java`:实现分隔线 - -* 创建`NoteItemTouchCallback.java`:实现手势操作 - -* 创建`list_divider.xml`:分隔线资源 - -### 3. 适配器重构 - -* 重写`NotesListAdapter.java`为`NotesRecyclerViewAdapter` - -* 继承`CursorRecyclerViewAdapter`保持Cursor数据源 - -* 实现ViewHolder模式,使用SparseArray优化选中状态 - -### 4. 布局修改 - -* 修改`note_list.xml`:将ListView替换为RecyclerView - -* 修改`NotesListActivity.java`:更新初始化和事件处理逻辑 - -### 5. 功能增强 - -* 添加DefaultItemAnimator实现增删改动画 - -* 实现ItemTouchHelper支持滑动删除和拖拽 - -* 实现局部刷新机制 - -## 预期效果 - -* 内存占用降低30-40% - -* 滚动帧率提升至55-60 FPS - -* 支持局部刷新和内置动画 - -* 代码可维护性显著提升 - -## 实施周期 - -预计10-15个工作日完成全部迁移和测试。 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java index ea3a47e..69ef02f 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java @@ -147,6 +147,22 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O closeSidebar(); } + @Override + public void onRenameFolder(long folderId) { + Log.d(TAG, "Rename folder: " + folderId); + // TODO: 文件夹操作主要由NotesListActivity处理 + // 这里可以添加跳转逻辑或通知NotesListActivity + closeSidebar(); + } + + @Override + public void onDeleteFolder(long folderId) { + Log.d(TAG, "Delete folder: " + folderId); + // TODO: 文件夹操作主要由NotesListActivity处理 + // 这里可以添加跳转逻辑或通知NotesListActivity + closeSidebar(); + } + // ==================== 私有方法 ==================== /** diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java index 4a9f86d..783a915 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -70,6 +70,7 @@ public class NotesRepository { public int bgColorId; public boolean isPinned; // 新增置顶字段 public boolean isLocked; // 新增锁定字段 + public int noteCount; // 新增笔记数量字段 public NoteInfo() {} @@ -131,16 +132,16 @@ public class NotesRepository { private NoteInfo noteFromCursor(Cursor cursor) { NoteInfo noteInfo = new NoteInfo(); noteInfo.id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); - + // Read TITLE and SNIPPET String dbTitle = ""; int titleIndex = cursor.getColumnIndex(NoteColumns.TITLE); if (titleIndex != -1) { dbTitle = cursor.getString(titleIndex); } - + noteInfo.snippet = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); - + // Prioritize TITLE, fallback to SNIPPET if (dbTitle != null && !dbTitle.trim().isEmpty()) { noteInfo.title = dbTitle; @@ -153,7 +154,7 @@ public class NotesRepository { noteInfo.modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE)); noteInfo.type = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.TYPE)); noteInfo.localModified = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.LOCAL_MODIFIED)); - + int bgColorIdIndex = cursor.getColumnIndex(NoteColumns.BG_COLOR_ID); if (bgColorIdIndex != -1 && !cursor.isNull(bgColorIdIndex)) { noteInfo.bgColorId = cursor.getInt(bgColorIdIndex); @@ -172,7 +173,15 @@ public class NotesRepository { } else { noteInfo.isLocked = false; } - + + // 读取笔记数量 + int notesCountIndex = cursor.getColumnIndex(NoteColumns.NOTES_COUNT); + if (notesCountIndex != -1) { + noteInfo.noteCount = cursor.getInt(notesCountIndex); + } else { + noteInfo.noteCount = 0; + } + return noteInfo; } @@ -1009,6 +1018,173 @@ public class NotesRepository { : content; } + /** + * 重命名文件夹 + *

+ * 修改文件夹名称 + *

+ * + * @param folderId 文件夹ID + * @param newName 新名称 + * @param callback 回调接口,返回影响的行数 + */ + public void renameFolder(long folderId, String newName, Callback callback) { + executor.execute(() -> { + try { + // 检查是否是系统文件夹(禁止重命名) + if (folderId <= 0) { + callback.onError(new IllegalArgumentException("System folder cannot be renamed")); + return; + } + + // 检查名称是否为空 + if (newName == null || newName.trim().isEmpty()) { + callback.onError(new IllegalArgumentException("Folder name cannot be empty")); + return; + } + + ContentValues values = new ContentValues(); + values.put(NoteColumns.SNIPPET, newName); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully renamed folder: " + folderId + " to: " + newName); + } else { + callback.onError(new RuntimeException("No folder found with ID: " + folderId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to rename folder: " + folderId, e); + callback.onError(e); + } + }); + } + + /** + * 重命名笔记 + *

+ * 修改笔记标题 + *

+ * + * @param noteId 笔记ID + * @param newName 新标题 + * @param callback 回调接口,返回影响的行数 + */ + public void renameNote(long noteId, String newName, Callback callback) { + executor.execute(() -> { + try { + // 检查名称是否为空 + if (newName == null || newName.trim().isEmpty()) { + callback.onError(new IllegalArgumentException("Note title cannot be empty")); + return; + } + + ContentValues values = new ContentValues(); + values.put(NoteColumns.SNIPPET, newName); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully renamed note: " + noteId + " to: " + newName); + } else { + callback.onError(new RuntimeException("No note found with ID: " + noteId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to rename note: " + noteId, e); + callback.onError(e); + } + }); + } + + /** + * 移动文件夹到目标文件夹 + *

+ * 将文件夹移动到新的父文件夹下 + *

+ * + * @param folderId 文件夹ID + * @param newParentId 目标父文件夹ID + * @param callback 回调接口,返回影响的行数 + */ + public void moveFolder(long folderId, long newParentId, Callback callback) { + executor.execute(() -> { + try { + // 检查是否是系统文件夹(禁止移动) + if (folderId <= 0) { + callback.onError(new IllegalArgumentException("System folder cannot be moved")); + return; + } + + // 检查目标文件夹是否有效(不能是系统文件夹) + if (newParentId < 0 && newParentId != Notes.ID_TRASH_FOLER) { + callback.onError(new IllegalArgumentException("Invalid target folder")); + return; + } + + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, newParentId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully moved folder: " + folderId + " to parent: " + newParentId); + } else { + callback.onError(new RuntimeException("No folder found with ID: " + folderId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to move folder: " + folderId, e); + callback.onError(e); + } + }); + } + + /** + * 删除文件夹(移到回收站) + *

+ * 将文件夹移动到回收站文件夹 + *

+ * + * @param folderId 文件夹ID + * @param callback 回调接口,返回影响的行数 + */ + public void deleteFolder(long folderId, Callback callback) { + executor.execute(() -> { + try { + // 检查是否是系统文件夹(禁止删除) + if (folderId <= 0) { + callback.onError(new IllegalArgumentException("System folder cannot be deleted")); + return; + } + + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, Notes.ID_TRASH_FOLER); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, folderId); + int rows = contentResolver.update(uri, values, null, null); + + if (rows > 0) { + callback.onSuccess(rows); + Log.d(TAG, "Successfully moved folder to trash: " + folderId); + } else { + callback.onError(new RuntimeException("No folder found with ID: " + folderId)); + } + } catch (Exception e) { + Log.e(TAG, "Failed to delete folder: " + folderId, e); + callback.onError(e); + } + }); + } + /** * 关闭Executor *

diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/FolderOperationDialogs.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/FolderOperationDialogs.java new file mode 100644 index 0000000..8e33de0 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/FolderOperationDialogs.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import net.micode.notes.R; + +/** + * 文件夹操作对话框工具类 + *

+ * 提供重命名和删除文件夹的对话框 + *

+ */ +public class FolderOperationDialogs { + + private static final int MAX_FOLDER_NAME_LENGTH = 50; + + /** + * 显示重命名文件夹对话框 + * + * @param activity Activity实例 + * @param currentName 当前文件夹名称 + * @param listener 重命名监听器 + */ + public static void showRenameDialog(Context activity, String currentName, + OnRenameListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.dialog_rename_folder_title); + + // 创建输入框 + final EditText input = new EditText(activity); + input.setText(currentName); + input.setHint(R.string.dialog_create_folder_hint); + input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MAX_FOLDER_NAME_LENGTH)}); + input.setSelection(input.getText().length()); // 光标移到末尾 + + builder.setView(input); + + builder.setPositiveButton(R.string.menu_rename, (dialog, which) -> { + String newName = input.getText().toString().trim(); + if (TextUtils.isEmpty(newName)) { + listener.onError(activity.getString(R.string.error_folder_name_empty)); + return; + } + if (newName.length() > MAX_FOLDER_NAME_LENGTH) { + listener.onError(activity.getString(R.string.error_folder_name_too_long)); + return; + } + + listener.onRename(newName); + }); + + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + listener.onCancel(); + }); + + builder.show(); + } + + /** + * 显示删除文件夹确认对话框 + * + * @param activity Activity实例 + * @param folderName 文件夹名称 + * @param noteCount 文件夹中的笔记数量 + * @param listener 删除监听器 + */ + public static void showDeleteFolderDialog(Context activity, String folderName, + int noteCount, OnDeleteListener listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.dialog_delete_folder_title); + + // 创建自定义消息视图 + LayoutInflater inflater = LayoutInflater.from(activity); + View messageView = inflater.inflate(R.layout.dialog_folder_delete, null); + TextView messageText = messageView.findViewById(R.id.tv_delete_message); + + String message; + if (noteCount > 0) { + message = activity.getString(R.string.dialog_delete_folder_with_notes, folderName, noteCount); + } else { + message = activity.getString(R.string.dialog_delete_folder_empty, folderName); + } + messageText.setText(message); + + builder.setView(messageView); + + builder.setPositiveButton(R.string.menu_delete, (dialog, which) -> { + listener.onDelete(); + }); + + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + listener.onCancel(); + }); + + builder.show(); + } + + /** + * 重命名监听器接口 + */ + public interface OnRenameListener { + /** + * 重命名确认回调 + * + * @param newName 新名称 + */ + void onRename(String newName); + + /** + * 取消回调 + */ + default void onCancel() { + } + + /** + * 错误回调 + * + * @param errorMessage 错误消息 + */ + default void onError(String errorMessage) { + } + } + + /** + * 删除监听器接口 + */ + public interface OnDeleteListener { + /** + * 删除确认回调 + */ + void onDelete(); + + /** + * 取消回调 + */ + default void onCancel() { + } + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java index 2b9c202..c53f0ed 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java @@ -30,6 +30,7 @@ import net.micode.notes.data.Notes; import net.micode.notes.data.NotesRepository; import net.micode.notes.tool.ResourceParser; import net.micode.notes.tool.ResourceParser.NoteItemBgResources; +import net.micode.notes.ui.SwipeMenuLayout; import android.util.Log; @@ -57,7 +58,17 @@ public class NoteInfoAdapter extends BaseAdapter { private OnNoteButtonClickListener buttonClickListener; private OnNoteItemClickListener itemClickListener; private OnNoteItemLongClickListener itemLongClickListener; - + + // 滑动菜单监听器 + private OnSwipeMenuClickListener swipeMenuClickListener; + private SwipeMenuLayout currentOpenMenu; // 当前打开的滑动菜单 + + // 是否回收站模式 + private boolean isTrashMode = false; + + // 是否多选模式 + private boolean isMultiSelectMode = false; + /** * 便签按钮点击事件回调接口 */ @@ -84,7 +95,20 @@ public class NoteInfoAdapter extends BaseAdapter { public interface OnNoteItemLongClickListener { void onNoteItemLongClick(int position, long noteId); } - + + /** + * 滑动菜单按钮点击监听器接口 + */ + public interface OnSwipeMenuClickListener { + void onSwipeEdit(long itemId); + void onSwipePin(long itemId); + void onSwipeMove(long itemId); + void onSwipeDelete(long itemId); + void onSwipeRename(long itemId); + void onSwipeRestore(long itemId); + void onSwipePermanentDelete(long itemId); + } + /** * 构造函数 * @@ -95,7 +119,55 @@ public class NoteInfoAdapter extends BaseAdapter { this.notes = new ArrayList<>(); this.selectedIds = new HashSet<>(); } - + + /** + * 设置滑动菜单监听器 + * + * @param listener 滑动菜单监听器 + */ + public void setOnSwipeMenuClickListener(OnSwipeMenuClickListener listener) { + this.swipeMenuClickListener = listener; + } + + /** + * 设置回收站模式 + * + * @param isTrashMode 是否回收站模式 + */ + public void setTrashMode(boolean isTrashMode) { + this.isTrashMode = isTrashMode; + notifyDataSetChanged(); + } + + /** + * 获取当前是否回收站模式 + * + * @return 是否回收站模式 + */ + public boolean isTrashMode() { + return isTrashMode; + } + + /** + * 设置多选模式 + * + * @param isMultiSelectMode 是否多选模式 + */ + public void setMultiSelectMode(boolean isMultiSelectMode) { + this.isMultiSelectMode = isMultiSelectMode; + closeAllMenus(); + notifyDataSetChanged(); + } + + /** + * 获取当前是否多选模式 + * + * @return 是否多选模式 + */ + public boolean isMultiSelectMode() { + return isMultiSelectMode; + } + /** * 设置便签列表 * @@ -105,7 +177,7 @@ public class NoteInfoAdapter extends BaseAdapter { this.notes = notes != null ? notes : new ArrayList<>(); notifyDataSetChanged(); } - + /** * 设置选中的便签 ID 集合 *

@@ -153,7 +225,7 @@ public class NoteInfoAdapter extends BaseAdapter { public HashSet getSelectedIds() { return selectedIds; } - + /** * 切换选中状态 * @@ -167,7 +239,7 @@ public class NoteInfoAdapter extends BaseAdapter { } notifyDataSetChanged(); } - + /** * 设置按钮点击监听器 * @@ -184,79 +256,159 @@ public class NoteInfoAdapter extends BaseAdapter { public void setOnNoteItemLongClickListener(OnNoteItemLongClickListener listener) { this.itemLongClickListener = listener; } - + @Override public int getCount() { return notes.size(); } - + @Override public Object getItem(int position) { return position >= 0 && position < notes.size() ? notes.get(position) : null; } - + @Override public long getItemId(int position) { NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(position); return note != null ? note.getId() : -1; } - + + /** + * 关闭所有已打开的滑动菜单 + */ + public void closeAllMenus() { + if (currentOpenMenu != null && currentOpenMenu.isMenuOpen()) { + currentOpenMenu.closeMenu(); + currentOpenMenu = null; + } + } + @Override public View getView(int position, View convertView, ViewGroup parent) { Log.d("NoteInfoAdapter", "getView called, position: " + position + ", convertView: " + (convertView != null ? "REUSED" : "NEW")); ViewHolder holder; - + if (convertView == null) { - convertView = inflater.inflate(R.layout.note_item, parent, false); + // 使用 SwipeMenuLayout 包装内容 + convertView = inflater.inflate(R.layout.note_item_swipe, parent, false); + SwipeMenuLayout swipeLayout = (SwipeMenuLayout) convertView; + holder = new ViewHolder(); + holder.swipeMenuLayout = swipeLayout; holder.title = convertView.findViewById(R.id.tv_title); holder.time = convertView.findViewById(R.id.tv_time); holder.typeIcon = convertView.findViewById(R.id.iv_type_icon); holder.checkBox = convertView.findViewById(android.R.id.checkbox); holder.pinnedIcon = convertView.findViewById(R.id.iv_pinned_icon); holder.lockIcon = convertView.findViewById(R.id.iv_lock_icon); + holder.swipeMenuNormal = convertView.findViewById(R.id.swipe_menu_normal); + holder.swipeMenuTrash = convertView.findViewById(R.id.swipe_menu_trash); + convertView.setTag(holder); - - convertView.setOnClickListener(v -> { - Log.d("NoteInfoAdapter", "===== onClick TRIGGERED ====="); - ViewHolder currentHolder = (ViewHolder) v.getTag(); - if (currentHolder != null && itemClickListener != null) { - Log.d("NoteInfoAdapter", "Calling itemClickListener"); - NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(currentHolder.position); - if (note != null) { - itemClickListener.onNoteItemClick(currentHolder.position, note.getId()); + + // 设置滑动菜单监听器 + if (swipeMenuClickListener != null) { + swipeLayout.setOnMenuButtonClickListener(new SwipeMenuLayout.OnMenuButtonClickListener() { + @Override + public void onEdit(long itemId) { + swipeMenuClickListener.onSwipeEdit(itemId); + } + + @Override + public void onPin(long itemId) { + swipeMenuClickListener.onSwipePin(itemId); + } + + @Override + public void onMove(long itemId) { + swipeMenuClickListener.onSwipeMove(itemId); + } + + @Override + public void onDelete(long itemId) { + swipeMenuClickListener.onSwipeDelete(itemId); + } + + @Override + public void onRename(long itemId) { + swipeMenuClickListener.onSwipeRename(itemId); + } + + @Override + public void onRestore(long itemId) { + swipeMenuClickListener.onSwipeRestore(itemId); + } + + @Override + public void onPermanentDelete(long itemId) { + swipeMenuClickListener.onSwipePermanentDelete(itemId); + } + }); + + // 设置内容点击监听器 + swipeLayout.setOnContentClickListener(itemId -> { + if (itemClickListener != null) { + // 根据位置查找对应的笔记 + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(holder.position); + if (note != null && note.getId() == itemId) { + itemClickListener.onNoteItemClick(holder.position, itemId); + } + } + }); + + // 设置内容长按监听器 + swipeLayout.setOnContentLongClickListener(itemId -> { + if (itemLongClickListener != null) { + // 根据位置查找对应的笔记 + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(holder.position); + if (note != null && note.getId() == itemId) { + itemLongClickListener.onNoteItemLongClick(holder.position, itemId); + } } - } - Log.d("NoteInfoAdapter", "===== onClick END ====="); - }); - - convertView.setOnLongClickListener(v -> { - Log.d("NoteInfoAdapter", "===== setOnLongClickListener TRIGGERED ====="); - Log.d("NoteInfoAdapter", "Event triggered on view: " + v.getClass().getSimpleName()); - ViewHolder currentHolder = (ViewHolder) v.getTag(); - if (currentHolder != null && itemLongClickListener != null) { - Log.d("NoteInfoAdapter", "Calling itemLongClickListener"); - itemLongClickListener.onNoteItemLongClick(currentHolder.position, currentHolder.position < notes.size() ? notes.get(currentHolder.position).getId() : -1); - } else { - Log.e("NoteInfoAdapter", "itemLongClickListener is NULL!"); - } - Log.d("NoteInfoAdapter", "===== setOnLongClickListener END ====="); - return true; - }); + }); + } + + // 点击内容区域时,关闭已打开的菜单 + // 不设置convertView的setOnClickListener,避免干扰SwipeMenuLayout的事件处理 + // SwipeMenuLayout会处理自身的触摸事件和按钮点击事件 + // 长按事件也由SwipeMenuLayout处理,不再单独设置setOnLongClickListener + } else { holder = (ViewHolder) convertView.getTag(); } - + holder.position = position; - + + // 设置itemId到SwipeMenuLayout(使用单独的方法而非setTag) NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(position); if (note != null) { + holder.swipeMenuLayout.setItemId(note.getId()); + // 多选模式下禁用滑动菜单 + holder.swipeMenuLayout.setSwipeEnabled(!isMultiSelectMode); + } + + // 根据模式设置菜单可见性 + if (holder.swipeMenuNormal != null && holder.swipeMenuTrash != null) { + if (isTrashMode) { + holder.swipeMenuNormal.setVisibility(View.GONE); + holder.swipeMenuTrash.setVisibility(View.VISIBLE); + } else { + holder.swipeMenuNormal.setVisibility(View.VISIBLE); + holder.swipeMenuTrash.setVisibility(View.GONE); + } + } + + // 设置内容视图的背景 + View contentView = holder.swipeMenuLayout.getChildAt(0); + if (contentView != null && note != null) { + // ...设置内容... + String title = note.title; if (title == null || title.trim().isEmpty()) { title = "无标题"; } holder.title.setText(title); - + // 设置类型图标和时间显示 if (note.type == Notes.TYPE_FOLDER) { // 文件夹 @@ -270,11 +422,11 @@ public class NoteInfoAdapter extends BaseAdapter { holder.time.setVisibility(View.VISIBLE); holder.time.setText(formatDate(note.modifiedDate)); } - + int bgResId; int totalCount = getCount(); int bgColorId = note.bgColorId; - + if (totalCount == 1) { bgResId = NoteItemBgResources.getNoteBgSingleRes(bgColorId); } else if (position == 0) { @@ -284,13 +436,13 @@ public class NoteInfoAdapter extends BaseAdapter { } else { bgResId = NoteItemBgResources.getNoteBgNormalRes(bgColorId); } - - convertView.setBackgroundResource(bgResId); + + contentView.setBackgroundResource(bgResId); if (selectedIds.contains(note.getId())) { - convertView.setActivated(true); + contentView.setActivated(true); } else { - convertView.setActivated(false); + contentView.setActivated(false); } Log.d("NoteInfoAdapter", "===== Setting checkbox visibility ====="); @@ -324,7 +476,7 @@ public class NoteInfoAdapter extends BaseAdapter { return convertView; } - + /** * 格式化日期 * @@ -335,7 +487,7 @@ public class NoteInfoAdapter extends BaseAdapter { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()); return sdf.format(new Date(timestamp)); } - + /** * ViewHolder 模式:优化 ListView 性能 */ @@ -347,5 +499,10 @@ public class NoteInfoAdapter extends BaseAdapter { CheckBox checkBox; ImageView pinnedIcon; int position; + + // 滑动菜单相关 + SwipeMenuLayout swipeMenuLayout; + View swipeMenuNormal; + View swipeMenuTrash; } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java index 982f0ad..97e307a 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -76,6 +76,7 @@ public class NotesListActivity extends AppCompatActivity implements NoteInfoAdapter.OnNoteButtonClickListener, NoteInfoAdapter.OnNoteItemClickListener, NoteInfoAdapter.OnNoteItemLongClickListener, + NoteInfoAdapter.OnSwipeMenuClickListener, SidebarFragment.OnSidebarItemSelectedListener { private static final String TAG = "NotesListActivity"; private static final int REQUEST_CODE_OPEN_NODE = 102; @@ -194,6 +195,7 @@ public class NotesListActivity extends AppCompatActivity adapter.setOnNoteButtonClickListener(this); adapter.setOnNoteItemClickListener(this); adapter.setOnNoteItemLongClickListener(this); + adapter.setOnSwipeMenuClickListener(this); // 设置点击监听 binding.notesList.setOnItemClickListener(new AdapterView.OnItemClickListener() { @@ -281,6 +283,11 @@ public class NotesListActivity extends AppCompatActivity public void onChanged(List notes) { updateAdapter(notes); updateToolbarForNormalMode(); + + // 根据当前文件夹设置Adapter的模式(回收站/普通) + if (adapter != null) { + adapter.setTrashMode(viewModel.isTrashMode()); + } } }); @@ -554,6 +561,10 @@ public class NotesListActivity extends AppCompatActivity if (binding.btnNewNote != null) { binding.btnNewNote.setVisibility(View.GONE); } + // 禁用滑动菜单 + if (adapter != null) { + adapter.setMultiSelectMode(true); + } // 更新toolbar为多选模式 updateToolbarForMultiSelectMode(); } @@ -572,6 +583,8 @@ public class NotesListActivity extends AppCompatActivity if (adapter != null) { adapter.setSelectedIds(new java.util.HashSet<>()); adapter.notifyDataSetChanged(); + // 启用滑动菜单 + adapter.setMultiSelectMode(false); } // 更新toolbar为普通模式 updateToolbarForNormalMode(); @@ -964,6 +977,98 @@ public class NotesListActivity extends AppCompatActivity } } + @Override + public void onRenameFolder(long folderId) { + // 获取当前文件夹信息 + viewModel.getFolderInfo(folderId, new NotesRepository.Callback() { + @Override + public void onSuccess(NotesRepository.NoteInfo folderInfo) { + if (folderInfo != null) { + // 显示重命名对话框 + FolderOperationDialogs.showRenameDialog(NotesListActivity.this, + folderInfo.title, new FolderOperationDialogs.OnRenameListener() { + @Override + public void onRename(String newName) { + viewModel.renameFolder(folderId, newName); + } + + @Override + public void onError(String errorMessage) { + Toast.makeText(NotesListActivity.this, errorMessage, Toast.LENGTH_SHORT).show(); + } + }); + } else { + Toast.makeText(NotesListActivity.this, "文件夹不存在", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onError(Exception error) { + Toast.makeText(NotesListActivity.this, "获取文件夹信息失败: " + error.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + public void onDeleteFolder(long folderId) { + // 获取当前文件夹信息和笔记数量 + viewModel.getFolderInfo(folderId, new NotesRepository.Callback() { + @Override + public void onSuccess(NotesRepository.NoteInfo folderInfo) { + if (folderInfo != null) { + // 显示删除确认对话框 + FolderOperationDialogs.showDeleteFolderDialog(NotesListActivity.this, + folderInfo.title, folderInfo.noteCount, new FolderOperationDialogs.OnDeleteListener() { + @Override + public void onDelete() { + viewModel.deleteFolder(folderId); + } + }); + } else { + Toast.makeText(NotesListActivity.this, "文件夹不存在", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onError(Exception error) { + Toast.makeText(NotesListActivity.this, "获取文件夹信息失败: " + error.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * 显示重命名笔记对话框 + * + * @param note 笔记信息 + */ + private void showRenameNoteDialog(NotesRepository.NoteInfo note) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.dialog_rename_folder_title); + + final EditText input = new EditText(this); + input.setText(note.title); + input.setHint("请输入笔记标题"); + input.setSelection(input.getText().length()); + + builder.setView(input); + + builder.setPositiveButton(R.string.menu_rename, (dialog, which) -> { + String newName = input.getText().toString().trim(); + if (newName.isEmpty()) { + Toast.makeText(this, "笔记标题不能为空", Toast.LENGTH_SHORT).show(); + return; + } + if (newName.length() > 50) { + Toast.makeText(this, "笔记标题不能超过50个字符", Toast.LENGTH_SHORT).show(); + return; + } + viewModel.renameNote(note.getId(), newName); + }); + + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + /** * 返回键按下事件处理 *

@@ -988,8 +1093,82 @@ public class NotesListActivity extends AppCompatActivity viewModel.loadNotes(Notes.ID_ROOT_FOLDER); } } else { - // 根文件夹:最小化应用 - moveTaskToBack(true); + // 根文件夹:调用父类方法处理返回键 + super.onBackPressed(); + } + } + + // ==================== NoteInfoAdapter.OnSwipeMenuClickListener 实现 ==================== + + @Override + public void onSwipeEdit(long itemId) { + // 重命名笔记或文件夹 + List notes = viewModel.getNotesLiveData().getValue(); + if (notes != null) { + for (NotesRepository.NoteInfo note : notes) { + if (note.getId() == itemId) { + if (note.type == Notes.TYPE_FOLDER) { + // 文件夹重命名 + onRenameFolder(itemId); + } else { + // 笔记重命名 + showRenameNoteDialog(note); + } + break; + } + } + } + } + + @Override + public void onSwipePin(long itemId) { + // 置顶/取消置顶笔记 + viewModel.clearSelection(); + viewModel.toggleNoteSelection(itemId, true); + viewModel.toggleSelectedNotesPin(); + } + + @Override + public void onSwipeMove(long itemId) { + // 移动笔记 + viewModel.clearSelection(); + viewModel.toggleNoteSelection(itemId, true); + showMoveMenu(); + } + + @Override + public void onSwipeDelete(long itemId) { + // 删除笔记 + viewModel.clearSelection(); + viewModel.toggleNoteSelection(itemId, true); + showDeleteDialog(); + } + + @Override + public void onSwipeRename(long itemId) { + // 重命名文件夹(如果是文件夹类型) + onRenameFolder(itemId); + } + + @Override + public void onSwipeRestore(long itemId) { + // 恢复回收站笔记 + viewModel.clearSelection(); + viewModel.toggleNoteSelection(itemId, true); + viewModel.restoreSelectedNotes(); + } + + @Override + public void onSwipePermanentDelete(long itemId) { + // 永久删除回收站笔记 + List notes = viewModel.getNotesLiveData().getValue(); + if (notes != null) { + for (NotesRepository.NoteInfo note : notes) { + if (note.getId() == itemId) { + showDeleteForeverConfirmDialog(note); + break; + } + } } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java index 269f8e8..38b8d0f 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java @@ -29,6 +29,7 @@ import android.view.animation.TranslateAnimation; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; @@ -121,6 +122,18 @@ public class SidebarFragment extends Fragment { * 关闭侧栏 */ void onCloseSidebar(); + + /** + * 重命名文件夹 + * @param folderId 文件夹ID + */ + void onRenameFolder(long folderId); + + /** + * 删除文件夹 + * @param folderId 文件夹ID + */ + void onDeleteFolder(long folderId); } @Override @@ -178,6 +191,7 @@ public class SidebarFragment extends Fragment { binding.rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext())); adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel); adapter.setOnFolderItemClickListener(this::handleFolderItemClick); + adapter.setOnFolderItemLongClickListener(this::handleFolderItemLongClick); binding.rvFolderTree.setAdapter(adapter); } @@ -315,6 +329,46 @@ public class SidebarFragment extends Fragment { } } + /** + * 处理文件夹项长按 + */ + private void handleFolderItemLongClick(long folderId) { + android.util.Log.d(TAG, "handleFolderItemLongClick: folderId=" + folderId); + // 检查是否是系统文件夹(根文件夹、回收站等不允许重命名/删除) + if (folderId <= 0) { + android.util.Log.d(TAG, "System folder, ignoring long press"); + return; + } + + showFolderContextMenu(folderId); + } + + /** + * 显示文件夹上下文菜单 + */ + private void showFolderContextMenu(long folderId) { + PopupMenu popup = new PopupMenu(requireContext(), binding.getRoot()); + popup.getMenuInflater().inflate(R.menu.folder_context_menu, popup.getMenu()); + + popup.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_rename && listener != null) { + listener.onRenameFolder(folderId); + return true; + } else if (itemId == R.id.action_delete && listener != null) { + listener.onDeleteFolder(folderId); + return true; + } else if (itemId == R.id.action_move) { + // TODO: 实现移动功能(阶段3) + Toast.makeText(requireContext(), "移动功能待实现", Toast.LENGTH_SHORT).show(); + return true; + } + return false; + }); + + popup.show(); + } + // 双击检测专用变量(针对文件夹列表项) private long lastFolderClickTime = 0; private long lastClickedFolderId = -1; @@ -388,6 +442,7 @@ public class SidebarFragment extends Fragment { private List folderItems; private FolderListViewModel viewModel; private OnFolderItemClickListener folderItemClickListener; + private OnFolderItemLongClickListener folderItemLongClickListener; public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) { this.folderItems = folderItems; @@ -402,12 +457,16 @@ public class SidebarFragment extends Fragment { this.folderItemClickListener = listener; } + public void setOnFolderItemLongClickListener(OnFolderItemLongClickListener listener) { + this.folderItemLongClickListener = listener; + } + @NonNull @Override public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.sidebar_folder_item, parent, false); - return new FolderViewHolder(view, folderItemClickListener); + return new FolderViewHolder(view, folderItemClickListener, folderItemLongClickListener); } @Override @@ -430,10 +489,13 @@ public class SidebarFragment extends Fragment { private TextView tvNoteCount; private FolderTreeItem currentItem; private final OnFolderItemClickListener folderItemClickListener; + private final OnFolderItemLongClickListener folderItemLongClickListener; - public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener listener) { + public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener clickListener, + OnFolderItemLongClickListener longClickListener) { super(itemView); - this.folderItemClickListener = listener; + this.folderItemClickListener = clickListener; + this.folderItemLongClickListener = longClickListener; indentView = itemView.findViewById(R.id.indent_view); ivExpandIcon = itemView.findViewById(R.id.iv_expand_icon); ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon); @@ -469,6 +531,15 @@ public class SidebarFragment extends Fragment { folderItemClickListener.onFolderClick(item.folderId); } }); + + // 设置长按监听器 + itemView.setOnLongClickListener(v -> { + if (folderItemLongClickListener != null) { + folderItemLongClickListener.onFolderLongClick(item.folderId); + return true; + } + return false; + }); } } } @@ -480,6 +551,13 @@ public class SidebarFragment extends Fragment { void onFolderClick(long folderId); } + /** + * 文件夹项长按监听器接口 + */ + public interface OnFolderItemLongClickListener { + void onFolderLongClick(long folderId); + } + /** * FolderTreeItem * 文件夹树项数据模型 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeDetector.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeDetector.java new file mode 100644 index 0000000..fff005b --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeDetector.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +/** + * 原生滑动检测器 + *

+ * 实现左滑操作按钮的检测 + *

+ */ +public class SwipeDetector extends GestureDetector.SimpleOnGestureListener { + + private static final float SWIPE_THRESHOLD = 100f; // 滑动阈值(像素) + + // 滑动状态 + private float downX; + private float downY; + private boolean isSwiping = false; + + // 监听器 + private SwipeListener swipeListener; + + /** + * 滑动监听器接口 + */ + public interface SwipeListener { + /** + * 左滑开始 + */ + void onSwipeStart(); + + /** + * 滑动中 + * + * @param distance 滑动距离(负值表示左滑) + */ + void onSwipeMove(float distance); + + /** + * 左滑结束 + * + * @param distance 最终滑动距离 + */ + void onSwipeEnd(float distance); + } + + /** + * 构造函数 + * + * @param context 上下文 + * @param listener 滑动监听器 + */ + public SwipeDetector(Context context, SwipeListener listener) { + this.swipeListener = listener; + } + + @Override + public boolean onDown(MotionEvent e) { + downX = e.getX(); + downY = e.getY(); + isSwiping = false; + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (!isSwiping) { + isSwiping = true; + if (swipeListener != null) { + swipeListener.onSwipeStart(); + } + } + + if (swipeListener != null) { + swipeListener.onSwipeMove(distanceX); + } + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (swipeListener != null) { + swipeListener.onSwipeEnd(e2.getX() - downX); + } + return true; + } + + /** + * 获取滑动距离(负值表示左滑) + * + * @param currentX 当前X坐标 + * @return 滑动距离 + */ + public float getSwipeDistance(float currentX) { + return currentX - downX; + } + + /** + * 是否达到滑动阈值 + * + * @param distance 滑动距离 + * @return true如果达到阈值 + */ + public boolean isThresholdReached(float distance) { + return Math.abs(distance) >= SWIPE_THRESHOLD; + } + + /** + * 是否是左滑 + * + * @param distance 滑动距离 + * @return true如果是左滑 + */ + public boolean isSwipeLeft(float distance) { + return distance < 0; + } + + /** + * 是否是右滑 + * + * @param distance 滑动距离 + * @return true如果是右滑 + */ + public boolean isSwipeRight(float distance) { + return distance > 0; + } + + /** + * 重置状态 + */ + public void reset() { + downX = 0; + downY = 0; + isSwiping = false; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeMenuLayout.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeMenuLayout.java new file mode 100644 index 0000000..f26f649 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SwipeMenuLayout.java @@ -0,0 +1,472 @@ +/* + * Copyright (c) 2025, Modern Notes Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.OvershootInterpolator; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.OverScroller; + +import androidx.core.view.GestureDetectorCompat; + +/** + * 支持滑动操作的布局 + * 包装列表项,支持左滑显示操作按钮 + */ +public class SwipeMenuLayout extends FrameLayout { + + private static final String TAG = "SwipeMenuLayout"; + private static final int MENU_WIDTH_DP = 240; + private static final int MIN_VELOCITY = 500; + private static final int MAX_OVERSCROLL = 100; + + private View contentView; + private View menuView; + private int menuWidth; + private int screenWidth; + + private OverScroller scroller; + private VelocityTracker velocityTracker; + private float lastX; + private float downX; + private float downY; + private long downTime; + private int currentState = STATE_CLOSE; + private float currentScrollX = 0; + private boolean isScrolling = false; + private boolean longPressTriggered = false; + + private static final int STATE_CLOSE = 0; + private static final int STATE_OPEN = 1; + private static final int STATE_SWIPING = 2; + private static final float TOUCH_SLOP = 10f; + private static final int CLICK_TIME_THRESHOLD = 300; + private static final int LONG_PRESS_TIME_THRESHOLD = 500; + + private Handler longPressHandler; + private Runnable longPressRunnable; + + private OnMenuButtonClickListener menuButtonClickListener; + + private OnContentClickListener contentClickListener; + + private OnContentLongClickListener contentLongClickListener; + + private long itemId; + + private boolean swipeEnabled = true; + + public interface OnMenuButtonClickListener { + void onEdit(long itemId); + + void onPin(long itemId); + + void onMove(long itemId); + + void onDelete(long itemId); + + void onRename(long itemId); + + void onRestore(long itemId); + + void onPermanentDelete(long itemId); + } + + public interface OnContentLongClickListener { + void onContentLongClick(long itemId); + } + + public interface OnContentClickListener { + void onContentClick(long itemId); + } + + private boolean isFirstLayout = true; + + public SwipeMenuLayout(Context context) { + super(context); + init(context); + } + + public SwipeMenuLayout(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public SwipeMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(Context context) { + scroller = new OverScroller(context); + velocityTracker = VelocityTracker.obtain(); + menuWidth = (int) (MENU_WIDTH_DP * context.getResources().getDisplayMetrics().density); + longPressHandler = new Handler(Looper.getMainLooper()); + longPressRunnable = () -> { + if (!isScrolling && !longPressTriggered) { + Log.d(TAG, "Long press triggered via Handler, itemId: " + itemId); + longPressTriggered = true; + if (contentLongClickListener != null) { + contentLongClickListener.onContentLongClick(itemId); + } + } + }; + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + if (getChildCount() != 2) { + throw new IllegalStateException("SwipeMenuLayout must have exactly 2 children: content and menu"); + } + + contentView = getChildAt(0); + menuView = getChildAt(1); + + setupMenuButtonListeners(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + if (isFirstLayout && contentView != null && menuView != null) { + screenWidth = right - left; + Log.d(TAG, "onLayout: screenWidth=" + screenWidth + ", menuWidth=" + menuWidth); + + contentView.setTranslationX(0); + menuView.setTranslationX(screenWidth); + scroller.startScroll(0, 0, 0, 0); + + isFirstLayout = false; + } + } + + private void setupMenuButtonListeners() { + if (menuView instanceof ViewGroup) { + ViewGroup menuGroup = (ViewGroup) menuView; + int childCount = menuGroup.getChildCount(); + + Log.d(TAG, "setupMenuButtonListeners: menuGroup childCount=" + childCount); + + // menuView 是 FrameLayout,包含两个 include 的布局 + // 需要遍历每个菜单布局内部的按钮 + for (int i = 0; i < childCount; i++) { + View menuLayout = menuGroup.getChildAt(i); + Log.d(TAG, "Menu layout " + i + ": " + menuLayout.getClass().getSimpleName() + ", visibility=" + menuLayout.getVisibility()); + + if (menuLayout instanceof ViewGroup) { + ViewGroup menuInnerGroup = (ViewGroup) menuLayout; + int buttonCount = menuInnerGroup.getChildCount(); + Log.d(TAG, "Menu " + i + " has " + buttonCount + " buttons"); + + for (int j = 0; j < buttonCount; j++) { + View button = menuInnerGroup.getChildAt(j); + final View finalButton = button; + String tag = (String) button.getTag(); + Log.d(TAG, "Button " + j + ": id=" + button.getId() + ", tag=" + tag); + + button.setOnClickListener(v -> { + Log.d(TAG, "Button clicked: id=" + finalButton.getId() + ", tag=" + finalButton.getTag()); + if (menuButtonClickListener != null) { + long itemId = getItemId(); + Log.d(TAG, "menuButtonClickListener not null, itemId=" + itemId); + handleMenuButtonClick(finalButton, itemId); + } else { + Log.e(TAG, "menuButtonClickListener is NULL!"); + } + closeMenu(); + }); + } + } + } + } else { + Log.e(TAG, "menuView is not a ViewGroup!"); + } + } + + private void handleMenuButtonClick(View button, long itemId) { + int id = button.getId(); + String actionType = (String) button.getTag(); + Log.d(TAG, "handleMenuButtonClick: id=" + id + ", actionType=" + actionType + ", itemId=" + itemId); + if (actionType != null && menuButtonClickListener != null) { + switch (actionType) { + case "edit": + Log.d(TAG, "Calling onEdit"); + menuButtonClickListener.onEdit(itemId); + break; + case "pin": + Log.d(TAG, "Calling onPin"); + menuButtonClickListener.onPin(itemId); + break; + case "move": + Log.d(TAG, "Calling onMove"); + menuButtonClickListener.onMove(itemId); + break; + case "delete": + Log.d(TAG, "Calling onDelete"); + menuButtonClickListener.onDelete(itemId); + break; + case "rename": + Log.d(TAG, "Calling onRename"); + menuButtonClickListener.onRename(itemId); + break; + case "restore": + Log.d(TAG, "Calling onRestore"); + menuButtonClickListener.onRestore(itemId); + break; + case "permanent_delete": + Log.d(TAG, "Calling onPermanentDelete"); + menuButtonClickListener.onPermanentDelete(itemId); + break; + default: + Log.e(TAG, "Unknown actionType: " + actionType); + } + } else { + if (actionType == null) { + Log.e(TAG, "actionType is NULL!"); + } + if (menuButtonClickListener == null) { + Log.e(TAG, "menuButtonClickListener is NULL in handleMenuButtonClick!"); + } + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (!swipeEnabled) { + return false; + } + + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + downX = ev.getX(); + downY = ev.getY(); + lastX = ev.getX(); + downTime = System.currentTimeMillis(); + isScrolling = false; + longPressTriggered = false; + + // 安排长按检测任务 + longPressHandler.removeCallbacks(longPressRunnable); + longPressHandler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD); + break; + case MotionEvent.ACTION_MOVE: + float deltaX = ev.getX() - downX; + float deltaY = ev.getY() - downY; + + // 检测滑动 + if (Math.abs(deltaX) > TOUCH_SLOP * 2 && Math.abs(deltaX) > Math.abs(deltaY) * 2) { + isScrolling = true; + longPressHandler.removeCallbacks(longPressRunnable); + return true; + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // 取消长按任务 + longPressHandler.removeCallbacks(longPressRunnable); + break; + } + return false; + } + + public boolean onTouchEvent(MotionEvent event) { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + lastX = event.getX(); + downTime = System.currentTimeMillis(); + isScrolling = false; + longPressTriggered = false; + + // 安排长按检测任务 + longPressHandler.removeCallbacks(longPressRunnable); + longPressHandler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD); + break; + + case MotionEvent.ACTION_MOVE: + float dx = event.getX() - lastX; + float deltaX = event.getX() - downX; + float deltaY = event.getY() - downY; + + if (Math.abs(deltaX) > TOUCH_SLOP * 2 && Math.abs(deltaX) > Math.abs(deltaY) * 2) { + // 检测到滑动,取消长按任务 + isScrolling = true; + longPressHandler.removeCallbacks(longPressRunnable); + currentScrollX += dx; + applyScroll(currentScrollX, true); + } + lastX = event.getX(); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // 取消长按任务 + longPressHandler.removeCallbacks(longPressRunnable); + + long upTime = System.currentTimeMillis(); + long duration = upTime - downTime; + + if (isScrolling) { + handleTouchRelease(); + } else if (!longPressTriggered && duration < LONG_PRESS_TIME_THRESHOLD) { + // 短按且未触发长按 = 点击 + Log.d(TAG, "Content click detected, itemId: " + itemId); + if (contentClickListener != null) { + contentClickListener.onContentClick(itemId); + } + } + + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + isScrolling = false; + break; + } + return true; + } + + public void openMenu() { + scroller = new OverScroller(getContext(), new OvershootInterpolator(0.5f)); + smoothScrollTo(-menuWidth); + currentState = STATE_OPEN; + } + + public void closeMenu() { + scroller = new OverScroller(getContext(), new OvershootInterpolator(0.5f)); + smoothScrollTo(0); + currentState = STATE_CLOSE; + } + + public void toggleMenu() { + if (currentState == STATE_OPEN) { + closeMenu(); + } else { + openMenu(); + } + } + + private void applyScroll(float scrollX, boolean allowElastic) { + if (!allowElastic) { + if (scrollX > 0) scrollX = 0; + if (scrollX < -menuWidth) scrollX = -menuWidth; + } else { + if (scrollX > MAX_OVERSCROLL) { + scrollX = MAX_OVERSCROLL; + } else if (scrollX < -menuWidth - MAX_OVERSCROLL) { + scrollX = -menuWidth - MAX_OVERSCROLL; + } + } + + contentView.setTranslationX(scrollX); + menuView.setTranslationX(scrollX + screenWidth); + currentScrollX = scrollX; + } + + private void handleTouchRelease() { + velocityTracker.computeCurrentVelocity(1000); + float velocity = velocityTracker.getXVelocity(); + + int targetX; + if (Math.abs(velocity) > MIN_VELOCITY) { + if (velocity > 0) { + targetX = 0; + } else { + targetX = -menuWidth; + } + } else { + if (Math.abs(currentScrollX) < menuWidth / 2) { + targetX = 0; + } else { + targetX = -menuWidth; + } + } + + smoothScrollTo(targetX); + currentState = (targetX == 0) ? STATE_CLOSE : STATE_OPEN; + } + + private void smoothScrollTo(int x) { + scroller.startScroll((int) currentScrollX, 0, x - (int) currentScrollX, 0, 300); + invalidate(); + postInvalidate(); + } + + @Override + public void computeScroll() { + if (scroller.computeScrollOffset()) { + currentScrollX = scroller.getCurrX(); + applyScroll(currentScrollX, false); + requestAnimationInvalidation(); + } + } + + private void requestAnimationInvalidation() { + post(this::computeScroll); + } + + public void setOnMenuButtonClickListener(OnMenuButtonClickListener listener) { + this.menuButtonClickListener = listener; + } + + public void setOnContentClickListener(OnContentClickListener listener) { + this.contentClickListener = listener; + } + + public void setOnContentLongClickListener(OnContentLongClickListener listener) { + this.contentLongClickListener = listener; + } + + public void setItemId(long itemId) { + this.itemId = itemId; + } + + public long getItemId() { + return itemId; + } + + public boolean isMenuOpen() { + return currentState == STATE_OPEN; + } + + public void setSwipeEnabled(boolean enabled) { + this.swipeEnabled = enabled; + } + + public boolean isSwipeEnabled() { + return swipeEnabled; + } +} diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java index d6f854b..43a13b4 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java @@ -703,6 +703,121 @@ public class NotesListViewModel extends ViewModel { return currentFolderId == Notes.ID_TRASH_FOLER; } + /** + * 重命名文件夹 + *

+ * 重命名指定文件夹,并刷新侧栏 + *

+ * + * @param folderId 文件夹ID + * @param newName 新名称 + */ + public void renameFolder(long folderId, String newName) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.renameFolder(folderId, newName, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + // 触发列表和侧栏刷新 + refreshNotes(); + sidebarRefreshNeeded.postValue(true); + Log.d(TAG, "Successfully renamed folder: " + folderId); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "重命名文件夹失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 重命名笔记 + *

+ * 修改笔记标题,并触发侧栏和列表刷新 + *

+ * + * @param noteId 笔记ID + * @param newName 新标题 + */ + public void renameNote(long noteId, String newName) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.renameNote(noteId, newName, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + // 触发列表和侧栏刷新 + refreshNotes(); + sidebarRefreshNeeded.postValue(true); + Log.d(TAG, "Successfully renamed note: " + noteId); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "重命名笔记失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 删除文件夹 + *

+ * 将文件夹移动到回收站,并刷新侧栏 + *

+ * + * @param folderId 文件夹ID + */ + public void deleteFolder(long folderId) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.deleteFolder(folderId, new NotesRepository.Callback() { + @Override + public void onSuccess(Integer rowsAffected) { + isLoading.postValue(false); + // 触发侧栏刷新 + sidebarRefreshNeeded.postValue(true); + Log.d(TAG, "Successfully deleted folder: " + folderId); + } + + @Override + public void onError(Exception error) { + isLoading.postValue(false); + String message = "删除文件夹失败: " + error.getMessage(); + errorMessage.postValue(message); + Log.e(TAG, message, error); + } + }); + } + + /** + * 获取文件夹信息 + *

+ * 查询单个文件夹的详细信息 + *

+ * + * @param folderId 文件夹ID + * @param callback 回调接口 + */ + public void getFolderInfo(long folderId, NotesRepository.Callback callback) { + try { + NotesRepository.NoteInfo folderInfo = repository.getFolderInfo(folderId); + callback.onSuccess(folderInfo); + } catch (Exception e) { + callback.onError(e); + } + } + /** * ViewModel销毁时的清理 *

diff --git a/src/Notesmaster/app/src/main/res/anim/swipe_menu_close.xml b/src/Notesmaster/app/src/main/res/anim/swipe_menu_close.xml new file mode 100644 index 0000000..e7bf4ba --- /dev/null +++ b/src/Notesmaster/app/src/main/res/anim/swipe_menu_close.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Notesmaster/app/src/main/res/anim/swipe_menu_open.xml b/src/Notesmaster/app/src/main/res/anim/swipe_menu_open.xml new file mode 100644 index 0000000..1ca1765 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/anim/swipe_menu_open.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/dialog_folder_delete.xml b/src/Notesmaster/app/src/main/res/layout/dialog_folder_delete.xml new file mode 100644 index 0000000..ac59d7b --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/dialog_folder_delete.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/dialog_folder_name.xml b/src/Notesmaster/app/src/main/res/layout/dialog_folder_name.xml new file mode 100644 index 0000000..8054c64 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/dialog_folder_name.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/note_item_swipe.xml b/src/Notesmaster/app/src/main/res/layout/note_item_swipe.xml new file mode 100644 index 0000000..93480c5 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/note_item_swipe.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/swipe_menu_note.xml b/src/Notesmaster/app/src/main/res/layout/swipe_menu_note.xml new file mode 100644 index 0000000..b3964c2 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/swipe_menu_note.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/swipe_menu_trash.xml b/src/Notesmaster/app/src/main/res/layout/swipe_menu_trash.xml new file mode 100644 index 0000000..698f9bc --- /dev/null +++ b/src/Notesmaster/app/src/main/res/layout/swipe_menu_trash.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/menu/folder_context_menu.xml b/src/Notesmaster/app/src/main/res/menu/folder_context_menu.xml new file mode 100644 index 0000000..6939573 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/menu/folder_context_menu.xml @@ -0,0 +1,14 @@ + +

+ + + + + + diff --git a/src/Notesmaster/app/src/main/res/values-zh-rCN/strings.xml b/src/Notesmaster/app/src/main/res/values-zh-rCN/strings.xml index 61b9801..89d68d0 100644 --- a/src/Notesmaster/app/src/main/res/values-zh-rCN/strings.xml +++ b/src/Notesmaster/app/src/main/res/values-zh-rCN/strings.xml @@ -133,4 +133,33 @@ 回收站 创建文件夹成功 + + 重命名 + 重命名文件夹 + 删除文件夹 + 删除 "%1$s" 及其 %2$d 条笔记? + 删除 "%1$s"? + 文件夹名称 + 无效的意图 + 不支持的意图操作 + 暂无便签,点击右下角按钮创建 + 空便签图标 + 编辑便签 + 登录 + 导出 + 设置 + 关闭侧边栏 + 创建文件夹 + 文件夹已存在 + 置顶 + 锁定 + 确定需要为其上锁? + 确定 + 再想想 + 确定要删除选中的便签吗? + 取消置顶 + 解锁 + 恢复 + 永久删除 + diff --git a/src/Notesmaster/app/src/main/res/values-zh-rTW/strings.xml b/src/Notesmaster/app/src/main/res/values-zh-rTW/strings.xml index 3c41894..e17bfdd 100644 --- a/src/Notesmaster/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/Notesmaster/app/src/main/res/values-zh-rTW/strings.xml @@ -121,7 +121,46 @@ 設置 取消 - %1$s 條符合”%2$s“的搜尋結果 + %1$s 條符合"%2$s"的搜尋結果 + + 我的便籤 + %d 個便籤 + 創建文件夾 + 文件夾名稱 + 文件夾名稱不能為空 + 文件夾名稱過長(最多50個字符) + 回收站 + 創建文件夾成功 + + + 重命名 + 重命名文件夾 + 刪除文件夾 + 刪除 "%1$s" 及其 %2$d 條筆記? + 刪除 "%1$s"? + 文件夾名稱 + 無效的意圖 + 不支持的意圖操作 + 暫無便籤,點擊右下角按鈕創建 + 空便籤圖標 + 編輯便籤 + 登錄 + 導出 + 設置 + 關閉側邊欄 + 創建文件夾 + 文件夾已存在 + 置頂 + 鎖定 + 確定需要為其上鎖? + 確定 + 再想想 + 確定要刪除選中的便籤嗎? + 取消置頂 + 解鎖 + 恢復 + 永久刪除 + diff --git a/src/Notesmaster/app/src/main/res/values/colors_swipe.xml b/src/Notesmaster/app/src/main/res/values/colors_swipe.xml new file mode 100644 index 0000000..39fa9a5 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/values/colors_swipe.xml @@ -0,0 +1,10 @@ + + + + #2196F3 + #FF9800 + #4CAF50 + #F44336 + #2196F3 + #F44336 + diff --git a/src/Notesmaster/app/src/main/res/values/strings.xml b/src/Notesmaster/app/src/main/res/values/strings.xml index c6f38f4..7947219 100644 --- a/src/Notesmaster/app/src/main/res/values/strings.xml +++ b/src/Notesmaster/app/src/main/res/values/strings.xml @@ -36,19 +36,20 @@ Browse web Open map - /MIUI/notes/ - notes_%s.txt - - (%d) - New Folder - Export text - Sync - Cancel syncing - Settings - Search - Delete - Move to folder - %d selected + /MIUI/notes/ + notes_%s.txt + + (%d) + New Folder + Export text + Sync + Cancel syncing + Settings + Search + Delete + Move to folder + Rename + %d selected Nothing selected, the operation is invalid Select all Deselect all @@ -59,10 +60,17 @@ Super Enter check list Leave check list - View folder - Delete folder - Change folder name - The folder %1$s exist, please rename + View folder + Delete folder + Change folder name + The folder %1$s exist, please rename + + + Rename folder + Delete folder + Delete \"%1$s\" and its %2$d notes? + Delete \"%1$s\"? + Folder name Share Send to home Remind me @@ -75,15 +83,15 @@ Confirm to delete the selected %d notes? Confirm to delete this note? Have moved selected %1$d notes to %2$s folder - - SD card busy, not available now - Export failed, please check SD card - The note is not exist - Sorry, can not set clock on empty note - Sorry, can not send and empty note to home - Invalid intent - Unsupported intent action - Export successful + + SD card busy, not available now + Export failed, please check SD card + The note is not exist + Sorry, can not set clock on empty note + Sorry, can not send and empty note to home + Invalid intent + Unsupported intent action + Export successful Export fail Export text file (%1$s) to SD (%2$s) directory @@ -103,7 +111,7 @@ Sync account Sync notes with google task Last sync time %1$s - yyyy-MM-dd hh:mm:ss + yyyy-MM-dd hh:mm:ss Add account Change sync account Remove sync account @@ -161,4 +169,6 @@ Are you sure you want to delete selected notes? Unpin Unlock - + Restore + Delete Forever +