扩展功能:

1.回收站功能扩展

问题:
1.闪退问题
2.便签和文件夹区分度不高问题
3.Trash和Notes在界面显示混乱问题
pull/14/head
蒋天翔 1 month ago
parent 5d7ba7c9a1
commit 03f92f5d58

@ -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;
/**
*
* <p>
* 访Content Provider
*
* </p>
* <p>
* 使Executor线访UI线
* </p>
*
* @see Note
* @see Notes
*/
public class NotesRepository {
/**
*
* <p>
*
* </p>
*/
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)";
/**
* 访
* <p>
* 访
* </p>
*
* @param <T>
*/
public interface Callback<T> {
/**
*
*
* @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;
}
/**
*
* <p>
* ContentResolver线
* </p>
*
* @param contentResolver Content
*/
public NotesRepository(ContentResolver contentResolver) {
this.contentResolver = contentResolver;
// 使用单线程Executor确保数据访问的顺序性
this.executor = java.util.concurrent.Executors.newSingleThreadExecutor();
Log.d(TAG, "NotesRepository initialized");
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID{@link Notes#ID_ROOT_FOLDER}
* @param callback
*/
public void getNotes(long folderId, Callback<List<NoteInfo>> callback) {
executor.execute(() -> {
try {
List<NoteInfo> 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);
}
});
}
/**
*
* <p>
* 便便
* </p>
*
* @param folderId ID
* @return 便
*/
private List<NoteInfo> queryNotes(long folderId) {
List<NoteInfo> notes = new ArrayList<>();
List<NoteInfo> folders = new ArrayList<>();
List<NoteInfo> 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<Long> callback) {
executor.execute(() -> {
try {
long parentId = getParentFolderId(folderId);
callback.onSuccess(parentId);
} catch (Exception e) {
callback.onError(e);
}
});
}
/**
* ID
*
* @param folderId ID
* @return IDID
*/
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<NoteInfo> getFolderPath(long folderId) {
List<NoteInfo> 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<List<NoteInfo>> callback) {
executor.execute(() -> {
try {
List<NoteInfo> 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<Long> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param callback ID
*/
public void createNote(long folderId, Callback<Long> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteId ID
* @param content
* @param callback
*/
public void updateNote(long noteId, String content, Callback<Integer> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteId ID
* @param callback
*/
public void deleteNote(long noteId, Callback<Integer> 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<Long> noteIds, Callback<Integer> 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<Long> noteIds, Callback<Integer> 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<Long> noteIds, Callback<Integer> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param keyword
* @param callback
*/
public void searchNotes(String keyword, Callback<List<NoteInfo>> 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<NoteInfo> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID
* @param callback
*/
public void countNotes(long folderId, Callback<Integer> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param callback
*/
public void getFolders(Callback<List<NoteInfo>> 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<NoteInfo> 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);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteIds ID
* @param targetFolderId ID
* @param callback
*/
public void moveNotes(List<Long> noteIds, long targetFolderId, Callback<Integer> 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<Long> noteIds, boolean isPinned, Callback<Integer> 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
* <p>
* 访线
* </p>
*/
public void shutdown() {
if (executor != null && !executor.isShutdown()) {
executor.shutdown();
Log.d(TAG, "Executor shutdown");
}
}
}

@ -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;
/**
* 便
* <p>
* List<NoteInfo> ListView
* 便
* </p>
* <p>
* 使 ViewHolder
* </p>
*/
public class NoteInfoAdapter extends BaseAdapter {
private LayoutInflater inflater;
private List<NotesRepository.NoteInfo> notes;
private HashSet<Long> 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<NotesRepository.NoteInfo> notes) {
this.notes = notes != null ? notes : new ArrayList<>();
notifyDataSetChanged();
}
/**
* 便 ID
* <p>
* ViewModel selectedNoteIds
* Adapter selectedIds
* </p>
*
* @param selectedIds 便 ID
*/
public void setSelectedIds(HashSet<Long> selectedIds) {
if (selectedIds != null && selectedIds != this.selectedIds) {
this.selectedIds.clear();
this.selectedIds.addAll(selectedIds);
notifyDataSetChanged();
} else if (selectedIds == null) {
this.selectedIds.clear();
notifyDataSetChanged();
}
}
/**
* 便 ID
* <p>
* List<Long> HashSet
* </p>
*
* @param selectedIds 便 ID
*/
public void setSelectedIds(List<Long> selectedIds) {
if (selectedIds != null && !selectedIds.isEmpty()) {
this.selectedIds.clear();
this.selectedIds.addAll(selectedIds);
notifyDataSetChanged();
} else {
this.selectedIds.clear();
notifyDataSetChanged();
}
}
/**
* 便 ID
*
* @return 便 ID
*/
public HashSet<Long> 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;
}
}

@ -255,6 +255,7 @@ public class NotesListActivity extends AppCompatActivity
@Override
public void onChanged(List<NotesRepository.NoteInfo> 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));

@ -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
* <p>
*
* /
* </p>
*/
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<Long>() {
@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<FolderTreeAdapter.FolderViewHolder> {
private List<FolderTreeItem> folderItems;
private FolderListViewModel viewModel;
private OnFolderItemClickListener folderItemClickListener;
public FolderTreeAdapter(List<FolderTreeItem> folderItems, FolderListViewModel viewModel) {
this.folderItems = folderItems;
this.viewModel = viewModel;
}
public void setData(List<FolderTreeItem> 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;
}
}

@ -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
* <p>
*
*
* </p>
*/
public class FolderListViewModel extends AndroidViewModel {
private static final String TAG = "FolderListViewModel";
private MutableLiveData<List<FolderTreeItem>> folderTreeLiveData;
private NotesDatabaseHelper dbHelper;
private NotesRepository repository;
private long currentFolderId = Notes.ID_ROOT_FOLDER; // 当前文件夹ID
private Set<Long> 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<List<FolderTreeItem>> getFolderTree() {
return folderTreeLiveData;
}
/**
*
*/
public void loadFolderTree() {
new Thread(() -> {
List<FolderTreeItem> folderTree = buildFolderTree();
folderTreeLiveData.postValue(folderTree);
}).start();
}
/**
*
* <p>
*
* </p>
* @return
*/
private List<FolderTreeItem> buildFolderTree() {
// 查询所有文件夹(不包括系统文件夹)
List<Map<String, Object>> folders = queryAllFolders();
android.util.Log.d(TAG, "QueryAllFolders returned " + folders.size() + " folders");
// 构建文件夹映射表(方便查找父文件夹)
Map<Long, FolderNode> folderMap = new HashMap<>();
List<FolderNode> rootFolders = new ArrayList<>();
// 创建文件夹节点
for (Map<String, Object> folder : folders) {
long id = (Long) folder.get(NoteColumns.ID);
String name = (String) folder.get(NoteColumns.SNIPPET);
long parentId = (Long) folder.get(NoteColumns.PARENT_ID);
int noteCount = ((Number) folder.get(NoteColumns.NOTES_COUNT)).intValue();
android.util.Log.d(TAG, "Folder: id=" + id + ", name=" + name + ", parentId=" + parentId);
FolderNode node = new FolderNode(id, name, parentId, noteCount);
folderMap.put(id, node);
// 如果是顶级文件夹(父文件夹为根),添加到根列表
if (parentId == Notes.ID_ROOT_FOLDER) {
rootFolders.add(node);
android.util.Log.d(TAG, "Added root folder: " + name);
}
}
android.util.Log.d(TAG, "Root folders count: " + rootFolders.size());
// 构建父子关系
for (FolderNode node : folderMap.values()) {
if (node.parentId != Notes.ID_ROOT_FOLDER) {
FolderNode parent = folderMap.get(node.parentId);
if (parent != null) {
parent.children.add(node);
}
}
}
// 转换为扁平列表用于RecyclerView显示
List<FolderTreeItem> folderTree = new ArrayList<>();
// 检查根文件夹是否展开
boolean rootExpanded = expandedFolderIds.contains(Notes.ID_ROOT_FOLDER);
android.util.Log.d(TAG, "Root expanded: " + rootExpanded);
buildFolderTreeList(rootFolders, folderTree, 0, rootExpanded);
android.util.Log.d(TAG, "Final folder tree size: " + folderTree.size());
return folderTree;
}
/**
*
*
*
* @param nodes
* @param folderTree
* @param level
* @param forceExpandChildren
*/
private void buildFolderTreeList(List<FolderNode> nodes, List<FolderTreeItem> folderTree, int level, boolean forceExpandChildren) {
for (FolderNode node : nodes) {
// 顶级文件夹始终显示level=0
// 移除了之前的条件判断,让所有顶级文件夹都能显示
folderTree.add(new FolderTreeItem(
node.id,
node.name,
level,
!node.children.isEmpty(),
node.noteCount
));
// 只有当父文件夹在 expandedFolderIds 中时,才递归处理子文件夹
// 有子节点(!node.children.isEmpty())才检查展开状态
if (!node.children.isEmpty() && expandedFolderIds.contains(node.id)) {
buildFolderTreeList(node.children, folderTree, level + 1, false);
}
}
}
/**
*
* @return
*/
private List<Map<String, Object>> queryAllFolders() {
List<Map<String, Object>> folders = new ArrayList<>();
// 查询所有文件夹类型的笔记
String selection = NoteColumns.TYPE + " = ?";
String[] selectionArgs = new String[]{
String.valueOf(Notes.TYPE_FOLDER)
};
Cursor cursor = null;
try {
cursor = dbHelper.getReadableDatabase().query(
TABLE.NOTE,
null,
selection,
selectionArgs,
null,
null,
NoteColumns.MODIFIED_DATE + " DESC"
);
android.util.Log.d(TAG, "Query executed, cursor: " + (cursor != null ? cursor.getCount() : "null"));
if (cursor != null) {
android.util.Log.d(TAG, "Column names: " + java.util.Arrays.toString(cursor.getColumnNames()));
while (cursor.moveToNext()) {
Map<String, Object> folder = new HashMap<>();
long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID));
String name = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET));
// 尝试获取parent_id可能列名不对
int parentIdIndex = cursor.getColumnIndex(NoteColumns.PARENT_ID);
long parentId = -1;
if (parentIdIndex != -1) {
parentId = cursor.getLong(parentIdIndex);
}
// 尝试获取notes_count
int notesCountIndex = cursor.getColumnIndex(NoteColumns.NOTES_COUNT);
int noteCount = 0;
if (notesCountIndex != -1) {
noteCount = cursor.getInt(notesCountIndex);
}
android.util.Log.d(TAG, "Folder data: id=" + id + ", name=" + name + ", parentId=" + parentId + ", noteCount=" + noteCount);
folder.put(NoteColumns.ID, id);
folder.put(NoteColumns.SNIPPET, name);
folder.put(NoteColumns.PARENT_ID, parentId);
folder.put(NoteColumns.NOTES_COUNT, noteCount);
folders.add(folder);
}
}
} catch (Exception e) {
android.util.Log.e(TAG, "Error querying folders", e);
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
}
return folders;
}
/**
* FolderNode
*
*/
private static class FolderNode {
public long id;
public String name;
public long parentId;
public int noteCount;
public List<FolderNode> children = new ArrayList<>();
public FolderNode(long id, String name, long parentId, int noteCount) {
this.id = id;
this.name = name;
this.parentId = parentId;
this.noteCount = noteCount;
}
}
}

@ -0,0 +1,663 @@
/*
* 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.viewmodel;
import android.util.Log;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import net.micode.notes.data.Notes;
import net.micode.notes.data.NotesRepository;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/**
* ViewModel
* <p>
* UIActivity
*
* </p>
*
* @see NotesRepository
* @see Note
*/
public class NotesListViewModel extends ViewModel {
private static final String TAG = "NotesListViewModel";
private final NotesRepository repository;
// 笔记列表LiveData
private final MutableLiveData<List<NotesRepository.NoteInfo>> notesLiveData = new MutableLiveData<>();
// 加载状态LiveData
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
// 错误消息LiveData
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
// 选中的笔记ID集合
private final HashSet<Long> selectedNoteIds = new HashSet<>();
// 当前文件夹ID
private long currentFolderId = Notes.ID_ROOT_FOLDER;
// 文件夹路径LiveData用于面包屑导航
private final MutableLiveData<List<NotesRepository.NoteInfo>> folderPathLiveData = new MutableLiveData<>();
// 侧栏刷新通知LiveData删除等操作后通知侧栏刷新
private final MutableLiveData<Boolean> sidebarRefreshNeeded = new MutableLiveData<>(false);
// 文件夹导航历史(用于返回上一级)
private final List<Long> folderHistory = new ArrayList<>();
/**
*
*
* @param repository
*/
public NotesListViewModel(NotesRepository repository) {
this.repository = repository;
Log.d(TAG, "ViewModel created");
}
/**
* LiveData
*
* @return LiveData
*/
public MutableLiveData<List<NotesRepository.NoteInfo>> getNotesLiveData() {
return notesLiveData;
}
/**
* LiveData
*
* @return LiveData
*/
public MutableLiveData<Boolean> getIsLoading() {
return isLoading;
}
/**
* LiveData
*
* @return LiveData
*/
public MutableLiveData<String> getErrorMessage() {
return errorMessage;
}
/**
*
* <p>
*
* </p>
*
* @param folderId ID{@link Notes#ID_ROOT_FOLDER}
*/
public void loadNotes(long folderId) {
this.currentFolderId = folderId;
isLoading.postValue(true);
errorMessage.postValue(null);
// 加载文件夹路径
repository.getFolderPath(folderId, new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> path) {
folderPathLiveData.postValue(path);
}
@Override
public void onError(Exception error) {
Log.e(TAG, "Failed to load folder path", error);
}
});
// 加载笔记
repository.getNotes(folderId, new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> notes) {
isLoading.postValue(false);
notesLiveData.postValue(notes);
Log.d(TAG, "Successfully loaded " + notes.size() + " notes");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "加载笔记失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*/
public void refreshNotes() {
loadNotes(currentFolderId);
}
/**
*
* <p>
*
* </p>
*/
public void createNote() {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.createNote(currentFolderId, new NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long noteId) {
isLoading.postValue(false);
Log.d(TAG, "Successfully created note with ID: " + noteId);
refreshNotes();
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "创建笔记失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param noteId ID
*/
public void deleteNote(long noteId) {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.deleteNote(noteId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
selectedNoteIds.remove(noteId);
refreshNotes();
Log.d(TAG, "Successfully deleted note: " + noteId);
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "删除笔记失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*/
public void deleteSelectedNotes() {
if (selectedNoteIds.isEmpty()) {
errorMessage.postValue("请先选择要删除的笔记");
return;
}
isLoading.postValue(true);
errorMessage.postValue(null);
List<Long> noteIds = new ArrayList<>(selectedNoteIds);
repository.deleteNotes(noteIds, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
selectedNoteIds.clear();
refreshNotes();
Log.d(TAG, "Successfully deleted " + rowsAffected + " notes");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "批量删除失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param keyword
*/
public void searchNotes(String keyword) {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.searchNotes(keyword, new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> notes) {
isLoading.postValue(false);
notesLiveData.postValue(notes);
Log.d(TAG, "Search returned " + notes.size() + " results");
}
@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 selected
*/
public void toggleNoteSelection(long noteId, boolean selected) {
if (selected) {
selectedNoteIds.add(noteId);
} else {
selectedNoteIds.remove(noteId);
}
}
/**
*
* <p>
*
* </p>
*/
public void selectAllNotes() {
List<NotesRepository.NoteInfo> notes = notesLiveData.getValue();
if (notes != null) {
for (NotesRepository.NoteInfo note : notes) {
selectedNoteIds.add(note.getId());
}
}
}
/**
*
* <p>
*
* </p>
*/
public void deselectAllNotes() {
selectedNoteIds.clear();
}
/**
*
*
* @return true
*/
public boolean isAllSelected() {
List<NotesRepository.NoteInfo> notes = notesLiveData.getValue();
if (notes == null || notes.isEmpty()) {
return false;
}
return notes.size() == selectedNoteIds.size();
}
/**
*
*
* @return
*/
public int getSelectedCount() {
return selectedNoteIds.size();
}
/**
* ID
*
* @return ID
*/
public List<Long> getSelectedNoteIds() {
return new ArrayList<>(selectedNoteIds);
}
/**
* ID
*
* @return ID
*/
public long getCurrentFolderId() {
return currentFolderId;
}
/**
*
*
* @param folderId ID
*/
public void setCurrentFolderId(long folderId) {
this.currentFolderId = folderId;
}
/**
* LiveData
*
* @return LiveData
*/
public MutableLiveData<List<NotesRepository.NoteInfo>> getFolderPathLiveData() {
return folderPathLiveData;
}
/**
* LiveData
*
* @return LiveData
*/
public MutableLiveData<Boolean> getSidebarRefreshNeeded() {
return sidebarRefreshNeeded;
}
/**
*
*/
public void triggerSidebarRefresh() {
sidebarRefreshNeeded.postValue(true);
}
/**
*
*
* @param folderId ID
*/
public void enterFolder(long folderId) {
// 将当前文件夹添加到历史记录
if (currentFolderId != Notes.ID_ROOT_FOLDER && currentFolderId != Notes.ID_CALL_RECORD_FOLDER) {
folderHistory.add(currentFolderId);
}
loadNotes(folderId);
}
/**
*
*
* @return
*/
public boolean navigateUp() {
if (!folderHistory.isEmpty()) {
long parentFolderId = folderHistory.remove(folderHistory.size() - 1);
loadNotes(parentFolderId);
return true;
}
return false;
}
/**
*
* <p>
* 退
* </p>
*/
public void clearSelection() {
selectedNoteIds.clear();
}
/**
*
* <p>
*
* </p>
*/
public void loadFolders() {
isLoading.postValue(true);
errorMessage.postValue(null);
repository.getFolders(new NotesRepository.Callback<List<NotesRepository.NoteInfo>>() {
@Override
public void onSuccess(List<NotesRepository.NoteInfo> folders) {
isLoading.postValue(false);
notesLiveData.postValue(folders);
Log.d(TAG, "Successfully loaded " + folders.size() + " folders");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "加载文件夹失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*
* @param targetFolderId ID
*/
public void moveSelectedNotesToFolder(long targetFolderId) {
if (selectedNoteIds.isEmpty()) {
errorMessage.postValue("请先选择要移动的笔记");
return;
}
isLoading.postValue(true);
errorMessage.postValue(null);
List<Long> noteIds = new ArrayList<>(selectedNoteIds);
repository.moveNotes(noteIds, targetFolderId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
selectedNoteIds.clear();
refreshNotes();
Log.d(TAG, "Successfully moved " + rowsAffected + " notes");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "移动笔记失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
*/
public void toggleSelectedNotesPin() {
if (selectedNoteIds.isEmpty()) {
errorMessage.postValue("请先选择要操作的笔记");
return;
}
isLoading.postValue(true);
errorMessage.postValue(null);
// 检查当前选中笔记的置顶状态
List<NotesRepository.NoteInfo> allNotes = notesLiveData.getValue();
if (allNotes == null) return;
boolean hasUnpinned = false;
for (NotesRepository.NoteInfo note : allNotes) {
if (selectedNoteIds.contains(note.getId())) {
if (!note.isPinned) {
hasUnpinned = true;
break;
}
}
}
// 如果有未置顶的,则全部置顶;否则全部取消置顶
final boolean newPinState = hasUnpinned;
List<Long> noteIds = new ArrayList<>(selectedNoteIds);
repository.batchTogglePin(noteIds, newPinState, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
// 保持选中状态,方便用户查看
refreshNotes();
Log.d(TAG, "Successfully toggled pin state to " + newPinState);
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "置顶操作失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
*
* @return true
*/
public boolean isAllSelectedPinned() {
if (selectedNoteIds.isEmpty()) return false;
List<NotesRepository.NoteInfo> allNotes = notesLiveData.getValue();
if (allNotes == null) return false;
for (NotesRepository.NoteInfo note : allNotes) {
if (selectedNoteIds.contains(note.getId())) {
if (!note.isPinned) {
return false;
}
}
}
return true;
}
/**
*
* <p>
*
* </p>
*/
public void restoreSelectedNotes() {
if (selectedNoteIds.isEmpty()) {
errorMessage.postValue("请先选择要恢复的笔记");
return;
}
isLoading.postValue(true);
errorMessage.postValue(null);
List<Long> noteIds = new ArrayList<>(selectedNoteIds);
repository.restoreNotes(noteIds, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
selectedNoteIds.clear();
refreshNotes();
Log.d(TAG, "Successfully restored " + rowsAffected + " notes");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "恢复失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
* <p>
*
* </p>
*/
public void deleteSelectedNotesForever() {
if (selectedNoteIds.isEmpty()) {
errorMessage.postValue("请先选择要删除的笔记");
return;
}
isLoading.postValue(true);
errorMessage.postValue(null);
List<Long> noteIds = new ArrayList<>(selectedNoteIds);
repository.deleteNotesForever(noteIds, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
isLoading.postValue(false);
selectedNoteIds.clear();
refreshNotes();
Log.d(TAG, "Successfully permanently deleted " + rowsAffected + " notes");
}
@Override
public void onError(Exception error) {
isLoading.postValue(false);
String message = "永久删除失败: " + error.getMessage();
errorMessage.postValue(message);
Log.e(TAG, message, error);
}
});
}
/**
*
*
* @return true
*/
public boolean isTrashMode() {
return currentFolderId == Notes.ID_TRASH_FOLER;
}
/**
* ViewModel
* <p>
*
* </p>
*/
@Override
protected void onCleared() {
super.onCleared();
selectedNoteIds.clear();
Log.d(TAG, "ViewModel cleared");
}
}

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/darker_gray">
<path
android:fillColor="#666666"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFC107"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
</vector>

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="120dp"
android:height="120dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/darker_gray">
<path
android:fillColor="#666666"
android:pathData="M3,18h12v-2H3v2zM3,6v2h18V6H3zM3,13h18v-2H3v2z"/>
</vector>

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<!-- 便签项背景:可点击效果 -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true">
<shape android:shape="rectangle">
<solid android:color="#BBDEFB"/>
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#E0E0E0"/>
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#BBDEFB"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FFFFFF"/>
</shape>
</item>
</selector>

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
面包屑项布局
显示文件夹名称,支持点击和长按
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tv_breadcrumb_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/primary_color"
android:padding="4dp"
android:background="?attr/selectableItemBackground"
android:maxLines="1"
android:ellipsize="end"
android:maxEms="10" />

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
面包屑导航布局
显示当前文件夹路径,支持点击跳转和长按重命名
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/breadcrumb_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:background="@android:color/white"
android:gravity="center_vertical">
<!-- 面包屑项容器(动态添加) -->
<LinearLayout
android:id="@+id/breadcrumb_items"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center_vertical" />
<!-- 文件夹图标 -->
<ImageView
android:id="@+id/iv_folder_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="8dp"
android:src="@android:drawable/ic_menu_myplaces"
android:contentDescription="文件夹"
android:visibility="gone" />
</LinearLayout>

@ -31,6 +31,14 @@
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 类型图标(文件夹/便签) -->
<ImageView
android:id="@+id/iv_type_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
侧栏文件夹项布局
支持展开/收起图标、文件夹图标、名称和数量
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<!-- 缩进(用于子文件夹) -->
<View
android:id="@+id/indent_view"
android:layout_width="0dp"
android:layout_height="match_parent" />
<!-- 展开/收起箭头 -->
<ImageView
android:id="@+id/iv_expand_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/arrow_down_float"
android:contentDescription="展开/收起"
android:scaleType="centerInside"
android:rotation="0" />
<!-- 文件夹图标 -->
<ImageView
android:id="@+id/iv_folder_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:src="@android:drawable/ic_menu_myplaces"
android:contentDescription="文件夹"
android:scaleType="centerInside" />
<!-- 文件夹名称和数量 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical"
android:gravity="center_vertical">
<!-- 文件夹名称 -->
<TextView
android:id="@+id/tv_folder_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="@android:color/black"
android:ellipsize="end"
android:maxLines="1"
tools:text="工作" />
<!-- 文件夹数量 -->
<TextView
android:id="@+id/tv_note_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:ellipsize="end"
android:maxLines="1"
tools:text="5个便签" />
</LinearLayout>
</LinearLayout>

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
侧栏布局文件
包含文件夹树、菜单项、操作栏和回收站入口
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/sidebar_container"
android:layout_width="280dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:orientation="vertical"
android:background="@android:color/white"
android:elevation="8dp">
<!-- 操作栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:background="@android:color/white"
android:elevation="4dp">
<!-- 关闭按钮 -->
<ImageButton
android:id="@+id/btn_close_sidebar"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="关闭侧栏"
android:padding="8dp"
android:src="@android:drawable/ic_menu_close_clear_cancel" />
<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<!-- 创建文件夹按钮 -->
<ImageButton
android:id="@+id/btn_create_folder"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="创建文件夹"
android:padding="8dp"
android:src="@android:drawable/ic_input_add" />
</LinearLayout>
<!-- 分隔线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray" />
<!-- 根文件夹(始终显示) -->
<TextView
android:id="@+id/tv_root_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_menu_myplaces"
android:drawablePadding="12dp"
android:text="@string/root_folder_name"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 分隔线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray" />
<!-- 文件夹树列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_folder_tree"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp"
android:clipToPadding="false" />
<!-- 分隔线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray" />
<!-- 菜单项 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 同步 -->
<TextView
android:id="@+id/menu_sync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_menu_upload"
android:drawablePadding="12dp"
android:text="@string/menu_sync"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 登录 -->
<TextView
android:id="@+id/menu_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_lock_lock"
android:drawablePadding="12dp"
android:text="@string/menu_login"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 导出 -->
<TextView
android:id="@+id/menu_export"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_menu_save"
android:drawablePadding="12dp"
android:text="@string/menu_export"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 设置 -->
<TextView
android:id="@+id/menu_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_menu_preferences"
android:drawablePadding="12dp"
android:text="@string/menu_settings"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
</LinearLayout>
<!-- 分隔线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray" />
<!-- 回收站(固定底部) -->
<TextView
android:id="@+id/menu_trash"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@android:drawable/ic_menu_delete"
android:drawablePadding="12dp"
android:text="@string/menu_trash"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
</LinearLayout>

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
多选模式的Toolbar菜单
包含删除、移动、取消选项
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/multi_select_delete"
android:icon="@android:drawable/ic_menu_delete"
android:title="@string/menu_delete"
android:showAsAction="ifRoom" />
<item
android:id="@+id/multi_select_move"
android:icon="@android:drawable/ic_menu_sort_by_size"
android:title="@string/menu_move"
android:showAsAction="ifRoom" />
<item
android:id="@+id/multi_select_pin"
android:icon="@android:drawable/ic_menu_upload"
android:title="@string/menu_pin"
android:showAsAction="always" />
</menu>

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
多选模式的Toolbar布局
包含删除、移动按钮
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_delete"
android:icon="@android:drawable/ic_menu_delete"
android:title="@string/menu_delete"
android:showAsAction="always"
android:orderInCategory="1" />
<item
android:id="@+id/action_move"
android:icon="@android:drawable/ic_menu_sort_by_size"
android:title="@string/menu_move"
android:showAsAction="always"
android:orderInCategory="2" />
</menu>

@ -0,0 +1,255 @@
/*
* 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 org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import net.micode.notes.model.Note;
import net.micode.notes.data.NotesRepository.NoteInfo;
import java.util.List;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/**
* NotesRepository
* <p>
*
* </p>
*
* @see NotesRepository
*/
@RunWith(MockitoJUnitRunner.class)
public class NotesRepositoryTest {
private static final String TAG = "NotesRepositoryTest";
@Mock
private ContentResolver mockContentResolver;
private NotesRepository repository;
@Before
public void setUp() {
repository = new NotesRepository(mockContentResolver);
}
@After
public void tearDown() {
repository.shutdown();
}
/**
* Repository
*/
@Test
public void testRepositoryCreation() {
assertNotNull("Repository should not be null", repository);
}
/**
*
*/
@Test
public void testGetNotes() {
// Arrange
long folderId = Notes.ID_ROOT_FOLDER;
// Act
repository.getNotes(folderId, new NotesRepository.Callback<List<NoteInfo>>() {
@Override
public void onSuccess(List<NoteInfo> result) {
assertNotNull("Notes list should not be null", result);
// Mock返回空列表实际应用中会有数据
assertTrue("Notes count should be >= 0", result.size() >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testCreateNote() {
// Arrange
long folderId = Notes.ID_ROOT_FOLDER;
// Act
repository.createNote(folderId, new NotesRepository.Callback<Long>() {
@Override
public void onSuccess(Long noteId) {
assertNotNull("Note ID should not be null", noteId);
assertTrue("Note ID should be > 0", noteId > 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testUpdateNote() {
// Arrange
long noteId = 1L;
String content = "测试笔记内容";
// Act
repository.updateNote(noteId, content, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
assertNotNull("Rows affected should not be null", rowsAffected);
assertTrue("Rows affected should be >= 0", rowsAffected >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testDeleteNote() {
// Arrange
long noteId = 1L;
// Act
repository.deleteNote(noteId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer rowsAffected) {
assertNotNull("Rows affected should not be null", rowsAffected);
assertTrue("Rows affected should be >= 0", rowsAffected >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testSearchNotes() {
// Arrange
String keyword = "测试";
// Act
repository.searchNotes(keyword, new NotesRepository.Callback<List<NoteInfo>>() {
@Override
public void onSuccess(List<NoteInfo> result) {
assertNotNull("Search results should not be null", result);
assertTrue("Search results count should be >= 0", result.size() >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testSearchNotesWithEmptyKeyword() {
// Arrange
String keyword = "";
// Act
repository.searchNotes(keyword, new NotesRepository.Callback<List<NoteInfo>>() {
@Override
public void onSuccess(List<NoteInfo> result) {
assertNotNull("Search results should not be null", result);
// 空关键字应返回空列表
assertEquals("Search results should be empty", 0, result.size());
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testCountNotes() {
// Arrange
long folderId = Notes.ID_ROOT_FOLDER;
// Act
repository.countNotes(folderId, new NotesRepository.Callback<Integer>() {
@Override
public void onSuccess(Integer count) {
assertNotNull("Count should not be null", count);
assertTrue("Count should be >= 0", count >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
/**
*
*/
@Test
public void testGetFolders() {
// Act
repository.getFolders(new NotesRepository.Callback<List<NoteInfo>>() {
@Override
public void onSuccess(List<NoteInfo> result) {
assertNotNull("Folders should not be null", result);
assertTrue("Folders count should be >= 0", result.size() >= 0);
}
@Override
public void onError(Exception error) {
fail("Should not throw error: " + error.getMessage());
}
});
}
}
Loading…
Cancel
Save