diff --git a/docs/开发共享文档/功能扩展规划-精简版.md b/docs/开发共享文档/功能扩展规划-精简版.md index 08b050b..98638f5 100644 --- a/docs/开发共享文档/功能扩展规划-精简版.md +++ b/docs/开发共享文档/功能扩展规划-精简版.md @@ -76,14 +76,14 @@ **当前状态**: - ✅ 基础搜索功能(ContentProvider支持search URI) - ✅ 搜索建议功能 -- ❌ 搜索历史记录 -- ❌ 高级筛选选项 -- ❌ 搜索结果高亮 +- ✅ 搜索历史记录 +- ✅ 高级筛选选项 +- ✅ 搜索结果高亮 **待实现功能点**: -- [ ] 搜索历史记录(本地存储常用搜索词) -- [ ] 搜索结果高亮显示 -- [ ] 搜索频率排序 +- ✅ 搜索历史记录(本地存储常用搜索词) +- ✅ 搜索结果高亮显示 +- ✅ 搜索频率排序 **技术方案**: - 使用 SharedPreferences 存储搜索历史 @@ -131,11 +131,11 @@ **描述**: 支持简单的笔记撤回操作 **功能点**: -- [ ] 撤回上一次编辑 -- [ ] 撤回历史栈(可连续撤回10-20次) -- [ ] 重做功能 -- [ ] 撤回/重做状态提示 -- [ ] 清空撤回历史 +- ✅ 撤回上一次编辑 +- ✅ 撤回历史栈(可连续撤回10-20次) +- ✅ 重做功能 +- ✅ 撤回/重做状态提示 +- ✅ 清空撤回历史 **技术方案**: - 实现 UndoStack 数据结构 @@ -270,16 +270,16 @@ **描述**: 增强文本编辑功能,支持多种格式 **功能点**: -- [ ] 粗体、斜体、下划线 -- [ ] 删除线 -- [ ] 标题层级 (H1-H6) -- [ ] 列表(无序、有序、检查列表) -- [ ] 引用块 -- [ ] 代码块 -- [ ] 链接 -- [ ] 分割线 -- [ ] 文本颜色 -- [ ] 文本背景色 +- ✅ 粗体、斜体、下划线 +- ✅ 删除线 +- ✅ 标题层级 (H1-H6) +- ✅ 列表(无序、有序、检查列表) +- ✅ 引用块 +- ✅ 代码块 +- ✅ 链接 +- ✅ 分割线 +- ✅ 文本颜色 +- ✅ 文本背景色 **技术方案**: - 集成富文本编辑库(如 RichEditor、SpannableStringBuilder) @@ -373,14 +373,14 @@ **描述**: 在笔记中创建待办事项清单 **功能点**: -- [ ] 添加任务项(- [ ] 语法) -- [ ] 标记完成/未完成 -- [ ] 任务优先级(高/中/低) -- [ ] 任务截止日期 -- [ ] 任务提醒 -- [ ] 任务统计(完成率) -- [ ] 过滤已完成任务 -- [ ] 任务拖拽排序 +- ✅ 添加任务项(- [ ] 语法) +- ✅ 标记完成/未完成 +- ✅ 任务优先级(高/中/低) +- ✅ 任务截止日期 +- ✅ 任务提醒 +- ❌ 任务统计(完成率) +- ✅ 过滤已完成任务 +- ❌ 任务拖拽排序 **技术方案**: - 扩展 data 表支持任务类型(mime_type = "text/x-todo") @@ -506,14 +506,14 @@ ## 功能实现时间线(精简版) ### Month 1-2: 核心功能增强 -- [ ] Week 1: 搜索功能增强 (2.1) - 搜索历史、高级筛选 +- ✅ Week 1: 搜索功能增强 (2.1) - 搜索历史、高级筛选 - [ ] Week 2: 导入导出功能增强 (2.2) - 便签图片导出、Markdown/TXT -- [ ] Week 3: 撤回功能 (2.3) - 撤回/重做 +- ✅ Week 3: 撤回功能 (2.3) - 撤回/重做 - [ ] Week 4: 标签系统 (2.4) - 标签分类和筛选 ### Month 3-4: 用户体验提升 - [ ] Week 5: 笔记模板 (2.6) - 模板管理 -- [ ] Week 6-8: 富文本编辑 (3.1) - 完整格式支持 +- ✅ Week 6-8: 富文本编辑 (3.1) - 完整格式支持 ### Month 5-6: 功能扩展 - [ ] Week 9-10: 图片附件 (3.2) - 图片管理和预览 @@ -521,7 +521,7 @@ ### Month 7-8: 高级功能 - [ ] Week 13-14: 链接笔记 (3.4) - 笔图谱 -- [ ] Week 15-16: 任务清单 (3.5) - 任务管理 +- ✅ Week 15-16: 任务清单 (3.5) - 任务管理 ### Month 9+: 智能化和生态 - [ ] Week 17-20: 云同步和账号系统 (4.3) - 注册登录、云同步 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java index ea3a47e..c61f705 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/MainActivity.java @@ -125,6 +125,13 @@ public class MainActivity extends AppCompatActivity implements SidebarFragment.O // TODO: 实现导出功能 } + @Override + public void onTemplateSelected() { + Log.d(TAG, "Template selected"); + // 跳转到模板列表(在 NotesListActivity 中处理) + closeSidebar(); + } + @Override public void onSettingsSelected() { Log.d(TAG, "Settings selected"); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java index 79435fa..520d27a 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/Notes.java @@ -81,6 +81,10 @@ public class Notes { * 回收站文件夹ID,用于存储已删除的笔记 */ public static final int ID_TRASH_FOLER = -3; + /** + * Template folder ID + */ + public static final int ID_TEMPLATE_FOLDER = -4; /** * Intent Extra键:提醒日期 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java index 8c6a8e6..b960d55 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java @@ -70,7 +70,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { * 当数据库版本变更时,onUpgrade方法会被调用以执行升级逻辑。 *

