Merge pull request '新增笔记模板和图片插入功能' (#29) from jiangtianxiang_branch into master

pull/32/head
p7tupf26b 4 weeks ago
commit b2a4fbb5ed

@ -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) - 注册登录、云同步

@ -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");

@ -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

@ -70,7 +70,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper {
* onUpgrade
* </p>
*/
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
* <p>
*
* </p>
*
* @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);
}
}
}

@ -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<Long> 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<Long> 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;
}
}

@ -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 = "<img src=\"" + imagePath + "\">";
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 = "<img src=\"" + newSource + "\">";
// 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);
}

@ -466,6 +466,8 @@ public class NoteEditActivity extends AppCompatActivity implements OnClickListen
initNoteScreen();
}
private static final int REQUEST_CODE_PICK_IMAGE = 106;
/**
*
* <p>
@ -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<String> folderNames = new java.util.ArrayList<>();
final java.util.List<Long> 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<String> folderNames,
final java.util.List<Long> 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<String> 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<Long>() {
@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();
}
}

@ -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;
}
}
/**
*
* <p>
@ -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:
// 获取触摸坐标

@ -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<Long>() {
@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() {
// 打开设置页面

@ -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();

@ -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<NotesRepository.NoteInfo> 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<Long> callback) {
// 应用模板到根目录(或者让用户选择,这里简化为根目录)
// 实际上应该让用户选择,或者默认应用到当前上下文(如果是从新建笔记进入)
// 这里假设是从模板列表点击进入,则应用到根目录(或默认目录)
// 更好的逻辑是applyTemplate(templateId, Notes.ID_ROOT_FOLDER)
repository.applyTemplate(templateId, Notes.ID_ROOT_FOLDER, callback);
}
/**
* ViewModel
* <p>

@ -137,6 +137,20 @@
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 模板 -->
<TextView
android:id="@+id/menu_templates"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:drawableStart="@drawable/ic_menu_notes"
android:drawablePadding="12dp"
android:text="@string/menu_templates"
android:textSize="16sp"
android:textColor="@android:color/black"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical" />
<!-- 设置 -->
<TextView
android:id="@+id/menu_settings"

@ -29,6 +29,12 @@
android:icon="@drawable/ic_menu_rich_text"
app:showAsAction="always"/>
<item
android:id="@+id/menu_picture"
android:title="@string/menu_picture"
android:icon="@android:drawable/ic_menu_gallery"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_undo"
android:title="@string/menu_undo"
@ -45,6 +51,10 @@
android:id="@+id/menu_clear_history"
android:title="@string/menu_clear_history"/>
<item
android:id="@+id/menu_save_as_template"
android:title="@string/menu_save_as_template"/>
<item
android:id="@+id/menu_delete"
android:title="@string/menu_delete"/>

@ -140,6 +140,7 @@
<!-- Sidebar strings -->
<string name="menu_login">Login</string>
<string name="menu_export">Export</string>
<string name="menu_templates">Templates</string>
<string name="menu_settings">Settings</string>
<string name="menu_trash">Trash</string>
<string name="root_folder_name">My Notes</string>
@ -175,5 +176,7 @@
<string name="redo_success">Redo successful</string>
<string name="undo_fail">Nothing to undo</string>
<string name="redo_fail">Nothing to redo</string>
<string name="menu_save_as_template">Save as template</string>
<string name="menu_picture">Picture</string>
<string name="menu_rich_text">Rich Text</string>
</resources>

Loading…
Cancel
Save