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
new file mode 100644
index 0000000..50296cf
--- /dev/null
+++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java
@@ -0,0 +1,962 @@
+/*
+ * 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.data;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.Log;
+
+import net.micode.notes.data.Notes;
+import net.micode.notes.data.Notes.CallNote;
+import net.micode.notes.data.Notes.DataColumns;
+import net.micode.notes.data.Notes.NoteColumns;
+import net.micode.notes.data.Notes.TextNote;
+import net.micode.notes.model.Note;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * 笔记数据仓库
+ *
+ * 负责数据访问逻辑,统一管理Content Provider和缓存
+ * 提供笔记的增删改查、搜索、统计等功能
+ *
+ *
+ * 使用Executor进行后台线程数据访问,避免阻塞UI线程
+ *
+ *
+ * @see Note
+ * @see Notes
+ */
+public class NotesRepository {
+
+ /**
+ * 笔记信息类
+ *
+ * 存储从数据库查询的笔记基本信息
+ *
+ */
+ public static class NoteInfo {
+ public long id;
+ public String title;
+ public String snippet;
+ public long parentId;
+ public long createdDate;
+ public long modifiedDate;
+ public int type;
+ public int localModified;
+ public int bgColorId;
+ public boolean isPinned; // 新增置顶字段
+
+ public NoteInfo() {}
+
+ public long getId() {
+ return id;
+ }
+
+ public long getParentId() {
+ return parentId;
+ }
+
+ public String getNoteDataValue() {
+ return snippet;
+ }
+ }
+ private static final String TAG = "NotesRepository";
+
+ private final ContentResolver contentResolver;
+ private final ExecutorService executor;
+
+ // 选择条件常量
+ private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + " = ?";
+ private static final String ROOT_FOLDER_SELECTION = "(" +
+ NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " +
+ NoteColumns.PARENT_ID + "=?) OR (" +
+ NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " +
+ NoteColumns.NOTES_COUNT + ">0)";
+
+ /**
+ * 数据访问回调接口
+ *
+ * 统一的数据访问结果回调机制
+ *
+ *
+ * @param 返回数据类型
+ */
+ public interface Callback {
+ /**
+ * 成功回调
+ *
+ * @param result 返回的结果数据
+ */
+ void onSuccess(T result);
+
+ /**
+ * 失败回调
+ *
+ * @param error 异常对象
+ */
+ void onError(Exception error);
+ }
+
+ /**
+ * 从 Cursor 创建 NoteInfo 对象
+ *
+ * @param cursor 数据库游标
+ * @return NoteInfo 对象
+ */
+ private NoteInfo noteFromCursor(Cursor cursor) {
+ NoteInfo noteInfo = new NoteInfo();
+ noteInfo.id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
+ noteInfo.title = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
+ noteInfo.snippet = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
+ noteInfo.parentId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.PARENT_ID));
+ noteInfo.createdDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.CREATED_DATE));
+ 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);
+ } else {
+ noteInfo.bgColorId = 0;
+ }
+
+ int topIndex = cursor.getColumnIndex(NoteColumns.TOP);
+ if (topIndex != -1) {
+ noteInfo.isPinned = cursor.getInt(topIndex) > 0;
+ }
+
+ return noteInfo;
+ }
+
+ /**
+ * 构造函数
+ *
+ * 初始化ContentResolver和线程池
+ *
+ *
+ * @param contentResolver Content解析器
+ */
+ public NotesRepository(ContentResolver contentResolver) {
+ this.contentResolver = contentResolver;
+ // 使用单线程Executor确保数据访问的顺序性
+ this.executor = java.util.concurrent.Executors.newSingleThreadExecutor();
+ Log.d(TAG, "NotesRepository initialized");
+ }
+
+ /**
+ * 获取指定文件夹的笔记列表
+ *
+ * 支持根文件夹(显示所有笔记)和子文件夹两种模式
+ *
+ *
+ * @param folderId 文件夹ID,{@link Notes#ID_ROOT_FOLDER} 表示根文件夹
+ * @param callback 回调接口
+ */
+ public void getNotes(long folderId, Callback> callback) {
+ executor.execute(() -> {
+ try {
+ List notes = queryNotes(folderId);
+ callback.onSuccess(notes);
+ Log.d(TAG, "Successfully loaded notes for folder: " + folderId);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to load notes for folder: " + folderId, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 查询笔记列表(内部方法)
+ *
+ * 同时返回文件夹和便签,文件夹显示在便签之前
+ *
+ *
+ * @param folderId 文件夹ID
+ * @return 笔记列表(包含文件夹和便签)
+ */
+ private List queryNotes(long folderId) {
+ List notes = new ArrayList<>();
+ List folders = new ArrayList<>();
+ List normalNotes = new ArrayList<>();
+
+ String selection;
+ String[] selectionArgs;
+
+ if (folderId == Notes.ID_ROOT_FOLDER) {
+ // 根文件夹:显示所有文件夹和便签
+ selection = "(" + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?) OR (" +
+ NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " + NoteColumns.NOTES_COUNT + ">0)";
+ selectionArgs = new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)};
+ } else {
+ // 子文件夹:显示该文件夹下的文件夹和便签
+ selection = NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM;
+ selectionArgs = new String[]{String.valueOf(folderId)};
+ }
+
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_NOTE_URI,
+ null,
+ selection,
+ selectionArgs,
+ NoteColumns.MODIFIED_DATE + " DESC"
+ );
+
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ NoteInfo note = noteFromCursor(cursor);
+ if (note.type == Notes.TYPE_FOLDER) {
+ // 文件夹单独收集
+ folders.add(note);
+ } else if (note.type == Notes.TYPE_NOTE) {
+ // 便签收集
+ normalNotes.add(note);
+ } else if (note.type == Notes.TYPE_SYSTEM && note.id == Notes.ID_CALL_RECORD_FOLDER) {
+ // 通话记录文件夹
+ folders.add(note);
+ }
+ }
+ Log.d(TAG, "Query returned " + folders.size() + " folders and " + normalNotes.size() + " notes");
+ } finally {
+ cursor.close();
+ }
+ }
+
+ // 文件夹按修改时间倒序排列
+ folders.sort((a, b) -> Long.compare(b.modifiedDate, a.modifiedDate));
+ // 便签按修改时间倒序排列
+ normalNotes.sort((a, b) -> {
+ // 首先按置顶状态排序(置顶在前)
+ if (a.isPinned != b.isPinned) {
+ return a.isPinned ? -1 : 1;
+ }
+ // 其次按修改时间倒序排列
+ return Long.compare(b.modifiedDate, a.modifiedDate);
+ });
+
+ // 合并:文件夹在前,便签在后
+ notes.addAll(folders);
+ notes.addAll(normalNotes);
+
+ return notes;
+ }
+
+ /**
+ * 查询单个文件夹信息
+ *
+ * @param folderId 文件夹ID
+ * @return 文件夹信息,如果不存在返回null
+ */
+ public NoteInfo getFolderInfo(long folderId) {
+ if (folderId == Notes.ID_ROOT_FOLDER) {
+ NoteInfo root = new NoteInfo();
+ root.id = Notes.ID_ROOT_FOLDER;
+ root.title = "我的便签";
+ root.snippet = "我的便签";
+ root.type = Notes.TYPE_FOLDER;
+ return root;
+ }
+
+ String selection = NoteColumns.ID + "=?";
+ String[] selectionArgs = new String[]{String.valueOf(folderId)};
+
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_NOTE_URI,
+ null,
+ selection,
+ selectionArgs,
+ null
+ );
+
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ return noteFromCursor(cursor);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 查询文件夹的父文件夹ID(异步版本)
+ *
+ * @param folderId 文件夹ID
+ * @param callback 回调接口,返回父文件夹ID
+ */
+ public void getParentFolderId(long folderId, Callback callback) {
+ executor.execute(() -> {
+ try {
+ long parentId = getParentFolderId(folderId);
+ callback.onSuccess(parentId);
+ } catch (Exception e) {
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 查询文件夹的父文件夹ID
+ *
+ * @param folderId 文件夹ID
+ * @return 父文件夹ID,如果不存在返回根文件夹ID
+ */
+ public long getParentFolderId(long folderId) {
+ if (folderId == Notes.ID_ROOT_FOLDER || folderId == Notes.ID_CALL_RECORD_FOLDER) {
+ return Notes.ID_ROOT_FOLDER;
+ }
+
+ NoteInfo folder = getFolderInfo(folderId);
+ if (folder != null) {
+ return folder.parentId;
+ }
+ return Notes.ID_ROOT_FOLDER;
+ }
+
+ /**
+ * 获取文件夹路径(从根到当前)
+ *
+ * @param folderId 当前文件夹ID
+ * @return 文件夹路径列表(从根到当前)
+ */
+ public List getFolderPath(long folderId) {
+ List path = new ArrayList<>();
+ long currentId = folderId;
+
+ while (currentId != Notes.ID_ROOT_FOLDER) {
+ NoteInfo folder = getFolderInfo(currentId);
+ if (folder == null) {
+ break;
+ }
+ path.add(0, folder); // 添加到列表头部
+ currentId = folder.parentId;
+ }
+
+ // 添加根文件夹
+ NoteInfo root = new NoteInfo();
+ root.id = Notes.ID_ROOT_FOLDER;
+ root.title = "我的便签";
+ root.snippet = "我的便签";
+ root.type = Notes.TYPE_FOLDER;
+ path.add(0, root);
+
+ return path;
+ }
+
+ /**
+ * 获取文件夹路径(异步版本)
+ *
+ * @param folderId 当前文件夹ID
+ * @param callback 回调接口,返回文件夹路径列表
+ */
+ public void getFolderPath(long folderId, Callback> callback) {
+ executor.execute(() -> {
+ try {
+ List path = getFolderPath(folderId);
+ callback.onSuccess(path);
+ } catch (Exception e) {
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 创建新文件夹
+ *
+ * @param parentId 父文件夹ID
+ * @param name 文件夹名称
+ * @param callback 回调接口,返回新文件夹的ID
+ */
+ public void createFolder(long parentId, String name, Callback callback) {
+ executor.execute(() -> {
+ try {
+ ContentValues values = new ContentValues();
+ long currentTime = System.currentTimeMillis();
+
+ values.put(NoteColumns.PARENT_ID, parentId);
+ values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER);
+ values.put(NoteColumns.SNIPPET, name);
+ values.put(NoteColumns.CREATED_DATE, currentTime);
+ values.put(NoteColumns.MODIFIED_DATE, currentTime);
+ values.put(NoteColumns.LOCAL_MODIFIED, 1);
+ values.put(NoteColumns.NOTES_COUNT, 0);
+
+ Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values);
+
+ Long folderId = 0L;
+ if (uri != null) {
+ try {
+ folderId = ContentUris.parseId(uri);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to parse folder ID from URI", e);
+ }
+ }
+
+ callback.onSuccess(folderId);
+ Log.d(TAG, "Successfully created folder: " + name + " with ID: " + folderId);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to create folder: " + name, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 创建新笔记
+ *
+ * 在指定文件夹下创建一个空笔记
+ *
+ *
+ * @param folderId 父文件夹ID
+ * @param callback 回调接口,返回新笔记的ID
+ */
+ public void createNote(long folderId, Callback callback) {
+ executor.execute(() -> {
+ try {
+ ContentValues values = new ContentValues();
+ long currentTime = System.currentTimeMillis();
+
+ values.put(NoteColumns.PARENT_ID, folderId);
+ values.put(NoteColumns.TYPE, Notes.TYPE_NOTE);
+ values.put(NoteColumns.CREATED_DATE, currentTime);
+ values.put(NoteColumns.MODIFIED_DATE, currentTime);
+ values.put(NoteColumns.LOCAL_MODIFIED, 1);
+ values.put(NoteColumns.SNIPPET, "");
+
+ Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values);
+
+ Long noteId = 0L;
+ if (uri != null) {
+ try {
+ noteId = ContentUris.parseId(uri);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to parse note ID from URI", e);
+ }
+ }
+
+ if (noteId > 0) {
+ callback.onSuccess(noteId);
+ Log.d(TAG, "Successfully created note with ID: " + noteId);
+ } else {
+ callback.onError(new IllegalStateException("Failed to create note, invalid ID returned"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to create note", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 更新笔记内容
+ *
+ * 更新笔记的标题和内容,自动更新修改时间和本地修改标志
+ *
+ *
+ * @param noteId 笔记ID
+ * @param content 笔记内容
+ * @param callback 回调接口,返回影响的行数
+ */
+ public void updateNote(long noteId, String content, Callback callback) {
+ executor.execute(() -> {
+ try {
+ ContentValues values = new ContentValues();
+ long currentTime = System.currentTimeMillis();
+
+ values.put(NoteColumns.SNIPPET, extractSnippet(content));
+ values.put(NoteColumns.MODIFIED_DATE, currentTime);
+ 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) {
+ // 查询现有的文本数据记录
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_DATA_URI,
+ new String[]{DataColumns.ID},
+ DataColumns.NOTE_ID + " = ? AND " + DataColumns.MIME_TYPE + " = ?",
+ new String[]{String.valueOf(noteId), TextNote.CONTENT_ITEM_TYPE},
+ null
+ );
+
+ long dataId = 0;
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ dataId = cursor.getLong(cursor.getColumnIndexOrThrow(DataColumns.ID));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ // 更新或插入文本数据
+ ContentValues dataValues = new ContentValues();
+ dataValues.put(DataColumns.CONTENT, content);
+
+ if (dataId > 0) {
+ // 更新现有记录
+ Uri dataUri = ContentUris.withAppendedId(Notes.CONTENT_DATA_URI, dataId);
+ int dataRows = contentResolver.update(dataUri, dataValues, null, null);
+ if (dataRows > 0) {
+ callback.onSuccess(rows);
+ Log.d(TAG, "Successfully updated note: " + noteId);
+ } else {
+ callback.onError(new RuntimeException("Failed to update note data"));
+ }
+ } else {
+ // 插入新记录
+ dataValues.put(DataColumns.NOTE_ID, noteId);
+ dataValues.put(DataColumns.MIME_TYPE, TextNote.CONTENT_ITEM_TYPE);
+ Uri dataUri = contentResolver.insert(Notes.CONTENT_DATA_URI, dataValues);
+ if (dataUri != null) {
+ callback.onSuccess(rows);
+ Log.d(TAG, "Successfully updated note: " + noteId);
+ } else {
+ callback.onError(new RuntimeException("Failed to insert note data"));
+ }
+ }
+ } else {
+ callback.onError(new RuntimeException("No note found with ID: " + noteId));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to update note: " + noteId, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 删除笔记
+ *
+ * 将笔记移动到回收站文件夹
+ *
+ *
+ * @param noteId 笔记ID
+ * @param callback 回调接口,返回影响的行数
+ */
+ public void deleteNote(long noteId, Callback callback) {
+ executor.execute(() -> {
+ try {
+ 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, noteId);
+ int rows = contentResolver.update(uri, values, null, null);
+
+ if (rows > 0) {
+ callback.onSuccess(rows);
+ Log.d(TAG, "Successfully moved note to trash: " + noteId);
+ } else {
+ callback.onError(new RuntimeException("No note found with ID: " + noteId));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to delete note: " + noteId, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 批量删除笔记(逻辑删除,移动到回收站)
+ *
+ * @param noteIds 笔记ID列表
+ * @param callback 回调接口,返回影响的行数
+ */
+ public void deleteNotes(List noteIds, Callback callback) {
+ executor.execute(() -> {
+ try {
+ if (noteIds == null || noteIds.isEmpty()) {
+ callback.onError(new IllegalArgumentException("Note IDs list is empty"));
+ return;
+ }
+
+ int totalRows = 0;
+ for (Long noteId : noteIds) {
+ 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, noteId);
+ int rows = contentResolver.update(uri, values, null, null);
+ totalRows += rows;
+ }
+
+ if (totalRows > 0) {
+ callback.onSuccess(totalRows);
+ Log.d(TAG, "Successfully moved " + totalRows + " notes to trash");
+ } else {
+ callback.onError(new RuntimeException("No notes were deleted"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to batch delete notes", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 恢复笔记(从回收站移回根目录)
+ *
+ * @param noteIds 笔记ID列表
+ * @param callback 回调接口
+ */
+ public void restoreNotes(List noteIds, Callback callback) {
+ executor.execute(() -> {
+ try {
+ if (noteIds == null || noteIds.isEmpty()) {
+ callback.onError(new IllegalArgumentException("Note IDs list is empty"));
+ return;
+ }
+
+ int totalRows = 0;
+ // 恢复到根目录
+ long targetFolderId = Notes.ID_ROOT_FOLDER;
+
+ for (Long noteId : noteIds) {
+ ContentValues values = new ContentValues();
+ values.put(NoteColumns.PARENT_ID, targetFolderId);
+ values.put(NoteColumns.LOCAL_MODIFIED, 1);
+
+ Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
+ int rows = contentResolver.update(uri, values, null, null);
+ totalRows += rows;
+ }
+
+ if (totalRows > 0) {
+ callback.onSuccess(totalRows);
+ Log.d(TAG, "Successfully restored " + totalRows + " notes");
+ } else {
+ callback.onError(new RuntimeException("No notes were restored"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to restore notes", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 永久删除笔记(物理删除)
+ *
+ * @param noteIds 笔记ID列表
+ * @param callback 回调接口
+ */
+ public void deleteNotesForever(List noteIds, Callback callback) {
+ executor.execute(() -> {
+ try {
+ if (noteIds == null || noteIds.isEmpty()) {
+ callback.onError(new IllegalArgumentException("Note IDs list is empty"));
+ return;
+ }
+
+ int totalRows = 0;
+ for (Long noteId : noteIds) {
+ Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
+ int rows = contentResolver.delete(uri, null, null);
+ totalRows += rows;
+ }
+
+ if (totalRows > 0) {
+ callback.onSuccess(totalRows);
+ Log.d(TAG, "Successfully permanently deleted " + totalRows + " notes");
+ } else {
+ callback.onError(new RuntimeException("No notes were deleted"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to delete notes forever", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 搜索笔记
+ *
+ * 根据关键字在标题和内容中搜索笔记
+ *
+ *
+ * @param keyword 搜索关键字
+ * @param callback 回调接口
+ */
+ public void searchNotes(String keyword, Callback> callback) {
+ executor.execute(() -> {
+ try {
+ if (keyword == null || keyword.trim().isEmpty()) {
+ callback.onSuccess(new ArrayList<>());
+ return;
+ }
+
+ String selection = "(" + NoteColumns.TYPE + " = ?) AND (" +
+ NoteColumns.SNIPPET + " LIKE ? OR " +
+ NoteColumns.ID + " IN (SELECT " + DataColumns.NOTE_ID +
+ " FROM data WHERE " + DataColumns.CONTENT + " LIKE ?))";
+
+ String[] selectionArgs = new String[]{
+ String.valueOf(Notes.TYPE_NOTE),
+ "%" + keyword + "%",
+ "%" + keyword + "%"
+ };
+
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_NOTE_URI,
+ null,
+ selection,
+ selectionArgs,
+ NoteColumns.MODIFIED_DATE + " DESC"
+ );
+
+ List notes = new ArrayList<>();
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ notes.add(noteFromCursor(cursor));
+ }
+ Log.d(TAG, "Search returned " + cursor.getCount() + " results for: " + keyword);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ callback.onSuccess(notes);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to search notes: " + keyword, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 获取笔记统计信息
+ *
+ * 统计指定文件夹下的笔记数量
+ *
+ *
+ * @param folderId 文件夹ID
+ * @param callback 回调接口
+ */
+ public void countNotes(long folderId, Callback callback) {
+ executor.execute(() -> {
+ try {
+ String selection;
+ String[] selectionArgs;
+
+ if (folderId == Notes.ID_ROOT_FOLDER) {
+ selection = NoteColumns.TYPE + " != ?";
+ selectionArgs = new String[]{String.valueOf(Notes.TYPE_FOLDER)};
+ } else {
+ selection = NoteColumns.PARENT_ID + " = ?";
+ selectionArgs = new String[]{String.valueOf(folderId)};
+ }
+
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_NOTE_URI,
+ new String[]{"COUNT(*) AS count"},
+ selection,
+ selectionArgs,
+ null
+ );
+
+ int count = 0;
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ count = cursor.getInt(0);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ callback.onSuccess(count);
+ Log.d(TAG, "Counted " + count + " notes in folder: " + folderId);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to count notes in folder: " + folderId, e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 获取文件夹列表
+ *
+ * 查询所有文件夹类型的笔记
+ *
+ *
+ * @param callback 回调接口
+ */
+ public void getFolders(Callback> callback) {
+ executor.execute(() -> {
+ try {
+ String selection = NoteColumns.TYPE + " = ?";
+ String[] selectionArgs = new String[]{
+ String.valueOf(Notes.TYPE_FOLDER)
+ };
+
+ Cursor cursor = contentResolver.query(
+ Notes.CONTENT_NOTE_URI,
+ null,
+ selection,
+ selectionArgs,
+ NoteColumns.MODIFIED_DATE + " DESC"
+ );
+
+ List folders = new ArrayList<>();
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ folders.add(noteFromCursor(cursor));
+ }
+ Log.d(TAG, "Found " + cursor.getCount() + " folders");
+ } finally {
+ cursor.close();
+ }
+ }
+
+ callback.onSuccess(folders);
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to load folders", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 批量移动笔记到指定文件夹
+ *
+ * 将笔记从当前文件夹移动到目标文件夹
+ *
+ *
+ * @param noteIds 要移动的笔记ID列表
+ * @param targetFolderId 目标文件夹ID
+ * @param callback 回调接口
+ */
+ public void moveNotes(List noteIds, long targetFolderId, Callback callback) {
+ executor.execute(() -> {
+ try {
+ if (noteIds == null || noteIds.isEmpty()) {
+ callback.onError(new IllegalArgumentException("Note IDs list is empty"));
+ return;
+ }
+
+ int totalRows = 0;
+ for (Long noteId : noteIds) {
+ ContentValues values = new ContentValues();
+ values.put(NoteColumns.PARENT_ID, targetFolderId);
+ values.put(NoteColumns.LOCAL_MODIFIED, 1);
+
+ Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
+ int rows = contentResolver.update(uri, values, null, null);
+ totalRows += rows;
+ }
+
+ if (totalRows > 0) {
+ callback.onSuccess(totalRows);
+ Log.d(TAG, "Successfully moved " + totalRows + " notes to folder: " + targetFolderId);
+ } else {
+ callback.onError(new RuntimeException("No notes were moved"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to move notes", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 批量更新笔记置顶状态
+ *
+ * @param noteIds 笔记ID列表
+ * @param isPinned 是否置顶
+ * @param callback 回调接口
+ */
+ public void batchTogglePin(List noteIds, boolean isPinned, Callback callback) {
+ executor.execute(() -> {
+ try {
+ if (noteIds == null || noteIds.isEmpty()) {
+ callback.onError(new IllegalArgumentException("Note IDs list is empty"));
+ return;
+ }
+
+ int totalRows = 0;
+ ContentValues values = new ContentValues();
+ values.put(NoteColumns.TOP, isPinned ? 1 : 0);
+ values.put(NoteColumns.LOCAL_MODIFIED, 1);
+
+ for (Long noteId : noteIds) {
+ Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId);
+ int rows = contentResolver.update(uri, values, null, null);
+ totalRows += rows;
+ }
+
+ if (totalRows > 0) {
+ callback.onSuccess(totalRows);
+ Log.d(TAG, "Successfully updated pin state for " + totalRows + " notes");
+ } else {
+ callback.onError(new RuntimeException("No notes were updated"));
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to update pin state", e);
+ callback.onError(e);
+ }
+ });
+ }
+
+ /**
+ * 从内容中提取摘要
+ *
+ * @param content 笔记内容
+ * @return 摘要文本(最多100个字符)
+ */
+ private String extractSnippet(String content) {
+ if (content == null || content.isEmpty()) {
+ return "";
+ }
+ int maxLength = 100;
+ return content.length() > maxLength
+ ? content.substring(0, maxLength)
+ : content;
+ }
+
+ /**
+ * 关闭Executor
+ *
+ * 在不再需要数据访问时调用,释放线程池资源
+ *
+ */
+ public void shutdown() {
+ if (executor != null && !executor.isShutdown()) {
+ executor.shutdown();
+ Log.d(TAG, "Executor shutdown");
+ }
+ }
+}
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
new file mode 100644
index 0000000..f3a2df4
--- /dev/null
+++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java
@@ -0,0 +1,343 @@
+/*
+ * 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.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import net.micode.notes.R;
+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 android.util.Log;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 便签列表适配器
+ *
+ * 将 List 数据绑定到 ListView
+ * 支持便签显示、选中状态、图标显示
+ *
+ *
+ * 现代化实现:使用 ViewHolder 模式优化性能
+ *
+ */
+public class NoteInfoAdapter extends BaseAdapter {
+ private LayoutInflater inflater;
+ private List notes;
+ private HashSet selectedIds;
+ private OnNoteButtonClickListener buttonClickListener;
+ private OnNoteItemClickListener itemClickListener;
+ private OnNoteItemLongClickListener itemLongClickListener;
+
+ /**
+ * 便签按钮点击事件回调接口
+ */
+ public interface OnNoteButtonClickListener {
+ /**
+ * 编辑按钮点击事件
+ *
+ * @param position 位置
+ * @param noteId 便签 ID
+ */
+ void onEditButtonClick(int position, long noteId);
+ }
+
+ /**
+ * 便签项点击事件回调接口
+ */
+ public interface OnNoteItemClickListener {
+ void onNoteItemClick(int position, long noteId);
+ }
+
+ /**
+ * 便签项长按事件回调接口
+ */
+ public interface OnNoteItemLongClickListener {
+ void onNoteItemLongClick(int position, long noteId);
+ }
+
+ /**
+ * 构造函数
+ *
+ * @param context 上下文
+ */
+ public NoteInfoAdapter(Context context) {
+ this.inflater = LayoutInflater.from(context);
+ this.notes = new ArrayList<>();
+ this.selectedIds = new HashSet<>();
+ }
+
+ /**
+ * 设置便签列表
+ *
+ * @param notes 便签列表
+ */
+ public void setNotes(List notes) {
+ this.notes = notes != null ? notes : new ArrayList<>();
+ notifyDataSetChanged();
+ }
+
+ /**
+ * 设置选中的便签 ID 集合
+ *
+ * 用于多选模式同步,让 ViewModel 更新 selectedNoteIds 后,
+ * Adapter 的 selectedIds 也能同步更新
+ *
+ *
+ * @param selectedIds 选中的便签 ID 集合
+ */
+ public void setSelectedIds(HashSet selectedIds) {
+ if (selectedIds != null && selectedIds != this.selectedIds) {
+ this.selectedIds.clear();
+ this.selectedIds.addAll(selectedIds);
+ notifyDataSetChanged();
+ } else if (selectedIds == null) {
+ this.selectedIds.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * 设置选中的便签 ID 列表
+ *
+ * 重载方法,接受 List 参数,在内部转换为 HashSet
+ *
+ *
+ * @param selectedIds 选中的便签 ID 列表
+ */
+ public void setSelectedIds(List selectedIds) {
+ if (selectedIds != null && !selectedIds.isEmpty()) {
+ this.selectedIds.clear();
+ this.selectedIds.addAll(selectedIds);
+ notifyDataSetChanged();
+ } else {
+ this.selectedIds.clear();
+ notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * 获取选中的便签 ID
+ *
+ * @return 选中的便签 ID 集合
+ */
+ public HashSet getSelectedIds() {
+ return selectedIds;
+ }
+
+ /**
+ * 切换选中状态
+ *
+ * @param noteId 便签 ID
+ */
+ public void toggleSelection(long noteId) {
+ if (selectedIds.contains(noteId)) {
+ selectedIds.remove(noteId);
+ } else {
+ selectedIds.add(noteId);
+ }
+ notifyDataSetChanged();
+ }
+
+ /**
+ * 设置按钮点击监听器
+ *
+ * @param listener 监听器
+ */
+ public void setOnNoteButtonClickListener(OnNoteButtonClickListener listener) {
+ this.buttonClickListener = listener;
+ }
+
+ public void setOnNoteItemClickListener(OnNoteItemClickListener listener) {
+ this.itemClickListener = listener;
+ }
+
+ 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;
+ }
+
+ @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);
+ holder = new ViewHolder();
+ 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);
+ 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());
+ }
+ }
+ 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;
+ });
+ } else {
+ holder = (ViewHolder) convertView.getTag();
+ }
+
+ holder.position = position;
+
+ NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) getItem(position);
+ if (note != null) {
+ String title = note.snippet;
+ if (title == null || title.trim().isEmpty()) {
+ title = "无标题";
+ }
+ holder.title.setText(title);
+
+ // 设置类型图标和时间显示
+ if (note.type == Notes.TYPE_FOLDER) {
+ // 文件夹
+ holder.typeIcon.setVisibility(View.VISIBLE);
+ holder.typeIcon.setImageResource(R.drawable.ic_folder);
+ // 文件夹不显示时间
+ holder.time.setVisibility(View.GONE);
+ } else {
+ // 便签
+ holder.typeIcon.setVisibility(View.GONE);
+ 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) {
+ bgResId = NoteItemBgResources.getNoteBgFirstRes(bgColorId);
+ } else if (position == totalCount - 1) {
+ bgResId = NoteItemBgResources.getNoteBgLastRes(bgColorId);
+ } else {
+ bgResId = NoteItemBgResources.getNoteBgNormalRes(bgColorId);
+ }
+
+ convertView.setBackgroundResource(bgResId);
+
+ if (selectedIds.contains(note.getId())) {
+ convertView.setActivated(true);
+ } else {
+ convertView.setActivated(false);
+ }
+
+ Log.d("NoteInfoAdapter", "===== Setting checkbox visibility =====");
+ Log.d("NoteInfoAdapter", "selectedIds.isEmpty(): " + selectedIds.isEmpty());
+ Log.d("NoteInfoAdapter", "selectedIds.size(): " + selectedIds.size());
+ Log.d("NoteInfoAdapter", "selectedIds contains note " + note.getId() + ": " + selectedIds.contains(note.getId()));
+
+ if (!selectedIds.isEmpty()) {
+ Log.d("NoteInfoAdapter", "Setting checkbox VISIBLE");
+ holder.checkBox.setVisibility(View.VISIBLE);
+ holder.checkBox.setChecked(selectedIds.contains(note.getId()));
+ holder.checkBox.setClickable(false);
+ } else {
+ Log.d("NoteInfoAdapter", "Setting checkbox GONE");
+ holder.checkBox.setVisibility(View.GONE);
+ }
+ Log.d("NoteInfoAdapter", "===== Checkbox visibility set =====");
+
+ if (note.isPinned) {
+ holder.pinnedIcon.setVisibility(View.VISIBLE);
+ } else {
+ holder.pinnedIcon.setVisibility(View.GONE);
+ }
+ }
+
+ return convertView;
+ }
+
+ /**
+ * 格式化日期
+ *
+ * @param timestamp 时间戳
+ * @return 格式化后的日期字符串
+ */
+ private String formatDate(long timestamp) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
+ return sdf.format(new Date(timestamp));
+ }
+
+ /**
+ * ViewHolder 模式:优化 ListView 性能
+ */
+ private static class ViewHolder {
+ TextView title;
+ TextView time;
+ ImageView typeIcon;
+ CheckBox checkBox;
+ ImageView pinnedIcon;
+ int position;
+ }
+}
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 067fc53..6337608 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
@@ -255,6 +255,7 @@ public class NotesListActivity extends AppCompatActivity
@Override
public void onChanged(List notes) {
updateAdapter(notes);
+ updateToolbarForNormalMode();
}
});
@@ -417,7 +418,10 @@ public class NotesListActivity extends AppCompatActivity
Log.d(TAG, "Normal mode, checking item type");
NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position);
if (note != null) {
- if (note.type == Notes.TYPE_FOLDER) {
+ if (viewModel.isTrashMode()) {
+ // 回收站模式:弹出恢复/删除对话框
+ showTrashItemDialog(note);
+ } else if (note.type == Notes.TYPE_FOLDER) {
// 文件夹:进入该文件夹
Log.d(TAG, "Folder clicked, entering folder: " + note.getId());
viewModel.enterFolder(note.getId());
@@ -453,6 +457,50 @@ public class NotesListActivity extends AppCompatActivity
}
}
+ /**
+ * 显示回收站条目操作对话框
+ */
+ private void showTrashItemDialog(NotesRepository.NoteInfo note) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle("操作确认");
+ builder.setMessage("要恢复还是永久删除此笔记?");
+ builder.setPositiveButton("恢复", (dialog, which) -> {
+ // 临时将选中的ID设为当前ID,以便复用ViewModel的restoreSelectedNotes
+ // 这里为了简单,我们直接调用ViewModel的新方法restoreNote(note.getId())
+ // 但ViewModel还没这个方法,所以我们先手动构造一个List
+ viewModel.clearSelection();
+ viewModel.toggleNoteSelection(note.getId(), true);
+ viewModel.restoreSelectedNotes();
+ });
+ builder.setNegativeButton("永久删除", (dialog, which) -> {
+ showDeleteForeverConfirmDialog(note);
+ });
+ builder.setNeutralButton("再想想", null);
+ builder.show();
+ }
+
+ /**
+ * 显示永久删除确认对话框
+ */
+ private void showDeleteForeverConfirmDialog(NotesRepository.NoteInfo note) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle("永久删除");
+ builder.setMessage("确定要永久删除此笔记吗?删除后无法恢复!");
+ builder.setIcon(android.R.drawable.ic_dialog_alert);
+
+ builder.setPositiveButton("确定", (dialog, which) -> {
+ viewModel.clearSelection();
+ viewModel.toggleNoteSelection(note.getId(), true);
+ viewModel.deleteSelectedNotesForever();
+ });
+
+ // 设置“确定”按钮颜色为深色(通常系统默认就是强调色,如果需要特定颜色需自定义View或Theme)
+ // 这里使用默认样式,通常Positive是强调色
+
+ builder.setNegativeButton("取消", null);
+ builder.show();
+ }
+
/**
* 进入多选模式
*/
@@ -530,8 +578,17 @@ public class NotesListActivity extends AppCompatActivity
private void updateToolbarForNormalMode() {
if (toolbar == null) return;
- // 设置标题为应用名称
- toolbar.setTitle(R.string.app_name);
+ // 清除多选模式菜单
+ toolbar.getMenu().clear();
+
+ // 设置标题
+ if (viewModel.isTrashMode()) {
+ toolbar.setTitle(R.string.menu_trash);
+ } else {
+ toolbar.setTitle(R.string.app_name);
+ // 添加普通模式菜单
+ toolbar.inflateMenu(R.menu.note_list);
+ }
// 设置导航图标为汉堡菜单
toolbar.setNavigationIcon(android.R.drawable.ic_menu_sort_by_size);
@@ -541,11 +598,16 @@ public class NotesListActivity extends AppCompatActivity
}
});
- // 清除多选模式菜单
- toolbar.getMenu().clear();
-
- // 添加普通模式菜单(如果需要)
- // getMenuInflater().inflate(R.menu.note_list_options, menu);
+ // 如果是回收站模式,不显示新建按钮
+ if (viewModel.isTrashMode()) {
+ if (fabNewNote != null) {
+ fabNewNote.setVisibility(View.GONE);
+ }
+ } else {
+ if (fabNewNote != null) {
+ fabNewNote.setVisibility(View.VISIBLE);
+ }
+ }
}
@@ -714,8 +776,8 @@ public class NotesListActivity extends AppCompatActivity
@Override
public void onTrashSelected() {
- // TODO: 实现跳转到回收站
- Log.d(TAG, "Trash selected");
+ // 跳转到回收站
+ viewModel.enterFolder(Notes.ID_TRASH_FOLER);
// 关闭侧栏
if (drawerLayout != null) {
drawerLayout.closeDrawer(findViewById(R.id.sidebar_fragment));
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
new file mode 100644
index 0000000..041f68b
--- /dev/null
+++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java
@@ -0,0 +1,517 @@
+/*
+ * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
+ *
+ * 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.content.Context;
+import android.os.Bundle;
+import android.text.InputFilter;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.micode.notes.R;
+import net.micode.notes.data.Notes;
+import net.micode.notes.data.NotesRepository;
+import net.micode.notes.viewmodel.FolderListViewModel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 侧栏Fragment
+ *
+ * 显示文件夹树、菜单项和操作按钮
+ * 提供文件夹导航、创建、展开/收起等功能
+ *
+ */
+public class SidebarFragment extends Fragment {
+
+ private static final String TAG = "SidebarFragment";
+ private static final int MAX_FOLDER_NAME_LENGTH = 50;
+
+ // 视图组件
+ private RecyclerView rvFolderTree;
+ private TextView tvRootFolder;
+ private TextView menuSync;
+ private TextView menuLogin;
+ private TextView menuExport;
+ private TextView menuSettings;
+ private TextView menuTrash;
+
+ // 适配器和数据
+ private FolderTreeAdapter adapter;
+ private FolderListViewModel viewModel;
+
+ // 单击和双击检测
+ private long lastClickTime = 0;
+ private View lastClickedView = null;
+ private static final long DOUBLE_CLICK_INTERVAL = 300; // 毫秒
+
+ // 回调接口
+ private OnSidebarItemSelectedListener listener;
+
+ /**
+ * 侧栏项选择回调接口
+ */
+ public interface OnSidebarItemSelectedListener {
+ /**
+ * 跳转到指定文件夹
+ * @param folderId 文件夹ID
+ */
+ void onFolderSelected(long folderId);
+
+ /**
+ * 打开回收站
+ */
+ void onTrashSelected();
+
+ /**
+ * 同步
+ */
+ void onSyncSelected();
+
+ /**
+ * 登录
+ */
+ void onLoginSelected();
+
+ /**
+ * 导出
+ */
+ void onExportSelected();
+
+ /**
+ * 设置
+ */
+ void onSettingsSelected();
+
+ /**
+ * 创建文件夹
+ */
+ void onCreateFolder();
+
+ /**
+ * 关闭侧栏
+ */
+ void onCloseSidebar();
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (context instanceof OnSidebarItemSelectedListener) {
+ listener = (OnSidebarItemSelectedListener) context;
+ } else {
+ throw new RuntimeException(context.toString() + " must implement OnSidebarItemSelectedListener");
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ viewModel = new ViewModelProvider(this).get(FolderListViewModel.class);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.sidebar_layout, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ initViews(view);
+ setupListeners();
+ observeViewModel();
+ }
+
+ /**
+ * 刷新文件夹树(供外部调用,如删除笔记后)
+ */
+ public void refreshFolderTree() {
+ if (viewModel != null) {
+ viewModel.loadFolderTree();
+ }
+ }
+
+ /**
+ * 初始化视图
+ */
+ private void initViews(View view) {
+ rvFolderTree = view.findViewById(R.id.rv_folder_tree);
+ tvRootFolder = view.findViewById(R.id.tv_root_folder);
+ menuSync = view.findViewById(R.id.menu_sync);
+ menuLogin = view.findViewById(R.id.menu_login);
+ menuExport = view.findViewById(R.id.menu_export);
+ menuSettings = view.findViewById(R.id.menu_settings);
+ menuTrash = view.findViewById(R.id.menu_trash);
+
+ // 设置RecyclerView
+ rvFolderTree.setLayoutManager(new LinearLayoutManager(requireContext()));
+ adapter = new FolderTreeAdapter(new ArrayList<>(), viewModel);
+ adapter.setOnFolderItemClickListener(this::handleFolderItemClick);
+ rvFolderTree.setAdapter(adapter);
+ }
+
+ /**
+ * 设置监听器
+ */
+ private void setupListeners() {
+ View view = getView();
+ if (view == null) return;
+
+ // 根文件夹(单击展开/收起,双击跳转)
+ setupFolderClickListener(tvRootFolder, Notes.ID_ROOT_FOLDER);
+
+ // 关闭侧栏
+ view.findViewById(R.id.btn_close_sidebar).setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onCloseSidebar();
+ }
+ });
+
+ // 创建文件夹
+ view.findViewById(R.id.btn_create_folder).setOnClickListener(v -> showCreateFolderDialog());
+
+ // 菜单项
+ menuSync.setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onSyncSelected();
+ }
+ });
+
+ menuLogin.setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onLoginSelected();
+ }
+ });
+
+ menuExport.setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onExportSelected();
+ }
+ });
+
+ menuSettings.setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onSettingsSelected();
+ }
+ });
+
+ menuTrash.setOnClickListener(v -> {
+ if (listener != null) {
+ listener.onTrashSelected();
+ }
+ });
+ }
+
+ /**
+ * 设置文件夹的单击/双击监听器
+ */
+ private void setupFolderClickListener(View view, long folderId) {
+ view.setOnClickListener(v -> {
+ android.util.Log.d(TAG, "setupFolderClickListener: folderId=" + folderId);
+ long currentTime = System.currentTimeMillis();
+ if (lastClickedView == view && (currentTime - lastClickTime) < DOUBLE_CLICK_INTERVAL) {
+ android.util.Log.d(TAG, "Double click on root folder, jumping to: " + folderId);
+ // 这是双击,执行跳转
+ if (listener != null) {
+ // 根文件夹也可以跳转(回到根)
+ listener.onFolderSelected(folderId);
+ }
+ // 重置双击状态
+ lastClickTime = 0;
+ lastClickedView = null;
+ } else {
+ android.util.Log.d(TAG, "Single click on root folder, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms");
+ // 可能是单击,延迟处理
+ lastClickTime = currentTime;
+ lastClickedView = view;
+ view.postDelayed(() -> {
+ // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起)
+ if (System.currentTimeMillis() - lastClickTime >= DOUBLE_CLICK_INTERVAL) {
+ android.util.Log.d(TAG, "Toggling root folder expand");
+ toggleFolderExpand(folderId);
+ }
+ }, DOUBLE_CLICK_INTERVAL);
+ }
+ });
+ }
+
+ /**
+ * 观察ViewModel数据变化
+ */
+ private void observeViewModel() {
+ viewModel.getFolderTree().observe(getViewLifecycleOwner(), folderItems -> {
+ if (folderItems != null) {
+ adapter.setData(folderItems);
+ adapter.notifyDataSetChanged();
+ }
+ });
+
+ viewModel.loadFolderTree();
+ }
+
+ /**
+ * 切换文件夹展开/收起状态
+ */
+ private void toggleFolderExpand(long folderId) {
+ android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId);
+ viewModel.toggleFolderExpand(folderId);
+ }
+
+ /**
+ * 处理文件夹项点击(单击/双击)
+ */
+ private void handleFolderItemClick(long folderId) {
+ android.util.Log.d(TAG, "handleFolderItemClick: folderId=" + folderId);
+ long currentTime = System.currentTimeMillis();
+ if (lastClickedFolderId == folderId && (currentTime - lastFolderClickTime) < DOUBLE_CLICK_INTERVAL) {
+ android.util.Log.d(TAG, "Double click detected, jumping to folder: " + folderId);
+ // 这是双击,执行跳转
+ if (listener != null) {
+ listener.onFolderSelected(folderId);
+ }
+ // 重置双击状态
+ lastFolderClickTime = 0;
+ lastClickedFolderId = -1;
+ } else {
+ android.util.Log.d(TAG, "Single click, will toggle expand in " + DOUBLE_CLICK_INTERVAL + "ms");
+ // 可能是单击,延迟处理
+ lastFolderClickTime = currentTime;
+ lastClickedFolderId = folderId;
+ new android.os.Handler().postDelayed(() -> {
+ // 如果在延迟期间没有发生双击,则执行单击操作(展开/收起)
+ if (System.currentTimeMillis() - lastFolderClickTime >= DOUBLE_CLICK_INTERVAL) {
+ android.util.Log.d(TAG, "Toggling folder expand: " + folderId);
+ toggleFolderExpand(folderId);
+ }
+ }, DOUBLE_CLICK_INTERVAL);
+ }
+ }
+
+ // 双击检测专用变量(针对文件夹列表项)
+ private long lastFolderClickTime = 0;
+ private long lastClickedFolderId = -1;
+
+ /**
+ * 显示创建文件夹对话框
+ */
+ private void showCreateFolderDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
+ builder.setTitle(R.string.dialog_create_folder_title);
+
+ final EditText input = new EditText(requireContext());
+ input.setHint(R.string.dialog_create_folder_hint);
+ input.setFilters(new InputFilter[]{new InputFilter.LengthFilter(MAX_FOLDER_NAME_LENGTH)});
+
+ builder.setView(input);
+
+ builder.setPositiveButton(R.string.menu_create_folder, (dialog, which) -> {
+ String folderName = input.getText().toString().trim();
+ if (TextUtils.isEmpty(folderName)) {
+ Toast.makeText(requireContext(), R.string.error_folder_name_empty, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (folderName.length() > MAX_FOLDER_NAME_LENGTH) {
+ Toast.makeText(requireContext(), R.string.error_folder_name_too_long, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ // 创建文件夹
+ NotesRepository repository = new NotesRepository(requireContext().getContentResolver());
+ long parentId = viewModel.getCurrentFolderId();
+ if (parentId == 0) {
+ parentId = Notes.ID_ROOT_FOLDER;
+ }
+ repository.createFolder(parentId, folderName,
+ new NotesRepository.Callback() {
+ @Override
+ public void onSuccess(Long folderId) {
+ if (getActivity() != null) {
+ getActivity().runOnUiThread(() -> {
+ Toast.makeText(requireContext(), R.string.create_folder_success, Toast.LENGTH_SHORT).show();
+ // 刷新文件夹列表
+ viewModel.loadFolderTree();
+ });
+ }
+ }
+
+ @Override
+ public void onError(Exception error) {
+ if (getActivity() != null) {
+ getActivity().runOnUiThread(() -> {
+ Toast.makeText(requireContext(),
+ getString(R.string.error_folder_name_too_long) + ": " + error.getMessage(),
+ Toast.LENGTH_SHORT).show();
+ });
+ }
+ }
+ });
+ });
+
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.show();
+ }
+
+ /**
+ * FolderTreeAdapter
+ * 文件夹树适配器,支持层级显示和展开/收起
+ */
+ private static class FolderTreeAdapter extends RecyclerView.Adapter {
+
+ private List folderItems;
+ private FolderListViewModel viewModel;
+ private OnFolderItemClickListener folderItemClickListener;
+
+ public FolderTreeAdapter(List folderItems, FolderListViewModel viewModel) {
+ this.folderItems = folderItems;
+ this.viewModel = viewModel;
+ }
+
+ public void setData(List folderItems) {
+ this.folderItems = folderItems;
+ }
+
+ public void setOnFolderItemClickListener(OnFolderItemClickListener listener) {
+ this.folderItemClickListener = 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);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull FolderViewHolder holder, int position) {
+ FolderTreeItem item = folderItems.get(position);
+ boolean isExpanded = viewModel != null && viewModel.isFolderExpanded(item.folderId);
+ holder.bind(item, isExpanded);
+ }
+
+ @Override
+ public int getItemCount() {
+ return folderItems.size();
+ }
+
+ static class FolderViewHolder extends RecyclerView.ViewHolder {
+ private View indentView;
+ private ImageView ivExpandIcon;
+ private ImageView ivFolderIcon;
+ private TextView tvFolderName;
+ private TextView tvNoteCount;
+ private FolderTreeItem currentItem;
+ private final OnFolderItemClickListener folderItemClickListener;
+
+ public FolderViewHolder(@NonNull View itemView, OnFolderItemClickListener listener) {
+ super(itemView);
+ this.folderItemClickListener = listener;
+ indentView = itemView.findViewById(R.id.indent_view);
+ ivExpandIcon = itemView.findViewById(R.id.iv_expand_icon);
+ ivFolderIcon = itemView.findViewById(R.id.iv_folder_icon);
+ tvFolderName = itemView.findViewById(R.id.tv_folder_name);
+ tvNoteCount = itemView.findViewById(R.id.tv_note_count);
+ }
+
+ public void bind(FolderTreeItem item, boolean isExpanded) {
+ this.currentItem = item;
+
+ // 设置缩进
+ int indent = item.level * 32;
+ indentView.setLayoutParams(new LinearLayout.LayoutParams(indent, LinearLayout.LayoutParams.MATCH_PARENT));
+
+ // 设置展开/收起图标
+ if (item.hasChildren) {
+ ivExpandIcon.setVisibility(View.VISIBLE);
+ ivExpandIcon.setRotation(isExpanded ? 90 : 0);
+ } else {
+ ivExpandIcon.setVisibility(View.INVISIBLE);
+ }
+
+ // 设置文件夹名称
+ tvFolderName.setText(item.name);
+
+ // 设置便签数量
+ tvNoteCount.setText(String.format(itemView.getContext()
+ .getString(R.string.folder_note_count), item.noteCount));
+
+ // 设置点击监听器
+ itemView.setOnClickListener(v -> {
+ if (folderItemClickListener != null) {
+ folderItemClickListener.onFolderClick(item.folderId);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * 文件夹项点击监听器接口
+ */
+ public interface OnFolderItemClickListener {
+ void onFolderClick(long folderId);
+ }
+
+ /**
+ * FolderTreeItem
+ * 文件夹树项数据模型
+ */
+ public static class FolderTreeItem {
+ public long folderId;
+ public String name;
+ public int level; // 层级,0表示顶级
+ public boolean hasChildren;
+ public int noteCount;
+
+ public FolderTreeItem(long folderId, String name, int level, boolean hasChildren, int noteCount) {
+ this.folderId = folderId;
+ this.name = name;
+ this.level = level;
+ this.hasChildren = hasChildren;
+ this.noteCount = noteCount;
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ listener = null;
+ }
+}
diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java
new file mode 100644
index 0000000..d93e02d
--- /dev/null
+++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/FolderListViewModel.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net)
+ *
+ * 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.viewmodel;
+
+import android.app.Application;
+import android.database.Cursor;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import net.micode.notes.data.Notes;
+import net.micode.notes.data.NotesDatabaseHelper;
+import net.micode.notes.data.NotesDatabaseHelper.TABLE;
+import net.micode.notes.data.Notes.NoteColumns;
+import net.micode.notes.data.NotesRepository;
+import net.micode.notes.ui.SidebarFragment.FolderTreeItem;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 文件夹列表ViewModel
+ *
+ * 管理文件夹树的数据和业务逻辑
+ * 提供文件夹树的查询和构建功能
+ *
+ */
+public class FolderListViewModel extends AndroidViewModel {
+ private static final String TAG = "FolderListViewModel";
+
+ private MutableLiveData> folderTreeLiveData;
+ private NotesDatabaseHelper dbHelper;
+ private NotesRepository repository;
+ private long currentFolderId = Notes.ID_ROOT_FOLDER; // 当前文件夹ID
+ private Set expandedFolderIds = new HashSet<>(); // 已展开的文件夹ID集合
+
+ public FolderListViewModel(@NonNull Application application) {
+ super(application);
+ dbHelper = NotesDatabaseHelper.getInstance(application);
+ repository = new NotesRepository(application.getContentResolver());
+ folderTreeLiveData = new MutableLiveData<>();
+ }
+
+ /**
+ * 获取当前文件夹ID
+ */
+ public long getCurrentFolderId() {
+ return currentFolderId;
+ }
+
+ /**
+ * 设置当前文件夹ID
+ */
+ public void setCurrentFolderId(long folderId) {
+ this.currentFolderId = folderId;
+ }
+
+ /**
+ * 切换文件夹展开/收起状态
+ * @param folderId 文件夹ID
+ */
+ public void toggleFolderExpand(long folderId) {
+ android.util.Log.d(TAG, "toggleFolderExpand: folderId=" + folderId);
+ android.util.Log.d(TAG, "Before toggle, expandedFolders: " + expandedFolderIds);
+
+ if (expandedFolderIds.contains(folderId)) {
+ expandedFolderIds.remove(folderId);
+ android.util.Log.d(TAG, "Collapsed folder: " + folderId);
+ } else {
+ expandedFolderIds.add(folderId);
+ android.util.Log.d(TAG, "Expanded folder: " + folderId);
+ }
+
+ android.util.Log.d(TAG, "After toggle, expandedFolders: " + expandedFolderIds);
+
+ // 重新加载文件夹树
+ loadFolderTree();
+ }
+
+ /**
+ * 检查文件夹是否已展开
+ * @param folderId 文件夹ID
+ * @return 是否已展开
+ */
+ public boolean isFolderExpanded(long folderId) {
+ return expandedFolderIds.contains(folderId);
+ }
+
+ /**
+ * 获取文件夹树LiveData
+ */
+ public LiveData> getFolderTree() {
+ return folderTreeLiveData;
+ }
+
+ /**
+ * 加载文件夹树数据
+ */
+ public void loadFolderTree() {
+ new Thread(() -> {
+ List folderTree = buildFolderTree();
+ folderTreeLiveData.postValue(folderTree);
+ }).start();
+ }
+
+ /**
+ * 构建文件夹树
+ *
+ * 从数据库中查询所有文件夹,并构建层级结构
+ *
+ * @return 文件夹树列表
+ */
+ private List buildFolderTree() {
+ // 查询所有文件夹(不包括系统文件夹)
+ List