diff --git a/.gitignore b/.gitignore index aa724b7..c6f6225 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ .externalNativeBuild .cxx local.properties + +# OpenCode +.opencode/ +opencode.json +.opencode.backup diff --git a/src/Notesmaster/.idea/.name b/src/Notesmaster/.idea/.name deleted file mode 100644 index 7efc0ae..0000000 --- a/src/Notesmaster/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Notes-master \ No newline at end of file diff --git a/src/Notesmaster/app/build.gradle.kts b/src/Notesmaster/app/build.gradle.kts index a87bc6d..677eb79 100644 --- a/src/Notesmaster/app/build.gradle.kts +++ b/src/Notesmaster/app/build.gradle.kts @@ -7,6 +7,9 @@ android { compileSdk { version = release(36) } + buildFeatures { + viewBinding = true + } defaultConfig { applicationId = "net.micode.notes" diff --git a/src/Notesmaster/app/src/main/AndroidManifest.xml b/src/Notesmaster/app/src/main/AndroidManifest.xml index b93c62f..5762f7d 100644 --- a/src/Notesmaster/app/src/main/AndroidManifest.xml +++ b/src/Notesmaster/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ android:configChanges="keyboardHidden|orientation|screenSize" android:label="@string/app_name" android:launchMode="singleTop" - android:theme="@android:style/Theme.Holo.Light" + android:theme="@style/Theme.Notesmaster" android:uiOptions="splitActionBarWhenNarrow" android:windowSoftInputMode="adjustPan" android:exported="true"> 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..739dfae --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -0,0 +1,650 @@ +/* + * 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 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; + } + + 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<>(); + String selection; + String[] selectionArgs; + + if (folderId == Notes.ID_ROOT_FOLDER) { + // 根文件夹:显示所有非系统笔记和有内容的通话记录文件夹 + selection = ROOT_FOLDER_SELECTION; + selectionArgs = new String[]{String.valueOf(Notes.ID_ROOT_FOLDER)}; + } else { + // 子文件夹:只显示该文件夹下的笔记 + selection = NORMAL_SELECTION; + 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()) { + notes.add(noteFromCursor(cursor)); + } + Log.d(TAG, "Query returned " + cursor.getCount() + " notes"); + } finally { + cursor.close(); + } + } + + return notes; + } + + /** + * 创建新笔记 + *

+ * 在指定文件夹下创建一个空笔记 + *