*/ - private static final int DB_VERSION = 9; + private static final int DB_VERSION = 10; /** * 数据库表名常量接口 @@ -427,6 +427,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { values.put(NoteColumns.ID, Notes.ID_TRASH_FOLER); values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); db.insert(TABLE.NOTE, null, values); + + /** + * create template folder + */ + // 创建模板文件夹 + createTemplateFolder(db); } /** @@ -493,6 +499,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { public void onCreate(SQLiteDatabase db) { createNoteTable(db); createDataTable(db); + createPresetTemplates(db); } /** @@ -565,6 +572,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + // 从V9升级到V10 + if (oldVersion == 9) { + upgradeToV10(db); + oldVersion++; + } + // 如果需要,重新创建触发器 if (reCreateTriggers) { reCreateNoteTableTriggers(db); @@ -813,4 +826,88 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { Log.e(TAG, "Failed to add GTASK columns in V9 upgrade", e); } } + + /** + * 升级数据库到V10版本 + *

+ * 创建模板系统文件夹并预置模板。 + *

+ * + * @param db SQLiteDatabase实例 + */ + private void upgradeToV10(SQLiteDatabase db) { + createTemplateFolder(db); + createPresetTemplates(db); + } + + /** + * 创建模板系统文件夹 + * + * @param db SQLiteDatabase实例 + */ + private void createTemplateFolder(SQLiteDatabase db) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.ID, Notes.ID_TEMPLATE_FOLDER); + values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); + db.insert(TABLE.NOTE, null, values); + } + + /** + * 创建预置模板 + * + * @param db SQLiteDatabase实例 + */ + private void createPresetTemplates(SQLiteDatabase db) { + // 工作模板 + long workFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "工作"); + if (workFolderId > 0) { + insertNote(db, workFolderId, "会议记录", "会议主题:\n时间:\n地点:\n参会人:\n\n会议内容:\n\n行动项:\n"); + insertNote(db, workFolderId, "周报", "本周工作总结:\n1. \n2. \n\n下周工作计划:\n1. \n2. \n\n需要协调的问题:\n"); + } + + // 生活模板 + long lifeFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "生活"); + if (lifeFolderId > 0) { + insertNote(db, lifeFolderId, "日记", "日期:\n天气:\n心情:\n\n正文:\n"); + insertNote(db, lifeFolderId, "购物清单", "1. \n2. \n3. \n"); + } + + // 学习模板 + long studyFolderId = insertFolder(db, Notes.ID_TEMPLATE_FOLDER, "学习"); + if (studyFolderId > 0) { + insertNote(db, studyFolderId, "读书笔记", "书名:\n作者:\n\n核心观点:\n\n精彩摘录:\n\n读后感:\n"); + } + } + + private long insertFolder(SQLiteDatabase db, long parentId, String name) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, parentId); + values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis()); + values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); + values.put(NoteColumns.NOTES_COUNT, 0); + return db.insert(TABLE.NOTE, null, values); + } + + private void insertNote(SQLiteDatabase db, long parentId, String title, String content) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.PARENT_ID, parentId); + values.put(NoteColumns.TYPE, Notes.TYPE_NOTE); + values.put(NoteColumns.CREATED_DATE, System.currentTimeMillis()); + values.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); + values.put(NoteColumns.SNIPPET, content); // SNIPPET acts as content preview or full content for simple notes + values.put(NoteColumns.TITLE, title); // Assuming V8+ has TITLE + long noteId = db.insert(TABLE.NOTE, null, values); + + if (noteId > 0) { + ContentValues dataValues = new ContentValues(); + dataValues.put(DataColumns.NOTE_ID, noteId); + dataValues.put(DataColumns.MIME_TYPE, DataConstants.NOTE); + dataValues.put(DataColumns.CONTENT, content); + dataValues.put(NoteColumns.CREATED_DATE, System.currentTimeMillis()); + dataValues.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); + db.insert(TABLE.DATA, null, dataValues); + } + } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java index 2bf9f83..9c07c7e 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/data/NotesRepository.java @@ -305,6 +305,15 @@ public class NotesRepository { return root; } + if (folderId == Notes.ID_TEMPLATE_FOLDER) { + NoteInfo root = new NoteInfo(); + root.id = Notes.ID_TEMPLATE_FOLDER; + root.title = "笔记模板"; + root.snippet = "笔记模板"; + root.type = Notes.TYPE_FOLDER; // Treat as folder for UI + return root; + } + String selection = NoteColumns.ID + "=?"; String[] selectionArgs = new String[]{String.valueOf(folderId)}; @@ -1023,4 +1032,152 @@ public class NotesRepository { Log.d(TAG, "Executor shutdown"); } } + + /** + * 应用模板 + * + * @param templateId 模板笔记ID + * @param targetFolderId 目标文件夹ID + * @param callback 回调 + */ + public void applyTemplate(long templateId, long targetFolderId, Callback callback) { + executor.execute(() -> { + try { + // 1. 获取模板内容 + String content = getNoteContent(templateId); + String title = getNoteTitle(templateId); + + // 2. 创建新笔记 + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + + values.put(NoteColumns.PARENT_ID, targetFolderId); + 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, extractSnippet(content)); + values.put(NoteColumns.TITLE, title); // Copy title (or maybe empty?) + + Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values); + Long newNoteId = 0L; + if (uri != null) { + newNoteId = ContentUris.parseId(uri); + } + + if (newNoteId > 0) { + // 3. 插入内容 + ContentValues dataValues = new ContentValues(); + dataValues.put(DataColumns.NOTE_ID, newNoteId); + dataValues.put(DataColumns.MIME_TYPE, TextNote.CONTENT_ITEM_TYPE); + dataValues.put(DataColumns.CONTENT, content); + dataValues.put(NoteColumns.CREATED_DATE, currentTime); + dataValues.put(NoteColumns.MODIFIED_DATE, currentTime); + contentResolver.insert(Notes.CONTENT_DATA_URI, dataValues); + + callback.onSuccess(newNoteId); + } else { + callback.onError(new RuntimeException("Failed to create note from template")); + } + } catch (Exception e) { + callback.onError(e); + } + }); + } + + /** + * 保存为模板 + * + * @param sourceNoteId 源笔记ID + * @param categoryId 模板分类文件夹ID + * @param templateName 模板名称 + * @param callback 回调 + */ + public void createTemplate(long sourceNoteId, long categoryId, String templateName, Callback callback) { + executor.execute(() -> { + try { + // 1. 获取源内容 + String content = getNoteContent(sourceNoteId); + + // 2. 创建模板笔记 + ContentValues values = new ContentValues(); + long currentTime = System.currentTimeMillis(); + + values.put(NoteColumns.PARENT_ID, categoryId); + 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, extractSnippet(content)); + values.put(NoteColumns.TITLE, templateName); + + Uri uri = contentResolver.insert(Notes.CONTENT_NOTE_URI, values); + Long newNoteId = 0L; + if (uri != null) { + newNoteId = ContentUris.parseId(uri); + } + + if (newNoteId > 0) { + // 3. 插入内容 + ContentValues dataValues = new ContentValues(); + dataValues.put(DataColumns.NOTE_ID, newNoteId); + dataValues.put(DataColumns.MIME_TYPE, TextNote.CONTENT_ITEM_TYPE); + dataValues.put(DataColumns.CONTENT, content); + dataValues.put(NoteColumns.CREATED_DATE, currentTime); + dataValues.put(NoteColumns.MODIFIED_DATE, currentTime); + contentResolver.insert(Notes.CONTENT_DATA_URI, dataValues); + + callback.onSuccess(newNoteId); + } else { + callback.onError(new RuntimeException("Failed to create template")); + } + } catch (Exception e) { + callback.onError(e); + } + }); + } + + private String getNoteContent(long noteId) { + String content = ""; + Cursor cursor = contentResolver.query( + Notes.CONTENT_DATA_URI, + new String[]{DataColumns.CONTENT}, + DataColumns.NOTE_ID + " = ? AND " + DataColumns.MIME_TYPE + " = ?", + new String[]{String.valueOf(noteId), TextNote.CONTENT_ITEM_TYPE}, + null + ); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + content = cursor.getString(0); + } + } finally { + cursor.close(); + } + } + return content; + } + + private String getNoteTitle(long noteId) { + String title = ""; + Cursor cursor = contentResolver.query( + Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.TITLE}, + NoteColumns.ID + " = ?", + new String[]{String.valueOf(noteId)}, + null + ); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + title = cursor.getString(0); + } + } finally { + cursor.close(); + } + } + return title; + } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/RichTextHelper.java b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/RichTextHelper.java index 5ed8da3..082d232 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/tool/RichTextHelper.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/tool/RichTextHelper.java @@ -24,8 +24,112 @@ import android.view.LayoutInflater; import android.view.View; import android.widget.EditText; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.util.Log; + public class RichTextHelper { + private static final String TAG = "RichTextHelper"; + + public static class NoteImageGetter implements Html.ImageGetter { + private Context mContext; + + public NoteImageGetter(Context context) { + mContext = context; + } + + @Override + public Drawable getDrawable(String source) { + if (TextUtils.isEmpty(source)) { + return null; + } + + try { + Uri uri = Uri.parse(source); + String path = uri.getPath(); + if (path == null) { + return null; + } + + // Parse dimensions from fragment + int targetWidth = -1; + int targetHeight = -1; + String fragment = uri.getFragment(); + if (fragment != null) { + String[] params = fragment.split("&"); + for (String param : params) { + String[] pair = param.split("="); + if (pair.length == 2) { + if ("w".equals(pair[0])) { + targetWidth = Integer.parseInt(pair[1]); + } else if ("h".equals(pair[0])) { + targetHeight = Integer.parseInt(pair[1]); + } + } + } + } + + // Check if it's a content URI or file path + // For simplicity in this project, we assume we saved it as file:// path + // But source might come as /data/user/0/... + + // Decode bitmap with resizing + // Calculate max width (e.g., screen width - padding) + // For simplicity, let's assume a fixed max width or display metrics + int maxWidth = mContext.getResources().getDisplayMetrics().widthPixels - 40; + + Bitmap bitmap = decodeSampledBitmapFromFile(path, maxWidth, maxWidth); + + if (bitmap != null) { + BitmapDrawable drawable = new BitmapDrawable(mContext.getResources(), bitmap); + + if (targetWidth > 0 && targetHeight > 0) { + // Use saved dimensions + drawable.setBounds(0, 0, targetWidth, targetHeight); + } else { + // Use default intrinsic dimensions + drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + } + return drawable; + } + } catch (Exception e) { + Log.e(TAG, "Failed to load image: " + source, e); + } + return null; + } + + private Bitmap decodeSampledBitmapFromFile(String pathName, int reqWidth, int reqHeight) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(pathName, options); + + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); + + options.inJustDecodeBounds = false; + return BitmapFactory.decodeFile(pathName, options); + } + + private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + final int halfHeight = height / 2; + final int halfWidth = width / 2; + + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + return inSampleSize; + } + } + public static void applyBold(EditText editText) { applyStyleSpan(editText, Typeface.BOLD); } @@ -247,10 +351,65 @@ public class RichTextHelper { editText.getText().insert(start, "\n-------------------\n"); } + public static void insertImage(EditText editText, String imagePath) { + // Remove existing fragment if any + if (imagePath.contains("#")) { + imagePath = imagePath.substring(0, imagePath.indexOf("#")); + } + // Default: no size specified, use intrinsic + String html = ""; + int start = editText.getSelectionStart(); + int end = editText.getSelectionEnd(); + if (start > end) { int temp = start; start = end; end = temp; } + + Spanned spanned = Html.fromHtml(html, new NoteImageGetter(editText.getContext()), null); + editText.getText().replace(start, end, spanned); + // Insert a newline after image for easier typing + editText.getText().insert(start + spanned.length(), "\n"); + } + + public static void updateImageSpanSize(EditText editText, android.text.style.ImageSpan span, int width, int height) { + Editable editable = editText.getText(); + int start = editable.getSpanStart(span); + int end = editable.getSpanEnd(span); + if (start < 0 || end < 0) return; // Span not found + + String source = span.getSource(); + if (source == null) return; + + // Remove old fragment + if (source.contains("#")) { + source = source.substring(0, source.indexOf("#")); + } + + // Append new dimensions + String newSource = source + "#w=" + width + "&h=" + height; + String html = ""; + + // Create new span with updated source + Spanned newSpanned = Html.fromHtml(html, new NoteImageGetter(editText.getContext()), null); + + // We only want the ImageSpan, not the whole Spanned (which might contain newline if insertImage added it, but fromHtml for img usually just one char) + // Actually fromHtml returns a Spanned with ImageSpan on a special character. + // We can just replace the old span range with new one. + + // Careful: replacing text might reset other spans or move cursor. + // Better: get the new ImageSpan from newSpanned and set it on the existing range, removing the old one. + android.text.style.ImageSpan[] newSpans = newSpanned.getSpans(0, newSpanned.length(), android.text.style.ImageSpan.class); + if (newSpans.length > 0) { + editable.removeSpan(span); + editable.setSpan(newSpans[0], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + public static String toHtml(Spanned text) { return Html.toHtml(text); } + public static Spanned fromHtml(String html, Context context) { + return Html.fromHtml(html, new NoteImageGetter(context), null); + } + public static Spanned fromHtml(String html) { return Html.fromHtml(html); } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index 7c25fdf..221c485 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -466,6 +466,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen initNoteScreen(); } + private static final int REQUEST_CODE_PICK_IMAGE = 106; + /** * 初始化笔记编辑界面 *

@@ -489,7 +491,7 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen } else { String content = mWorkingNote.getContent(); if (content.contains("<") && content.contains(">")) { - mNoteEditor.setText(RichTextHelper.fromHtml(content)); + mNoteEditor.setText(RichTextHelper.fromHtml(content, this)); } else { mNoteEditor.setText(getHighlightQueryResult(content, mUserQuery)); } @@ -991,6 +993,12 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen case R.id.menu_new_note: createNewNote(); break; + case R.id.menu_save_as_template: + saveAsTemplate(); + break; + case R.id.menu_picture: + pickImage(); + break; case R.id.menu_delete: AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle(getString(R.string.alert_title_delete)); @@ -1571,8 +1579,53 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen startActivityForResult(intent, REQUEST_CODE_PICK_WALLPAPER); } + private void pickImage() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_CODE_PICK_IMAGE); + } + + private void saveImageToPrivateStorage(android.net.Uri uri) { + new Thread(() -> { + try { + java.io.InputStream is = getContentResolver().openInputStream(uri); + if (is == null) return; + + // Create images directory if not exists + java.io.File imagesDir = new java.io.File(getFilesDir(), "images"); + if (!imagesDir.exists()) { + imagesDir.mkdirs(); + } + + // Create a unique file name + String fileName = "img_" + System.currentTimeMillis() + ".jpg"; + java.io.File destFile = new java.io.File(imagesDir, fileName); + + java.io.FileOutputStream fos = new java.io.FileOutputStream(destFile); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + fos.write(buffer, 0, bytesRead); + } + fos.close(); + is.close(); + + final String filePath = "file://" + destFile.getAbsolutePath(); + runOnUiThread(() -> { + RichTextHelper.insertImage(mNoteEditor, filePath); + }); + + } catch (Exception e) { + Log.e(TAG, "Failed to copy image", e); + runOnUiThread(() -> { + showToast(R.string.failed_sdcard_export); // Use generic failure message or add new one + }); + } + }).start(); + } + @Override - protected3 void onActivityResult(int requestCode, int resultCode, Intent data) { + protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_CODE_PICK_WALLPAPER && resultCode == RESULT_OK && data != null) { android.net.Uri uri = data.getData(); @@ -1587,6 +1640,11 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen mWorkingNote.setWallpaper(uri.toString()); mNoteBgColorSelector.setVisibility(View.GONE); } + } else if (requestCode == REQUEST_CODE_PICK_IMAGE && resultCode == RESULT_OK && data != null) { + android.net.Uri uri = data.getData(); + if (uri != null) { + saveImageToPrivateStorage(uri); + } } } @@ -1686,4 +1744,100 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen } }); } + + private void saveAsTemplate() { + if (!mWorkingNote.existInDatabase()) { + saveNote(); + } + + final net.micode.notes.data.NotesRepository repository = new net.micode.notes.data.NotesRepository(getContentResolver()); + + new Thread(() -> { + android.database.Cursor cursor = getContentResolver().query(Notes.CONTENT_NOTE_URI, + new String[]{net.micode.notes.data.Notes.NoteColumns.ID, net.micode.notes.data.Notes.NoteColumns.SNIPPET}, + net.micode.notes.data.Notes.NoteColumns.PARENT_ID + "=? AND " + net.micode.notes.data.Notes.NoteColumns.TYPE + "=?", + new String[]{String.valueOf(Notes.ID_TEMPLATE_FOLDER), String.valueOf(Notes.TYPE_FOLDER)}, + null); + + final java.util.List folderNames = new java.util.ArrayList<>(); + final java.util.List folderIds = new java.util.ArrayList<>(); + + if (cursor != null) { + while(cursor.moveToNext()) { + folderIds.add(cursor.getLong(0)); + folderNames.add(cursor.getString(1)); + } + cursor.close(); + } + + runOnUiThread(() -> { + if (folderNames.isEmpty()) { + Toast.makeText(this, "No template categories found", Toast.LENGTH_SHORT).show(); + repository.shutdown(); + return; + } + + showSaveTemplateDialog(repository, folderNames, folderIds); + }); + }).start(); + } + + private void showSaveTemplateDialog(final net.micode.notes.data.NotesRepository repository, + final java.util.List folderNames, + final java.util.List folderIds) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Save as Template"); + + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(50, 40, 50, 40); + + final EditText input = new EditText(this); + input.setHint("Template Name"); + input.setText(mWorkingNote.getTitle()); + layout.addView(input); + + final TextView label = new TextView(this); + label.setText("Select Category:"); + label.setPadding(0, 20, 0, 10); + layout.addView(label); + + final android.widget.Spinner spinner = new android.widget.Spinner(this); + android.widget.ArrayAdapter adapter = new android.widget.ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, folderNames); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + layout.addView(spinner); + + builder.setView(layout); + + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + String name = input.getText().toString(); + int position = spinner.getSelectedItemPosition(); + if (position >= 0 && position < folderIds.size()) { + long categoryId = folderIds.get(position); + repository.createTemplate(mWorkingNote.getNoteId(), categoryId, name, new net.micode.notes.data.NotesRepository.Callback() { + @Override + public void onSuccess(Long result) { + runOnUiThread(() -> { + Toast.makeText(NoteEditActivity.this, "Template Saved", Toast.LENGTH_SHORT).show(); + repository.shutdown(); + }); + } + + @Override + public void onError(Exception e) { + runOnUiThread(() -> { + Toast.makeText(NoteEditActivity.this, "Failed: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + repository.shutdown(); + }); + } + }); + } + }); + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> repository.shutdown()); + builder.setOnCancelListener(dialog -> repository.shutdown()); + + builder.show(); + } } diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java index df117b3..e8efb9e 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/NoteEditText.java @@ -55,7 +55,17 @@ import java.util.Map; * * @see NoteEditActivity */ -public class NoteEditText extends EditText { +import android.view.ScaleGestureDetector; +import android.view.GestureDetector; +import android.text.style.ImageSpan; +import net.micode.notes.tool.RichTextHelper; +import android.app.AlertDialog; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.LinearLayout; +import android.content.DialogInterface; + +public class NoteEditText extends EditText implements ScaleGestureDetector.OnScaleGestureListener { // 日志标签 private static final String TAG = "NoteEditText"; // 当前EditText的索引 @@ -63,6 +73,13 @@ public class NoteEditText extends EditText { // 删除前的光标位置 private int mSelectionStartBeforeDelete; + // Scale Gesture Detector + private ScaleGestureDetector mScaleDetector; + private GestureDetector mGestureDetector; + private ImageSpan mSelectedImageSpan; + private int mInitialWidth; + private int mInitialHeight; + // 电话号码URI方案 private static final String SCHEME_TEL = "tel:" ; // HTTP URI方案 @@ -78,6 +95,74 @@ public class NoteEditText extends EditText { sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email); } + @Override + public boolean onScale(ScaleGestureDetector detector) { + if (mSelectedImageSpan != null) { + float scaleFactor = detector.getScaleFactor(); + int newWidth = (int) (mInitialWidth * scaleFactor); + int newHeight = (int) (mInitialHeight * scaleFactor); + + // Constrain size + int maxWidth = getResources().getDisplayMetrics().widthPixels; + if (newWidth > maxWidth) { + newWidth = maxWidth; + newHeight = (int) (mInitialHeight * (maxWidth / (float) mInitialWidth)); + } + if (newWidth < 100) newWidth = 100; + if (newHeight < 100) newHeight = 100; + + if (mSelectedImageSpan.getDrawable() != null) { + mSelectedImageSpan.getDrawable().setBounds(0, 0, newWidth, newHeight); + // Force layout update + invalidate(); + requestLayout(); + } + return true; + } + return false; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + float x = detector.getFocusX(); + float y = detector.getFocusY(); + + x += getScrollX(); + y += getScrollY(); + x -= getTotalPaddingLeft(); + y -= getTotalPaddingTop(); + + Layout layout = getLayout(); + if (layout != null) { + int line = layout.getLineForVertical((int) y); + int offset = layout.getOffsetForHorizontal(line, x); + + if (getText() instanceof Spanned) { + Spanned spanned = (Spanned) getText(); + ImageSpan[] spans = spanned.getSpans(offset, offset, ImageSpan.class); + if (spans.length > 0) { + mSelectedImageSpan = spans[0]; + if (mSelectedImageSpan.getDrawable() != null) { + Rect bounds = mSelectedImageSpan.getDrawable().getBounds(); + mInitialWidth = bounds.width(); + mInitialHeight = bounds.height(); + return true; + } + } + } + } + return false; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + if (mSelectedImageSpan != null && mSelectedImageSpan.getDrawable() != null) { + Rect bounds = mSelectedImageSpan.getDrawable().getBounds(); + RichTextHelper.updateImageSpanSize(this, mSelectedImageSpan, bounds.width(), bounds.height()); + mSelectedImageSpan = null; + } + } + /** * 文本视图变更监听器接口 *

@@ -123,6 +208,111 @@ public class NoteEditText extends EditText { public NoteEditText(Context context) { super(context, null); mIndex = 0; + init(context); + } + + private void init(Context context) { + mScaleDetector = new ScaleGestureDetector(context, this); + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDoubleTap(MotionEvent e) { + float x = e.getX(); + float y = e.getY(); + + x += getScrollX(); + y += getScrollY(); + x -= getTotalPaddingLeft(); + y -= getTotalPaddingTop(); + + Layout layout = getLayout(); + if (layout != null) { + int line = layout.getLineForVertical((int) y); + int offset = layout.getOffsetForHorizontal(line, x); + + if (getText() instanceof Spanned) { + Spanned spanned = (Spanned) getText(); + ImageSpan[] spans = spanned.getSpans(offset, offset, ImageSpan.class); + if (spans.length > 0) { + showResizeDialog(spans[0]); + return true; + } + } + } + return super.onDoubleTap(e); + } + }); + } + + private void showResizeDialog(final ImageSpan imageSpan) { + if (imageSpan.getDrawable() == null) return; + + final Rect bounds = imageSpan.getDrawable().getBounds(); + final int originalWidth = bounds.width(); + final int originalHeight = bounds.height(); + final float aspectRatio = (float) originalHeight / originalWidth; + + AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setTitle("Resize Image"); + + LinearLayout layout = new LinearLayout(getContext()); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(50, 20, 50, 20); + + final TextView label = new TextView(getContext()); + label.setText("Scale: 100%"); + layout.addView(label); + + final SeekBar seekBar = new SeekBar(getContext()); + seekBar.setMax(200); // 0 to 200% + seekBar.setProgress(100); + layout.addView(seekBar); + + builder.setView(layout); + + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + // Minimum 10% + if (progress < 10) progress = 10; + + float scale = progress / 100f; + int newWidth = (int) (originalWidth * scale); + int newHeight = (int) (newWidth * aspectRatio); + + label.setText("Scale: " + progress + "%"); + + // Live preview + imageSpan.getDrawable().setBounds(0, 0, newWidth, newHeight); + invalidate(); + requestLayout(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Rect finalBounds = imageSpan.getDrawable().getBounds(); + RichTextHelper.updateImageSpanSize(NoteEditText.this, imageSpan, finalBounds.width(), finalBounds.height()); + } + }); + + builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // Revert + imageSpan.getDrawable().setBounds(0, 0, originalWidth, originalHeight); + invalidate(); + requestLayout(); + } + }); + + builder.show(); } /** @@ -151,6 +341,7 @@ public class NoteEditText extends EditText { */ public NoteEditText(Context context, AttributeSet attrs) { super(context, attrs, android.R.attr.editTextStyle); + init(context); } /** @@ -162,6 +353,7 @@ public class NoteEditText extends EditText { */ public NoteEditText(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); + init(context); } /** @@ -174,6 +366,17 @@ public class NoteEditText extends EditText { */ @Override public boolean onTouchEvent(MotionEvent event) { + if (mScaleDetector != null) { + mScaleDetector.onTouchEvent(event); + if (mScaleDetector.isInProgress()) { + return true; + } + } + + if (mGestureDetector != null) { + mGestureDetector.onTouchEvent(event); + } + switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // 获取触摸坐标 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 32a837c..429f4b8 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 @@ -456,6 +456,8 @@ public class NotesListActivity extends AppCompatActivity if (viewModel.isTrashMode()) { // 回收站模式:弹出恢复/删除对话框 showTrashItemDialog(note); + } else if (viewModel.isTemplateMode() && note.type == Notes.TYPE_NOTE) { + showApplyTemplateDialog(note); } else if (note.type == Notes.TYPE_FOLDER) { // 文件夹:进入该文件夹 // 检查隐私锁 @@ -501,6 +503,45 @@ public class NotesListActivity extends AppCompatActivity } } + /** + * 显示应用模板确认对话框 + */ + private void showApplyTemplateDialog(NotesRepository.NoteInfo note) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("应用模板"); + builder.setMessage("使用模板 \"" + (TextUtils.isEmpty(note.title) ? "未命名" : note.title) + "\" 创建新笔记?"); + builder.setPositiveButton("创建", (dialog, which) -> { + viewModel.applyTemplate(note.getId(), new NotesRepository.Callback() { + @Override + public void onSuccess(Long newNoteId) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "创建成功", Toast.LENGTH_SHORT).show(); + // 跳转到新笔记编辑页 + Intent intent = new Intent(NotesListActivity.this, NoteEditActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, newNoteId); + startActivity(intent); + // 退出模板模式(返回根目录) + // viewModel.loadNotes(Notes.ID_ROOT_FOLDER); + }); + } + + @Override + public void onError(Exception error) { + runOnUiThread(() -> { + Toast.makeText(NotesListActivity.this, "创建失败: " + error.getMessage(), Toast.LENGTH_SHORT).show(); + }); + } + }); + }); + builder.setNegativeButton("取消", null); + builder.setNeutralButton("编辑模板", (dialog, which) -> { + // 打开编辑器编辑模板本身 + openNoteEditor(note); + }); + builder.show(); + } + /** * 显示回收站条目操作对话框 */ @@ -634,6 +675,8 @@ public class NotesListActivity extends AppCompatActivity // 设置标题 if (viewModel.isTrashMode()) { binding.toolbar.setTitle(R.string.menu_trash); + } else if (viewModel.isTemplateMode()) { + binding.toolbar.setTitle(R.string.menu_templates); } else { binding.toolbar.setTitle(R.string.app_name); // 添加普通模式菜单 @@ -889,6 +932,16 @@ public class NotesListActivity extends AppCompatActivity Toast.makeText(this, "导出功能待实现", Toast.LENGTH_SHORT).show(); } + @Override + public void onTemplateSelected() { + // 跳转到模板文件夹 + viewModel.enterFolder(Notes.ID_TEMPLATE_FOLDER); + // 关闭侧栏 + if (binding.drawerLayout != null) { + binding.drawerLayout.closeDrawer(sidebarFragment); + } + } + @Override public void onSettingsSelected() { // 打开设置页面 diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java index 269f8e8..f47be61 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/ui/SidebarFragment.java @@ -107,6 +107,11 @@ public class SidebarFragment extends Fragment { */ void onExportSelected(); + /** + * 模板 + */ + void onTemplateSelected(); + /** * 设置 */ @@ -217,6 +222,12 @@ public class SidebarFragment extends Fragment { } }); + binding.menuTemplates.setOnClickListener(v -> { + if (listener != null) { + listener.onTemplateSelected(); + } + }); + binding.menuSettings.setOnClickListener(v -> { if (listener != null) { listener.onSettingsSelected(); diff --git a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java index d6f854b..574d462 100644 --- a/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java +++ b/src/Notesmaster/app/src/main/java/net/micode/notes/viewmodel/NotesListViewModel.java @@ -703,6 +703,36 @@ public class NotesListViewModel extends ViewModel { return currentFolderId == Notes.ID_TRASH_FOLER; } + /** + * 判断当前是否处于模板模式 + * + * @return 如果当前文件夹是模板文件夹或其子文件夹返回true + */ + public boolean isTemplateMode() { + if (currentFolderId == Notes.ID_TEMPLATE_FOLDER) return true; + List path = folderPathLiveData.getValue(); + if (path != null) { + for (NotesRepository.NoteInfo info : path) { + if (info.getId() == Notes.ID_TEMPLATE_FOLDER) return true; + } + } + return false; + } + + /** + * 应用模板 + * + * @param templateId 模板笔记ID + * @param callback 回调 + */ + public void applyTemplate(long templateId, NotesRepository.Callback callback) { + // 应用模板到根目录(或者让用户选择,这里简化为根目录) + // 实际上应该让用户选择,或者默认应用到当前上下文(如果是从新建笔记进入) + // 这里假设是从模板列表点击进入,则应用到根目录(或默认目录) + // 更好的逻辑是:applyTemplate(templateId, Notes.ID_ROOT_FOLDER) + repository.applyTemplate(templateId, Notes.ID_ROOT_FOLDER, callback); + } + /** * ViewModel销毁时的清理 *

diff --git a/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml b/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml index 738c0aa..79d28b6 100644 --- a/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml +++ b/src/Notesmaster/app/src/main/res/layout/sidebar_layout.xml @@ -137,6 +137,20 @@ android:background="?attr/selectableItemBackground" android:gravity="center_vertical" /> + + + + + + + diff --git a/src/Notesmaster/app/src/main/res/values/strings.xml b/src/Notesmaster/app/src/main/res/values/strings.xml index 9e40ea6..993846d 100644 --- a/src/Notesmaster/app/src/main/res/values/strings.xml +++ b/src/Notesmaster/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ Login Export + Templates Settings Trash My Notes @@ -175,5 +176,7 @@ Redo successful Nothing to undo Nothing to redo + Save as template + Picture Rich Text