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+ * 初始化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+ * 在指定文件夹下创建一个空笔记 + *
+ * + * @param folderId 父文件夹ID + * @param callback 回调接口,返回新笔记的ID + */ + public void createNote(long folderId, Callback+ * 更新笔记的标题和内容,自动更新修改时间和本地修改标志 + *
+ * + * @param noteId 笔记ID + * @param content 笔记内容 + * @param callback 回调接口,返回影响的行数 + */ + public void updateNote(long noteId, String content, Callback+ * 将笔记移动到回收站文件夹 + *
+ * + * @param noteId 笔记ID + * @param callback 回调接口,返回影响的行数 + */ + public void deleteNote(long noteId, Callback+ * 将多个笔记移动到回收站文件夹 + *
+ * + * @param noteIds 笔记ID列表 + * @param callback 回调接口,返回影响的行数 + */ + public void deleteNotes(List+ * 根据关键字在标题和内容中搜索笔记 + *
+ * + * @param keyword 搜索关键字 + * @param callback 回调接口 + */ + public void searchNotes(String keyword, Callback+ * 统计指定文件夹下的笔记数量 + *
+ * + * @param folderId 文件夹ID + * @param callback 回调接口 + */ + public void countNotes(long folderId, Callback+ * 查询所有文件夹类型的笔记 + *
+ * + * @param callback 回调接口 + */ + public void getFolders(Callback+ * 将笔记从当前文件夹移动到目标文件夹 + *
+ * + * @param noteIds 要移动的笔记ID列表 + * @param targetFolderId 目标文件夹ID + * @param callback 回调接口 + */ + public void moveNotes(List+ * 在不再需要数据访问时调用,释放线程池资源 + *
+ */ + 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
+ * 现代化实现:使用 ViewHolder 模式优化性能 + *
+ */ +public class NoteInfoAdapter extends BaseAdapter { + private LayoutInflater inflater; + private List+ * 用于多选模式同步,让 ViewModel 更新 selectedNoteIds 后, + * Adapter 的 selectedIds 也能同步更新 + *
+ * + * @param selectedIds 选中的便签 ID 集合 + */ + public void setSelectedIds(HashSet
+ * 重载方法,接受 List
+ * 仅负责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- * 根据当前文件夹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); - } - - /** - * 查询完成回调 - *- * 根据查询令牌处理不同的查询结果: - *
- * 显示一个对话框,列出所有可用的目标文件夹供用户选择, - * 用于移动选中的笔记到指定文件夹。 - *
- * @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- * 启动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- * 在后台线程中删除选中的笔记。 - * 如果处于同步模式,将笔记移动到垃圾箱文件夹; - * 否则直接删除。同时更新相关的小部件。 - *
+ * 更新加载状态 */ - private void batchDelete() { - new AsyncTask- * 删除指定的文件夹及其包含的所有笔记。 - * 如果处于同步模式,将文件夹移动到垃圾箱; - * 否则直接删除。同时更新相关的小部件。 - *
- * @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- * 启动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"); + } } /** - * 返回键按下处理 - *- * 根据当前列表状态处理返回键事件: - *
- * 发送广播更新指定的小部件,使其显示最新的笔记内容。 - *
- * @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(); } /** - * 准备选项菜单 - *- * 根据当前列表状态加载不同的菜单资源: - *
- * 处理用户点击选项菜单的事件,包括: - *
- * 启动系统搜索界面,允许用户搜索笔记内容。 - *
- * @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- * 判断是否已设置同步账户,如果已设置则表示处于同步模式。 - *
- * @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 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+ * 从指定文件夹加载笔记列表 + *
+ * + * @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+ * 重新加载当前文件夹的笔记列表 + *
+ */ + public void refreshNotes() { + loadNotes(currentFolderId); + } + + /** + * 创建新笔记 + *+ * 在当前文件夹下创建一个空笔记,并刷新列表 + *
+ */ + public void createNote() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.createNote(currentFolderId, new NotesRepository.Callback+ * 将笔记移动到回收站,并刷新列表 + *
+ * + * @param noteId 笔记ID + */ + public void deleteNote(long noteId) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.deleteNote(noteId, new NotesRepository.Callback+ * 将选中的所有笔记移动到回收站 + *
+ */ + public void deleteSelectedNotes() { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要删除的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List+ * 根据关键字搜索笔记,更新笔记列表 + *
+ * + * @param keyword 搜索关键字 + */ + public void searchNotes(String keyword) { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.searchNotes(keyword, new NotesRepository.Callback+ * 选中当前列表中的所有笔记 + *
+ */ + public void selectAllNotes() { + List+ * 清空所有选中的笔记 + *
+ */ + public void deselectAllNotes() { + selectedNoteIds.clear(); + } + + /** + * 检查是否全选 + * + * @return 如果所有笔记都被选中返回true + */ + public boolean isAllSelected() { + List+ * 退出多选模式时调用 + *
+ */ + public void clearSelection() { + selectedNoteIds.clear(); + } + + /** + * 获取文件夹列表 + *+ * 加载所有文件夹类型的笔记 + *
+ */ + public void loadFolders() { + isLoading.postValue(true); + errorMessage.postValue(null); + + repository.getFolders(new NotesRepository.Callback+ * 批量移动笔记到目标文件夹 + *
+ * + * @param targetFolderId 目标文件夹ID + */ + public void moveSelectedNotesToFolder(long targetFolderId) { + if (selectedNoteIds.isEmpty()) { + errorMessage.postValue("请先选择要移动的笔记"); + return; + } + + isLoading.postValue(true); + errorMessage.postValue(null); + + List+ * 清理资源和状态 + *
+ */ + @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 @@ + + + + ++ * 测试笔记数据仓库的各个功能 + *
+ * + * @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