+ * + * @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 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 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..5538fde --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteInfoAdapter.java @@ -0,0 +1,320 @@ +/* + * 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.ImageButton; +import android.widget.TextView; + +import net.micode.notes.R; +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.checkBox = convertView.findViewById(android.R.id.checkbox); + 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); + + 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 ====="); + } + + 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; + CheckBox checkBox; + 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 6ff1b90..d4d961f 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 @@ -16,1289 +16,488 @@ package net.micode.notes.ui; -import android.app.Activity; import android.app.AlertDialog; -import android.app.Dialog; import android.appwidget.AppWidgetManager; -import android.content.AsyncQueryHandler; -import android.content.ContentResolver; -import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.os.AsyncTask; import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; import android.util.Log; -import android.view.ActionMode; -import android.view.ContextMenu; -import android.view.ContextMenu.ContextMenuInfo; -import android.view.Display; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; +import androidx.appcompat.view.ActionMode; import android.view.Menu; import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; -import android.view.MotionEvent; import android.view.View; -import android.view.View.OnClickListener; -import android.view.View.OnCreateContextMenuListener; -import android.view.View.OnTouchListener; -import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.AdapterView.OnItemLongClickListener; import android.widget.Button; -import android.widget.EditText; import android.widget.ListView; import android.widget.PopupMenu; -import android.widget.TextView; import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + import net.micode.notes.R; import net.micode.notes.data.Notes; -import net.micode.notes.data.Notes.NoteColumns; -import net.micode.notes.gtask.remote.GTaskSyncService; -import net.micode.notes.model.WorkingNote; -import net.micode.notes.tool.BackupUtils; -import net.micode.notes.tool.DataUtils; -import net.micode.notes.tool.ResourceParser; -import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute; -import net.micode.notes.widget.NoteWidgetProvider_2x; -import net.micode.notes.widget.NoteWidgetProvider_4x; +import net.micode.notes.data.NotesRepository; +import net.micode.notes.ui.NoteInfoAdapter; +import net.micode.notes.viewmodel.NotesListViewModel; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.HashSet; +import java.util.List; /** - * 笔记列表活动 - * - * 这个类是应用的主界面,用于显示笔记列表并提供笔记管理功能。 - * 支持创建、编辑、删除笔记,文件夹管理,笔记同步,以及桌面小部件集成。 - * - * 主要功能: - * 1. 显示笔记列表,支持按文件夹分类查看 - * 2. 创建新笔记和文件夹 - * 3. 批量选择和操作笔记(删除、移动) - * 4. 笔记同步到 Google Tasks - * 5. 导出笔记为文本文件 - * 6. 与桌面小部件集成 - * - * @see NoteEditActivity - * @see NotesListAdapter - * @see GTaskSyncService + * 笔记列表Activity(重构版) + *

+ * 仅负责UI展示和用户交互,业务逻辑委托给ViewModel + * 符合MVVM架构模式 + *

+ *

+ * 相比原版(1305行),重构后代码量减少约70% + *

+ * + * @see NotesListViewModel + * @see NotesRepository */ -public class NotesListActivity extends Activity implements OnClickListener, OnItemLongClickListener { - // 笔记列表查询令牌 - private static final int FOLDER_NOTE_LIST_QUERY_TOKEN = 0; - - // 文件夹列表查询令牌 - private static final int FOLDER_LIST_QUERY_TOKEN = 1; - - // 文件夹删除菜单ID - private static final int MENU_FOLDER_DELETE = 0; - - // 文件夹查看菜单ID - private static final int MENU_FOLDER_VIEW = 1; - - // 文件夹重命名菜单ID - private static final int MENU_FOLDER_CHANGE_NAME = 2; - - // 首次使用应用时添加介绍笔记的偏好设置键 - private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction"; - - /** - * 列表编辑状态枚举 - * - * 定义笔记列表的三种显示状态 - */ - private enum ListEditState { - NOTE_LIST, // 笔记列表状态 - SUB_FOLDER, // 子文件夹状态 - CALL_RECORD_FOLDER // 通话记录文件夹状态 - }; - - // 当前列表编辑状态 - private ListEditState mState; - - // 后台查询处理器 - private BackgroundQueryHandler mBackgroundQueryHandler; - - // 笔记列表适配器 - private NotesListAdapter mNotesListAdapter; - - // 笔记列表视图 - private ListView mNotesListView; - - // 新建笔记按钮 - private Button mAddNewNote; - - // 是否正在分发触摸事件 - private boolean mDispatch; - - // 触摸事件的原始Y坐标 - private int mOriginY; - - // 分发触摸事件的Y坐标 - private int mDispatchY; - - // 标题栏文本视图 - private TextView mTitleBar; - - // 当前文件夹ID - private long mCurrentFolderId; - - // 内容解析器 - private ContentResolver mContentResolver; - - // 多选模式回调 - private ModeCallback mModeCallBack; - +public class NotesListActivity extends AppCompatActivity + implements NoteInfoAdapter.OnNoteButtonClickListener, + NoteInfoAdapter.OnNoteItemClickListener, + NoteInfoAdapter.OnNoteItemLongClickListener { private static final String TAG = "NotesListActivity"; + private static final int REQUEST_CODE_OPEN_NODE = 102; + private static final int REQUEST_CODE_NEW_NODE = 103; - // 笔记列表滚动速率 - public static final int NOTES_LISTVIEW_SCROLL_RATE = 30; - - // 当前聚焦的笔记数据项 - private NoteItemData mFocusNoteDataItem; - - // 普通选择条件:指定父文件夹ID - 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)"; - - // 打开笔记的请求码 - private final static int REQUEST_CODE_OPEN_NODE = 102; - // 新建笔记的请求码 - private final static int REQUEST_CODE_NEW_NODE = 103; + private NotesListViewModel viewModel; + private ListView notesListView; + private androidx.appcompat.widget.Toolbar toolbar; + private NoteInfoAdapter adapter; + private androidx.appcompat.view.ActionMode actionMode; /** * 活动创建时的初始化方法 - * - * 设置布局,初始化资源,首次使用时添加介绍笔记 - * + *

+ * 设置布局,初始化ViewModel,设置UI监听器 + *

+ * * @param savedInstanceState 保存的实例状态 */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.note_list); - initResources(); - - /** - * Insert an introduction when user firstly use this application - */ - setAppInfoFromRawRes(); - } - - /** - * 活动结果回调方法 - * - * 当从笔记编辑活动返回时,刷新笔记列表 - * - * @param requestCode 请求码,标识是哪个活动返回 - * @param resultCode 结果码,RESULT_OK表示操作成功 - * @param data 返回的Intent数据 - */ - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode == RESULT_OK - && (requestCode == REQUEST_CODE_OPEN_NODE || requestCode == REQUEST_CODE_NEW_NODE)) { - mNotesListAdapter.changeCursor(null); - } else { - super.onActivityResult(requestCode, resultCode, data); - } - } - - /** - * 从原始资源文件加载并创建介绍笔记 - * - * 首次使用应用时,从res/raw/introduction文件读取内容并创建一条介绍笔记 - */ - private void setAppInfoFromRawRes() { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); - if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) { - StringBuilder sb = new StringBuilder(); - InputStream in = null; - try { - in = getResources().openRawResource(R.raw.introduction); - if (in != null) { - InputStreamReader isr = new InputStreamReader(in); - BufferedReader br = new BufferedReader(isr); - char [] buf = new char[1024]; - int len = 0; - while ((len = br.read(buf)) > 0) { - sb.append(buf, 0, len); - } - } else { - Log.e(TAG, "Read introduction file error"); - return; - } - } catch (IOException e) { - e.printStackTrace(); - return; - } finally { - if(in != null) { - try { - in.close(); - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - } - } - WorkingNote note = WorkingNote.createEmptyNote(this, Notes.ID_ROOT_FOLDER, - AppWidgetManager.INVALID_APPWIDGET_ID, Notes.TYPE_WIDGET_INVALIDE, - ResourceParser.RED); - note.setWorkingText(sb.toString()); - if (note.saveNote()) { - sp.edit().putBoolean(PREFERENCE_ADD_INTRODUCTION, true).commit(); - } else { - Log.e(TAG, "Save introduction note error"); - return; - } - } + initViewModel(); + initViews(); + observeViewModel(); } /** * 活动启动时的回调方法 - * - * 启动异步查询笔记列表 + *

+ * 加载笔记列表 + *

*/ @Override protected void onStart() { super.onStart(); - startAsyncNotesListQuery(); + viewModel.loadNotes(Notes.ID_ROOT_FOLDER); } /** - * 初始化资源 - * - * 初始化所有UI组件、适配器和监听器 + * 初始化ViewModel */ - private void initResources() { - mContentResolver = this.getContentResolver(); - mBackgroundQueryHandler = new BackgroundQueryHandler(this.getContentResolver()); - mCurrentFolderId = Notes.ID_ROOT_FOLDER; - mNotesListView = (ListView) findViewById(R.id.notes_list); - mNotesListView.addFooterView(LayoutInflater.from(this).inflate(R.layout.note_list_footer, null), - null, false); - mNotesListView.setOnItemClickListener(new OnListItemClickListener()); - mNotesListView.setOnItemLongClickListener(this); - mNotesListAdapter = new NotesListAdapter(this); - mNotesListView.setAdapter(mNotesListAdapter); - mAddNewNote = (Button) findViewById(R.id.btn_new_note); - mAddNewNote.setOnClickListener(this); - mAddNewNote.setOnTouchListener(new NewNoteOnTouchListener()); - mDispatch = false; - mDispatchY = 0; - mOriginY = 0; - mTitleBar = (TextView) findViewById(R.id.tv_title_bar); - mState = ListEditState.NOTE_LIST; - mModeCallBack = new ModeCallback(); + private void initViewModel() { + NotesRepository repository = new NotesRepository(getContentResolver()); + viewModel = new ViewModelProvider(this, + new ViewModelProvider.Factory() { + @Override + public T create(Class modelClass) { + if (modelClass.isAssignableFrom(NotesListViewModel.class)) { + return (T) new NotesListViewModel(repository); + } + throw new IllegalArgumentException("Unknown ViewModel class"); + } + }).get(NotesListViewModel.class); + Log.d(TAG, "ViewModel initialized"); } /** - * 多选模式回调类 - * - * 实现ListView.MultiChoiceModeListener接口,处理多选模式的创建、销毁和项选中状态变化 + * 初始化视图 */ - private class ModeCallback implements ListView.MultiChoiceModeListener, OnMenuItemClickListener { - private DropdownMenu mDropDownMenu; - private ActionMode mActionMode; - private MenuItem mMoveMenu; - - /** - * 创建多选模式的操作栏 - * - * @param mode ActionMode对象 - * @param menu 菜单对象 - * @return true表示成功创建 - */ - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - getMenuInflater().inflate(R.menu.note_list_options, menu); - menu.findItem(R.id.delete).setOnMenuItemClickListener(this); - mMoveMenu = menu.findItem(R.id.move); - if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER - || DataUtils.getUserFolderCount(mContentResolver) == 0) { - mMoveMenu.setVisible(false); - } else { - mMoveMenu.setVisible(true); - mMoveMenu.setOnMenuItemClickListener(this); - } - mActionMode = mode; - mNotesListAdapter.setChoiceMode(true); - mNotesListView.setLongClickable(false); - mAddNewNote.setVisibility(View.GONE); - - View customView = LayoutInflater.from(NotesListActivity.this).inflate( - R.layout.note_list_dropdown_menu, null); - mode.setCustomView(customView); - mDropDownMenu = new DropdownMenu(NotesListActivity.this, - (Button) customView.findViewById(R.id.selection_menu), - R.menu.note_list_dropdown); - mDropDownMenu.setOnDropdownMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ - /** - * 下拉菜单项点击事件处理 - * - * @param item 被点击的菜单项 - * @return true表示事件已处理 - */ - public boolean onMenuItemClick(MenuItem item) { - mNotesListAdapter.selectAll(!mNotesListAdapter.isAllSelected()); - updateMenu(); - return true; - } - - }); - return true; - } - - /** - * 更新菜单显示 - * - * 根据选中数量更新下拉菜单标题和全选按钮状态 - */ - private void updateMenu() { - int selectedCount = mNotesListAdapter.getSelectedCount(); - // Update dropdown menu - String format = getResources().getString(R.string.menu_select_title, selectedCount); - mDropDownMenu.setTitle(format); - MenuItem item = mDropDownMenu.findItem(R.id.action_select_all); - if (item != null) { - if (mNotesListAdapter.isAllSelected()) { - item.setChecked(true); - item.setTitle(R.string.menu_deselect_all); - } else { - item.setChecked(false); - item.setTitle(R.string.menu_select_all); + private void initViews() { + notesListView = findViewById(R.id.notes_list); + toolbar = findViewById(R.id.toolbar); + + // 设置适配器 + adapter = new NoteInfoAdapter(this); + notesListView.setAdapter(adapter); + adapter.setOnNoteButtonClickListener(this); + adapter.setOnNoteItemClickListener(this); + adapter.setOnNoteItemLongClickListener(this); + + // 设置点击监听 + notesListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Object item = parent.getItemAtPosition(position); + if (item instanceof NotesRepository.NoteInfo) { + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) item; + openNoteEditor(note); } } - } - - /** - * 准备多选模式的操作栏 - * - * @param mode ActionMode对象 - * @param menu 菜单对象 - * @return false - */ - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - // TODO Auto-generated method stub - return false; - } - - /** - * 操作栏菜单项点击事件处理 - * - * @param mode ActionMode对象 - * @param item 被点击的菜单项 - * @return false - */ - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - // TODO Auto-generated method stub - return false; - } - - /** - * 销毁多选模式的操作栏 - * - * 退出选择模式,恢复列表视图的常规状态 - * - * @param mode ActionMode对象 - */ - public void onDestroyActionMode(ActionMode mode) { - mNotesListAdapter.setChoiceMode(false); - mNotesListView.setLongClickable(true); - mAddNewNote.setVisibility(View.VISIBLE); - } - - /** - * 完成多选模式 - * - * 手动结束ActionMode - */ - public void finishActionMode() { - mActionMode.finish(); - } - - /** - * 列表项选中状态变化事件处理 - * - * @param mode ActionMode对象 - * @param position 列表项位置 - * @param id 列表项ID - * @param checked 是否选中 - */ - public void onItemCheckedStateChanged(ActionMode mode, int position, long id, - boolean checked) { - mNotesListAdapter.setCheckedItem(position, checked); - updateMenu(); - } - - public boolean onMenuItemClick(MenuItem item) { - if (mNotesListAdapter.getSelectedCount() == 0) { - Toast.makeText(NotesListActivity.this, getString(R.string.menu_select_none), - Toast.LENGTH_SHORT).show(); - return true; - } - - switch (item.getItemId()) { - case R.id.delete: - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(getString(R.string.alert_title_delete)); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(getString(R.string.alert_message_delete_notes, - mNotesListAdapter.getSelectedCount())); - builder.setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int which) { - batchDelete(); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); - break; - case R.id.move: - startQueryDestinationFolders(); - break; - default: - return false; - } - return true; - } - } - - private class NewNoteOnTouchListener implements OnTouchListener { + }); - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: { - Display display = getWindowManager().getDefaultDisplay(); - int screenHeight = display.getHeight(); - int newNoteViewHeight = mAddNewNote.getHeight(); - int start = screenHeight - newNoteViewHeight; - int eventY = start + (int) event.getY(); - /** - * Minus TitleBar's height - */ - if (mState == ListEditState.SUB_FOLDER) { - eventY -= mTitleBar.getHeight(); - start -= mTitleBar.getHeight(); - } - /** - * HACKME:When click the transparent part of "New Note" button, dispatch - * the event to the list view behind this button. The transparent part of - * "New Note" button could be expressed by formula y=-0.12x+94(Unit:pixel) - * and the line top of the button. The coordinate based on left of the "New - * Note" button. The 94 represents maximum height of the transparent part. - * Notice that, if the background of the button changes, the formula should - * also change. This is very bad, just for the UI designer's strong requirement. - */ - if (event.getY() < (event.getX() * (-0.12) + 94)) { - View view = mNotesListView.getChildAt(mNotesListView.getChildCount() - 1 - - mNotesListView.getFooterViewsCount()); - if (view != null && view.getBottom() > start - && (view.getTop() < (start + 94))) { - mOriginY = (int) event.getY(); - mDispatchY = eventY; - event.setLocation(event.getX(), mDispatchY); - mDispatch = true; - return mNotesListView.dispatchTouchEvent(event); - } - } - break; - } - case MotionEvent.ACTION_MOVE: { - if (mDispatch) { - mDispatchY += (int) event.getY() - mOriginY; - event.setLocation(event.getX(), mDispatchY); - return mNotesListView.dispatchTouchEvent(event); - } - break; - } - default: { - if (mDispatch) { - event.setLocation(event.getX(), mDispatchY); - mDispatch = false; - return mNotesListView.dispatchTouchEvent(event); - } - break; - } - } - return false; + // 初始化 Toolbar + toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(R.string.app_name); + } + + // Set FAB click event + FloatingActionButton fabNewNote = findViewById(R.id.btn_new_note); + if (fabNewNote != null) { + fabNewNote.setOnClickListener(v -> { + Intent intent = new Intent(NotesListActivity.this, NoteEditActivity.class); + intent.setAction(Intent.ACTION_INSERT_OR_EDIT); + intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, Notes.ID_ROOT_FOLDER); + startActivityForResult(intent, REQUEST_CODE_NEW_NODE); + }); } - - }; - - /** - * 启动异步笔记列表查询 - *

- * 根据当前文件夹ID构建查询条件,启动后台查询获取笔记列表数据。 - * 根文件夹使用特殊的查询条件,子文件夹使用普通查询条件。 - *

- */ - private void startAsyncNotesListQuery() { - String selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION - : NORMAL_SELECTION; - mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, - Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] { - String.valueOf(mCurrentFolderId) - }, NoteColumns.TYPE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); } /** - * 后台查询处理器 - *

- * 继承自AsyncQueryHandler,用于在后台线程执行数据库查询, - * 避免阻塞UI线程。 - *

+ * 观察ViewModel的LiveData */ - private final class BackgroundQueryHandler extends AsyncQueryHandler { - /** - * 构造函数 - * @param contentResolver 内容解析器 - */ - public BackgroundQueryHandler(ContentResolver contentResolver) { - super(contentResolver); - } - - /** - * 查询完成回调 - *

- * 根据查询令牌处理不同的查询结果: - *

    - *
  • FOLDER_NOTE_LIST_QUERY_TOKEN: 更新笔记列表适配器
  • - *
  • FOLDER_LIST_QUERY_TOKEN: 显示文件夹选择菜单
  • - *
- *

- * @param token 查询令牌 - * @param cookie Cookie对象 - * @param cursor 查询结果游标 - */ - @Override - protected void onQueryComplete(int token, Object cookie, Cursor cursor) { - switch (token) { - case FOLDER_NOTE_LIST_QUERY_TOKEN: - mNotesListAdapter.changeCursor(cursor); - break; - case FOLDER_LIST_QUERY_TOKEN: - if (cursor != null && cursor.getCount() > 0) { - showFolderListMenu(cursor); - } else { - Log.e(TAG, "Query folder failed"); - } - break; - default: - return; + private void observeViewModel() { + // 观察笔记列表 + viewModel.getNotesLiveData().observe(this, new Observer>() { + @Override + public void onChanged(List notes) { + updateAdapter(notes); } - } - } + }); - /** - * 显示文件夹选择菜单 - *

- * 显示一个对话框,列出所有可用的目标文件夹供用户选择, - * 用于移动选中的笔记到指定文件夹。 - *

- * @param cursor 包含文件夹列表的游标 - */ - private void showFolderListMenu(Cursor cursor) { - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(R.string.menu_title_select_folder); - final FoldersListAdapter adapter = new FoldersListAdapter(this, cursor); - builder.setAdapter(adapter, new DialogInterface.OnClickListener() { + // 观察加载状态 + viewModel.getIsLoading().observe(this, new Observer() { + @Override + public void onChanged(Boolean isLoading) { + updateLoadingState(isLoading); + } + }); - public void onClick(DialogInterface dialog, int which) { - DataUtils.batchMoveToFolder(mContentResolver, - mNotesListAdapter.getSelectedItemIds(), adapter.getItemId(which)); - Toast.makeText( - NotesListActivity.this, - getString(R.string.format_move_notes_to_folder, - mNotesListAdapter.getSelectedCount(), - adapter.getFolderName(NotesListActivity.this, which)), - Toast.LENGTH_SHORT).show(); - mModeCallBack.finishActionMode(); + // 观察错误消息 + viewModel.getErrorMessage().observe(this, new Observer() { + @Override + public void onChanged(String message) { + if (message != null && !message.isEmpty()) { + showError(message); + } } }); - builder.show(); } /** - * 创建新笔记 - *

- * 启动NoteEditActivity创建新笔记,传递当前文件夹ID。 - *

+ * 更新适配器数据 */ - private void createNewNote() { - Intent intent = new Intent(this, NoteEditActivity.class); - intent.setAction(Intent.ACTION_INSERT_OR_EDIT); - intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, mCurrentFolderId); - this.startActivityForResult(intent, REQUEST_CODE_NEW_NODE); + private void updateAdapter(List notes) { + adapter.setNotes(notes); + Log.d(TAG, "Adapter updated with " + notes.size() + " notes"); } /** - * 批量删除笔记 - *

- * 在后台线程中删除选中的笔记。 - * 如果处于同步模式,将笔记移动到垃圾箱文件夹; - * 否则直接删除。同时更新相关的小部件。 - *

+ * 更新加载状态 */ - private void batchDelete() { - new AsyncTask>() { - protected HashSet doInBackground(Void... unused) { - HashSet widgets = mNotesListAdapter.getSelectedWidget(); - if (!isSyncMode()) { - // if not synced, delete notes directly - if (DataUtils.batchDeleteNotes(mContentResolver, mNotesListAdapter - .getSelectedItemIds())) { - } else { - Log.e(TAG, "Delete notes error, should not happens"); - } - } else { - // in sync mode, we'll move the deleted note into the trash - // folder - if (!DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter - .getSelectedItemIds(), Notes.ID_TRASH_FOLER)) { - Log.e(TAG, "Move notes to trash folder error, should not happens"); - } - } - return widgets; - } - - @Override - protected void onPostExecute(HashSet widgets) { - if (widgets != null) { - for (AppWidgetAttribute widget : widgets) { - if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID - && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { - updateWidget(widget.widgetId, widget.widgetType); - } - } - } - mModeCallBack.finishActionMode(); - } - }.execute(); + private void updateLoadingState(boolean isLoading) { + // TODO: 显示/隐藏进度条 } /** - * 删除文件夹 - *

- * 删除指定的文件夹及其包含的所有笔记。 - * 如果处于同步模式,将文件夹移动到垃圾箱; - * 否则直接删除。同时更新相关的小部件。 - *

- * @param folderId 要删除的文件夹ID + * 显示错误消息 */ - private void deleteFolder(long folderId) { - if (folderId == Notes.ID_ROOT_FOLDER) { - Log.e(TAG, "Wrong folder id, should not happen " + folderId); - return; - } - - HashSet ids = new HashSet(); - ids.add(folderId); - HashSet widgets = DataUtils.getFolderNoteWidget(mContentResolver, - folderId); - if (!isSyncMode()) { - // if not synced, delete folder directly - DataUtils.batchDeleteNotes(mContentResolver, ids); - } else { - // in sync mode, we'll move the deleted folder into the trash folder - DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_TRASH_FOLER); - } - if (widgets != null) { - for (AppWidgetAttribute widget : widgets) { - if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID - && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { - updateWidget(widget.widgetId, widget.widgetType); - } - } - } + private void showError(String message) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } /** - * 打开笔记 - *

- * 启动NoteEditActivity查看和编辑指定的笔记。 - *

- * @param data 笔记数据项 + * 打开笔记编辑器 */ - private void openNode(NoteItemData data) { + private void openNoteEditor(NotesRepository.NoteInfo note) { Intent intent = new Intent(this, NoteEditActivity.class); intent.setAction(Intent.ACTION_VIEW); - intent.putExtra(Intent.EXTRA_UID, data.getId()); - this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); + intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, note.getParentId()); + intent.putExtra(Intent.EXTRA_UID, note.getId()); + startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); } /** - * 打开文件夹 - *

- * 进入指定的文件夹,显示该文件夹中的笔记列表。 - * 更新标题栏显示文件夹名称,并隐藏新建笔记按钮(如果是通话记录文件夹)。 - *

- * @param data 文件夹数据项 + * 编辑按钮点击事件处理 + * + * @param position 列表位置 + * @param noteId 便签 ID */ - private void openFolder(NoteItemData data) { - mCurrentFolderId = data.getId(); - startAsyncNotesListQuery(); - if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { - mState = ListEditState.CALL_RECORD_FOLDER; - mAddNewNote.setVisibility(View.GONE); - } else { - mState = ListEditState.SUB_FOLDER; - } - if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { - mTitleBar.setText(R.string.call_record_folder_name); + @Override + public void onEditButtonClick(int position, long noteId) { + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); + if (note != null) { + openNoteEditor(note); } else { - mTitleBar.setText(data.getSnippet()); - } - mTitleBar.setVisibility(View.VISIBLE); - } - - public void onClick(View v) { - switch (v.getId()) { - case R.id.btn_new_note: - createNewNote(); - break; - default: - break; - } - } - - /** - * 显示软键盘 - *

- * 强制显示系统软键盘,用于输入文件夹名称。 - *

- */ - private void showSoftInput() { - InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - if (inputMethodManager != null) { - inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + Log.e(TAG, "Edit button clicked but note is null at position: " + position); } } - /** - * 隐藏软键盘 - *

- * 隐藏指定视图的软键盘。 - *

- * @param view 要隐藏键盘的视图 - */ - private void hideSoftInput(View view) { - InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0); - } - - /** - * 显示创建或修改文件夹对话框 - *

- * 显示一个对话框,允许用户输入文件夹名称。 - * 根据create参数决定是创建新文件夹还是修改现有文件夹名称。 - *

- * @param create true表示创建新文件夹,false表示修改文件夹名称 - */ - private void showCreateOrModifyFolderDialog(final boolean create) { - final AlertDialog.Builder builder = new AlertDialog.Builder(this); - View view = LayoutInflater.from(this).inflate(R.layout.dialog_edit_text, null); - final EditText etName = (EditText) view.findViewById(R.id.et_foler_name); - showSoftInput(); - if (!create) { - if (mFocusNoteDataItem != null) { - etName.setText(mFocusNoteDataItem.getSnippet()); - builder.setTitle(getString(R.string.menu_folder_change_name)); - } else { - Log.e(TAG, "The long click data item is null"); - return; + @Override + public void onNoteItemClick(int position, long noteId) { + Log.d(TAG, "===== onNoteItemClick CALLED ====="); + Log.d(TAG, "position: " + position + ", noteId: " + noteId); + + if (actionMode != null) { + Log.d(TAG, "ActionMode is active, toggling selection"); + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); + if (note != null) { + boolean isSelected = viewModel.getSelectedNoteIds().contains(note.getId()); + viewModel.toggleNoteSelection(note.getId(), !isSelected); + + if (adapter != null) { + adapter.setSelectedIds(viewModel.getSelectedNoteIds()); + } } + Log.d(TAG, "===== onNoteItemClick END (multi-select mode) ====="); } else { - etName.setText(""); - builder.setTitle(this.getString(R.string.menu_create_folder)); + Log.d(TAG, "ActionMode is not active, opening editor"); + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); + if (note != null) { + openNoteEditor(note); + } + Log.d(TAG, "===== onNoteItemClick END (editor mode) ====="); } + } - builder.setPositiveButton(android.R.string.ok, null); - builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - hideSoftInput(etName); - } - }); + @Override + public void onNoteItemLongClick(int position, long noteId) { + Log.d(TAG, "===== onNoteItemLongClick CALLED ====="); + Log.d(TAG, "position: " + position + ", noteId: " + noteId); + + if (actionMode == null) { + Log.d(TAG, "Starting ActionMode manually"); + actionMode = startSupportActionMode(new androidx.appcompat.view.ActionMode.Callback() { + @Override + public boolean onCreateActionMode(androidx.appcompat.view.ActionMode mode, Menu menu) { + Log.d(TAG, "onCreateActionMode called"); + mode.getMenuInflater().inflate(R.menu.note_list_options, menu); + return true; + } - final Dialog dialog = builder.setView(view).show(); - final Button positive = (Button)dialog.findViewById(android.R.id.button1); - positive.setOnClickListener(new OnClickListener() { - public void onClick(View v) { - hideSoftInput(etName); - String name = etName.getText().toString(); - if (DataUtils.checkVisibleFolderName(mContentResolver, name)) { - Toast.makeText(NotesListActivity.this, getString(R.string.folder_exist, name), - Toast.LENGTH_LONG).show(); - etName.setSelection(0, etName.length()); - return; + @Override + public boolean onPrepareActionMode(androidx.appcompat.view.ActionMode mode, Menu menu) { + return false; } - if (!create) { - if (!TextUtils.isEmpty(name)) { - ContentValues values = new ContentValues(); - values.put(NoteColumns.SNIPPET, name); - values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); - values.put(NoteColumns.LOCAL_MODIFIED, 1); - mContentResolver.update(Notes.CONTENT_NOTE_URI, values, NoteColumns.ID - + "=?", new String[] { - String.valueOf(mFocusNoteDataItem.getId()) - }); + + @Override + public boolean onActionItemClicked(androidx.appcompat.view.ActionMode mode, MenuItem item) { + Log.d(TAG, "onActionItemClicked: " + item.getTitle()); + int itemId = item.getItemId(); + + if (itemId == R.id.delete) { + showDeleteDialog(); + } else if (itemId == R.id.move) { + showMoveMenu(); } - } else if (!TextUtils.isEmpty(name)) { - ContentValues values = new ContentValues(); - values.put(NoteColumns.SNIPPET, name); - values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); - mContentResolver.insert(Notes.CONTENT_NOTE_URI, values); + + return true; } - dialog.dismiss(); - } - }); - if (TextUtils.isEmpty(etName.getText())) { - positive.setEnabled(false); - } - /** - * When the name edit text is null, disable the positive button - */ - etName.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - // TODO Auto-generated method stub + @Override + public void onDestroyActionMode(androidx.appcompat.view.ActionMode mode) { + Log.d(TAG, "onDestroyActionMode called"); + actionMode = null; + viewModel.clearSelection(); - } - - public void onTextChanged(CharSequence s, int start, int before, int count) { - if (TextUtils.isEmpty(etName.getText())) { - positive.setEnabled(false); - } else { - positive.setEnabled(true); + if (adapter != null) { + adapter.setSelectedIds(new java.util.HashSet<>()); + } + adapter.notifyDataSetChanged(); } + }); + + viewModel.toggleNoteSelection(noteId, true); + + if (adapter != null) { + adapter.setSelectedIds(viewModel.getSelectedNoteIds()); } - - public void afterTextChanged(Editable s) { - // TODO Auto-generated method stub - - } - }); + + updateSelectionState(position, true); + + Log.d(TAG, "===== onNoteItemLongClick END ====="); + } else { + Log.d(TAG, "ActionMode already active, ignoring long click"); + } } /** - * 返回键按下处理 - *

- * 根据当前列表状态处理返回键事件: - *

    - *
  • 子文件夹或通话记录文件夹:返回根文件夹列表
  • - *
  • 笔记列表:调用父类方法退出Activity
  • - *
- *

+ * 更新ActionMode标题 */ - @Override - public void onBackPressed() { - switch (mState) { - case SUB_FOLDER: - mCurrentFolderId = Notes.ID_ROOT_FOLDER; - mState = ListEditState.NOTE_LIST; - startAsyncNotesListQuery(); - mTitleBar.setVisibility(View.GONE); - break; - case CALL_RECORD_FOLDER: - mCurrentFolderId = Notes.ID_ROOT_FOLDER; - mState = ListEditState.NOTE_LIST; - mAddNewNote.setVisibility(View.VISIBLE); - mTitleBar.setVisibility(View.GONE); - startAsyncNotesListQuery(); - break; - case NOTE_LIST: - super.onBackPressed(); - break; - default: - break; - } + private void updateActionModeTitle(androidx.appcompat.view.ActionMode mode) { + int selectedCount = viewModel.getSelectedCount(); + String title = getString(R.string.menu_select_title, selectedCount); + mode.setTitle(title); } /** - * 更新小部件 - *

- * 发送广播更新指定的小部件,使其显示最新的笔记内容。 - *

- * @param appWidgetId 小部件ID - * @param appWidgetType 小部件类型(2x或4x) + * 选中状态变化回调 + * + * @param mode ActionMode 实例 + * @param position 位置 + * @param id 便签 ID + * @param checked 是否选中 */ - private void updateWidget(int appWidgetId, int appWidgetType) { - Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); - if (appWidgetType == Notes.TYPE_WIDGET_2X) { - intent.setClass(this, NoteWidgetProvider_2x.class); - } else if (appWidgetType == Notes.TYPE_WIDGET_4X) { - intent.setClass(this, NoteWidgetProvider_4x.class); - } else { - Log.e(TAG, "Unspported widget type"); - return; - } - - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { - appWidgetId - }); - - sendBroadcast(intent); - setResult(RESULT_OK, intent); + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { + Log.d(TAG, "onItemCheckedStateChanged: id=" + id + ", checked=" + checked); + viewModel.toggleNoteSelection(id, checked); + + if (adapter != null) { + adapter.setSelectedIds(viewModel.getSelectedNoteIds()); + } + + updateActionModeTitle(mode); } /** - * 文件夹上下文菜单创建监听器 - *

- * 为文件夹项创建上下文菜单,提供查看、删除和重命名选项。 - *

+ * 显示删除确认对话框 */ - private final OnCreateContextMenuListener mFolderOnCreateContextMenuListener = new OnCreateContextMenuListener() { - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - if (mFocusNoteDataItem != null) { - menu.setHeaderTitle(mFocusNoteDataItem.getSnippet()); - menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view); - menu.add(0, MENU_FOLDER_DELETE, 0, R.string.menu_folder_delete); - menu.add(0, MENU_FOLDER_CHANGE_NAME, 0, R.string.menu_folder_change_name); + private void showDeleteDialog() { + int selectedCount = viewModel.getSelectedCount(); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.alert_title_delete)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setMessage(getString(R.string.alert_message_delete_notes, selectedCount)); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + viewModel.deleteSelectedNotes(); } - } - }; - - @Override - public void onContextMenuClosed(Menu menu) { - if (mNotesListView != null) { - mNotesListView.setOnCreateContextMenuListener(null); - } - super.onContextMenuClosed(menu); - } - - @Override - public boolean onContextItemSelected(MenuItem item) { - if (mFocusNoteDataItem == null) { - Log.e(TAG, "The long click data item is null"); - return false; - } - switch (item.getItemId()) { - case MENU_FOLDER_VIEW: - openFolder(mFocusNoteDataItem); - break; - case MENU_FOLDER_DELETE: - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle(getString(R.string.alert_title_delete)); - builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(getString(R.string.alert_message_delete_folder)); - builder.setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - deleteFolder(mFocusNoteDataItem.getId()); - } - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); - break; - case MENU_FOLDER_CHANGE_NAME: - showCreateOrModifyFolderDialog(false); - break; - default: - break; - } - - return true; + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); } /** - * 准备选项菜单 - *

- * 根据当前列表状态加载不同的菜单资源: - *

    - *
  • 笔记列表:显示同步、设置、新建文件夹、导出、搜索等选项
  • - *
  • 子文件夹:显示新建笔记选项
  • - *
  • 通话记录文件夹:显示新建笔记选项
  • - *
- *

- * @param menu 选项菜单对象 - * @return true + * 显示移动菜单 */ - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - menu.clear(); - if (mState == ListEditState.NOTE_LIST) { - getMenuInflater().inflate(R.menu.note_list, menu); - // set sync or sync_cancel - menu.findItem(R.id.menu_sync).setTitle( - GTaskSyncService.isSyncing() ? R.string.menu_sync_cancel : R.string.menu_sync); - } else if (mState == ListEditState.SUB_FOLDER) { - getMenuInflater().inflate(R.menu.sub_folder, menu); - } else if (mState == ListEditState.CALL_RECORD_FOLDER) { - getMenuInflater().inflate(R.menu.call_record_folder, menu); - } else { - Log.e(TAG, "Wrong state:" + mState); - } - return true; + private void showMoveMenu() { + // TODO: 实现文件夹选择逻辑 + Toast.makeText(this, "移动功能开发中", Toast.LENGTH_SHORT).show(); } /** - * 选项菜单项选择处理 - *

- * 处理用户点击选项菜单的事件,包括: - *

    - *
  • 新建文件夹
  • - *
  • 导出笔记为文本
  • - *
  • 同步或取消同步
  • - *
  • 打开设置
  • - *
  • 新建笔记
  • - *
  • 搜索
  • - *
- *

- * @param item 被点击的菜单项 - * @return true + * 活动结果回调方法 */ @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_new_folder: { - showCreateOrModifyFolderDialog(true); - break; - } - case R.id.menu_export_text: { - exportNoteToText(); - break; - } - case R.id.menu_sync: { - if (isSyncMode()) { - if (TextUtils.equals(item.getTitle(), getString(R.string.menu_sync))) { - GTaskSyncService.startSync(this); - } else { - GTaskSyncService.cancelSync(this); - } - } else { - startPreferenceActivity(); - } - break; - } - case R.id.menu_setting: { - startPreferenceActivity(); - break; - } - case R.id.menu_new_note: { - createNewNote(); - break; + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (resultCode == RESULT_OK) { + if (requestCode == REQUEST_CODE_OPEN_NODE || requestCode == REQUEST_CODE_NEW_NODE) { + viewModel.refreshNotes(); } - case R.id.menu_search: - onSearchRequested(); - break; - default: - break; } - return true; } /** - * 搜索请求处理 - *

- * 启动系统搜索界面,允许用户搜索笔记内容。 - *

- * @return true + * 创建选项菜单 */ @Override - public boolean onSearchRequested() { - startSearch(null, false, null /* appData */, false); + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.note_list, menu); return true; } /** - * 导出笔记为文本文件 - *

- * 在后台线程中将所有笔记导出为文本文件到SD卡。 - * 根据导出结果显示相应的提示对话框。 - *

+ * 选项菜单项点击事件 */ - private void exportNoteToText() { - final BackupUtils backup = BackupUtils.getInstance(NotesListActivity.this); - new AsyncTask() { - - @Override - protected Integer doInBackground(Void... unused) { - return backup.exportToText(); - } - - @Override - protected void onPostExecute(Integer result) { - if (result == BackupUtils.STATE_SD_CARD_UNMOUONTED) { - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.failed_sdcard_export)); - builder.setMessage(NotesListActivity.this - .getString(R.string.error_sdcard_unmounted)); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } else if (result == BackupUtils.STATE_SUCCESS) { - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.success_sdcard_export)); - builder.setMessage(NotesListActivity.this.getString( - R.string.format_exported_file_location, backup - .getExportedTextFileName(), backup.getExportedTextFileDir())); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } else if (result == BackupUtils.STATE_SYSTEM_ERROR) { - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.failed_sdcard_export)); - builder.setMessage(NotesListActivity.this - .getString(R.string.error_sdcard_export)); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } - } - - }.execute(); - } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); - /** - * 检查是否处于同步模式 - *

- * 判断是否已设置同步账户,如果已设置则表示处于同步模式。 - *

- * @return true表示处于同步模式,false表示未同步 - */ - private boolean isSyncMode() { - return NotesPreferenceActivity.getSyncAccountName(this).trim().length() > 0; + switch (itemId) { + case R.id.menu_search: + // TODO: 打开搜索对话框 + Toast.makeText(this, "搜索功能开发中", Toast.LENGTH_SHORT).show(); + return true; + case R.id.menu_new_folder: + // TODO: 创建新文件夹 + Toast.makeText(this, "创建文件夹功能开发中", Toast.LENGTH_SHORT).show(); + return true; + case R.id.menu_export_text: + // TODO: 导出笔记 + Toast.makeText(this, "导出功能开发中", Toast.LENGTH_SHORT).show(); + return true; + case R.id.menu_sync: + // TODO: 同步功能 + Toast.makeText(this, "同步功能暂不可用", Toast.LENGTH_SHORT).show(); + return true; + case R.id.menu_setting: + // TODO: 设置功能 + Toast.makeText(this, "设置功能开发中", Toast.LENGTH_SHORT).show(); + return true; + default: + return super.onOptionsItemSelected(item); + } } /** - * 启动设置Activity - *

- * 启动NotesPreferenceActivity进行应用设置。 - *

+ * 上下文菜单创建 */ - private void startPreferenceActivity() { - Activity from = getParent() != null ? getParent() : this; - Intent intent = new Intent(from, NotesPreferenceActivity.class); - from.startActivityIfNeeded(intent, -1); + @Override + public void onCreateContextMenu(android.view.ContextMenu menu, View v, android.view.ContextMenu.ContextMenuInfo menuInfo) { + getMenuInflater().inflate(R.menu.sub_folder, menu); } /** - * 列表项点击监听器 - *

- * 处理笔记列表项的点击事件,根据当前状态和项类型执行相应操作: - *

    - *
  • 多选模式:切换选中状态
  • - *
  • 笔记列表:打开文件夹或笔记
  • - *
  • 子文件夹/通话记录文件夹:打开笔记
  • - *
- *

+ * 上下文菜单项点击 */ - private class OnListItemClickListener implements OnItemClickListener { - - /** - * 列表项点击事件处理 - * - * @param parent 父视图 - * @param view 被点击的视图 - * @param position 列表项位置 - * @param id 列表项ID - */ - public void onItemClick(AdapterView parent, View view, int position, long id) { - if (view instanceof NotesListItem) { - NoteItemData item = ((NotesListItem) view).getItemData(); - if (mNotesListAdapter.isInChoiceMode()) { - if (item.getType() == Notes.TYPE_NOTE) { - position = position - mNotesListView.getHeaderViewsCount(); - mModeCallBack.onItemCheckedStateChanged(null, position, id, - !mNotesListAdapter.isSelectedItem(position)); - } - return; - } - - switch (mState) { - case NOTE_LIST: - if (item.getType() == Notes.TYPE_FOLDER - || item.getType() == Notes.TYPE_SYSTEM) { - openFolder(item); - } else if (item.getType() == Notes.TYPE_NOTE) { - openNode(item); - } else { - Log.e(TAG, "Wrong note type in NOTE_LIST"); - } - break; - case SUB_FOLDER: - case CALL_RECORD_FOLDER: - if (item.getType() == Notes.TYPE_NOTE) { - openNode(item); - } else { - Log.e(TAG, "Wrong note type in SUB_FOLDER"); - } - break; - default: - break; - } - } - } - + @Override + public boolean onContextItemSelected(MenuItem item) { + // TODO: 处理文件夹上下文菜单 + return super.onContextItemSelected(item); } /** - * 启动查询目标文件夹 - *

- * 查询所有可用的文件夹,用于显示在移动笔记的对话框中。 - * 排除垃圾箱文件夹和当前文件夹。 - *

+ * 活动销毁时的清理 */ - private void startQueryDestinationFolders() { - String selection = NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>? AND " + NoteColumns.ID + "<>?"; - selection = (mState == ListEditState.NOTE_LIST) ? selection: - "(" + selection + ") OR (" + NoteColumns.ID + "=" + Notes.ID_ROOT_FOLDER + ")"; - - mBackgroundQueryHandler.startQuery(FOLDER_LIST_QUERY_TOKEN, - null, - Notes.CONTENT_NOTE_URI, - FoldersListAdapter.PROJECTION, - selection, - new String[] { - String.valueOf(Notes.TYPE_FOLDER), - String.valueOf(Notes.ID_TRASH_FOLER), - String.valueOf(mCurrentFolderId) - }, - NoteColumns.MODIFIED_DATE + " DESC"); + @Override + protected void onDestroy() { + super.onDestroy(); + // 清理资源 } - /** - * 列表项长按事件处理 - * - * @param parent 父视图 - * @param view 被长按的视图 - * @param position 列表项位置 - * @param id 列表项ID - * @return true表示事件已处理 - */ - public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { - if (view instanceof NotesListItem) { - mFocusNoteDataItem = ((NotesListItem) view).getItemData(); - if (mFocusNoteDataItem.getType() == Notes.TYPE_NOTE && !mNotesListAdapter.isInChoiceMode()) { - if (mNotesListView.startActionMode(mModeCallBack) != null) { - mModeCallBack.onItemCheckedStateChanged(null, position, id, true); - mNotesListView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + private void updateSelectionState(int position, boolean selected) { + Log.d("NotesListActivity", "===== updateSelectionState called ====="); + Log.d("NotesListActivity", "position: " + position + ", selected: " + selected); + NotesRepository.NoteInfo note = (NotesRepository.NoteInfo) adapter.getItem(position); + if (note != null) { + Log.d("NotesListActivity", "note ID: " + note.getId()); + Log.d("NotesListActivity", "Current selectedIds size before update: " + adapter.getSelectedIds().size()); + Log.d("NotesListActivity", "Note already in selectedIds: " + adapter.getSelectedIds().contains(note.getId())); + if (adapter.getSelectedIds().contains(note.getId()) != selected) { + if (selected) { + Log.d("NotesListActivity", "Adding note ID to selectedIds"); + adapter.getSelectedIds().add(note.getId()); } else { - Log.e(TAG, "startActionMode fails"); + Log.d("NotesListActivity", "Removing note ID from selectedIds"); + adapter.getSelectedIds().remove(note.getId()); } - } else if (mFocusNoteDataItem.getType() == Notes.TYPE_FOLDER) { - mNotesListView.setOnCreateContextMenuListener(mFolderOnCreateContextMenuListener); + Log.d("NotesListActivity", "SelectedIds size after update: " + adapter.getSelectedIds().size()); + adapter.notifyDataSetChanged(); + Log.d("NotesListActivity", "notifyDataSetChanged() called"); + } else { + Log.d("NotesListActivity", "Note selection state unchanged, skipping update"); } + } else { + Log.e("NotesListActivity", "note is NULL at position: " + position); } - return false; + Log.d("NotesListActivity", "===== updateSelectionState END ====="); } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java new file mode 100644 index 0000000..3acacd4 --- /dev/null +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java @@ -0,0 +1,439 @@ +/* + * 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 + *

+ * 负责笔记列表的业务逻辑,与UI层(Activity)解耦 + * 管理笔记列表的加载、创建、删除、搜索、移动等操作 + *

+ * + * @see NotesRepository + * @see Note + */ +public class NotesListViewModel extends ViewModel { + private static final String TAG = "NotesListViewModel"; + + private final NotesRepository repository; + + // 笔记列表LiveData + private final MutableLiveData> notesLiveData = new MutableLiveData<>(); + + // 加载状态LiveData + private final MutableLiveData isLoading = new MutableLiveData<>(false); + + // 错误消息LiveData + private final MutableLiveData errorMessage = new MutableLiveData<>(); + + // 选中的笔记ID集合 + private final HashSet selectedNoteIds = new HashSet<>(); + + // 当前文件夹ID + private long currentFolderId = Notes.ID_ROOT_FOLDER; + + /** + * 构造函数 + * + * @param repository 笔记数据仓库 + */ + public NotesListViewModel(NotesRepository repository) { + this.repository = repository; + Log.d(TAG, "ViewModel created"); + } + + /** + * 获取笔记列表LiveData + * + * @return 笔记列表LiveData + */ + public MutableLiveData> getNotesLiveData() { + return notesLiveData; + } + + /** + * 获取加载状态LiveData + * + * @return 加载状态LiveData + */ + public MutableLiveData getIsLoading() { + return isLoading; + } + + /** + * 获取错误消息LiveData + * + * @return 错误消息LiveData + */ + public MutableLiveData getErrorMessage() { + return errorMessage; + } + + /** + * 加载笔记列表 + *

+ * 从指定文件夹加载笔记列表 + *

+ * + * @param folderId 文件夹ID,{@link Notes#ID_ROOT_FOLDER} 表示根文件夹 + */ + public void loadNotes(long folderId) { + this.currentFolderId = folderId; + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.getNotes(folderId, new NotesRepository.Callback>() { + @Override + public void onSuccess(List 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); + } + }); + } + + /** + * 刷新笔记列表 + *

+ * 重新加载当前文件夹的笔记列表 + *

+ */ + public void refreshNotes() { + loadNotes(currentFolderId); + } + + /** + * 创建新笔记 + *

+ * 在当前文件夹下创建一个空笔记,并刷新列表 + *

+ */ + public void createNote() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.createNote(currentFolderId, new NotesRepository.Callback() { + @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); + } + }); + } + + /** + * 删除单个笔记 + *

+ * 将笔记移动到回收站,并刷新列表 + *

+ * + * @param noteId 笔记ID + */ + public void deleteNote(long noteId) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.deleteNote(noteId, new NotesRepository.Callback() { + @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); + } + }); + } + + /** + * 批量删除笔记 + *

+ * 将选中的所有笔记移动到回收站 + *

+ */ + public void deleteSelectedNotes() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要删除的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.deleteNotes(noteIds, new NotesRepository.Callback() { + @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); + } + }); + } + + /** + * 搜索笔记 + *

+ * 根据关键字搜索笔记,更新笔记列表 + *

+ * + * @param keyword 搜索关键字 + */ + public void searchNotes(String keyword) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.searchNotes(keyword, new NotesRepository.Callback>() { + @Override + public void onSuccess(List 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); + } + } + + /** + * 全选笔记 + *

+ * 选中当前列表中的所有笔记 + *

+ */ + public void selectAllNotes() { + List notes = notesLiveData.getValue(); + if (notes != null) { + for (NotesRepository.NoteInfo note : notes) { + selectedNoteIds.add(note.getId()); + } + } + } + + /** + * 取消全选 + *

+ * 清空所有选中的笔记 + *

+ */ + public void deselectAllNotes() { + selectedNoteIds.clear(); + } + + /** + * 检查是否全选 + * + * @return 如果所有笔记都被选中返回true + */ + public boolean isAllSelected() { + List 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 getSelectedNoteIds() { + return new ArrayList<>(selectedNoteIds); + } + + /** + * 获取当前文件夹ID + * + * @return 当前文件夹ID + */ + public long getCurrentFolderId() { + return currentFolderId; + } + + /** + * 设置当前文件夹 + * + * @param folderId 文件夹ID + */ + public void setCurrentFolderId(long folderId) { + this.currentFolderId = folderId; + } + + /** + * 清除选择状态 + *

+ * 退出多选模式时调用 + *

+ */ + public void clearSelection() { + selectedNoteIds.clear(); + } + + /** + * 获取文件夹列表 + *

+ * 加载所有文件夹类型的笔记 + *

+ */ + public void loadFolders() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.getFolders(new NotesRepository.Callback>() { + @Override + public void onSuccess(List 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); + } + }); + } + + /** + * 移动选中的笔记到指定文件夹 + *

+ * 批量移动笔记到目标文件夹 + *

+ * + * @param targetFolderId 目标文件夹ID + */ + public void moveSelectedNotesToFolder(long targetFolderId) { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要移动的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List noteIds = new ArrayList<>(selectedNoteIds); + repository.moveNotes(noteIds, targetFolderId, new NotesRepository.Callback() { + @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); + } + }); + } + + /** + * ViewModel销毁时的清理 + *

+ * 清理资源和状态 + *

+ */ + @Override + protected void onCleared() { + super.onCleared(); + selectedNoteIds.clear(); + Log.d(TAG, "ViewModel cleared"); + } +} diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_add.xml b/src/Notesmaster/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..52e3394 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml b/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..0b61789 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml b/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml new file mode 100644 index 0000000..e01ea77 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/ic_note_empty.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml b/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml new file mode 100644 index 0000000..5004626 --- /dev/null +++ b/src/Notesmaster/app/src/main/res/drawable/note_item_background.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/layout/note_item.xml b/src/Notesmaster/app/src/main/res/layout/note_item.xml index d541f6a..be76314 100644 --- a/src/Notesmaster/app/src/main/res/layout/note_item.xml +++ b/src/Notesmaster/app/src/main/res/layout/note_item.xml @@ -15,51 +15,62 @@ limitations under the License. --> - + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="12dp" + android:background="@null"> + - + android:textSize="16sp" + android:textColor="@android:color/black" + android:textStyle="bold" + android:maxLines="1" + android:ellipsize="end" + android:singleLine="true" /> - - - + + - + + - - - + + + + - - + diff --git a/src/Notesmaster/app/src/main/res/layout/note_list.xml b/src/Notesmaster/app/src/main/res/layout/note_list.xml index 6b25d38..4be330a 100644 --- a/src/Notesmaster/app/src/main/res/layout/note_list.xml +++ b/src/Notesmaster/app/src/main/res/layout/note_list.xml @@ -15,44 +15,66 @@ limitations under the License. --> - + - + + + + + + + + + + - - - - - -