diff --git a/doc/实践统计-阅读分析和维护开源软件.xlsx b/doc/实践统计-阅读分析和维护开源软件.xlsx new file mode 100644 index 0000000..25cbbbb Binary files /dev/null and b/doc/实践统计-阅读分析和维护开源软件.xlsx differ diff --git a/doc/实践考评-阅读维护开源软件的团队自评报告.xlsx b/doc/实践考评-阅读维护开源软件的团队自评报告.xlsx new file mode 100644 index 0000000..5f4bb65 Binary files /dev/null and b/doc/实践考评-阅读维护开源软件的团队自评报告.xlsx differ diff --git a/doc/实践考评-阅读维护开源软件的汇报.pptx b/doc/实践考评-阅读维护开源软件的汇报.pptx new file mode 100644 index 0000000..09f8679 Binary files /dev/null and b/doc/实践考评-阅读维护开源软件的汇报.pptx differ diff --git a/doc/小米便签泛读、标注和维护报告文档.docx b/doc/小米便签泛读、标注和维护报告文档.docx new file mode 100644 index 0000000..8b6c64f Binary files /dev/null and b/doc/小米便签泛读、标注和维护报告文档.docx differ diff --git a/doc/小米便签软件的质量分析报告文档.docx b/doc/小米便签质量分析报告文档.docx similarity index 54% rename from doc/小米便签软件的质量分析报告文档.docx rename to doc/小米便签质量分析报告文档.docx index 45d8267..1e5628e 100644 Binary files a/doc/小米便签软件的质量分析报告文档.docx and b/doc/小米便签质量分析报告文档.docx differ diff --git a/doc/小米便签软件的泛读、标注和维护报告文档.docx b/doc/小米便签软件的泛读、标注和维护报告文档.docx deleted file mode 100644 index 50dff9a..0000000 Binary files a/doc/小米便签软件的泛读、标注和维护报告文档.docx and /dev/null differ diff --git a/src/.gitignore b/src/.gitignore index 7df8dff..aa724b7 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,9 +1,15 @@ -# generated files -bin/ -gen/ - -# Local configuration file (sdk path, etc) -project.properties -.settings/ -.classpath -.project +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/src/NOTICE b/src/NOTICE deleted file mode 100644 index 9a54521..0000000 --- a/src/NOTICE +++ /dev/null @@ -1,190 +0,0 @@ - - Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - - 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. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - diff --git a/src/README b/src/README deleted file mode 100644 index fc0d824..0000000 --- a/src/README +++ /dev/null @@ -1,23 +0,0 @@ -[中文] - -1. MiCode便签是小米便签的社区开源版,由MIUI团队(www.miui.com) 发起并贡献第一批代码,遵循NOTICE文件所描述的开源协议, - 今后为MiCode社区(www.micode.net) 拥有,并由社区发布和维护。 - -2. Bug反馈和跟踪,请访问Github, - https://github.com/MiCode/Notes/issues?sort=created&direction=desc&state=open - -3. 功能建议和综合讨论,请访问MiCode, - http://micode.net/forum.php?mod=forumdisplay&fid=38 - - -[English] - -1. MiCode Notes is open source edition of XM notepad, it's first initiated and sponsored by MIUI team (www.miui.com). - It's opened under license described by NOTICE file. It's owned by the MiCode community (www.micode.net). In future, - the MiCode community will release and maintain this project. - -2. Regarding issue tracking, please visit Github, - https://github.com/MiCode/Notes/issues?sort=created&direction=desc&state=open - -3. Regarding feature request and general discussion, please visit Micode forum, - http://micode.net/forum.php?mod=forumdisplay&fid=38 diff --git a/src/app/.gitignore b/src/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/src/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/src/app/build.gradle.kts b/src/app/build.gradle.kts new file mode 100644 index 0000000..39b05ee --- /dev/null +++ b/src/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "net.micode.notes" + + // 修改这里:匹配你下载的 SDK 30 + compileSdk = 34 + + defaultConfig { + applicationId = "net.micode.notes" + minSdk = 30 + // 修改这里:匹配你下载的 SDK 30 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // === 这里是新增的代码,用来解决 Jar 包冲突报错 === + packaging { + resources { + excludes += "META-INF/DEPENDENCIES" + excludes += "META-INF/NOTICE" + excludes += "META-INF/LICENSE" + excludes += "META-INF/LICENSE.txt" + excludes += "META-INF/NOTICE.txt" + } + } + // ============================================== +} + +dependencies { + // === 修改部分:使用兼容 SDK 30 的旧版本库 === + // 替换掉了原本的 libs.appcompat 等引用,改为写死的旧版本号 + // ViewPager2 用于左右滑动 + implementation("androidx.recyclerview:recyclerview:1.3.0") + implementation("androidx.viewpager2:viewpager2:1.0.0") +// Material Design 用于底部导航栏 + implementation("com.google.android.material:material:1.9.0") +// Fragment 核心库 (确保使用 AndroidX 版本) + implementation("androidx.fragment:fragment:1.5.7") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.appcompat:appcompat:1.3.1") + implementation("com.google.android.material:material:1.4.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.drawerlayout:drawerlayout:1.2.0") + implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0") + + // 如果你需要 Activity 的新特性,也需要指定旧版本,否则默认会拉取最新的导致报错 + implementation("androidx.activity:activity:1.2.4") + + // Google Play Services - 用于地理围栏 + implementation("com.google.android.gms:play-services-location:21.0.1") + + // === 保持不变:你的 Jar 包路径 === + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + + // === 测试库也建议指定旧版本,防止冲突 === + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.3") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") +} \ No newline at end of file diff --git a/src/app/libs/httpclient-osgi-4.5.14.jar b/src/app/libs/httpclient-osgi-4.5.14.jar new file mode 100644 index 0000000..270124f Binary files /dev/null and b/src/app/libs/httpclient-osgi-4.5.14.jar differ diff --git a/src/app/libs/httpclient-win-4.5.14.jar b/src/app/libs/httpclient-win-4.5.14.jar new file mode 100644 index 0000000..66c75aa Binary files /dev/null and b/src/app/libs/httpclient-win-4.5.14.jar differ diff --git a/src/app/libs/httpcore-4.4.16.jar b/src/app/libs/httpcore-4.4.16.jar new file mode 100644 index 0000000..aed581e Binary files /dev/null and b/src/app/libs/httpcore-4.4.16.jar differ diff --git a/src/app/proguard-rules.pro b/src/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/src/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/src/app/src/androidTest/java/net/micode/notes/ExampleInstrumentedTest.java b/src/app/src/androidTest/java/net/micode/notes/ExampleInstrumentedTest.java new file mode 100644 index 0000000..a889a75 --- /dev/null +++ b/src/app/src/androidTest/java/net/micode/notes/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package net.micode.notes; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("net.micode.notes", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/src/AndroidManifest.xml b/src/app/src/main/AndroidManifest.xml similarity index 78% rename from src/AndroidManifest.xml rename to src/app/src/main/AndroidManifest.xml index 3096559..d50951c 100644 --- a/src/AndroidManifest.xml +++ b/src/app/src/main/AndroidManifest.xml @@ -2,6 +2,14 @@ + + + + + + + + @@ -10,12 +18,16 @@ + + + + @@ -26,17 +38,24 @@ android:theme="@style/NoteTheme" tools:targetApi="31"> - + + android:theme="@style/SplashTheme" + android:screenOrientation="portrait"> + + + - + - + android:process=":remote" /> + + + + + + + searchRelevantNotes(Context context, String query) { + List results = new ArrayList<>(); + // 1. 尝试关键词搜索 + // ... (保留你之前的代码逻辑) ... + + // ---【修改开始:在这里插入逻辑】--- + + // ... (执行原本的 SQL 查询) ... + + // 关键修改点:如果关键词搜索结果为空,或者用户只发了简单的问候 + if (results.isEmpty()) { + Log.d("NoteRetriever", "关键词搜索无果,启动兜底策略:获取最近 10 条便签"); + results.addAll(getRecentNotes(context, 10)); + } else { + Log.d("NoteRetriever", "关键词搜索成功,找到 " + results.size() + " 条"); + } + + return results; + } + + // 【新增方法】获取最近的 N 条便签 + private static List getRecentNotes(Context context, int limit) { + List list = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = context.getContentResolver().query( + Notes.CONTENT_NOTE_URI, // 直接查 Note 主表 + new String[]{NoteColumns.ID, NoteColumns.SNIPPET, NoteColumns.MODIFIED_DATE}, + NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>?", + new String[]{String.valueOf(Notes.TYPE_NOTE), String.valueOf(Notes.ID_TRASH_FOLER)}, + NoteColumns.MODIFIED_DATE + " DESC LIMIT " + limit // 按时间倒序,取前 N 条 + ); + + if (cursor != null && cursor.moveToFirst()) { + do { + long id = cursor.getLong(0); + String snippet = cursor.getString(1); // 标题 + long date = cursor.getLong(2); + + // 再去 Data 表查这一条的完整内容 + String content = getNoteContent(context, id); + + // 如果内容为空,用标题代替 + if (TextUtils.isEmpty(content)) content = snippet; + + list.add(new RetrievedNote(id, snippet, content, date)); + } while (cursor.moveToNext()); + } + } catch (Exception e) { + Log.e("NoteRetriever", "Get recent notes failed", e); + } finally { + if (cursor != null) cursor.close(); + } + return list; + } + + // 【新增辅助方法】获取单条便签的内容 + private static String getNoteContent(Context context, long noteId) { + String content = ""; + Cursor c = null; + try { + c = context.getContentResolver().query( + Notes.CONTENT_DATA_URI, + new String[]{DataColumns.CONTENT}, + DataColumns.NOTE_ID + "=? AND " + DataColumns.MIME_TYPE + "=?", + new String[]{String.valueOf(noteId), Notes.TextNote.CONTENT_ITEM_TYPE}, + null + ); + if (c != null && c.moveToFirst()) { + content = c.getString(0); + } + } finally { + if (c != null) c.close(); + } + return content; + } + + // 辅助方法:获取标题 + private static String getSnippet(Context context, long noteId) { + String snippet = "无标题"; + Cursor c = context.getContentResolver().query( + Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.SNIPPET}, + NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)}, + null); + if (c != null) { + if (c.moveToFirst()) snippet = c.getString(0); + c.close(); + } + return snippet; + } + + // 辅助方法:获取时间 + private static long getDate(Context context, long noteId) { + long date = 0; + Cursor c = context.getContentResolver().query( + Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.MODIFIED_DATE}, + NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)}, + null); + if (c != null) { + if (c.moveToFirst()) date = c.getLong(0); + c.close(); + } + return date; + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/ai/PromptBuilder.java b/src/app/src/main/java/net/micode/notes/ai/PromptBuilder.java new file mode 100644 index 0000000..975e40b --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ai/PromptBuilder.java @@ -0,0 +1,65 @@ +package net.micode.notes.ai; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * AI 提示词构建器 + *

+ * 负责为 DeepSeek 大模型构建系统提示词,支持两种模式: + *

    + *
  • 通用 AI 模式:不依赖本地便签,直接回答问题
  • + *
  • 私人管家模式 (RAG):注入用户便签作为知识库,基于便签内容回答
  • + *
+ *

+ * + * @see net.micode.notes.ai.NoteRetriever + * @see net.micode.notes.ai.AIService + */ +public class PromptBuilder { + + /** + * 构建系统提示词 + * @param relatedNotes 检索到的便签 + * @param useRAG 是否开启 RAG(知识库)模式 + */ + public static String buildSystemPrompt(List relatedNotes, boolean useRAG) { + // === 分支 1:通用 AI 模式 (未勾选知识库) === + if (!useRAG) { + return "你是一个智能且博学的 AI 助手。请根据用户的提问直接给出准确、有帮助的回答。不需要参考任何本地便签信息。"; + } + + // === 分支 2:私人管家模式 (勾选知识库) === + StringBuilder sb = new StringBuilder(); + + // 1. 身份定义与当前环境 + String currentTime = new SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm", Locale.CHINA).format(new Date()); + sb.append("你是一个基于用户个人便签的私人管家 'Mi管家'。\n"); + sb.append("当前时间是:").append(currentTime).append("。\n"); + sb.append("你需要根据用户提供的【参考便签】来回答用户的问题。\n\n"); + + // 2. 注入参考资料 + if (relatedNotes != null && !relatedNotes.isEmpty()) { + sb.append("【参考便签】:\n"); + for (NoteRetriever.RetrievedNote note : relatedNotes) { + sb.append("[RefID:").append(note.id).append("] "); + sb.append("标题:").append(note.title).append("\n"); + sb.append("内容:").append(note.content).append("\n"); + sb.append("修改时间:").append(new SimpleDateFormat("MM-dd", Locale.CHINA).format(new Date(note.modifiedDate))).append("\n"); + sb.append("----------------\n"); + } + } else { + sb.append("【参考便签】:(无相关记录)\n"); + } + + // 3. 回答约束 (严苛模式) + sb.append("\n【回答要求】:\n"); + sb.append("1. 请根据当前时间和参考便签进行推理。\n"); + sb.append("2. 如果在参考便签中找到了答案,请简明扼要地回答,并**必须**在句末标注来源,格式为 [Ref:便签标题]。\n"); + sb.append("3. 如果参考便签中没有相关信息,请诚实回答“我在您的便签中没找到相关记录”,不要编造。\n"); + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/src/net/micode/notes/data/Contact.java b/src/app/src/main/java/net/micode/notes/data/Contact.java similarity index 90% rename from src/src/net/micode/notes/data/Contact.java rename to src/app/src/main/java/net/micode/notes/data/Contact.java index fa62f49..48e4758 100644 --- a/src/src/net/micode/notes/data/Contact.java +++ b/src/app/src/main/java/net/micode/notes/data/Contact.java @@ -25,6 +25,15 @@ import android.util.Log; import java.util.HashMap; +/** + * 联系人查询工具 + *

+ * 根据电话号码从系统联系人数据库查询对应联系人姓名。 + * 用于通话记录便签中显示来电/去电方的名称。内部使用缓存减少重复查询。 + *

+ * + * @see net.micode.notes.data.Notes + */ public class Contact { private static HashMap sContactCache; private static final String TAG = "Contact"; diff --git a/src/src/net/micode/notes/data/Notes.java b/src/app/src/main/java/net/micode/notes/data/Notes.java similarity index 95% rename from src/src/net/micode/notes/data/Notes.java rename to src/app/src/main/java/net/micode/notes/data/Notes.java index f240604..49d7f75 100644 --- a/src/src/net/micode/notes/data/Notes.java +++ b/src/app/src/main/java/net/micode/notes/data/Notes.java @@ -34,7 +34,10 @@ public class Notes { public static final int ID_TEMPARAY_FOLDER = -1; public static final int ID_CALL_RECORD_FOLDER = -2; public static final int ID_TRASH_FOLER = -3; - + // [新增] 灵感箱文件夹 ID + public static final int ID_INSPIRATION_FOLDER = -4; + // [新增] 隐私空间文件夹 ID(存放私密便签,仅在输入「隐私空间」后可见) + public static final int ID_PRIVACY_FOLDER = -5; public static final String INTENT_EXTRA_ALERT_DATE = "net.micode.notes.alert_date"; public static final String INTENT_EXTRA_BACKGROUND_ID = "net.micode.notes.background_color_id"; public static final String INTENT_EXTRA_WIDGET_ID = "net.micode.notes.widget_id"; @@ -165,6 +168,12 @@ public class Notes { *

Type : INTEGER (long)

*/ public static final String VERSION = "version"; + + /** + * Whether the note or folder is pinned to top (1) or not (0). + *

Type : INTEGER

+ */ + public static final String PINNED = "pinned"; } public interface DataColumns { diff --git a/src/src/net/micode/notes/data/NotesDatabaseHelper.java b/src/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java similarity index 86% rename from src/src/net/micode/notes/data/NotesDatabaseHelper.java rename to src/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java index ffe5d57..4f432fc 100644 --- a/src/src/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/app/src/main/java/net/micode/notes/data/NotesDatabaseHelper.java @@ -30,12 +30,12 @@ import net.micode.notes.data.Notes.NoteColumns; public class NotesDatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; - private static final int DB_VERSION = 4; + private static final int DB_VERSION = 7; public interface TABLE { public static final String NOTE = "note"; - public static final String DATA = "data"; + public static final String TODO_ITEMS = "todo_items"; } private static final String TAG = "NotesDatabaseHelper"; @@ -60,7 +60,8 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + - NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.PINNED + " INTEGER NOT NULL DEFAULT 0" + ")"; private static final String CREATE_DATA_TABLE_SQL = @@ -82,6 +83,19 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { "CREATE INDEX IF NOT EXISTS note_id_index ON " + TABLE.DATA + "(" + DataColumns.NOTE_ID + ");"; + private static final String CREATE_TODO_ITEMS_TABLE_SQL = + "CREATE TABLE " + TABLE.TODO_ITEMS + "(" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "content TEXT NOT NULL DEFAULT ''," + + "is_done INTEGER NOT NULL DEFAULT 0," + + "reminder_type INTEGER NOT NULL DEFAULT 0," + + "reminder_timestamp INTEGER NOT NULL DEFAULT 0," + + "latitude REAL NOT NULL DEFAULT 0," + + "longitude REAL NOT NULL DEFAULT 0," + + "location_name TEXT NOT NULL DEFAULT ''," + + "created_time INTEGER NOT NULL DEFAULT 0" + + ")"; + /** * Increase folder's note count when move note to the folder */ @@ -268,6 +282,14 @@ 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 privacy space folder (hidden, only visible when user enters via Mi Steward) + */ + values.clear(); + values.put(NoteColumns.ID, Notes.ID_PRIVACY_FOLDER); + values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); + db.insert(TABLE.NOTE, null, values); } public void createDataTable(SQLiteDatabase db) { @@ -298,6 +320,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { public void onCreate(SQLiteDatabase db) { createNoteTable(db); createDataTable(db); + createTodoTable(db); } @Override @@ -322,6 +345,21 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + if (oldVersion == 4) { + upgradeToV5(db); + oldVersion++; + } + + if (oldVersion == 5) { + upgradeToV6(db); + oldVersion++; + } + + if (oldVersion == 6) { + upgradeToV7(db); + oldVersion++; + } + if (reCreateTriggers) { reCreateNoteTableTriggers(db); reCreateDataTableTriggers(db); @@ -359,4 +397,26 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0"); } + + private void upgradeToV5(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.PINNED + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV6(SQLiteDatabase db) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.ID, Notes.ID_PRIVACY_FOLDER); + values.put(NoteColumns.TYPE, Notes.TYPE_SYSTEM); + db.insert(TABLE.NOTE, null, values); + } + + private void upgradeToV7(SQLiteDatabase db) { + db.execSQL(CREATE_TODO_ITEMS_TABLE_SQL); + Log.d(TAG, "todo_items table has been created"); + } + + public void createTodoTable(SQLiteDatabase db) { + db.execSQL(CREATE_TODO_ITEMS_TABLE_SQL); + Log.d(TAG, "todo_items table has been created"); + } } diff --git a/src/src/net/micode/notes/data/NotesProvider.java b/src/app/src/main/java/net/micode/notes/data/NotesProvider.java similarity index 100% rename from src/src/net/micode/notes/data/NotesProvider.java rename to src/app/src/main/java/net/micode/notes/data/NotesProvider.java diff --git a/src/app/src/main/java/net/micode/notes/data/TodoDao.java b/src/app/src/main/java/net/micode/notes/data/TodoDao.java new file mode 100644 index 0000000..a04cd67 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/data/TodoDao.java @@ -0,0 +1,153 @@ +package net.micode.notes.data; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import net.micode.notes.data.NotesDatabaseHelper.TABLE; +import net.micode.notes.todo.TodoItem; + +import java.util.ArrayList; +import java.util.List; + +/** + * 待办事项数据访问层 (CRUD) + */ +public class TodoDao { + private static final String TAG = "TodoDao"; + + private static final String COL_ID = "id"; + private static final String COL_CONTENT = "content"; + private static final String COL_IS_DONE = "is_done"; + private static final String COL_REMINDER_TYPE = "reminder_type"; + private static final String COL_REMINDER_TIMESTAMP = "reminder_timestamp"; + private static final String COL_LATITUDE = "latitude"; + private static final String COL_LONGITUDE = "longitude"; + private static final String COL_LOCATION_NAME = "location_name"; + private static final String COL_CREATED_TIME = "created_time"; + + private final NotesDatabaseHelper mHelper; + + public TodoDao(Context context) { + mHelper = NotesDatabaseHelper.getInstance(context); + } + + public long insert(TodoItem item) { + SQLiteDatabase db = mHelper.getWritableDatabase(); + ContentValues cv = toContentValues(item); + long id = db.insert(TABLE.TODO_ITEMS, null, cv); + Log.d(TAG, "insert todo id=" + id); + return id; + } + + public int update(TodoItem item) { + if (item.getId() <= 0) return 0; + SQLiteDatabase db = mHelper.getWritableDatabase(); + ContentValues cv = toContentValues(item); + return db.update(TABLE.TODO_ITEMS, cv, COL_ID + "=?", new String[]{String.valueOf(item.getId())}); + } + + public int delete(long id) { + SQLiteDatabase db = mHelper.getWritableDatabase(); + return db.delete(TABLE.TODO_ITEMS, COL_ID + "=?", new String[]{String.valueOf(id)}); + } + + public int deleteByIds(List ids) { + if (ids == null || ids.isEmpty()) return 0; + SQLiteDatabase db = mHelper.getWritableDatabase(); + StringBuilder sb = new StringBuilder(COL_ID).append(" IN ("); + for (int i = 0; i < ids.size(); i++) { + sb.append(i > 0 ? "," : "").append(ids.get(i)); + } + sb.append(")"); + return db.delete(TABLE.TODO_ITEMS, sb.toString(), null); + } + + public TodoItem getById(long id) { + SQLiteDatabase db = mHelper.getReadableDatabase(); + try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_ID + "=?", new String[]{String.valueOf(id)}, null, null, null)) { + if (c != null && c.moveToFirst()) { + return fromCursor(c); + } + } + return null; + } + + public List getAll() { + List list = new ArrayList<>(); + SQLiteDatabase db = mHelper.getReadableDatabase(); + try (Cursor c = db.query(TABLE.TODO_ITEMS, null, null, null, null, null, COL_IS_DONE + " ASC, " + COL_CREATED_TIME + " DESC")) { + if (c != null) { + while (c.moveToNext()) { + list.add(fromCursor(c)); + } + } + } + return list; + } + + public List getUndone() { + List list = new ArrayList<>(); + SQLiteDatabase db = mHelper.getReadableDatabase(); + try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_IS_DONE + "=0", null, null, null, COL_CREATED_TIME + " DESC")) { + if (c != null) { + while (c.moveToNext()) { + list.add(fromCursor(c)); + } + } + } + return list; + } + + public List getDone() { + List list = new ArrayList<>(); + SQLiteDatabase db = mHelper.getReadableDatabase(); + try (Cursor c = db.query(TABLE.TODO_ITEMS, null, COL_IS_DONE + "=1", null, null, null, COL_CREATED_TIME + " DESC")) { + if (c != null) { + while (c.moveToNext()) { + list.add(fromCursor(c)); + } + } + } + return list; + } + + public int getUndoneCount() { + SQLiteDatabase db = mHelper.getReadableDatabase(); + try (Cursor c = db.rawQuery("SELECT COUNT(*) FROM " + TABLE.TODO_ITEMS + " WHERE " + COL_IS_DONE + "=0", null)) { + if (c != null && c.moveToFirst()) { + return c.getInt(0); + } + } + return 0; + } + + private ContentValues toContentValues(TodoItem item) { + ContentValues cv = new ContentValues(); + cv.put(COL_CONTENT, item.getContent()); + cv.put(COL_IS_DONE, item.isDone() ? 1 : 0); + cv.put(COL_REMINDER_TYPE, item.getReminderType()); + cv.put(COL_REMINDER_TIMESTAMP, item.getReminderTimestamp()); + cv.put(COL_LATITUDE, item.getLatitude()); + cv.put(COL_LONGITUDE, item.getLongitude()); + cv.put(COL_LOCATION_NAME, item.getLocationName()); + cv.put(COL_CREATED_TIME, item.getCreatedTime()); + return cv; + } + + private TodoItem fromCursor(Cursor c) { + TodoItem item = new TodoItem(); + item.setId(c.getLong(c.getColumnIndexOrThrow(COL_ID))); + item.setContent(c.getString(c.getColumnIndexOrThrow(COL_CONTENT))); + item.setDone(c.getInt(c.getColumnIndexOrThrow(COL_IS_DONE)) == 1); + item.setReminderType(c.getInt(c.getColumnIndexOrThrow(COL_REMINDER_TYPE))); + item.setReminderTimestamp(c.getLong(c.getColumnIndexOrThrow(COL_REMINDER_TIMESTAMP))); + item.setLatitude(c.getDouble(c.getColumnIndexOrThrow(COL_LATITUDE))); + item.setLongitude(c.getDouble(c.getColumnIndexOrThrow(COL_LONGITUDE))); + item.setLocationName(c.getString(c.getColumnIndexOrThrow(COL_LOCATION_NAME))); + item.setCreatedTime(c.getLong(c.getColumnIndexOrThrow(COL_CREATED_TIME))); + return item; + } +} diff --git a/src/src/net/micode/notes/gtask/data/MetaData.java b/src/app/src/main/java/net/micode/notes/gtask/data/MetaData.java similarity index 90% rename from src/src/net/micode/notes/gtask/data/MetaData.java rename to src/app/src/main/java/net/micode/notes/gtask/data/MetaData.java index 3a2050b..54e3ed3 100644 --- a/src/src/net/micode/notes/gtask/data/MetaData.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/MetaData.java @@ -24,7 +24,16 @@ import net.micode.notes.tool.GTaskStringUtils; import org.json.JSONException; import org.json.JSONObject; - +/** + * GTask 元数据任务 + *

+ * 继承自 {@link Task},用于存储与 GTask 同步相关的元信息(如关联的 gtask id)。 + * 作为特殊类型的任务用于同步时传递附加信息。 + *

+ * + * @see Task + * @see GTaskStringUtils + */ public class MetaData extends Task { private final static String TAG = MetaData.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/data/Node.java b/src/app/src/main/java/net/micode/notes/gtask/data/Node.java similarity index 87% rename from src/src/net/micode/notes/gtask/data/Node.java rename to src/app/src/main/java/net/micode/notes/gtask/data/Node.java index 63950e0..c86f850 100644 --- a/src/src/net/micode/notes/gtask/data/Node.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/Node.java @@ -20,6 +20,17 @@ import android.database.Cursor; import org.json.JSONObject; +/** + * Google Task 同步节点基类 + *

+ * GTask 同步体系中的抽象基类,定义任务/任务列表的通用字段(gid、名称、修改时间、删除标记) + * 及同步动作常量。子类 {@link TaskList} 和 {@link Task} 实现与本地数据库、远程 JSON 的互转逻辑。 + *

+ * + * @see TaskList + * @see Task + * @see MetaData + */ public abstract class Node { public static final int SYNC_ACTION_NONE = 0; diff --git a/src/src/net/micode/notes/gtask/data/SqlData.java b/src/app/src/main/java/net/micode/notes/gtask/data/SqlData.java similarity index 95% rename from src/src/net/micode/notes/gtask/data/SqlData.java rename to src/app/src/main/java/net/micode/notes/gtask/data/SqlData.java index d3ec3be..b0a616a 100644 --- a/src/src/net/micode/notes/gtask/data/SqlData.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/SqlData.java @@ -34,7 +34,17 @@ import net.micode.notes.gtask.exception.ActionFailureException; import org.json.JSONException; import org.json.JSONObject; - +/** + * 便签数据块与 GTask 内容互转工具 + *

+ * 负责便签的 Data 表(文本、富媒体等)与 GTask 任务内容(notes 字段)的转换。 + * 与 {@link SqlNote} 配合,完成便签与 GTask 任务的双向同步。 + *

+ * + * @see SqlNote + * @see Task + * @see Notes.DataColumns + */ public class SqlData { private static final String TAG = SqlData.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/data/SqlNote.java b/src/app/src/main/java/net/micode/notes/gtask/data/SqlNote.java similarity index 98% rename from src/src/net/micode/notes/gtask/data/SqlNote.java rename to src/app/src/main/java/net/micode/notes/gtask/data/SqlNote.java index 79a4095..99e4fcf 100644 --- a/src/src/net/micode/notes/gtask/data/SqlNote.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/SqlNote.java @@ -37,7 +37,17 @@ import org.json.JSONObject; import java.util.ArrayList; - +/** + * 便签与 GTask 任务互转工具 + *

+ * 负责本地便签(Notes ContentProvider)与 GTask {@link Task} 之间的转换。 + * 提供 Note 的 CRUD、同步状态更新、JSON 序列化等能力,供 {@link GTaskManager} 调用。 + *

+ * + * @see Task + * @see SqlData + * @see GTaskManager + */ public class SqlNote { private static final String TAG = SqlNote.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/data/Task.java b/src/app/src/main/java/net/micode/notes/gtask/data/Task.java similarity index 97% rename from src/src/net/micode/notes/gtask/data/Task.java rename to src/app/src/main/java/net/micode/notes/gtask/data/Task.java index 6a19454..7c78f65 100644 --- a/src/src/net/micode/notes/gtask/data/Task.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/Task.java @@ -31,7 +31,17 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; - +/** + * Google Task 任务实体 + *

+ * 表示 GTask 中的单个任务,与便签可相互 sync。包含完成状态、便签内容、元信息等, + * 负责与远程 JSON 及本地数据库的转换。 + *

+ * + * @see Node + * @see TaskList + * @see SqlNote + */ public class Task extends Node { private static final String TAG = Task.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/data/TaskList.java b/src/app/src/main/java/net/micode/notes/gtask/data/TaskList.java similarity index 97% rename from src/src/net/micode/notes/gtask/data/TaskList.java rename to src/app/src/main/java/net/micode/notes/gtask/data/TaskList.java index 4ea21c5..30f9998 100644 --- a/src/src/net/micode/notes/gtask/data/TaskList.java +++ b/src/app/src/main/java/net/micode/notes/gtask/data/TaskList.java @@ -29,7 +29,17 @@ import org.json.JSONObject; import java.util.ArrayList; - +/** + * Google Task 任务列表实体 + *

+ * 表示 GTask 中的任务列表(顶层分组),包含多个 {@link Task} 子任务。 + * 负责与 GTask API 的 JSON 格式及本地便签数据库的同步转换。 + *

+ * + * @see Node + * @see Task + * @see GTaskManager + */ public class TaskList extends Node { private static final String TAG = TaskList.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/exception/ActionFailureException.java b/src/app/src/main/java/net/micode/notes/gtask/exception/ActionFailureException.java similarity index 80% rename from src/src/net/micode/notes/gtask/exception/ActionFailureException.java rename to src/app/src/main/java/net/micode/notes/gtask/exception/ActionFailureException.java index 15504be..9b5bca3 100644 --- a/src/src/net/micode/notes/gtask/exception/ActionFailureException.java +++ b/src/app/src/main/java/net/micode/notes/gtask/exception/ActionFailureException.java @@ -16,6 +16,15 @@ package net.micode.notes.gtask.exception; +/** + * GTask 操作失败异常 + *

+ * 当 GTask 同步过程中发生业务逻辑错误(如 API 返回失败、数据格式异常)时抛出。 + * 继承自 RuntimeException,调用方可不强制捕获。 + *

+ * + * @see NetworkFailureException + */ public class ActionFailureException extends RuntimeException { private static final long serialVersionUID = 4425249765923293627L; diff --git a/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java b/src/app/src/main/java/net/micode/notes/gtask/exception/NetworkFailureException.java similarity index 82% rename from src/src/net/micode/notes/gtask/exception/NetworkFailureException.java rename to src/app/src/main/java/net/micode/notes/gtask/exception/NetworkFailureException.java index b08cfb1..5fd1511 100644 --- a/src/src/net/micode/notes/gtask/exception/NetworkFailureException.java +++ b/src/app/src/main/java/net/micode/notes/gtask/exception/NetworkFailureException.java @@ -16,6 +16,15 @@ package net.micode.notes.gtask.exception; +/** + * GTask 网络异常 + *

+ * 当 GTask 同步过程中发生网络相关错误(如超时、连接失败)时抛出。 + * 继承自 Exception,调用方需显式捕获或声明。 + *

+ * + * @see ActionFailureException + */ public class NetworkFailureException extends Exception { private static final long serialVersionUID = 2107610287180234136L; diff --git a/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskASyncTask.java similarity index 94% rename from src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java rename to src/app/src/main/java/net/micode/notes/gtask/remote/GTaskASyncTask.java index 4454a1a..9330ca1 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskASyncTask.java +++ b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskASyncTask.java @@ -28,7 +28,16 @@ import net.micode.notes.R; import net.micode.notes.ui.NotesListActivity; import net.micode.notes.ui.NotesPreferenceActivity; - +/** + * GTask 同步异步任务 + *

+ * 在后台执行便签与 Google Task 的同步,通过通知栏显示进度。 + * 完成后通过 OnCompleteListener 回调通知调用方。 + *

+ * + * @see GTaskManager + * @see GTaskSyncService + */ public class GTaskASyncTask extends AsyncTask { private static int GTASK_SYNC_NOTIFICATION_ID = 5234235; diff --git a/src/src/net/micode/notes/gtask/remote/GTaskClient.java b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskClient.java similarity index 95% rename from src/src/net/micode/notes/gtask/remote/GTaskClient.java rename to src/app/src/main/java/net/micode/notes/gtask/remote/GTaskClient.java index c67dfdf..da206d9 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskClient.java +++ b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskClient.java @@ -60,7 +60,17 @@ import java.util.zip.GZIPInputStream; import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; - +/** + * Google Task API 客户端 + *

+ * 封装与 Google Task 服务的 HTTP 通信,负责登录、获取任务列表、增删改任务等操作。 + * 使用 Apache HttpClient,配合 AccountManager 完成 Google 账户认证。 + *

+ * + * @see GTaskManager + * @see TaskList + * @see Task + */ public class GTaskClient { private static final String TAG = GTaskClient.class.getSimpleName(); @@ -306,9 +316,11 @@ public class GTaskClient { input = new InflaterInputStream(entity.getContent(), inflater); } + InputStreamReader isr = null; + BufferedReader br = null; try { - InputStreamReader isr = new InputStreamReader(input); - BufferedReader br = new BufferedReader(isr); + isr = new InputStreamReader(input); + br = new BufferedReader(isr); StringBuilder sb = new StringBuilder(); while (true) { @@ -319,7 +331,27 @@ public class GTaskClient { sb = sb.append(buff); } } finally { - input.close(); + if (br != null) { + try { + br.close(); + } catch (IOException e) { + Log.e(TAG, "close BufferedReader", e); + } + } + if (isr != null) { + try { + isr.close(); + } catch (IOException e) { + Log.e(TAG, "close InputStreamReader", e); + } + } + if (input != null) { + try { + input.close(); + } catch (IOException e) { + Log.e(TAG, "close InputStream", e); + } + } } } diff --git a/src/src/net/micode/notes/gtask/remote/GTaskManager.java b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskManager.java similarity index 98% rename from src/src/net/micode/notes/gtask/remote/GTaskManager.java rename to src/app/src/main/java/net/micode/notes/gtask/remote/GTaskManager.java index d2b4082..535f5ae 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskManager.java +++ b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskManager.java @@ -47,7 +47,18 @@ import java.util.HashSet; import java.util.Iterator; import java.util.Map; - +/** + * Google Task 同步管理器 + *

+ * 协调便签与 Google Task 的双向同步,负责任务列表与便签的映射、冲突处理、同步状态广播。 + * 单例模式,供 {@link GTaskSyncService} 和 {@link GTaskASyncTask} 调用。 + *

+ * + * @see GTaskClient + * @see GTaskASyncTask + * @see SqlNote + * @see SqlData + */ public class GTaskManager { private static final String TAG = GTaskManager.class.getSimpleName(); diff --git a/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskSyncService.java similarity index 94% rename from src/src/net/micode/notes/gtask/remote/GTaskSyncService.java rename to src/app/src/main/java/net/micode/notes/gtask/remote/GTaskSyncService.java index cca36f7..1c13160 100644 --- a/src/src/net/micode/notes/gtask/remote/GTaskSyncService.java +++ b/src/app/src/main/java/net/micode/notes/gtask/remote/GTaskSyncService.java @@ -23,6 +23,16 @@ import android.content.Intent; import android.os.Bundle; import android.os.IBinder; +/** + * GTask 同步服务 + *

+ * 前台 Service,接收同步启动/取消 Intent,委托 {@link GTaskASyncTask} 执行同步。 + * 通过广播发送同步状态与进度,供 UI 层监听。 + *

+ * + * @see GTaskASyncTask + * @see GTaskManager + */ public class GTaskSyncService extends Service { public final static String ACTION_STRING_NAME = "sync_action_type"; diff --git a/src/app/src/main/java/net/micode/notes/model/ChatMessage.java b/src/app/src/main/java/net/micode/notes/model/ChatMessage.java new file mode 100644 index 0000000..c7794c4 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/model/ChatMessage.java @@ -0,0 +1,43 @@ +package net.micode.notes.model; + +import java.util.List; + +/** + * 聊天消息实体类 + *

+ * 用于 Mi 管家 AI 对话界面,区分用户消息和 AI 回复。 + * 支持 AI 思考中状态及参考资料列表的展示。 + *

+ * + * @see net.micode.notes.ui.ChatAdapter + */ +public class ChatMessage { + public static final int TYPE_USER = 0; + public static final int TYPE_AI = 1; + + private int type; + private String content; + private boolean isThinking; // 是否正在思考(仅 AI 有效) + private List references; // 参考资料标题列表(仅 AI 有效) + + // 用户消息构造 + public ChatMessage(String content) { + this.type = TYPE_USER; + this.content = content; + } + + // AI 消息构造 + public ChatMessage() { + this.type = TYPE_AI; + this.content = ""; + this.isThinking = true; // 默认一开始在思考 + } + + public int getType() { return type; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public boolean isThinking() { return isThinking; } + public void setThinking(boolean thinking) { isThinking = thinking; } + public List getReferences() { return references; } + public void setReferences(List references) { this.references = references; } +} diff --git a/src/src/net/micode/notes/model/Note.java b/src/app/src/main/java/net/micode/notes/model/Note.java similarity index 96% rename from src/src/net/micode/notes/model/Note.java rename to src/app/src/main/java/net/micode/notes/model/Note.java index 0d9070b..61dcafc 100644 --- a/src/src/net/micode/notes/model/Note.java +++ b/src/app/src/main/java/net/micode/notes/model/Note.java @@ -108,7 +108,12 @@ public class Note { mNoteDiffValues.put(NoteColumns.MODIFIED_DATE, System.currentTimeMillis()); } - // 委托方法:将文本数据的设置操作转发给内部类处理 + /** + * 委托方法:将文本数据的设置操作转发给内部类 NoteData 处理。 + * + * @param key 数据列名(如 DataColumns.CONTENT、TextNote.MODE) + * @param value 要设置的值 + */ public void setTextData(String key, String value) { mNoteData.setTextData(key, value); } @@ -118,11 +123,20 @@ public class Note { mNoteData.setTextDataId(id); } + /** + * 获取关联的文本数据在 Data 表中的行 ID。 + * + * @return 文本数据行 ID,0 表示尚未插入 + */ public long getTextDataId() { return mNoteData.mTextDataId; } - // 委托方法:设置关联的通话记录数据行 ID + /** + * 委托方法:设置关联的通话记录数据行 ID。 + * + * @param id Data 表中通话记录行的 ID + */ public void setCallDataId(long id) { mNoteData.setCallDataId(id); } diff --git a/src/src/net/micode/notes/model/WorkingNote.java b/src/app/src/main/java/net/micode/notes/model/WorkingNote.java similarity index 91% rename from src/src/net/micode/notes/model/WorkingNote.java rename to src/app/src/main/java/net/micode/notes/model/WorkingNote.java index e1bf532..1b21449 100644 --- a/src/src/net/micode/notes/model/WorkingNote.java +++ b/src/app/src/main/java/net/micode/notes/model/WorkingNote.java @@ -18,12 +18,14 @@ package net.micode.notes.model; import android.appwidget.AppWidgetManager; import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.text.TextUtils; import android.util.Log; import net.micode.notes.data.Notes; +import net.micode.notes.tool.DataUtils; import net.micode.notes.data.Notes.CallNote; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.DataConstants; @@ -222,6 +224,7 @@ public class WorkingNote { // [新代码开始] 使用 ThreadExecutor 在后台线程执行保存,防止卡顿 // ================================================================================= final Context context = mContext; + final String contentForSnippet = mContent != null ? mContent : ""; // 提交异步任务 ThreadExecutor.getInstance().execute(new Runnable() { @@ -245,7 +248,14 @@ public class WorkingNote { // mNote.syncNote 会将内存中的 Diff 写入数据库 mNote.syncNote(context, mNoteId); - // 3. 更新桌面小部件 (如果关联了 Widget) + // 3. 标题(snippet)写回:以第一行/第三行/无标题/图片便签 规则覆盖触发器写入的全文 + String snippet = DataUtils.computeNoteSnippet(contentForSnippet); + ContentValues cv = new ContentValues(); + cv.put(NoteColumns.SNIPPET, snippet); + context.getContentResolver().update( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mNoteId), cv, null, null); + + // 4. 更新桌面小部件 (如果关联了 Widget) if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && mWidgetType != Notes.TYPE_WIDGET_INVALIDE && mNoteSettingStatusListener != null) { @@ -313,6 +323,11 @@ public class WorkingNote { } } + /** + * 设置设置变更监听器,用于在背景色、闹钟、Widget、清单模式变更时通知 UI 刷新。 + * + * @param l 监听器实例,可为 null + */ public void setOnSettingStatusChangedListener(NoteSettingChangedListener l) { mNoteSettingStatusListener = l; } @@ -328,6 +343,11 @@ public class WorkingNote { } } + /** + * 标记便签为已删除状态。若关联了 Widget,会触发 Widget 更新。 + * + * @param mark true 表示已删除 + */ public void markDeleted(boolean mark) { mIsDeleted = mark; if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID @@ -358,6 +378,11 @@ public class WorkingNote { } } + /** + * 设置关联的桌面小部件类型(2x 或 4x)。 + * + * @param type Notes.TYPE_WIDGET_2X 或 Notes.TYPE_WIDGET_4X + */ public void setWidgetType(int type) { if (type != mWidgetType) { mWidgetType = type; @@ -365,6 +390,11 @@ public class WorkingNote { } } + /** + * 设置关联的桌面小部件 ID。 + * + * @param id AppWidgetManager 分配的小部件 ID + */ public void setWidgetId(int id) { if (id != mWidgetId) { mWidgetId = id; @@ -392,6 +422,11 @@ public class WorkingNote { mNote.setNoteValue(NoteColumns.PARENT_ID, String.valueOf(Notes.ID_CALL_RECORD_FOLDER)); } + /** + * 判断是否已设置闹钟提醒。 + * + * @return true 表示已设置提醒时间 + */ public boolean hasClockAlert() { return (mAlertDate > 0 ? true : false); } diff --git a/src/app/src/main/java/net/micode/notes/todo/ConfettiView.java b/src/app/src/main/java/net/micode/notes/todo/ConfettiView.java new file mode 100644 index 0000000..c8ad004 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/ConfettiView.java @@ -0,0 +1,130 @@ +package net.micode.notes.todo; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AccelerateInterpolator; + +import java.util.ArrayList; +import java.util.Random; + +/** + * 简易撒花粒子动画 View(手写 Canvas 实现,无需第三方库) + */ +public class ConfettiView extends View { + /** 粒子数量,铺满整屏 */ + private static final int PARTICLE_COUNT = 260; + private static final long DURATION_MS = 2800; + + private final ArrayList mParticles = new ArrayList<>(); + private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Random mRandom = new Random(); + private long mStartTime; + + private static class Particle { + float x, y; + float vx, vy; + float size; + int color; + float rotation; + float rotationSpeed; + } + + public ConfettiView(Context context) { + super(context); + init(); + } + + public ConfettiView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + mPaint.setStyle(Paint.Style.FILL); + } + + public void start() { + mParticles.clear(); + setVisibility(VISIBLE); + // 先设为 VISIBLE 再 post,确保布局后 getWidth/getHeight 有值(GONE 时宽高为 0 会直接 return) + post(this::initParticlesIfSized); + } + + private void initParticlesIfSized() { + initParticlesIfSized(0); + } + + private static final int MAX_LAYOUT_RETRY = 3; + + private void initParticlesIfSized(int retryCount) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) { + if (retryCount < MAX_LAYOUT_RETRY) { + requestLayout(); + post(() -> initParticlesIfSized(retryCount + 1)); + } + return; + } + + int[] colors = { + Color.parseColor("#FF6B6B"), + Color.parseColor("#4ECDC4"), + Color.parseColor("#FFE66D"), + Color.parseColor("#95E1D3"), + Color.parseColor("#F38181"), + Color.parseColor("#AA96DA"), + Color.parseColor("#FCBAD3"), + Color.parseColor("#A8D8EA"), + }; + + for (int i = 0; i < PARTICLE_COUNT; i++) { + Particle p = new Particle(); + p.x = (mRandom.nextFloat() - 0.1f) * (w * 1.2f); + p.y = -mRandom.nextFloat() * h * 0.6f; + p.vx = (mRandom.nextFloat() - 0.5f) * 10; + p.vy = 5 + mRandom.nextFloat() * 14; + p.size = 8 + mRandom.nextFloat() * 12; + p.color = colors[mRandom.nextInt(colors.length)]; + p.rotation = mRandom.nextFloat() * 360; + p.rotationSpeed = (mRandom.nextFloat() - 0.5f) * 24; + mParticles.add(p); + } + mStartTime = System.currentTimeMillis(); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mParticles.isEmpty()) return; + long elapsed = System.currentTimeMillis() - mStartTime; + if (elapsed >= DURATION_MS) { + setVisibility(GONE); + return; + } + + float progress = (float) elapsed / DURATION_MS; + AccelerateInterpolator interpolator = new AccelerateInterpolator(); + float alpha = 1f - interpolator.getInterpolation(progress); + + for (Particle p : mParticles) { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.3f; + p.rotation += p.rotationSpeed; + + mPaint.setColor(p.color); + mPaint.setAlpha((int) (255 * alpha)); + canvas.save(); + canvas.translate(p.x, p.y); + canvas.rotate(p.rotation); + canvas.drawRect(-p.size / 2, -p.size / 4, p.size / 2, p.size / 4, mPaint); + canvas.restore(); + } + invalidate(); + } +} diff --git a/src/app/src/main/java/net/micode/notes/todo/TodoAdapter.java b/src/app/src/main/java/net/micode/notes/todo/TodoAdapter.java new file mode 100644 index 0000000..3593a36 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/TodoAdapter.java @@ -0,0 +1,251 @@ +package net.micode.notes.todo; + +import android.graphics.Paint; +import android.view.LayoutInflater; + +import androidx.core.content.ContextCompat; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import net.micode.notes.R; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 待办列表适配器 + *

+ * RecyclerView Adapter,分区展示未完成与已完成待办。支持多选模式(长按进入), + * 勾选完成时支持删除线样式。提供 OnItemClickListener 与 OnItemLongClickListener 回调。 + *

+ * + * @see TodoFragment + * @see TodoItem + */ +public class TodoAdapter extends RecyclerView.Adapter { + private static final int VIEW_TYPE_SECTION = 0; + private static final int VIEW_TYPE_ITEM = 1; + + private final List mItems = new ArrayList<>(); + private final Set mSelectedPositions = new HashSet<>(); + private boolean mChoiceMode; + private OnItemClickListener mItemClickListener; + private OnItemLongClickListener mItemLongClickListener; + + public interface OnItemClickListener { + void onItemClick(TodoItem item, int adapterPosition); + void onCheckChanged(TodoItem item, boolean checked); + } + + public interface OnItemLongClickListener { + void onItemLongClick(TodoItem item, int adapterPosition); + } + + /** + * 设置待办数据:先显示未完成分区及项,再显示已完成分区及项。 + * + * @param undone 未完成的待办列表 + * @param done 已完成的待办列表 + */ + public void setData(List undone, List done) { + mItems.clear(); + if (!undone.isEmpty()) { + mItems.add("undone"); + mItems.addAll(undone); + } + if (!done.isEmpty()) { + mItems.add("done"); + mItems.addAll(done); + } + notifyDataSetChanged(); + } + + /** 设置 item 点击与勾选变更监听器 */ + public void setOnItemClickListener(OnItemClickListener l) { mItemClickListener = l; } + /** 设置 item 长按监听器 */ + public void setOnItemLongClickListener(OnItemLongClickListener l) { mItemLongClickListener = l; } + + /** + * 切换多选模式。切换时清空当前选中状态。 + * + * @param mode true 表示进入多选模式 + */ + public void setChoiceMode(boolean mode) { + mChoiceMode = mode; + mSelectedPositions.clear(); + notifyDataSetChanged(); + } + + /** 判断是否处于多选模式 */ + public boolean isChoiceMode() { return mChoiceMode; } + + /** + * 设置指定位置的选中状态。 + * + * @param position 列表位置 + * @param checked true 表示选中 + */ + public void setChecked(int position, boolean checked) { + if (checked) mSelectedPositions.add(position); + else mSelectedPositions.remove(position); + notifyItemChanged(position); + } + + /** 判断指定位置是否被选中 */ + public boolean isChecked(int position) { return mSelectedPositions.contains(position); } + + /** 获取当前选中的待办数量 */ + public int getSelectedCount() { return mSelectedPositions.size(); } + + /** + * 获取所有选中待办的数据库 ID 列表,用于批量删除等操作。 + */ + public List getSelectedIds() { + List ids = new ArrayList<>(); + for (Integer pos : mSelectedPositions) { + Object o = mItems.get(pos); + if (o instanceof TodoItem) ids.add(((TodoItem) o).getId()); + } + return ids; + } + + /** + * 全选或全不选。仅对 TodoItem 生效,分区标题行不参与。 + * + * @param select true 表示全选,false 表示全不选 + */ + public void selectAll(boolean select) { + mSelectedPositions.clear(); + if (select) { + for (int i = 0; i < mItems.size(); i++) { + if (mItems.get(i) instanceof TodoItem) mSelectedPositions.add(i); + } + } + notifyDataSetChanged(); + } + + /** + * 判断是否已全选所有待办项。 + */ + public boolean isAllSelected() { + int itemCount = 0; + for (Object o : mItems) if (o instanceof TodoItem) itemCount++; + return itemCount > 0 && mSelectedPositions.size() == itemCount; + } + + private boolean isSection(int position) { + return mItems.get(position) instanceof String; + } + + private TodoItem getItemAt(int position) { + Object o = mItems.get(position); + return o instanceof TodoItem ? (TodoItem) o : null; + } + + @Override + public int getItemViewType(int position) { + return isSection(position) ? VIEW_TYPE_SECTION : VIEW_TYPE_ITEM; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == VIEW_TYPE_SECTION) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_todo_section, parent, false); + return new SectionHolder(v); + } else { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_todo, parent, false); + return new ItemHolder(v); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder instanceof SectionHolder) { + String section = (String) mItems.get(position); + ((SectionHolder) holder).bind(section.equals("undone") + ? holder.itemView.getContext().getString(R.string.todo_section_undone) + : holder.itemView.getContext().getString(R.string.todo_section_done)); + } else if (holder instanceof ItemHolder) { + TodoItem item = getItemAt(position); + if (item != null) { + ((ItemHolder) holder).bind(item, mChoiceMode, mSelectedPositions.contains(position)); + } + } + } + + @Override + public int getItemCount() { return mItems.size(); } + + private class SectionHolder extends RecyclerView.ViewHolder { + TextView tvTitle; + + SectionHolder(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_section_title); + } + + void bind(String title) { + tvTitle.setText(title); + } + } + + private class ItemHolder extends RecyclerView.ViewHolder { + CheckBox cbTodo; + TextView tvContent; + + ItemHolder(View itemView) { + super(itemView); + cbTodo = itemView.findViewById(R.id.cb_todo); + tvContent = itemView.findViewById(R.id.tv_todo_content); + } + + void bind(TodoItem item, boolean choiceMode, boolean checked) { + // 先移除监听器,避免 setChecked 触发复用前的旧回调导致错更新或闪退 + cbTodo.setOnCheckedChangeListener(null); + cbTodo.setChecked(item.isDone()); + + tvContent.setText(item.getContent()); + if (item.isDone()) { + tvContent.setPaintFlags(tvContent.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); + tvContent.setTextColor(ContextCompat.getColor(itemView.getContext(), android.R.color.darker_gray)); + } else { + tvContent.setPaintFlags(tvContent.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); + tvContent.setTextColor(ContextCompat.getColor(itemView.getContext(), R.color.text_primary)); + } + + cbTodo.setVisibility(View.VISIBLE); + if (choiceMode) { + cbTodo.setChecked(checked); + cbTodo.setOnCheckedChangeListener((b, isChecked) -> setChecked(getAdapterPosition(), isChecked)); + } else { + cbTodo.setOnCheckedChangeListener((b, isChecked) -> { + if (mItemClickListener != null) mItemClickListener.onCheckChanged(item, isChecked); + }); + } + + itemView.setOnClickListener(v -> { + if (choiceMode) { + setChecked(getAdapterPosition(), !mSelectedPositions.contains(getAdapterPosition())); + } else if (mItemClickListener != null) { + mItemClickListener.onItemClick(item, getAdapterPosition()); + } + }); + + itemView.setOnLongClickListener(v -> { + if (mItemLongClickListener != null) { + mItemLongClickListener.onItemLongClick(item, getAdapterPosition()); + } + return false; + }); + } + } +} diff --git a/src/app/src/main/java/net/micode/notes/todo/TodoAlarmReceiver.java b/src/app/src/main/java/net/micode/notes/todo/TodoAlarmReceiver.java new file mode 100644 index 0000000..c78dd3f --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/TodoAlarmReceiver.java @@ -0,0 +1,61 @@ +package net.micode.notes.todo; + +/** + * 待办时间提醒广播接收器 + *

+ * 接收 AlarmManager 在设定时间触发的广播,弹出通知并引导用户打开主界面查看待办。 + * 需在 Android 8.0+ 创建通知渠道。 + *

+ * + * @see TodoReminderManager + */ +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +import net.micode.notes.R; +import net.micode.notes.ui.MainActivity; + +public class TodoAlarmReceiver extends BroadcastReceiver { + private static final String CHANNEL_ID = "todo_reminder"; + + @Override + public void onReceive(Context context, Intent intent) { + long todoId = intent.getLongExtra("todo_id", 0); + String content = intent.getStringExtra("todo_content"); + if (content == null) content = "待办提醒"; + + createNotificationChannel(context); + + Intent openIntent = new Intent(context, MainActivity.class); + openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity(context, (int) todoId, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.icon_app) + .setContentTitle("待办提醒") + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pi) + .setAutoCancel(true); + + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.notify((int) todoId, builder.build()); + } + + private void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, + "待办提醒", NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.createNotificationChannel(channel); + } + } +} diff --git a/src/app/src/main/java/net/micode/notes/todo/TodoGeofenceReceiver.java b/src/app/src/main/java/net/micode/notes/todo/TodoGeofenceReceiver.java new file mode 100644 index 0000000..dcbe6e2 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/TodoGeofenceReceiver.java @@ -0,0 +1,97 @@ +package net.micode.notes.todo; + +/** + * 待办地点提醒广播接收器 + *

+ * 接收 Geofencing 在用户进入设定地点范围时触发的广播,弹出通知提醒用户待办内容。 + * 使用 Google Play Services 的 Geofencing API 实现。 + *

+ * + * @see TodoReminderManager + * @see com.google.android.gms.location.GeofencingClient + */ +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import com.google.android.gms.location.Geofence; +import com.google.android.gms.location.GeofencingEvent; + +import net.micode.notes.R; +import net.micode.notes.ui.MainActivity; + +import java.util.List; + +public class TodoGeofenceReceiver extends BroadcastReceiver { + private static final String TAG = "TodoGeofenceReceiver"; + private static final String CHANNEL_ID = "todo_geofence"; + + @Override + public void onReceive(Context context, Intent intent) { + GeofencingEvent event = GeofencingEvent.fromIntent(intent); + if (event == null) return; + if (event.hasError()) { + Log.e(TAG, "Geofence error: " + event.getErrorCode()); + return; + } + + int transition = event.getGeofenceTransition(); + if (transition != Geofence.GEOFENCE_TRANSITION_ENTER) return; + + List triggering = event.getTriggeringGeofences(); + if (triggering == null || triggering.isEmpty()) return; + + for (Geofence g : triggering) { + String requestId = g.getRequestId(); + if (requestId.startsWith("todo_")) { + long todoId = Long.parseLong(requestId.substring(5)); + String content = intent.getStringExtra("todo_content"); + String locationName = intent.getStringExtra("location_name"); + if (content == null) { + net.micode.notes.data.TodoDao dao = new net.micode.notes.data.TodoDao(context); + net.micode.notes.todo.TodoItem item = dao.getById(todoId); + content = item != null ? item.getContent() : "待办"; + if (item != null && locationName == null) locationName = item.getLocationName(); + } + String title = "到达" + (locationName != null ? locationName : "目标地点"); + showNotification(context, (int) todoId, title, content); + } + } + } + + private void showNotification(Context context, int id, String title, String content) { + createNotificationChannel(context); + + Intent openIntent = new Intent(context, MainActivity.class); + openIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity(context, id, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.icon_app) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pi) + .setAutoCancel(true); + + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.notify(id, builder.build()); + } + + private void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, + "待办地点提醒", NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.createNotificationChannel(channel); + } + } +} diff --git a/src/app/src/main/java/net/micode/notes/todo/TodoItem.java b/src/app/src/main/java/net/micode/notes/todo/TodoItem.java new file mode 100644 index 0000000..8669855 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/TodoItem.java @@ -0,0 +1,62 @@ +package net.micode.notes.todo; + +/** + * 待办事项实体类 + */ +public class TodoItem { + /** 提醒类型:无 */ + public static final int REMINDER_NONE = 0; + /** 提醒类型:时间 */ + public static final int REMINDER_TIME = 1; + /** 提醒类型:地点 */ + public static final int REMINDER_LOCATION = 2; + + private long id; + private String content; + private boolean isDone; + private int reminderType; + private long reminderTimestamp; + private double latitude; + private double longitude; + private String locationName; + private long createdTime; + + public TodoItem() { + this.id = 0; + this.content = ""; + this.isDone = false; + this.reminderType = REMINDER_NONE; + this.reminderTimestamp = 0; + this.latitude = 0; + this.longitude = 0; + this.locationName = ""; + this.createdTime = System.currentTimeMillis(); + } + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + + public String getContent() { return content; } + public void setContent(String content) { this.content = content != null ? content : ""; } + + public boolean isDone() { return isDone; } + public void setDone(boolean done) { isDone = done; } + + public int getReminderType() { return reminderType; } + public void setReminderType(int reminderType) { this.reminderType = reminderType; } + + public long getReminderTimestamp() { return reminderTimestamp; } + public void setReminderTimestamp(long reminderTimestamp) { this.reminderTimestamp = reminderTimestamp; } + + public double getLatitude() { return latitude; } + public void setLatitude(double latitude) { this.latitude = latitude; } + + public double getLongitude() { return longitude; } + public void setLongitude(double longitude) { this.longitude = longitude; } + + public String getLocationName() { return locationName; } + public void setLocationName(String locationName) { this.locationName = locationName != null ? locationName : ""; } + + public long getCreatedTime() { return createdTime; } + public void setCreatedTime(long createdTime) { this.createdTime = createdTime; } +} diff --git a/src/app/src/main/java/net/micode/notes/todo/TodoReminderManager.java b/src/app/src/main/java/net/micode/notes/todo/TodoReminderManager.java new file mode 100644 index 0000000..48b2944 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/todo/TodoReminderManager.java @@ -0,0 +1,121 @@ +package net.micode.notes.todo; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.util.Log; + +import com.google.android.gms.location.Geofence; +import com.google.android.gms.location.GeofencingClient; +import com.google.android.gms.location.GeofencingRequest; +import com.google.android.gms.location.LocationServices; + +import net.micode.notes.data.TodoDao; + +import java.util.ArrayList; +import java.util.List; + +/** + * 待办提醒管理:时间提醒 (AlarmManager) + 地点提醒 (Geofencing) + */ +public class TodoReminderManager { + private static final String TAG = "TodoReminderManager"; + private static final float GEOFENCE_RADIUS_METERS = 100f; + + public static void scheduleReminders(Context context, TodoItem item) { + if (item == null || item.getId() <= 0) return; + + cancelReminders(context, item.getId()); + + if (item.getReminderType() == TodoItem.REMINDER_TIME && item.getReminderTimestamp() > 0) { + scheduleTimeReminder(context, item); + } else if (item.getReminderType() == TodoItem.REMINDER_LOCATION + && item.getLatitude() != 0 && item.getLongitude() != 0) { + scheduleGeofenceReminder(context, item); + } + } + + public static void cancelReminders(Context context, long todoId) { + cancelTimeReminder(context, todoId); + cancelGeofenceReminder(context, todoId); + } + + private static void scheduleTimeReminder(Context context, TodoItem item) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) return; + if (item.getReminderTimestamp() <= System.currentTimeMillis()) return; + + Intent intent = new Intent(context, TodoAlarmReceiver.class); + intent.putExtra("todo_id", item.getId()); + intent.putExtra("todo_content", item.getContent()); + PendingIntent pi = PendingIntent.getBroadcast(context, (int) item.getId(), intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (am.canScheduleExactAlarms()) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi); + } else { + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi); + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi); + } else { + am.set(AlarmManager.RTC_WAKEUP, item.getReminderTimestamp(), pi); + } + Log.d(TAG, "Scheduled time reminder for todo " + item.getId()); + } + + private static void cancelTimeReminder(Context context, long todoId) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) return; + Intent intent = new Intent(context, TodoAlarmReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast(context, (int) todoId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + am.cancel(pi); + } + + private static void scheduleGeofenceReminder(Context context, TodoItem item) { + GeofencingClient client = LocationServices.getGeofencingClient(context); + Geofence geofence = new Geofence.Builder() + .setRequestId("todo_" + item.getId()) + .setCircularRegion(item.getLatitude(), item.getLongitude(), GEOFENCE_RADIUS_METERS) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER) + .build(); + + List list = new ArrayList<>(); + list.add(geofence); + + GeofencingRequest request = new GeofencingRequest.Builder() + .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) + .addGeofences(list) + .build(); + + Intent intent = new Intent(context, TodoGeofenceReceiver.class); + intent.putExtra("todo_id", item.getId()); + intent.putExtra("todo_content", item.getContent()); + intent.putExtra("location_name", item.getLocationName()); + PendingIntent pi = PendingIntent.getBroadcast(context, (int) item.getId(), intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + client.addGeofences(request, pi) + .addOnSuccessListener(aVoid -> Log.d(TAG, "Geofence added for todo " + item.getId())) + .addOnFailureListener(e -> Log.e(TAG, "Geofence add failed: " + e.getMessage())); + } + + private static void cancelGeofenceReminder(Context context, long todoId) { + GeofencingClient client = LocationServices.getGeofencingClient(context); + List ids = new ArrayList<>(); + ids.add("todo_" + todoId); + client.removeGeofences(ids) + .addOnSuccessListener(aVoid -> Log.d(TAG, "Geofence removed for todo " + todoId)) + .addOnFailureListener(e -> Log.e(TAG, "Geofence remove failed: " + e.getMessage())); + } + + /** 在删除待办时调用,取消所有提醒 */ + public static void cancelAllForTodo(Context context, long todoId) { + cancelReminders(context, todoId); + } +} diff --git a/src/src/net/micode/notes/tool/BackupUtils.java b/src/app/src/main/java/net/micode/notes/tool/BackupUtils.java similarity index 97% rename from src/src/net/micode/notes/tool/BackupUtils.java rename to src/app/src/main/java/net/micode/notes/tool/BackupUtils.java index a444b7b..00be11b 100644 --- a/src/src/net/micode/notes/tool/BackupUtils.java +++ b/src/app/src/main/java/net/micode/notes/tool/BackupUtils.java @@ -327,18 +327,27 @@ public class BackupUtils { } mFileName = file.getName(); mFileDirectory = mContext.getString(R.string.file_path); + FileOutputStream fos = null; PrintStream ps = null; try { - FileOutputStream fos = new FileOutputStream(file); + fos = new FileOutputStream(file); ps = new PrintStream(fos); + return ps; } catch (FileNotFoundException e) { e.printStackTrace(); return null; } catch (NullPointerException e) { e.printStackTrace(); return null; + } finally { + if (ps == null && fos != null) { + try { + fos.close(); + } catch (IOException e) { + Log.e(TAG, "close FileOutputStream", e); + } + } } - return ps; } } diff --git a/src/src/net/micode/notes/tool/DataUtils.java b/src/app/src/main/java/net/micode/notes/tool/DataUtils.java similarity index 78% rename from src/src/net/micode/notes/tool/DataUtils.java rename to src/app/src/main/java/net/micode/notes/tool/DataUtils.java index e9dde4b..8cbf2f7 100644 --- a/src/src/net/micode/notes/tool/DataUtils.java +++ b/src/app/src/main/java/net/micode/notes/tool/DataUtils.java @@ -81,6 +81,40 @@ public class DataUtils { return false; } + /** + * 批量设置便签/文件夹的置顶状态。 + * + * @param resolver ContentResolver + * @param ids 便签或文件夹 ID 集合 + * @param pinned true 置顶,false 取消置顶 + * @return 是否全部更新成功 + */ + public static boolean batchSetPinned(ContentResolver resolver, HashSet ids, boolean pinned) { + if (ids == null || ids.isEmpty()) { + return true; + } + int value = pinned ? 1 : 0; + ArrayList operationList = new ArrayList(); + for (long id : ids) { + if (id <= 0) continue; // 跳过系统文件夹 + ContentProviderOperation.Builder builder = ContentProviderOperation + .newUpdate(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id)); + builder.withValue(NoteColumns.PINNED, value); + builder.withValue(NoteColumns.LOCAL_MODIFIED, 1); + operationList.add(builder.build()); + } + if (operationList.isEmpty()) return true; + try { + ContentProviderResult[] results = resolver.applyBatch(Notes.AUTHORITY, operationList); + return results != null && results.length > 0; + } catch (RemoteException e) { + Log.e(TAG, "batchSetPinned: " + e.getMessage()); + } catch (OperationApplicationException e) { + Log.e(TAG, "batchSetPinned: " + e.getMessage()); + } + return false; + } + /** * 将单个便签移动到指定文件夹。 * 同时也记录了原文件夹 ID (ORIGIN_PARENT_ID),便于后续可能的撤销操作。 @@ -345,4 +379,44 @@ public class DataUtils { } return snippet; } + + /** 列表标题:无有效内容时显示 */ + private static final String FALLBACK_UNTITLED = "无标题"; + /** 仅图片无文字时的标题 */ + private static final String FALLBACK_IMAGE_NOTE = "图片便签"; + /** 新建便签模板占位符,用于判断是否取第三行 */ + private static final String TEMPLATE_FIRST_LINE = "标题"; + private static final String TEMPLATE_THIRD_LINE = "开始书写"; + + /** + * 根据便签正文计算列表显示的标题(snippet)。 + * 规则:1)去掉 <img src="..."/> 后取首行;2)若首行为「标题」则取第三行;3)若第三行为「开始书写」或空则显示「无标题」;4)若去图后无文字则显示「图片便签」。 + */ + public static String computeNoteSnippet(String content) { + if (content == null) return FALLBACK_UNTITLED; + String text = content.replaceAll("", "").trim(); + if (text.isEmpty()) return FALLBACK_IMAGE_NOTE; + String[] lines = text.split("\n", -1); + String firstLine = lines.length > 0 ? lines[0].trim() : ""; + if (TEMPLATE_FIRST_LINE.equals(firstLine)) { + String thirdLine = lines.length > 2 ? lines[2].trim() : ""; + if (thirdLine.isEmpty() || TEMPLATE_THIRD_LINE.equals(thirdLine)) return FALLBACK_UNTITLED; + return truncateForSnippet(thirdLine); + } + if (firstLine.isEmpty()) return FALLBACK_UNTITLED; + return truncateForSnippet(firstLine); + } + + /** + * 截断字符串用于 snippet 显示:去首尾空格,超过 60 字时截断并追加省略号。 + * + * @param s 原始字符串 + * @return 截断后的字符串,空则返回「无标题」 + */ + private static String truncateForSnippet(String s) { + if (s == null) return FALLBACK_UNTITLED; + s = s.trim(); + if (s.isEmpty()) return FALLBACK_UNTITLED; + return s.length() > 60 ? s.substring(0, 60) + "…" : s; + } } \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/tool/FloatingService.java b/src/app/src/main/java/net/micode/notes/tool/FloatingService.java new file mode 100644 index 0000000..6f959e0 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/FloatingService.java @@ -0,0 +1,91 @@ +package net.micode.notes.tool; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.projection.MediaProjection; +import android.media.projection.MediaProjectionManager; +import android.os.Build; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; + +import net.micode.notes.R; + +/** + * 灵感球前台服务 + *

+ * 负责管理悬浮球(灵感球)的显示与生命周期。 + * 配合 MediaProjection 实现屏幕截图能力,用户点击悬浮球可快速捕捉灵感并写入便签。 + * 需在 Android 10+ 上指定 FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION 类型。 + *

+ * + * @see FloatingViewManager + * @see ScreenCaptureManager + */ +public class FloatingService extends Service { + + private FloatingViewManager mManager; + private ScreenCaptureManager mCaptureManager; + + @Override + public IBinder onBind(Intent intent) { return null; } + + @Override + public void onCreate() { + super.onCreate(); + mManager = new FloatingViewManager(this); + startForegroundServiceNotification(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && intent.hasExtra("data")) { + int resultCode = intent.getIntExtra("resultCode", -1); + Intent data = intent.getParcelableExtra("data"); + + MediaProjectionManager mm = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + MediaProjection mp = mm.getMediaProjection(resultCode, data); + + // 关键:确保这里每次都是 new 出来的 + mCaptureManager = new ScreenCaptureManager(this); + mCaptureManager.setMediaProjection(mp); + mManager.setCaptureManager(mCaptureManager); + } + mManager.showFloatingBall(); + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mManager.removeFloatingBall(); + } + + private void startForegroundServiceNotification() { + String channelId = "inspiration_channel"; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(channelId, "灵感球服务", NotificationManager.IMPORTANCE_LOW); + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel); + } + + // 1. 构建通知,删掉了那行报错的代码 + Notification notification = new NotificationCompat.Builder(this, channelId) + .setContentTitle("灵感球已开启") + .setContentText("点击悬浮球,捕捉瞬间灵感") + .setSmallIcon(R.drawable.icon_app) + .build(); + + // 2. 在这里指定前台服务类型(这是最标准的写法) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10 (Q) 以上支持指定类型 + startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION); + } else { + // 老版本直接启动 + startForeground(1, notification); + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/tool/FloatingViewManager.java b/src/app/src/main/java/net/micode/notes/tool/FloatingViewManager.java new file mode 100644 index 0000000..f304669 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/FloatingViewManager.java @@ -0,0 +1,302 @@ +package net.micode.notes.tool; + + + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import net.micode.notes.tool.DataUtils; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.util.Log; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Toast; + +// 关键:必须导入内部类 NoteColumns 和 DataColumns +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Notes.DataColumns; + +import java.io.File; +import java.io.FileOutputStream; + +/** + * 悬浮球管理器 (Floating Ball Manager) + *

+ * 职责: + * 1. 管理截屏灵感功能的悬浮球 UI:显示、隐藏、拖拽、贴边。 + * 2. 响应悬浮球点击:触发截屏 → 弹出输入框 → 将「图片+文字」保存至灵感箱。 + * 3. 与 {@link ScreenCaptureManager} 协作完成截屏,与 {@link MediaUtils} 协作完成图片存储。 + */ +public class FloatingViewManager { + + private Context mContext; + private WindowManager mWindowManager; + private View mFloatingView; + private WindowManager.LayoutParams mParams; + private ScreenCaptureManager mCaptureManager; + private int mScreenWidth, mScreenHeight; + + /** + * 构造悬浮球管理器,初始化窗口管理器和屏幕尺寸。 + * + * @param context 上下文,用于获取 WindowManager 和 DisplayMetrics + */ + public FloatingViewManager(Context context) { + mContext = context; + mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics m = context.getResources().getDisplayMetrics(); + mScreenWidth = m.widthPixels; + mScreenHeight = m.heightPixels; + } + + /** + * 设置截屏管理器引用。点击悬浮球时将调用其 capture 方法执行截屏。 + * + * @param scm 截屏管理器实例,可为 null(此时点击会提示「截屏服务未初始化」) + */ + public void setCaptureManager(ScreenCaptureManager scm) { + this.mCaptureManager = scm; + } + + /** + * 显示悬浮球。 + * 若已显示则忽略。根据系统版本使用 TYPE_APPLICATION_OVERLAY 或 TYPE_PHONE。 + */ + public void showFloatingBall() { + if (mFloatingView != null) return; + + mParams = new WindowManager.LayoutParams(); + mParams.type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_PHONE; + mParams.format = PixelFormat.RGBA_8888; + mParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; + mParams.gravity = Gravity.LEFT | Gravity.TOP; + mParams.width = WindowManager.LayoutParams.WRAP_CONTENT; + mParams.height = WindowManager.LayoutParams.WRAP_CONTENT; + mParams.x = mScreenWidth; + mParams.y = mScreenHeight / 2; + + mFloatingView = LayoutInflater.from(mContext).inflate(R.layout.layout_floating_ball, null); + initTouchListener(); + mWindowManager.addView(mFloatingView, mParams); + } + + /** + * 初始化悬浮球触摸监听:支持拖拽移动,短按触发截屏,松手后贴边吸附。 + */ + private void initTouchListener() { + View ball = mFloatingView.findViewById(R.id.iv_floating_ball); + ball.setOnTouchListener(new View.OnTouchListener() { + private int initialX, initialY; + private float initialTouchX, initialTouchY; + private boolean isDrag; + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + initialX = mParams.x; initialY = mParams.y; + initialTouchX = event.getRawX(); initialTouchY = event.getRawY(); + isDrag = false; + return true; + case MotionEvent.ACTION_MOVE: + int dx = (int) (event.getRawX() - initialTouchX); + int dy = (int) (event.getRawY() - initialTouchY); + if (!isDrag && (Math.abs(dx) > 20 || Math.abs(dy) > 20)) isDrag = true; + if (isDrag) { + mParams.x = initialX + dx; mParams.y = initialY + dy; + mWindowManager.updateViewLayout(mFloatingView, mParams); + } + return true; + case MotionEvent.ACTION_UP: + if (!isDrag) onBallClick(); else snapToEdge(); + return true; + } + return false; + } + }); + } + + /** + * 将悬浮球吸附到屏幕左边缘或右边缘(根据当前位置与屏幕中心的相对关系)。 + */ + private void snapToEdge() { + mParams.x = (mParams.x + mFloatingView.getWidth() / 2 < mScreenWidth / 2) ? 0 : mScreenWidth; + mWindowManager.updateViewLayout(mFloatingView, mParams); + } + + /** + * 悬浮球点击回调:隐藏小球 → 执行截屏 → 弹出输入框保存灵感。 + * 含振动反馈、3 秒超时保险,防止截屏无响应导致小球永久消失。 + */ + private void onBallClick() { + if (mCaptureManager == null) { + Toast.makeText(mContext, "截屏服务未初始化", Toast.LENGTH_SHORT).show(); + return; + } + + // 1. 视觉与触觉反馈 + mFloatingView.animate().scaleX(0.7f).scaleY(0.7f).setDuration(100).withEndAction(() -> { + mFloatingView.animate().scaleX(1.0f).scaleY(1.0f).setDuration(100).start(); + }).start(); + + Vibrator vibrator = (Vibrator) mContext.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator != null) vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)); + + // 2. 隐藏小球开始截图 + mFloatingView.setVisibility(View.GONE); + + // 增加一个“超时保险”:如果 3 秒还没截到图,强行把球变回来 + final Handler timeoutHandler = new Handler(Looper.getMainLooper()); + final Runnable timeoutRunnable = () -> { + if (mFloatingView.getVisibility() == View.GONE) { + mFloatingView.setVisibility(View.VISIBLE); + Toast.makeText(mContext, "截图响应超时", Toast.LENGTH_SHORT).show(); + } + }; + timeoutHandler.postDelayed(timeoutRunnable, 3000); + + // 3. 执行截图 + new Handler(Looper.getMainLooper()).postDelayed(() -> { + mCaptureManager.capture(bitmap -> { + // 截图有回应了,移除超时保险 + timeoutHandler.removeCallbacks(timeoutRunnable); + + mFloatingView.setVisibility(View.VISIBLE); + if (bitmap != null) { + handleCapturedBitmap(bitmap); + } else { + Toast.makeText(mContext, "截图失败,请重试", Toast.LENGTH_SHORT).show(); + } + }); + }, 200); // 稍微延长一点等待时间,确保小球完全消失 + } + + /** + * 处理截屏得到的 Bitmap:压缩为 JPEG 保存至应用目录,然后弹出灵感输入框。 + * + * @param bitmap 截屏得到的位图,不应为 null + */ + private void handleCapturedBitmap(Bitmap bitmap) { + try { + File file = MediaUtils.createImageFile(mContext); + try (FileOutputStream out = new FileOutputStream(file)) { + bitmap.compress(Bitmap.CompressFormat.JPEG, 70, out); + } + // 使用 getCanonicalPath() 规范化路径,符合 G.FIO.01 + String path = file.getCanonicalPath(); + showInputWindow(path); + } catch (Exception e) { e.printStackTrace(); } + } + + /** + * 弹出悬浮输入框,供用户输入灵感文字。确认后调用 saveInspirationToDb 保存。 + * + * @param imagePath 已保存的截屏图片的本地绝对路径 + */ + private void showInputWindow(String imagePath) { + WindowManager.LayoutParams p = new WindowManager.LayoutParams(); + p.type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_PHONE; + p.format = PixelFormat.RGBA_8888; + p.flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND; + p.dimAmount = 0.5f; + p.width = WindowManager.LayoutParams.MATCH_PARENT; + p.height = WindowManager.LayoutParams.WRAP_CONTENT; + p.gravity = Gravity.CENTER; + + View inputView = LayoutInflater.from(mContext).inflate(R.layout.layout_floating_input, null); + EditText et = inputView.findViewById(R.id.et_inspiration); + + inputView.findViewById(R.id.btn_send_inspiration).setOnClickListener(v -> { + String text = et.getText().toString(); + saveInspirationToDb(imagePath, text); + mWindowManager.removeView(inputView); + }); + + mWindowManager.addView(inputView, p); + et.requestFocus(); + // 延时弹出软键盘 + new Handler(Looper.getMainLooper()).postDelayed(() -> { + InputMethodManager imm = (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(et, InputMethodManager.SHOW_IMPLICIT); + }, 200); + } + + /** + * 将「图片路径 + 用户输入文字」保存为灵感便签到数据库。 + * 在后台线程执行:插入 Note 主表 → 插入 Data 附表 → 更新 snippet。 + * + * @param path 图片本地路径 + * @param text 用户输入的灵感文字,可为空 + */ + private void saveInspirationToDb(String path, String text) { + new Thread(() -> { + try { + // 1. 插入 Note 主表(snippet 先占位,插入 Data 后会被触发器覆盖,再在下面按规则写回) + ContentValues v = new ContentValues(); + long now = System.currentTimeMillis(); + v.put(net.micode.notes.data.Notes.NoteColumns.PARENT_ID, net.micode.notes.data.Notes.ID_ROOT_FOLDER); + v.put(net.micode.notes.data.Notes.NoteColumns.SNIPPET, ""); + v.put(net.micode.notes.data.Notes.NoteColumns.TYPE, 0); // TYPE_NOTE + v.put(net.micode.notes.data.Notes.NoteColumns.MODIFIED_DATE, now); + v.put(net.micode.notes.data.Notes.NoteColumns.CREATED_DATE, now); + v.put(net.micode.notes.data.Notes.NoteColumns.LOCAL_MODIFIED, 1); + v.put(net.micode.notes.data.Notes.NoteColumns.BG_COLOR_ID, 0); + + Uri uri = mContext.getContentResolver().insert(net.micode.notes.data.Notes.CONTENT_NOTE_URI, v); + if (uri == null) return; + long noteId = android.content.ContentUris.parseId(uri); + + // 2. 插入 Data 附表 + ContentValues dv = new ContentValues(); + dv.put(Notes.DataColumns.NOTE_ID, noteId); + dv.put(Notes.DataColumns.MIME_TYPE, "vnd.android.cursor.item/text_note"); + String fullContent = "\n" + (text == null ? "" : text); + dv.put(Notes.DataColumns.CONTENT, fullContent); + dv.put(Notes.DataColumns.DATA1, 0); + + mContext.getContentResolver().insert(Notes.CONTENT_DATA_URI, dv); + + // 3. 灵感便签标题修复:优先用用户输入的文字作为 snippet,仅无文字时用「图片便签」,禁止用文件路径 + String snippet = DataUtils.computeNoteSnippet(fullContent); + ContentValues uv = new ContentValues(); + uv.put(Notes.NoteColumns.SNIPPET, snippet); + mContext.getContentResolver().update( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), uv, null, null); + + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(mContext, "灵感已存入灵感箱", Toast.LENGTH_SHORT).show()); + } catch (Exception e) { + e.printStackTrace(); + } + }).start(); + } + + /** + * 移除并销毁悬浮球视图。调用后 showFloatingBall 可再次显示新的悬浮球。 + */ + public void removeFloatingBall() { + if (mFloatingView != null) { + mWindowManager.removeView(mFloatingView); + mFloatingView = null; + } + } +} \ No newline at end of file diff --git a/src/src/net/micode/notes/tool/GTaskStringUtils.java b/src/app/src/main/java/net/micode/notes/tool/GTaskStringUtils.java similarity index 100% rename from src/src/net/micode/notes/tool/GTaskStringUtils.java rename to src/app/src/main/java/net/micode/notes/tool/GTaskStringUtils.java diff --git a/src/app/src/main/java/net/micode/notes/tool/MediaUtils.java b/src/app/src/main/java/net/micode/notes/tool/MediaUtils.java new file mode 100644 index 0000000..9ea0529 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/MediaUtils.java @@ -0,0 +1,249 @@ +package net.micode.notes.tool; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Environment; +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 媒体处理工具类 + * 职责:封装所有与图片文件IO、Uri解析、Bitmap压缩相关的底层逻辑。 + * 符合“高内聚、信息隐藏”原则。 + */ +public class MediaUtils { + private static final String TAG = "MediaUtils"; + private static final String IMAGE_DIR_NAME = "images"; + + /** + * 创建一个用于存放相机拍摄照片的空文件 + * 位置:/Android/data/包名/files/Pictures/images/ + */ + public static File createImageFile(Context context) throws IOException { + return createImageFileWithExtension(context, ".jpg"); + } + + /** + * 创建带指定扩展名的图片文件(用于相册/截图/微信等不同来源的图片格式)。 + * + * @param context 上下文,用于 getExternalFilesDir + * @param extension 扩展名,如 ".jpg"、".png",需以点开头 + * @return 新创建的空文件,位于 Pictures/images 目录 + */ + private static File createImageFileWithExtension(Context context, String extension) throws IOException { + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date()); + String imageFileName = "NOTE_" + timeStamp + "_"; + + File storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES); + if (storageDir != null && !storageDir.exists()) { + storageDir.mkdirs(); + } + + if (extension == null || !extension.startsWith(".")) { + extension = ".jpg"; + } + return File.createTempFile(imageFileName, extension, storageDir); + } + + /** + * 根据 Uri 或 MIME 类型决定目标文件扩展名(截图多为 PNG,微信可能为 jpg/webp)。 + * + * @param context 上下文,用于 ContentResolver.getType + * @param uri 图片来源 Uri + * @return 扩展名字符串,如 ".jpg"、".png" + */ + private static String getExtensionFromUri(Context context, Uri uri) { + if (uri == null) return ".jpg"; + String mime = context.getContentResolver().getType(uri); + if (mime != null) { + if (mime.contains("png")) return ".png"; + if (mime.contains("webp")) return ".webp"; + if (mime.contains("gif")) return ".gif"; + } + return ".jpg"; + } + + /** + * 将 Uri 指向的图片(相册/截图/微信等 ContentProvider 或 file Uri)复制到应用沙盒 + * 目的:防止用户在相册删除原图后,便签内图片丢失;并兼容不同来源的 Uri 权限差异。 + * @return 复制后的本地文件路径,失败返回 null + */ + public static String copyUriToInternalStorage(Context context, Uri sourceUri) { + if (context == null || sourceUri == null) return null; + + // 1. file:// 方案:截图或部分文件管理器返回的是文件路径,直接复制文件 + if ("file".equalsIgnoreCase(sourceUri.getScheme())) { + String path = sourceUri.getPath(); + if (path != null && !path.isEmpty()) { + return copyFileToInternalStorage(context, new File(path)); + } + Log.e(TAG, "file Uri has no path: " + sourceUri); + return null; + } + + // 2. content:// 方案:相册、微信等返回的 Content Uri + ContentResolver contentResolver = context.getContentResolver(); + String extension = getExtensionFromUri(context, sourceUri); + + // 2a. 优先尝试 openInputStream(多数相册/MediaStore 支持) + InputStream inputStream = null; + try { + inputStream = contentResolver.openInputStream(sourceUri); + } catch (SecurityException e) { + Log.w(TAG, "openInputStream SecurityException, try openFileDescriptor: " + e.getMessage()); + } catch (java.io.FileNotFoundException e) { + Log.w(TAG, "openInputStream FileNotFoundException, try openFileDescriptor: " + e.getMessage()); + } + if (inputStream != null) { + String path = copyStreamToInternalStorage(context, inputStream, extension); + closeQuietly(inputStream); + return path; + } + + // 2b. 部分应用(如微信、某些截图)仅支持 openFileDescriptor,用此方式读取 + try { + android.os.ParcelFileDescriptor pfd = contentResolver.openFileDescriptor(sourceUri, "r"); + if (pfd != null) { + FileInputStream fis = new FileInputStream(pfd.getFileDescriptor()); + String path = copyStreamToInternalStorage(context, fis, extension); + closeQuietly(fis); + try { pfd.close(); } catch (IOException ignored) { } + return path; + } + } catch (SecurityException e) { + Log.e(TAG, "openFileDescriptor SecurityException: " + e.getMessage()); + } catch (IOException e) { + Log.e(TAG, "openFileDescriptor IOException: " + e.getMessage()); + } + + Log.e(TAG, "copyUriToInternalStorage failed for: " + sourceUri); + return null; + } + + /** + * 将本地文件复制到应用沙盒(用于 file:// Uri 或已有路径) + */ + private static String copyFileToInternalStorage(Context context, File sourceFile) { + if (sourceFile == null || !sourceFile.exists() || !sourceFile.canRead()) { + String pathForLog = "null"; + if (sourceFile != null) { + try { + pathForLog = sourceFile.getCanonicalPath(); + } catch (IOException e) { + pathForLog = "(path unavailable)"; + } + } + Log.e(TAG, "Source file not readable: " + pathForLog); + return null; + } + String ext = ".jpg"; + String name = sourceFile.getName().toLowerCase(); + if (name.endsWith(".png")) ext = ".png"; + else if (name.endsWith(".webp")) ext = ".webp"; + else if (name.endsWith(".gif")) ext = ".gif"; + + FileInputStream fis = null; + try { + fis = new FileInputStream(sourceFile); + return copyStreamToInternalStorage(context, fis, ext); + } catch (IOException e) { + Log.e(TAG, "copyFileToInternalStorage failed", e); + return null; + } finally { + closeQuietly(fis); + } + } + + /** + * 将输入流写入应用沙盒新文件 + */ + private static String copyStreamToInternalStorage(Context context, InputStream input, String extension) { + FileOutputStream outputStream = null; + try { + File targetFile = createImageFileWithExtension(context, extension); + outputStream = new FileOutputStream(targetFile); + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + outputStream.flush(); + return targetFile.getCanonicalPath(); + } catch (IOException e) { + Log.e(TAG, "Failed to copy stream to internal storage", e); + return null; + } finally { + closeQuietly(outputStream); + } + } + + /** + * 安全关闭输出流,忽略关闭过程中的异常。 + */ + private static void closeQuietly(OutputStream os) { + if (os != null) try { os.close(); } catch (IOException ignored) { } + } + + /** + * 安全关闭输入流,忽略关闭过程中的异常。 + */ + private static void closeQuietly(InputStream is) { + if (is != null) try { is.close(); } catch (IOException ignored) { } + } + + /** + * 获取压缩后的 Bitmap,防止 OOM (Out Of Memory) + * @param path 图片路径 + * @param reqWidth 期望的宽度(通常是屏幕宽度) + * @param reqHeight 期望的高度 + */ + public static Bitmap getCompressedBitmap(String path, int reqWidth, int reqHeight) { + // 1. 只读取图片的尺寸信息,不加载像素到内存 + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, options); + + // 2. 计算压缩比例 + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); + + // 3. 真正加载图片 + options.inJustDecodeBounds = false; + return BitmapFactory.decodeFile(path, options); + } + + /** + * 计算 Bitmap 采样率 (inSampleSize),使解码后的图片宽高不超过指定值,防止 OOM。 + * + * @param options 已设置 inJustDecodeBounds=true 的 Options + * @param reqWidth 期望最大宽度 + * @param reqHeight 期望最大高度 + * @return 2 的幂次,如 1、2、4、8 + */ + private static 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; + // 计算最大的 2 的幂次,保证宽高仍大于期望宽高 + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + return inSampleSize; + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/tool/PrivacySpaceManager.java b/src/app/src/main/java/net/micode/notes/tool/PrivacySpaceManager.java new file mode 100644 index 0000000..aa9a424 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/PrivacySpaceManager.java @@ -0,0 +1,30 @@ +package net.micode.notes.tool; + +/** + * 隐私空间状态管理 + * 职责:维护当前是否为隐私空间模式的会话状态(不持久化)。 + * 退出软件后再次进入将自动恢复为正常空间。 + */ +public class PrivacySpaceManager { + + private static volatile boolean sInPrivacySpace = false; + + /** 是否为隐私空间模式 */ + public static boolean isInPrivacySpace() { + return sInPrivacySpace; + } + + /** 进入隐私空间(由 Mi 管家输入「隐私空间」触发) */ + public static void enterPrivacySpace() { + sInPrivacySpace = true; + } + + /** + * 退出隐私空间。 + * 注:按需求,退出软件后再次进入自动恢复,故无需手动调用; + * 若进程存活时需重置(如测试),可调用此方法。 + */ + public static void exitPrivacySpace() { + sInPrivacySpace = false; + } +} diff --git a/src/src/net/micode/notes/tool/ResourceParser.java b/src/app/src/main/java/net/micode/notes/tool/ResourceParser.java similarity index 80% rename from src/src/net/micode/notes/tool/ResourceParser.java rename to src/app/src/main/java/net/micode/notes/tool/ResourceParser.java index fb81ce7..36f1601 100644 --- a/src/src/net/micode/notes/tool/ResourceParser.java +++ b/src/app/src/main/java/net/micode/notes/tool/ResourceParser.java @@ -9,6 +9,8 @@ import android.content.Context; import android.preference.PreferenceManager; import net.micode.notes.R; + +import java.security.SecureRandom; import net.micode.notes.ui.NotesPreferenceActivity; /** @@ -89,7 +91,7 @@ public class ResourceParser { public static int getDefaultBgId(Context context) { if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean( NotesPreferenceActivity.PREFERENCE_SET_BG_COLOR_KEY, false)) { - return (int) (Math.random() * NoteBgResources.BG_EDIT_RESOURCES.length); + return new SecureRandom().nextInt(NoteBgResources.BG_EDIT_RESOURCES.length); } else { return BG_DEFAULT_COLOR; } @@ -142,25 +144,62 @@ public class ResourceParser { R.drawable.list_red_single }; + /** + * 根据颜色 ID 获取列表第一项(顶部圆角)背景资源 ID。 + */ public static int getNoteBgFirstRes(int id) { return BG_FIRST_RESOURCES[id]; } + /** + * 根据颜色 ID 获取列表最后一项(底部圆角)背景资源 ID。 + */ public static int getNoteBgLastRes(int id) { return BG_LAST_RESOURCES[id]; } + /** + * 根据颜色 ID 获取列表单项(全圆角)背景资源 ID。 + */ public static int getNoteBgSingleRes(int id) { return BG_SINGLE_RESOURCES[id]; } + /** + * 根据颜色 ID 获取列表中间项(无圆角)背景资源 ID。 + */ public static int getNoteBgNormalRes(int id) { return BG_NORMAL_RESOURCES[id]; } + /** + * 获取文件夹列表项的背景资源 ID。 + */ public static int getFolderBgRes() { return R.drawable.list_folder; } + + /** 置顶时列表项左侧标签条颜色(加深),与 BG 五色对应 */ + private final static int[] PINNED_BAR_COLOR_RESOURCES = new int[] { + R.color.note_pinned_bar_yellow, + R.color.note_pinned_bar_blue, + R.color.note_pinned_bar_white, + R.color.note_pinned_bar_green, + R.color.note_pinned_bar_red + }; + + /** + * 根据背景色 ID 获取置顶标签条的颜色资源 ID。 + * + * @param bgColorId 背景颜色 ID(0-4) + * @return 置顶条颜色资源 ID,非法 ID 时返回黄色 + */ + public static int getPinnedBarColorRes(int bgColorId) { + if (bgColorId < 0 || bgColorId >= PINNED_BAR_COLOR_RESOURCES.length) { + return PINNED_BAR_COLOR_RESOURCES[0]; + } + return PINNED_BAR_COLOR_RESOURCES[bgColorId]; + } } /** @@ -176,6 +215,9 @@ public class ResourceParser { R.drawable.widget_2x_red, }; + /** + * 根据颜色 ID 获取 2x2 小部件背景资源 ID。 + */ public static int getWidget2xBgResource(int id) { return BG_2X_RESOURCES[id]; } @@ -188,6 +230,9 @@ public class ResourceParser { R.drawable.widget_4x_red }; + /** + * 根据颜色 ID 获取 4x4 小部件背景资源 ID。 + */ public static int getWidget4xBgResource(int id) { return BG_4X_RESOURCES[id]; } @@ -217,6 +262,9 @@ public class ResourceParser { return TEXTAPPEARANCE_RESOURCES[id]; } + /** + * 获取字体样式资源数组长度,用于 bounds 校验。 + */ public static int getResourcesSize() { return TEXTAPPEARANCE_RESOURCES.length; } diff --git a/src/app/src/main/java/net/micode/notes/tool/ScreenCaptureManager.java b/src/app/src/main/java/net/micode/notes/tool/ScreenCaptureManager.java new file mode 100644 index 0000000..aade2f2 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/ScreenCaptureManager.java @@ -0,0 +1,110 @@ +package net.micode.notes.tool; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.media.Image; +import android.media.ImageReader; +import android.media.projection.MediaProjection; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.view.WindowManager; +import java.nio.ByteBuffer; + +/** + * 屏幕截图管理器 + *

+ * 基于 MediaProjection 实现屏幕内容捕获,将画面转为 Bitmap。 + * 配合 FloatingViewManager 使用,用户点击灵感球后可截取当前屏幕并创建便签。 + * 注意:ImageReader 需全局持有复用,VirtualDisplay 每次截图时创建并在完成后释放。 + *

+ * + * @see FloatingViewManager + * @see FloatingService + */ +public class ScreenCaptureManager { + private MediaProjection mMediaProjection; + private int mScreenWidth, mScreenHeight, mScreenDensity; + private ImageReader mImageReader; // 【关键】全局持有,不销毁 + private VirtualDisplay mVirtualDisplay; + + public ScreenCaptureManager(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics metrics = new DisplayMetrics(); + wm.getDefaultDisplay().getMetrics(metrics); + mScreenWidth = metrics.widthPixels; + mScreenHeight = metrics.heightPixels; + mScreenDensity = metrics.densityDpi; + + // 【关键】构造时就创建好 ImageReader,一直复用 + // maxImages 设为 2,避免 Buffer 不够 + mImageReader = ImageReader.newInstance(mScreenWidth, mScreenHeight, PixelFormat.RGBA_8888, 2); + } + + public void setMediaProjection(MediaProjection mp) { + this.mMediaProjection = mp; + if (mMediaProjection != null) { + // Android 14 必须注册回调,否则崩 + mMediaProjection.registerCallback(new MediaProjection.Callback() {}, null); + } + } + + public void capture(final OnCaptureListener listener) { + if (mMediaProjection == null || mImageReader == null) return; + + // 【关键】每次只需创建 VirtualDisplay + mVirtualDisplay = mMediaProjection.createVirtualDisplay( + "ScreenCapture", + mScreenWidth, mScreenHeight, mScreenDensity, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + mImageReader.getSurface(), + null, null); + + // 设置单次监听 + mImageReader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() { + @Override + public void onImageAvailable(ImageReader reader) { + // 一旦收到图片,立刻移除监听,防止重复触发 + reader.setOnImageAvailableListener(null, null); + + // 停止投影 (释放 VirtualDisplay,但不释放 ImageReader) + if (mVirtualDisplay != null) { + mVirtualDisplay.release(); + mVirtualDisplay = null; + } + + Bitmap bitmap = null; + Image image = null; + try { + image = reader.acquireLatestImage(); + if (image != null) { + Image.Plane[] planes = image.getPlanes(); + ByteBuffer buffer = planes[0].getBuffer(); + int pixelStride = planes[0].getPixelStride(); + int rowStride = planes[0].getRowStride(); + int rowPadding = rowStride - pixelStride * mScreenWidth; + bitmap = Bitmap.createBitmap(mScreenWidth + rowPadding / pixelStride, mScreenHeight, Bitmap.Config.ARGB_8888); + bitmap.copyPixelsFromBuffer(buffer); + bitmap = Bitmap.createBitmap(bitmap, 0, 0, mScreenWidth, mScreenHeight); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (image != null) image.close(); // 必须关闭 Image 释放 Buffer + + final Bitmap result = bitmap; + new Handler(Looper.getMainLooper()).post(() -> { + if (listener != null) listener.onCaptured(result); + }); + } + } + }, null); // 在当前线程(主线程)回调,因为这里逻辑很快,为了避免线程死锁先这样 + } + + public interface OnCaptureListener { + void onCaptured(Bitmap bitmap); + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/tool/ThreadExecutor.java b/src/app/src/main/java/net/micode/notes/tool/ThreadExecutor.java new file mode 100644 index 0000000..a10f64e --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/tool/ThreadExecutor.java @@ -0,0 +1,40 @@ +package net.micode.notes.tool; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 线程池工具类 + * 作用:专门负责在后台执行耗时操作,解放主线程。 + */ +public class ThreadExecutor { + // 单例模式:保证整个APP只有一个线程池,避免资源浪费 + private static ThreadExecutor instance; + + // 核心成员:ExecutorService 就是 Java 里的“线程池管理者” + private final ExecutorService mService; + + // 构造函数私有化:不准外面随便 new + private ThreadExecutor() { + // newSingleThreadExecutor:创建一个单线程的线程池。 + // 就像开设了一个“单人窗口”,所有任务按顺序排队执行。 + // 为什么用单线程?因为数据库写入最好按顺序来,防止两个线程同时写同一个便签导致冲突。 + mService = Executors.newSingleThreadExecutor(); + } + + // 获取唯一实例的方法 + public static synchronized ThreadExecutor getInstance() { + if (instance == null) { + instance = new ThreadExecutor(); + } + return instance; + } + + /** + * 执行任务的方法 + * @param task 一个 Runnable 对象,里面包裹着你要干的活 + */ + public void execute(Runnable task) { + mService.execute(task); + } +} diff --git a/src/src/net/micode/notes/ui/AlarmAlertActivity.java b/src/app/src/main/java/net/micode/notes/ui/AlarmAlertActivity.java similarity index 100% rename from src/src/net/micode/notes/ui/AlarmAlertActivity.java rename to src/app/src/main/java/net/micode/notes/ui/AlarmAlertActivity.java diff --git a/src/src/net/micode/notes/ui/AlarmInitReceiver.java b/src/app/src/main/java/net/micode/notes/ui/AlarmInitReceiver.java similarity index 100% rename from src/src/net/micode/notes/ui/AlarmInitReceiver.java rename to src/app/src/main/java/net/micode/notes/ui/AlarmInitReceiver.java diff --git a/src/src/net/micode/notes/ui/AlarmReceiver.java b/src/app/src/main/java/net/micode/notes/ui/AlarmReceiver.java similarity index 100% rename from src/src/net/micode/notes/ui/AlarmReceiver.java rename to src/app/src/main/java/net/micode/notes/ui/AlarmReceiver.java diff --git a/src/app/src/main/java/net/micode/notes/ui/CenterImageSpan.java b/src/app/src/main/java/net/micode/notes/ui/CenterImageSpan.java new file mode 100644 index 0000000..7b2030c --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/CenterImageSpan.java @@ -0,0 +1,52 @@ +package net.micode.notes.ui; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.style.ImageSpan; + +/** + * 多行/多图时保持行高正确:保留 paint 的 descent/bottom,只扩展 ascent/top 以容纳图片, + * 避免整行高度被压成 0 或错位导致后续内容不可见。 + */ +public class CenterImageSpan extends ImageSpan { + + public CenterImageSpan(Drawable d, String source) { + super(d, source); + } + + @Override + public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + Drawable d = getDrawable(); + Rect rect = d.getBounds(); + if (rect.isEmpty()) { + return 0; + } + int w = rect.right; + int h = rect.bottom; + if (fm != null) { + // 只向上扩展行高以容纳图片,保留原有 descent/bottom,避免多行/多图时行高错位 + fm.ascent = Math.min(fm.ascent, -h); + fm.top = Math.min(fm.top, -h); + } + return w; + } + + @Override + public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { + Drawable b = getDrawable(); + canvas.save(); + + // 调试模式:不计算居中,直接画在 x 坐标(靠左) + // y 是基线位置,bottom 是行底 + // 我们让图片底部对齐到底部 + int transY = bottom - b.getBounds().bottom; + + // 暂时直接用 x,确保能看见 + canvas.translate(x, transY); + + b.draw(canvas); + canvas.restore(); + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/ui/ChatAdapter.java b/src/app/src/main/java/net/micode/notes/ui/ChatAdapter.java new file mode 100644 index 0000000..25121c2 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/ChatAdapter.java @@ -0,0 +1,126 @@ +package net.micode.notes.ui; + +/** + * Mi 管家对话列表适配器 + *

+ * RecyclerView Adapter,用于展示用户消息与 AI 回复。区分 TYPE_USER 与 TYPE_AI 两种布局, + * 支持 AI 思考中状态及参考资料列表的展示,支持流式更新最后一条消息。 + *

+ * + * @see net.micode.notes.model.ChatMessage + * @see net.micode.notes.ui.MiStewardFragment + */ +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import net.micode.notes.R; +import net.micode.notes.model.ChatMessage; +import java.util.ArrayList; +import java.util.List; + +public class ChatAdapter extends RecyclerView.Adapter { + + private List mData = new ArrayList<>(); + + public void addMessage(ChatMessage message) { + mData.add(message); + notifyItemInserted(mData.size() - 1); + } + + public void clear() { + mData.clear(); + notifyDataSetChanged(); + } + + // 获取最后一条消息(用于流式更新) + public ChatMessage getLastMessage() { + if (mData.isEmpty()) return null; + return mData.get(mData.size() - 1); + } + + public void updateLastMessage() { + if (!mData.isEmpty()) { + notifyItemChanged(mData.size() - 1); + } + } + + @Override + public int getItemViewType(int position) { + return mData.get(position).getType(); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == ChatMessage.TYPE_USER) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_user, parent, false); + return new UserViewHolder(v); + } else { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_ai, parent, false); + return new AIViewHolder(v); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ChatMessage msg = mData.get(position); + if (holder instanceof UserViewHolder) { + ((UserViewHolder) holder).tvContent.setText(msg.getContent()); + } else if (holder instanceof AIViewHolder) { + AIViewHolder aiHolder = (AIViewHolder) holder; + + if (msg.isThinking()) { + aiHolder.layoutThinking.setVisibility(View.VISIBLE); + aiHolder.tvContent.setVisibility(View.GONE); + aiHolder.layoutRefs.setVisibility(View.GONE); + } else { + aiHolder.layoutThinking.setVisibility(View.GONE); + aiHolder.tvContent.setVisibility(View.VISIBLE); + aiHolder.tvContent.setText(msg.getContent()); + + // 处理参考资料 + if (msg.getReferences() != null && !msg.getReferences().isEmpty()) { + aiHolder.layoutRefs.setVisibility(View.VISIBLE); + StringBuilder refs = new StringBuilder(); + for (String ref : msg.getReferences()) { + refs.append("📄 ").append(ref).append("\n"); + } + aiHolder.tvRefs.setText(refs.toString().trim()); + } else { + aiHolder.layoutRefs.setVisibility(View.GONE); + } + } + } + } + + @Override + public int getItemCount() { + return mData.size(); + } + + static class UserViewHolder extends RecyclerView.ViewHolder { + TextView tvContent; + UserViewHolder(View itemView) { + super(itemView); + tvContent = itemView.findViewById(R.id.tv_user_content); + } + } + + static class AIViewHolder extends RecyclerView.ViewHolder { + View layoutThinking; + TextView tvContent; + View layoutRefs; + TextView tvRefs; + AIViewHolder(View itemView) { + super(itemView); + layoutThinking = itemView.findViewById(R.id.layout_thinking); + tvContent = itemView.findViewById(R.id.tv_ai_content); + layoutRefs = itemView.findViewById(R.id.layout_references); + tvRefs = itemView.findViewById(R.id.tv_reference_list); + } + } +} diff --git a/src/src/net/micode/notes/ui/DateTimePicker.java b/src/app/src/main/java/net/micode/notes/ui/DateTimePicker.java similarity index 100% rename from src/src/net/micode/notes/ui/DateTimePicker.java rename to src/app/src/main/java/net/micode/notes/ui/DateTimePicker.java diff --git a/src/src/net/micode/notes/ui/DateTimePickerDialog.java b/src/app/src/main/java/net/micode/notes/ui/DateTimePickerDialog.java similarity index 100% rename from src/src/net/micode/notes/ui/DateTimePickerDialog.java rename to src/app/src/main/java/net/micode/notes/ui/DateTimePickerDialog.java diff --git a/src/src/net/micode/notes/ui/DropdownMenu.java b/src/app/src/main/java/net/micode/notes/ui/DropdownMenu.java similarity index 100% rename from src/src/net/micode/notes/ui/DropdownMenu.java rename to src/app/src/main/java/net/micode/notes/ui/DropdownMenu.java diff --git a/src/src/net/micode/notes/ui/FoldersListAdapter.java b/src/app/src/main/java/net/micode/notes/ui/FoldersListAdapter.java similarity index 100% rename from src/src/net/micode/notes/ui/FoldersListAdapter.java rename to src/app/src/main/java/net/micode/notes/ui/FoldersListAdapter.java diff --git a/src/app/src/main/java/net/micode/notes/ui/InspirationFragment.java b/src/app/src/main/java/net/micode/notes/ui/InspirationFragment.java new file mode 100644 index 0000000..c994858 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/InspirationFragment.java @@ -0,0 +1,142 @@ +package net.micode.notes.ui; + +import android.app.Activity; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.media.projection.MediaProjectionManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.ListView; +import android.widget.Switch; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.tool.FloatingService; + +public class InspirationFragment extends Fragment { + + private ListView mListView; + private NotesListAdapter mAdapter; + private BackgroundQueryHandler mQueryHandler; + private Switch mSwitchFloating; + + private static final int QUERY_TOKEN = 202; + private static final int REQUEST_CODE_OVERLAY_PERMISSION = 1001; + private static final int REQUEST_CODE_SCREEN_CAPTURE = 1002; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_inspiration, container, false); + initViews(view); + return view; + } + + private void initViews(View view) { + mListView = view.findViewById(R.id.inspiration_list); + mSwitchFloating = view.findViewById(R.id.switch_floating_ball); + + mAdapter = new NotesListAdapter(getActivity()); + mListView.setAdapter(mAdapter); + mQueryHandler = new BackgroundQueryHandler(getActivity().getContentResolver()); + mListView.setOnItemClickListener(new android.widget.AdapterView.OnItemClickListener() { + @Override + public void onItemClick(android.widget.AdapterView parent, View view, int position, long id) { + // 跳转到编辑页 (复用 NoteEditActivity) + Intent intent = new Intent(getActivity(), NoteEditActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, id); // 传入便签 ID + startActivity(intent); + } + }); + mSwitchFloating.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + checkAndStartService(); + } else { + stopFloatingService(); + } + } + }); + } + + private void checkAndStartService() { + // 1. 检查悬浮窗权限 + if (!Settings.canDrawOverlays(getContext())) { + Toast.makeText(getActivity(), "请授予悬浮窗权限", Toast.LENGTH_SHORT).show(); + Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getActivity().getPackageName())); + startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION); + mSwitchFloating.setChecked(false); + return; + } + + // 2. 申请截屏权限 + MediaProjectionManager mm = (MediaProjectionManager) getActivity().getSystemService(Context.MEDIA_PROJECTION_SERVICE); + startActivityForResult(mm.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CODE_OVERLAY_PERMISSION) { + if (Settings.canDrawOverlays(getContext())) { + checkAndStartService(); + } + } else if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) { + if (resultCode == Activity.RESULT_OK && data != null) { + startFloatingService(resultCode, data); + } else { + mSwitchFloating.setChecked(false); + Toast.makeText(getActivity(), "未授权截屏,无法开启", Toast.LENGTH_SHORT).show(); + } + } + } + + private void startFloatingService(int resultCode, Intent data) { + Intent intent = new Intent(getActivity(), FloatingService.class); + intent.putExtra("resultCode", resultCode); + intent.putExtra("data", data); + getActivity().startForegroundService(intent); + } + + private void stopFloatingService() { + Intent intent = new Intent(getActivity(), FloatingService.class); + getActivity().stopService(intent); + } + + @Override + public void onStart() { + super.onStart(); + refreshList(); + } + + private void refreshList() { + String selection = NoteColumns.PARENT_ID + "=?"; + String[] selectionArgs = { String.valueOf(Notes.ID_INSPIRATION_FOLDER) }; + mQueryHandler.startQuery(QUERY_TOKEN, null, Notes.CONTENT_NOTE_URI, + NoteItemData.PROJECTION, selection, selectionArgs, NoteColumns.MODIFIED_DATE + " DESC"); + } + + private final class BackgroundQueryHandler extends AsyncQueryHandler { + public BackgroundQueryHandler(ContentResolver cr) { super(cr); } + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + if (token == QUERY_TOKEN) mAdapter.changeCursor(cursor); + } + } +} \ No newline at end of file diff --git a/src/app/src/main/java/net/micode/notes/ui/MainActivity.java b/src/app/src/main/java/net/micode/notes/ui/MainActivity.java new file mode 100644 index 0000000..bac7b96 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/MainActivity.java @@ -0,0 +1,178 @@ +package net.micode.notes.ui; + +/** + * 应用主界面 Activity + *

+ * 承载底部导航(Mi管家 / 便签 / 待办)三页 ViewPager2,以及侧边栏 Drawer。 + * 负责页面切换、侧边栏菜单回调、隐私空间模式的界面切换。 + *

+ * + * @see MiStewardFragment + * @see NotesListFragment + * @see TodoFragment + */ +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.Fragment; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; +import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.navigation.NavigationView; +import net.micode.notes.R; + +public class MainActivity extends AppCompatActivity { + + private ViewPager2 mViewPager; + private BottomNavigationView mBottomNav; + private DrawerLayout mDrawerLayout; + private NavigationView mNavView; + + /** 便签列表页引用,由 NotesListFragment 注册,用于侧边栏菜单回调 */ + private NotesListFragment mNotesListFragment; + + private View mMainContentRoot; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mViewPager = findViewById(R.id.view_pager); + mBottomNav = findViewById(R.id.bottom_navigation); + mDrawerLayout = findViewById(R.id.drawer_layout); + mNavView = findViewById(R.id.nav_view); + mMainContentRoot = findViewById(R.id.main_content_root); + + mViewPager.setAdapter(new FragmentStateAdapter(this) { + @NonNull + @Override + public Fragment createFragment(int position) { + if (position == 0) { + return new MiStewardFragment(); + } else if (position == 1) { + return new NotesListFragment(); + } else { + return new TodoFragment(); + } + } + + @Override + public int getItemCount() { + return 3; + } + }); + + mBottomNav.setOnItemSelectedListener(item -> { + if (item.getItemId() == R.id.nav_mi_steward) { + mViewPager.setCurrentItem(0); + return true; + } else if (item.getItemId() == R.id.nav_notes) { + mViewPager.setCurrentItem(1); + return true; + } else if (item.getItemId() == R.id.nav_todo) { + mViewPager.setCurrentItem(2); + return true; + } + return false; + }); + + mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + if (position == 0) { + mBottomNav.setSelectedItemId(R.id.nav_mi_steward); + } else if (position == 1) { + mBottomNav.setSelectedItemId(R.id.nav_notes); + } else { + mBottomNav.setSelectedItemId(R.id.nav_todo); + } + } + }); + + // 侧边栏菜单点击 + mNavView.setNavigationItemSelectedListener(item -> { + int id = item.getItemId(); + if (id == R.id.drawer_new_note) { + mViewPager.setCurrentItem(1); + mViewPager.post(() -> { + if (mNotesListFragment != null) { + mNotesListFragment.createNewNoteFromDrawer(); + } + }); + mDrawerLayout.closeDrawers(); + return true; + } + if (id == R.id.drawer_new_folder) { + mViewPager.setCurrentItem(1); + mViewPager.post(() -> { + if (mNotesListFragment != null) { + mNotesListFragment.showCreateOrModifyFolderDialogFromDrawer(true); + } + }); + mDrawerLayout.closeDrawers(); + return true; + } + if (id == R.id.drawer_export) { + mViewPager.setCurrentItem(1); + mViewPager.post(() -> { + if (mNotesListFragment != null) { + mNotesListFragment.exportNoteToTextFromDrawer(); + } + }); + mDrawerLayout.closeDrawers(); + return true; + } + return false; + }); + } + + /** 供便签页 Toolbar 菜单按钮调用,打开侧边栏 */ + public void openDrawer() { + if (mDrawerLayout != null) { + mDrawerLayout.openDrawer(mNavView); + } + } + + /** 由 NotesListFragment 在 onViewCreated 时注册,onDestroyView 时置空 */ + public void setNotesListFragment(NotesListFragment fragment) { + mNotesListFragment = fragment; + } + + /** + * 进入隐私空间:界面变暗 + 切换到便签列表 + * 由 MiStewardFragment 在用户输入「隐私空间」时调用 + */ + public void enterPrivacySpace() { + int darkColor = ContextCompat.getColor(this, R.color.privacy_space_background); + if (mMainContentRoot != null) { + mMainContentRoot.setBackgroundColor(darkColor); + } + if (mBottomNav != null) { + mBottomNav.setBackgroundColor(darkColor); + } + mViewPager.setCurrentItem(1); + mViewPager.post(() -> { + if (mNotesListFragment != null) { + mNotesListFragment.onEnterPrivacySpace(); + } + }); + } + + /** 退出隐私空间:恢复正常主题 */ + public void exitPrivacySpace() { + if (mMainContentRoot != null) { + mMainContentRoot.setBackgroundColor(ContextCompat.getColor(this, R.color.surface_white)); + } + if (mBottomNav != null) { + mBottomNav.setBackgroundColor(ContextCompat.getColor(this, R.color.surface_white)); + } + } +} diff --git a/src/app/src/main/java/net/micode/notes/ui/MiStewardFragment.java b/src/app/src/main/java/net/micode/notes/ui/MiStewardFragment.java new file mode 100644 index 0000000..05977d1 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/MiStewardFragment.java @@ -0,0 +1,253 @@ +package net.micode.notes.ui; + +/** + * Mi 管家 AI 对话页 Fragment + *

+ * 主界面底部导航「Mi管家」页,提供与 DeepSeek 大模型的对话能力。 + * 支持 RAG 知识库模式(基于本地便签)与通用 AI 模式,可开关灵感球悬浮窗。 + * 用户输入「隐私空间」可触发进入隐私空间模式。 + *

+ * + * @see ChatAdapter + * @see AIService + * @see NoteRetriever + * @see PromptBuilder + */ +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.projection.MediaProjectionManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Switch; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import net.micode.notes.R; +import net.micode.notes.ai.AIService; +import net.micode.notes.ai.NoteRetriever; +import net.micode.notes.ai.PromptBuilder; +import net.micode.notes.model.ChatMessage; +import net.micode.notes.tool.FloatingService; +import net.micode.notes.tool.PrivacySpaceManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MiStewardFragment extends Fragment { + + private RecyclerView mRecyclerView; + private ChatAdapter mAdapter; + private EditText mInputBox; + private View mLogoArea; + private Switch mSwitchFloating; + private CheckBox mCbUseKnowledge; + + private static final int REQUEST_CODE_OVERLAY_PERMISSION = 1001; + private static final int REQUEST_CODE_SCREEN_CAPTURE = 1002; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_mi_steward, container, false); + initViews(view); + return view; + } + + @Override + public void onResume() { + super.onResume(); + if (getView() != null && PrivacySpaceManager.isInPrivacySpace()) { + getView().setBackgroundColor(androidx.core.content.ContextCompat.getColor(requireContext(), R.color.privacy_space_background)); + } + } + + private void initViews(View view) { + mRecyclerView = view.findViewById(R.id.recycler_view); + mInputBox = view.findViewById(R.id.et_input); + mLogoArea = view.findViewById(R.id.center_logo_area); + mCbUseKnowledge = view.findViewById(R.id.cb_use_knowledge); + mSwitchFloating = view.findViewById(R.id.switch_steward_floating); + Button btnSend = view.findViewById(R.id.btn_send); + ImageView btnNewChat = view.findViewById(R.id.btn_new_chat); + + mAdapter = new ChatAdapter(); + mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + mRecyclerView.setAdapter(mAdapter); + + // 1. 发送按钮逻辑 + btnSend.setOnClickListener(v -> { + String text = mInputBox.getText().toString().trim(); + if (TextUtils.isEmpty(text)) return; + + mLogoArea.setVisibility(View.GONE); + mRecyclerView.setVisibility(View.VISIBLE); + mAdapter.addMessage(new ChatMessage(text)); + mInputBox.setText(""); + scrollToBottom(); + + // 检测「隐私空间」四个字,有且仅有此四字时进入隐私空间 + if ("隐私空间".equals(text)) { + enterPrivacySpaceFlow(); + return; + } + + performAIQuery(text); + }); + + // 2. 新对话按钮逻辑 (左上角) + btnNewChat.setOnClickListener(v -> { + mAdapter.clear(); + mLogoArea.setVisibility(View.VISIBLE); + mRecyclerView.setVisibility(View.GONE); + Toast.makeText(getContext(), "已开启新对话", Toast.LENGTH_SHORT).show(); + }); + + // 3. 灵感球开关逻辑 (右上角) + mSwitchFloating.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + checkAndStartFloatingService(); + } else { + Intent intent = new Intent(getActivity(), FloatingService.class); + getActivity().stopService(intent); + Toast.makeText(getActivity(), "灵感球已关闭", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void checkAndStartFloatingService() { + // 第一步:检查悬浮窗权限 + if (!Settings.canDrawOverlays(getContext())) { + Toast.makeText(getActivity(), "请授予悬浮窗权限", Toast.LENGTH_SHORT).show(); + Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getActivity().getPackageName())); + startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION); + mSwitchFloating.setChecked(false); // 暂时拨回,等待授权 + return; + } + + // 第二步:申请截屏权限 + MediaProjectionManager mm = (MediaProjectionManager) getActivity().getSystemService(Context.MEDIA_PROJECTION_SERVICE); + if (mm != null) { + startActivityForResult(mm.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CODE_OVERLAY_PERMISSION) { + if (Settings.canDrawOverlays(getContext())) { + checkAndStartFloatingService(); // 重新走流程,进入下一步申请截屏 + } + } else if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) { + if (resultCode == Activity.RESULT_OK && data != null) { + // 第三步:启动服务 + Intent intent = new Intent(getActivity(), FloatingService.class); + intent.putExtra("resultCode", resultCode); + intent.putExtra("data", data); + getActivity().startForegroundService(intent); + mSwitchFloating.setChecked(true); + Toast.makeText(getActivity(), "灵感球已开启", Toast.LENGTH_SHORT).show(); + } else { + mSwitchFloating.setChecked(false); + Toast.makeText(getActivity(), "未授权截屏,无法开启", Toast.LENGTH_SHORT).show(); + } + } + } + + private void performAIQuery(String userQuery) { + ChatMessage aiMsg = new ChatMessage(); + mAdapter.addMessage(aiMsg); + scrollToBottom(); + + boolean useRAG = mCbUseKnowledge.isChecked(); + + new Thread(() -> { + List relatedNotes = null; + if (useRAG) { + relatedNotes = NoteRetriever.searchRelevantNotes(getContext(), userQuery); + } + + String systemPrompt = PromptBuilder.buildSystemPrompt(relatedNotes, useRAG); + + AIService.chatWithKnowledge(systemPrompt, userQuery, new AIService.AIResultCallback() { + @Override + public void onSuccess(String result) { + List refs = new ArrayList<>(); + Pattern pattern = Pattern.compile("\\[Ref:(.*?)\\]"); + Matcher matcher = pattern.matcher(result); + while (matcher.find()) { + refs.add(matcher.group(1)); + } + String cleanContent = matcher.replaceAll("").trim(); + + new Handler(Looper.getMainLooper()).post(() -> { + ChatMessage lastMsg = mAdapter.getLastMessage(); + if (lastMsg != null && lastMsg.getType() == ChatMessage.TYPE_AI) { + lastMsg.setThinking(false); + lastMsg.setContent(cleanContent); + lastMsg.setReferences(refs); + mAdapter.updateLastMessage(); + scrollToBottom(); + } + }); + } + + @Override + public void onError(String error) { + new Handler(Looper.getMainLooper()).post(() -> { + ChatMessage lastMsg = mAdapter.getLastMessage(); + if (lastMsg != null) { + lastMsg.setThinking(false); + lastMsg.setContent("AI 遇到点小麻烦:" + error); + mAdapter.updateLastMessage(); + } + }); + } + }); + }).start(); + } + + private void scrollToBottom() { + mRecyclerView.scrollToPosition(mAdapter.getItemCount() - 1); + } + + /** + * 进入隐私空间流程:Mi 管家回复 + 变暗 + 切换到便签列表 + */ + private void enterPrivacySpaceFlow() { + // 1. Mi 管家回复 + ChatMessage aiMsg = new ChatMessage(); + aiMsg.setThinking(false); + aiMsg.setContent("隐私空间已开启~"); + mAdapter.addMessage(aiMsg); + scrollToBottom(); + + // 2. 进入隐私空间模式 + PrivacySpaceManager.enterPrivacySpace(); + + // 3. 通知 MainActivity:变暗 UI 并切换到便签列表 + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).enterPrivacySpace(); + } + } +} \ No newline at end of file diff --git a/src/src/net/micode/notes/ui/NoteEditActivity.java b/src/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java similarity index 67% rename from src/src/net/micode/notes/ui/NoteEditActivity.java rename to src/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java index e0df11b..712d51e 100644 --- a/src/src/net/micode/notes/ui/NoteEditActivity.java +++ b/src/app/src/main/java/net/micode/notes/ui/NoteEditActivity.java @@ -17,13 +17,24 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Paint; +import android.Manifest; +import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import android.graphics.Color; +import android.text.Editable; import android.text.Spannable; +import android.text.SpannableStringBuilder; import android.text.SpannableString; import android.text.TextUtils; +import android.text.TextWatcher; import android.text.format.DateUtils; import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; @@ -54,8 +65,11 @@ import net.micode.notes.ui.NoteEditText.OnTextViewChangeListener; import net.micode.notes.widget.NoteWidgetProvider_2x; import net.micode.notes.widget.NoteWidgetProvider_4x; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -145,6 +159,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, private NoteEditText mNoteEditor; // 核心编辑框 (普通模式) private View mNoteEditorPanel; // 编辑区域容器 private LinearLayout mEditTextList; // 清单模式下的容器 (CheckList) + private TextView mTvCharCount; // 左下角字数统计 // 业务逻辑核心对象 (ViewModel) private WorkingNote mWorkingNote; @@ -159,6 +174,10 @@ public class NoteEditActivity extends Activity implements OnClickListener, public static final String TAG_CHECKED = String.valueOf('\u221A'); public static final String TAG_UNCHECKED = String.valueOf('\u25A1'); + /** 新建便签模板占位符:第一行、第三行,光标定位时自动清空 */ + public static final String TEMPLATE_PLACEHOLDER_TITLE = "标题"; + public static final String TEMPLATE_PLACEHOLDER_BODY = "开始书写"; + // 搜索相关 private String mUserQuery; private Pattern mPattern; @@ -179,7 +198,10 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 在 XML 布局中我们植入了一个 ID 为 btn_ai_polish 的 ImageButton // 这里手动绑定点击监听器,触发 AI 逻辑 findViewById(R.id.btn_ai_polish).setOnClickListener(this); - // ============================ + // 绑定富文本工具栏 + findViewById(R.id.btn_bold).setOnClickListener(this); + findViewById(R.id.btn_font_size_up).setOnClickListener(this); + findViewById(R.id.btn_font_size_down).setOnClickListener(this); // 绑定插入图片按钮 findViewById(R.id.btn_insert_image).setOnClickListener(this); // ... findViewById(R.id.btn_ai_polish).setOnClickListener(this); @@ -194,15 +216,29 @@ public class NoteEditActivity extends Activity implements OnClickListener, @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); - if (savedInstanceState != null && savedInstanceState.containsKey(Intent.EXTRA_UID)) { - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.putExtra(Intent.EXTRA_UID, savedInstanceState.getLong(Intent.EXTRA_UID)); - if (!initActivityState(intent)) { - finish(); - return; - } - Log.d(TAG, "Restoring from killed activity"); + if (savedInstanceState == null) return; + long noteId = savedInstanceState.getLong(Intent.EXTRA_UID, -1); + if (noteId < 0) return; + Intent intent; + // noteId==0 表示当时是新建便签(异步保存未完成),用 folderId 按“新建”还原,避免 ACTION_VIEW(0) 失败导致 finish 退回列表 + if (noteId == 0 && savedInstanceState.containsKey(KEY_SAVED_FOLDER_ID)) { + long folderId = savedInstanceState.getLong(KEY_SAVED_FOLDER_ID, Notes.ID_ROOT_FOLDER); + intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, folderId); + } else if (noteId > 0) { + intent = new Intent(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, noteId); + } else { + return; + } + if (!initActivityState(intent)) { + finish(); + return; + } + if (savedInstanceState.containsKey(KEY_CURRENT_PHOTO_PATH)) { + mCurrentPhotoPath = savedInstanceState.getString(KEY_CURRENT_PHOTO_PATH); } + Log.d(TAG, "Restoring from killed activity, noteId=" + noteId); } /** @@ -210,20 +246,25 @@ public class NoteEditActivity extends Activity implements OnClickListener, * 根据 Intent 的 Action 判断是 "查看/编辑旧便签" 还是 "新建便签" */ private boolean initActivityState(Intent intent) { + /** + * If the user specified the {@link Intent#ACTION_VIEW} but not provided with id, + * then jump to the NotesListActivity + */ mWorkingNote = null; - // Case 1: 查看或编辑已有便签 + // 1. 处理“查看/编辑”模式 if (TextUtils.equals(Intent.ACTION_VIEW, intent.getAction())) { long noteId = intent.getLongExtra(Intent.EXTRA_UID, 0); - mUserQuery = ""; + mUserQuery = ""; // 初始化为空字符串,防止后续渲染空指针 - // 处理来自搜索结果的跳转,可能会带有高亮关键词 (User Query) + /** + * Starting from the searched result + */ if (intent.hasExtra(SearchManager.EXTRA_DATA_KEY)) { noteId = Long.parseLong(intent.getStringExtra(SearchManager.EXTRA_DATA_KEY)); mUserQuery = intent.getStringExtra(SearchManager.USER_QUERY); } - // 校验便签是否存在 if (!DataUtils.visibleInNoteDatabase(getContentResolver(), noteId, Notes.TYPE_NOTE)) { Intent jump = new Intent(this, NotesListActivity.class); startActivity(jump); @@ -231,7 +272,6 @@ public class NoteEditActivity extends Activity implements OnClickListener, finish(); return false; } else { - // 加载数据 mWorkingNote = WorkingNote.load(this, noteId); if (mWorkingNote == null) { Log.e(TAG, "load note failed with note id" + noteId); @@ -239,13 +279,15 @@ public class NoteEditActivity extends Activity implements OnClickListener, return false; } } - // 此时不自动弹出软键盘 + + // 键盘默认隐藏 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); } - // Case 2: 新建便签 (INSERT_OR_EDIT) + // 2. 处理“新建”模式 else if(TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction())) { + // New note long folderId = intent.getLongExtra(Notes.INTENT_EXTRA_FOLDER_ID, 0); int widgetId = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); @@ -254,15 +296,20 @@ public class NoteEditActivity extends Activity implements OnClickListener, int bgResId = intent.getIntExtra(Notes.INTENT_EXTRA_BACKGROUND_ID, ResourceParser.getDefaultBgId(this)); - // 处理从通话记录创建便签的特殊逻辑 + // Parse call-record note String phoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER); long callDate = intent.getLongExtra(Notes.INTENT_EXTRA_CALL_DATE, 0); - if (callDate != 0 && phoneNumber != null) { + + // 【关键修改】如果文件夹是灵感箱 (-4),强制不走通话记录逻辑,直接创建空便签 + if (folderId == Notes.ID_INSPIRATION_FOLDER) { + mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); + } + // 只有当有电话号码且不是灵感箱时,才尝试作为通话记录处理 + else if (callDate != 0 && phoneNumber != null) { if (TextUtils.isEmpty(phoneNumber)) { Log.w(TAG, "The call record number is null"); } long noteId = 0; - // 如果该通话记录已经有对应的便签,则打开它 if ((noteId = DataUtils.getNoteIdByPhoneNumberAndCallDate(getContentResolver(), phoneNumber, callDate)) > 0) { mWorkingNote = WorkingNote.load(this, noteId); @@ -272,18 +319,17 @@ public class NoteEditActivity extends Activity implements OnClickListener, return false; } } else { - // 否则创建新的通话便签 mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); mWorkingNote.convertToCallNote(phoneNumber, callDate); } } else { - // 创建普通空便签 + // 普通新建 mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); } - // 新建时自动弹出软键盘 + // 新建时键盘自动弹出 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); @@ -292,7 +338,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, finish(); return false; } - // 注册监听器,当 WorkingNote 属性改变时回调本 Activity 刷新 UI + mWorkingNote.setOnSettingStatusChangedListener(this); return true; } @@ -308,48 +354,123 @@ public class NoteEditActivity extends Activity implements OnClickListener, * 设置字体、背景、内容、修改时间等 */ private void initNoteScreen() { + if (mNoteEditor == null || mWorkingNote == null) return; mNoteEditor.setTextAppearance(this, TextAppearanceResources .getTexAppearanceResource(mFontSizeId)); - + mNoteEditor.setTextColor(0xFF000000); // 纯黑 + mNoteEditor.setHintTextColor(0xFF888888); // 灰色提示 + mNoteEditor.setBackgroundColor(0x00000000); // 背景透明 + mNoteEditor.setEnabled(true); + mNoteEditor.setFocusable(true); + mNoteEditor.setFocusableInTouchMode(true); // 根据模式决定显示 EditText 还是 ListLayout if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { switchToListMode(mWorkingNote.getContent()); } else { // 普通模式:设置文本并处理搜索高亮 - // 原来是:mNoteEditor.setText(...) - // 修改为:使用支持图片的设置方法 if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { switchToListMode(mWorkingNote.getContent()); } else { - // 这一步至关重要!让它解析 标签 - mNoteEditor.setTextWithImages(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery).toString()); - mNoteEditor.setSelection(mNoteEditor.getText().length()); + String content = mWorkingNote.getContent(); + // 新建便签且无内容时:使用模板预设(第一行「标题」、第二行当前时间、第三行「开始书写」) + if (!mWorkingNote.existInDatabase() && TextUtils.isEmpty(content)) { + applyNewNoteTemplate(mNoteEditor); + } else { + mNoteEditor.setPlaceholderLinesForClear("", ""); // 非模板内容不启用占位清空 + mNoteEditor.setTextWithImages(getHighlightQueryResult(content, mUserQuery).toString()); + } + if (mNoteEditor.getText() != null) { + mNoteEditor.setSelection(mNoteEditor.getText().length()); + } } - mNoteEditor.setSelection(mNoteEditor.getText().length()); } // 重置背景色选择器的选中状态 for (Integer id : sBgSelectorSelectionMap.keySet()) { - findViewById(sBgSelectorSelectionMap.get(id)).setVisibility(View.GONE); + View sel = findViewById(sBgSelectorSelectionMap.get(id)); + if (sel != null) sel.setVisibility(View.GONE); } // 应用背景资源 - mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId()); - mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + if (mHeadViewPanel != null) mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId()); + if (mNoteEditorPanel != null) mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); // 显示最后修改时间 - mNoteHeaderHolder.tvModified.setText(DateUtils.formatDateTime(this, - mWorkingNote.getModifiedDate(), DateUtils.FORMAT_SHOW_DATE - | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME - | DateUtils.FORMAT_SHOW_YEAR)); + if (mNoteHeaderHolder != null && mNoteHeaderHolder.tvModified != null) { + mNoteHeaderHolder.tvModified.setText(DateUtils.formatDateTime(this, + mWorkingNote.getModifiedDate(), DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_SHOW_YEAR)); + } showAlertHeader(); // 刷新闹钟状态 + updateCharCount(); // 左下角字数统计 + } + + /** + * 新建便签模板:第一行「标题」(灰、大号),第二行当前时间,第三行「开始书写」(灰、小号)。 + */ + private void applyNewNoteTemplate(NoteEditText editor) { + if (editor == null) return; + SpannableStringBuilder sb = new SpannableStringBuilder(); + String timeStr = new SimpleDateFormat("M月d日 a h:mm", Locale.CHINA).format(new Date()); + int p0 = 0; + sb.append(TEMPLATE_PLACEHOLDER_TITLE); + sb.setSpan(new ForegroundColorSpan(Color.GRAY), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + sb.setSpan(new RelativeSizeSpan(1.5f), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + sb.append("\n").append(timeStr).append("\n"); + p0 = sb.length(); + sb.append(TEMPLATE_PLACEHOLDER_BODY); + sb.setSpan(new ForegroundColorSpan(Color.GRAY), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + sb.setSpan(new RelativeSizeSpan(0.8f), p0, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + editor.setText(sb); + editor.setPlaceholderLinesForClear(TEMPLATE_PLACEHOLDER_TITLE, TEMPLATE_PLACEHOLDER_BODY); + } + + /** + * 计算“可计数字符”长度:排除图片标签(<img src="路径"/>)和换行符,只统计实际可见文字字数。 + */ + private static int getCountableTextLength(CharSequence text) { + if (text == null) return 0; + String s = text.toString(); + // 移除 整段标签(图片在文本里实为路径,不参与字数) + s = s.replaceAll("", ""); + // 移除换行符,回车换行不记为字 + s = s.replace("\r", "").replace("\n", ""); + return s.length(); + } + + /** + * 更新左下角字数显示。普通模式取编辑框可计数字符长度,清单模式取所有列表项可计数字符之和。 + */ + private void updateCharCount() { + if (mTvCharCount == null) return; + int count = 0; + if (mWorkingNote != null && mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { + if (mEditTextList != null) { + for (int i = 0; i < mEditTextList.getChildCount(); i++) { + android.view.View child = mEditTextList.getChildAt(i); + if (child != null) { + NoteEditText et = (NoteEditText) child.findViewById(R.id.et_edit_text); + if (et != null && et.getText() != null) { + count += getCountableTextLength(et.getText()); + } + } + } + } + } else { + if (mNoteEditor != null && mNoteEditor.getText() != null) { + count = getCountableTextLength(mNoteEditor.getText()); + } + } + mTvCharCount.setText(getString(R.string.note_char_count, count)); } /** * 显示或隐藏闹钟提示头 */ private void showAlertHeader() { + if (mWorkingNote == null || mNoteHeaderHolder == null) return; if (mWorkingNote.hasClockAlert()) { long time = System.currentTimeMillis(); if (time > mWorkingNote.getAlertDate()) { @@ -363,7 +484,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, } else { mNoteHeaderHolder.tvAlertDate.setVisibility(View.GONE); mNoteHeaderHolder.ivAlertIcon.setVisibility(View.GONE); - }; + } } @Override @@ -372,6 +493,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, initActivityState(intent); } + private static final String KEY_SAVED_FOLDER_ID = "saved_folder_id"; + private static final String KEY_CURRENT_PHOTO_PATH = "current_photo_path"; + @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); @@ -379,8 +503,16 @@ public class NoteEditActivity extends Activity implements OnClickListener, if (!mWorkingNote.existInDatabase()) { saveNote(); } - outState.putLong(Intent.EXTRA_UID, mWorkingNote.getNoteId()); - Log.d(TAG, "Save working note id: " + mWorkingNote.getNoteId() + " onSaveInstanceState"); + long noteId = mWorkingNote.getNoteId(); + outState.putLong(Intent.EXTRA_UID, noteId); + // 新建便签时 noteId 可能仍为 0(异步保存未完成),恢复时需用 folderId 按“新建”还原 + if (noteId == 0) { + outState.putLong(KEY_SAVED_FOLDER_ID, mWorkingNote.getFolderId()); + } + if (mCurrentPhotoPath != null) { + outState.putString(KEY_CURRENT_PHOTO_PATH, mCurrentPhotoPath); + } + Log.d(TAG, "Save working note id: " + noteId + " onSaveInstanceState"); } /** @@ -389,13 +521,12 @@ public class NoteEditActivity extends Activity implements OnClickListener, */ @Override public boolean dispatchTouchEvent(MotionEvent ev) { - if (mNoteBgColorSelector.getVisibility() == View.VISIBLE + if (mNoteBgColorSelector != null && mNoteBgColorSelector.getVisibility() == View.VISIBLE && !inRangeOfView(mNoteBgColorSelector, ev)) { mNoteBgColorSelector.setVisibility(View.GONE); return true; } - - if (mFontSizeSelector.getVisibility() == View.VISIBLE + if (mFontSizeSelector != null && mFontSizeSelector.getVisibility() == View.VISIBLE && !inRangeOfView(mFontSizeSelector, ev)) { mFontSizeSelector.setVisibility(View.GONE); return true; @@ -403,6 +534,13 @@ public class NoteEditActivity extends Activity implements OnClickListener, return super.dispatchTouchEvent(ev); } + /** + * 判断触摸点是否在指定 View 的范围内。 + * + * @param view 目标 View + * @param ev 触摸事件 + * @return true 表示触摸点在 View 内,false 表示在 View 外 + */ private boolean inRangeOfView(View view, MotionEvent ev) { int []location = new int[2]; view.getLocationOnScreen(location); @@ -417,6 +555,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, return true; } + /** + * 初始化所有 UI 控件引用,绑定点击监听器,加载字体偏好。 + */ private void initResources() { mHeadViewPanel = findViewById(R.id.note_title); mNoteHeaderHolder = new HeadViewHolder(); @@ -424,9 +565,23 @@ public class NoteEditActivity extends Activity implements OnClickListener, mNoteHeaderHolder.ivAlertIcon = (ImageView) findViewById(R.id.iv_alert_icon); mNoteHeaderHolder.tvAlertDate = (TextView) findViewById(R.id.tv_alert_date); mNoteHeaderHolder.ibSetBgColor = (ImageView) findViewById(R.id.btn_set_bg_color); - mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); + if (mNoteHeaderHolder.ibSetBgColor != null) { + mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); + } mNoteEditor = (NoteEditText) findViewById(R.id.note_edit_view); + mTvCharCount = (TextView) findViewById(R.id.tv_char_count); mNoteEditorPanel = findViewById(R.id.sv_note_edit); + // 普通模式:编辑框内容变化时实时更新字数 + mNoteEditor.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + @Override + public void afterTextChanged(Editable s) { + updateCharCount(); + } + }); mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector); for (int id : sBgSelectorBtnsMap.keySet()) { ImageView iv = (ImageView) findViewById(id); @@ -460,7 +615,10 @@ public class NoteEditActivity extends Activity implements OnClickListener, clearSettingState(); } - // 更新桌面 Widget 内容 + /** + * 更新桌面小部件内容。 + * 根据便签关联的 Widget 类型发送广播,通知 2x 或 4x 规格的 Widget 刷新显示。 + */ private void updateWidget() { Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); if (mWorkingNote.getWidgetType() == Notes.TYPE_WIDGET_2X) { @@ -492,8 +650,25 @@ public class NoteEditActivity extends Activity implements OnClickListener, showInsertImageDialog(); return; } - // ================================= - + // === 富文本工具栏:对选中区域应用样式(仅运行时,不持久化) === + if (id == R.id.btn_bold) { + if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) { + mNoteEditor.applyBoldToSelection(); + } + return; + } + if (id == R.id.btn_font_size_up) { + if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) { + mNoteEditor.applyFontSizeUpToSelection(); + } + return; + } + if (id == R.id.btn_font_size_down) { + if (mNoteEditor != null && mWorkingNote != null && mWorkingNote.getCheckListMode() != TextNote.MODE_CHECK_LIST) { + mNoteEditor.applyFontSizeDownToSelection(); + } + return; + } // ============================================= // [新增] AI 智能助手入口 // 点击放大镜图标,弹出功能选择对话框 @@ -518,33 +693,50 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 处理更改背景色按钮 if (id == R.id.btn_set_bg_color) { - mNoteBgColorSelector.setVisibility(View.VISIBLE); - findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( - View.VISIBLE); + if (mNoteBgColorSelector != null && mWorkingNote != null) { + mNoteBgColorSelector.setVisibility(View.VISIBLE); + Integer selId = sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId()); + if (selId != null) { + View sel = findViewById(selId); + if (sel != null) sel.setVisibility(View.VISIBLE); + } + } } // 处理具体背景色点击 else if (sBgSelectorBtnsMap.containsKey(id)) { - findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( - View.GONE); - mWorkingNote.setBgColorId(sBgSelectorBtnsMap.get(id)); - mNoteBgColorSelector.setVisibility(View.GONE); + if (mWorkingNote != null) { + Integer selId = sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId()); + if (selId != null) { + View sel = findViewById(selId); + if (sel != null) sel.setVisibility(View.GONE); + } + mWorkingNote.setBgColorId(sBgSelectorBtnsMap.get(id)); + if (mNoteBgColorSelector != null) mNoteBgColorSelector.setVisibility(View.GONE); + } } // 处理具体字体大小点击 else if (sFontSizeBtnsMap.containsKey(id)) { - findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.GONE); + Integer oldSelId = sFontSelectorSelectionMap.get(mFontSizeId); + if (oldSelId != null) { + View oldSel = findViewById(oldSelId); + if (oldSel != null) oldSel.setVisibility(View.GONE); + } mFontSizeId = sFontSizeBtnsMap.get(id); mSharedPrefs.edit().putInt(PREFERENCE_FONT_SIZE, mFontSizeId).commit(); - findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE); - + Integer newSelId = sFontSelectorSelectionMap.get(mFontSizeId); + if (newSelId != null) { + View newSel = findViewById(newSelId); + if (newSel != null) newSel.setVisibility(View.VISIBLE); + } // 字体改变后需要刷新视图 - if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { + if (mWorkingNote != null && mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { getWorkingText(); switchToListMode(mWorkingNote.getContent()); - } else { + } else if (mNoteEditor != null) { mNoteEditor.setTextAppearance(this, TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); } - mFontSizeSelector.setVisibility(View.GONE); + if (mFontSizeSelector != null) mFontSizeSelector.setVisibility(View.GONE); } } @@ -557,12 +749,16 @@ public class NoteEditActivity extends Activity implements OnClickListener, super.onBackPressed(); } - // 隐藏设置面板 + /** + * 隐藏背景色、字体大小等设置面板。 + * + * @return true 表示已关闭某个面板并消费了返回键,false 表示无需处理 + */ private boolean clearSettingState() { - if (mNoteBgColorSelector.getVisibility() == View.VISIBLE) { + if (mNoteBgColorSelector != null && mNoteBgColorSelector.getVisibility() == View.VISIBLE) { mNoteBgColorSelector.setVisibility(View.GONE); return true; - } else if (mFontSizeSelector.getVisibility() == View.VISIBLE) { + } else if (mFontSizeSelector != null && mFontSizeSelector.getVisibility() == View.VISIBLE) { mFontSizeSelector.setVisibility(View.GONE); return true; } @@ -671,7 +867,12 @@ public class NoteEditActivity extends Activity implements OnClickListener, d.show(); } - // 调用系统分享 + /** + * 调用系统分享 Intent,将便签内容分享到其他应用。 + * + * @param context 上下文 + * @param info 要分享的文本内容 + */ private void sendTo(Context context, String info) { Intent intent = new Intent(Intent.ACTION_SEND); intent.putExtra(Intent.EXTRA_TEXT, info); @@ -679,7 +880,9 @@ public class NoteEditActivity extends Activity implements OnClickListener, context.startActivity(intent); } - // 保存当前并创建新便签 + /** + * 保存当前便签并跳转至新建便签页面。 + */ private void createNewNote() { saveNote(); finish(); @@ -689,7 +892,10 @@ public class NoteEditActivity extends Activity implements OnClickListener, startActivity(intent); } - // 删除便签 + /** + * 删除当前便签。 + * 同步模式下移动到回收站,非同步模式下物理删除。 + */ private void deleteCurrentNote() { if (mWorkingNote.existInDatabase()) { HashSet ids = new HashSet(); @@ -712,6 +918,12 @@ public class NoteEditActivity extends Activity implements OnClickListener, mWorkingNote.markDeleted(true); } + /** + * 判断当前是否处于同步模式(已配置 Google 账户)。 + * 同步模式下删除会移动至回收站,非同步模式下直接物理删除。 + * + * @return true 表示已配置同步账户 + */ private boolean isSyncMode() { return NotesPreferenceActivity.getSyncAccountName(this).trim().length() > 0; } @@ -721,15 +933,31 @@ public class NoteEditActivity extends Activity implements OnClickListener, saveNote(); } if (mWorkingNote.getNoteId() > 0) { - Intent intent = new Intent(this, AlarmReceiver.class); + if (set && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, 0); + } + } + Intent intent = new Intent(this, NoteReminderReceiver.class); intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId())); - PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + int requestCode = (int) (mWorkingNote.getNoteId() % Integer.MAX_VALUE); + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, requestCode, intent, flags); AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE)); showAlertHeader(); if(!set) { alarmManager.cancel(pendingIntent); } else { - alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, date, pendingIntent); + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent); + } } } else { Log.e(TAG, "Clock alert setting error"); @@ -766,6 +994,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, edit.append(text); edit.requestFocus(); edit.setSelection(length); + updateCharCount(); } // 处理清单项回车(新增一行) @@ -783,6 +1012,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, ((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text)) .setIndex(i); } + updateCharCount(); } // 切换到清单模式:将文本拆分为列表项 @@ -801,21 +1031,26 @@ public class NoteEditActivity extends Activity implements OnClickListener, mNoteEditor.setVisibility(View.GONE); mEditTextList.setVisibility(View.VISIBLE); + updateCharCount(); } // 获取带有高亮的 Spannable 文本 (用于搜索结果高亮) private Spannable getHighlightQueryResult(String fullText, String userQuery) { - SpannableString spannable = new SpannableString(fullText == null ? "" : fullText); + String safeText = fullText == null ? "" : fullText; + SpannableString spannable = new SpannableString(safeText); if (!TextUtils.isEmpty(userQuery)) { - mPattern = Pattern.compile(userQuery); - Matcher m = mPattern.matcher(fullText); - int start = 0; - while (m.find(start)) { - spannable.setSpan( - new BackgroundColorSpan(this.getResources().getColor( - R.color.user_query_highlight)), m.start(), m.end(), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - start = m.end(); + try { + mPattern = Pattern.compile(userQuery); + Matcher m = mPattern.matcher(safeText); + int start = 0; + while (m.find(start)) { + spannable.setSpan( + new BackgroundColorSpan(ContextCompat.getColor(this, R.color.user_query_highlight)), + m.start(), m.end(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + start = m.end(); + } + } catch (Exception e) { + Log.w(TAG, "getHighlightQueryResult: " + e.getMessage()); } } return spannable; @@ -850,6 +1085,17 @@ public class NoteEditActivity extends Activity implements OnClickListener, edit.setOnTextViewChangeListener(this); edit.setIndex(index); edit.setText(getHighlightQueryResult(item, mUserQuery)); + // 清单模式:每项内容变化时实时更新字数 + edit.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + @Override + public void afterTextChanged(Editable s) { + updateCharCount(); + } + }); return view; } @@ -863,6 +1109,7 @@ public class NoteEditActivity extends Activity implements OnClickListener, } else { mEditTextList.getChildAt(index).findViewById(R.id.cb_edit_item).setVisibility(View.GONE); } + updateCharCount(); } public void onCheckListModeChanged(int oldMode, int newMode) { @@ -877,8 +1124,15 @@ public class NoteEditActivity extends Activity implements OnClickListener, mEditTextList.setVisibility(View.GONE); mNoteEditor.setVisibility(View.VISIBLE); } + updateCharCount(); } + /** + * 从 UI 获取当前编辑内容并同步到 WorkingNote。 + * 清单模式会按行拼接复选框符号(√/□)和文本。 + * + * @return 清单模式下是否至少有一项被勾选 + */ private boolean getWorkingText() { boolean hasChecked = false; if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { @@ -912,6 +1166,10 @@ public class NoteEditActivity extends Activity implements OnClickListener, return saved; } + /** + * 将当前便签以快捷方式形式发送到桌面。 + * 需便签已保存(有 noteId),否则提示错误。 + */ private void sendToDesktop() { if (!mWorkingNote.existInDatabase()) { saveNote(); @@ -937,6 +1195,12 @@ public class NoteEditActivity extends Activity implements OnClickListener, } } + /** + * 创建桌面快捷方式时使用的标题:去除复选框符号,最多取前 10 个字符。 + * + * @param content 便签正文 + * @return 截断后的标题 + */ private String makeShortcutIconTitle(String content) { content = content.replace(TAG_CHECKED, ""); content = content.replace(TAG_UNCHECKED, ""); @@ -944,10 +1208,19 @@ public class NoteEditActivity extends Activity implements OnClickListener, SHORTCUT_ICON_TITLE_MAX_LEN) : content; } + /** + * 显示短时 Toast 提示。 + */ private void showToast(int resId) { showToast(resId, Toast.LENGTH_SHORT); } + /** + * 显示指定时长的 Toast 提示。 + * + * @param resId 字符串资源 ID + * @param duration Toast.LENGTH_SHORT 或 Toast.LENGTH_LONG + */ private void showToast(int resId, int duration) { Toast.makeText(this, resId, duration).show(); } @@ -1087,31 +1360,51 @@ public class NoteEditActivity extends Activity implements OnClickListener, // 2. 启动相机 private void takePhoto() { Intent takePictureIntent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); - // 确保有相机应用能处理这个 Intent - if (takePictureIntent.resolveActivity(getPackageManager()) != null) { - try { - // 使用 MediaUtils 创建临时文件 (高内聚:Activity 不关心文件怎么创建的) - java.io.File photoFile = net.micode.notes.tool.MediaUtils.createImageFile(this); - mCurrentPhotoPath = photoFile.getAbsolutePath(); - - // 获取安全的 Content Uri (使用我们刚配置好的 FileProvider) - android.net.Uri photoURI = androidx.core.content.FileProvider.getUriForFile(this, - "net.micode.notes.fileprovider", - photoFile); - - takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI); - startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); - } catch (java.io.IOException ex) { - Toast.makeText(this, "创建图片文件失败", Toast.LENGTH_SHORT).show(); - } - } else { + android.content.pm.ResolveInfo resolveInfo = getPackageManager().resolveActivity( + takePictureIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + if (resolveInfo == null || resolveInfo.activityInfo == null) { Toast.makeText(this, "未找到相机应用", Toast.LENGTH_SHORT).show(); + return; + } + try { + java.io.File photoFile = net.micode.notes.tool.MediaUtils.createImageFile(this); + mCurrentPhotoPath = photoFile.getCanonicalPath(); + + android.net.Uri photoURI = androidx.core.content.FileProvider.getUriForFile(this, + "net.micode.notes.fileprovider", + photoFile); + + takePictureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoURI); + takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + takePictureIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Android 7.1+ 部分机型依赖 ClipData 传递 URI 权限 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + takePictureIntent.setClipData(android.content.ClipData.newUri(getContentResolver(), "", photoURI)); + } + // 显式授予已解析的相机应用对该 URI 的写权限,提高兼容性 + grantUriPermission(resolveInfo.activityInfo.packageName, photoURI, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + + startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); + } catch (java.io.IOException ex) { + Log.e(TAG, "takePhoto createImageFile", ex); + Toast.makeText(this, "创建图片文件失败", Toast.LENGTH_SHORT).show(); + } catch (IllegalArgumentException ex) { + Log.e(TAG, "takePhoto FileProvider URI", ex); + Toast.makeText(this, "无法创建拍照存储路径", Toast.LENGTH_SHORT).show(); + } catch (Exception ex) { + Log.e(TAG, "takePhoto", ex); + Toast.makeText(this, "无法启动相机: " + ex.getMessage(), Toast.LENGTH_SHORT).show(); } } - // 3. 打开相册 + // 3. 打开相册(使用 GET_CONTENT 以更好兼容截图、微信等来源的图片) private void pickFromGallery() { - Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + // 确保选图器返回的 Uri 可被本应用读取(部分相册/文件管理器依赖此标志) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(intent, REQUEST_CODE_OPEN_ALBUM); } @Override @@ -1119,26 +1412,33 @@ public class NoteEditActivity extends Activity implements OnClickListener, if (resultCode == RESULT_OK) { switch (requestCode) { case REQUEST_CODE_TAKE_PHOTO: - if (mCurrentPhotoPath != null) { - // === [修改] 调用 NoteEditText 的接口插入图片 === + if (mCurrentPhotoPath != null && mNoteEditor != null) { mNoteEditor.insertImage(mCurrentPhotoPath); - // 【新增】立刻把含标签的新文本保存到 Model,防止 onResume 覆盖 - mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + mNoteEditor.setTextWithImages(mNoteEditor.getText().toString()); + if (mWorkingNote != null) { + mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + } } + mCurrentPhotoPath = null; break; case REQUEST_CODE_OPEN_ALBUM: if (data != null && data.getData() != null) { android.net.Uri selectedImage = data.getData(); + try { + final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (takeFlags != 0) { + getContentResolver().takePersistableUriPermission(selectedImage, takeFlags); + } + } catch (SecurityException ignored) { } String localPath = net.micode.notes.tool.MediaUtils.copyUriToInternalStorage(this, selectedImage); if (localPath != null) { mNoteEditor.insertImage(localPath); - // 【新增】立刻保存到 Model + mNoteEditor.setTextWithImages(mNoteEditor.getText().toString()); mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + } else { + Toast.makeText(this, R.string.error_image_copy_failed, Toast.LENGTH_SHORT).show(); } - - - // ============================================ } break; diff --git a/src/src/net/micode/notes/ui/NoteEditText.java b/src/app/src/main/java/net/micode/notes/ui/NoteEditText.java similarity index 58% rename from src/src/net/micode/notes/ui/NoteEditText.java rename to src/app/src/main/java/net/micode/notes/ui/NoteEditText.java index 217f887..9af7e4b 100644 --- a/src/src/net/micode/notes/ui/NoteEditText.java +++ b/src/app/src/main/java/net/micode/notes/ui/NoteEditText.java @@ -7,10 +7,15 @@ package net.micode.notes.ui; import android.content.Context; import android.graphics.Rect; +import android.graphics.Typeface; import android.text.Layout; import android.text.Selection; +import android.text.Spannable; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; import android.text.style.URLSpan; import android.util.AttributeSet; import android.util.Log; @@ -19,6 +24,7 @@ import android.view.KeyEvent; import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; import android.view.MotionEvent; +import android.view.View; import android.widget.EditText; import android.widget.Toast; @@ -85,6 +91,13 @@ public class NoteEditText extends EditText { private OnTextViewChangeListener mOnTextViewChangeListener; + /** 伪 Placeholder:当光标定位到该行且内容完全匹配时自动清空该行 */ + private String mPlaceholderLine1; + private String mPlaceholderLine2; + + /** + * 代码创建的构造器,默认索引为 0。 + */ public NoteEditText(Context context) { super(context, null); mIndex = 0; @@ -111,6 +124,13 @@ public class NoteEditText extends EditText { e.printStackTrace(); } } + + /** + * 设置富文本内容:解析文本中的 <img src="路径"/> 标签,将图片渲染为 CenterImageSpan。 + * 用于加载便签时还原内嵌图片,以及插入图片后刷新显示。 + * + * @param text 包含 img 标签的原始文本 + */ public void setTextWithImages(String text) { // 1. 先设置纯文本 setText(text); @@ -154,23 +174,78 @@ public class NoteEditText extends EditText { e.printStackTrace(); } } + // 强制重新测量与绘制,避免长文本/多图时高度未更新导致内容不可见 + requestLayout(); + post(new Runnable() { + @Override + public void run() { + invalidate(); + } + }); } + + /** + * 设置当前编辑框在清单列表中的索引,用于 Enter/Delete 回调时标识行号。 + * + * @param index 从 0 开始的索引 + */ public void setIndex(int index) { mIndex = index; } + /** + * 设置文本变更监听器,用于在行首删除、回车换行、焦点变化时通知父容器。 + * + * @param listener 监听器实例,可为 null 表示不接收回调 + */ public void setOnTextViewChangeListener(OnTextViewChangeListener listener) { mOnTextViewChangeListener = listener; } + /** + * 设置“占位行”文案:当用户光标定位到该行且内容完全等于 line1 或 line2 时,自动清空该行便于输入。 + */ + public void setPlaceholderLinesForClear(String line1, String line2) { + mPlaceholderLine1 = line1; + mPlaceholderLine2 = line2; + } + + /** + * XML 布局 inflate 时使用的双参数构造器。 + */ public NoteEditText(Context context, AttributeSet attrs) { super(context, attrs, android.R.attr.editTextStyle); } + /** + * 带 defStyle 的完整构造器,用于主题或样式继承场景。 + */ public NoteEditText(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } + /** + * 确保 EditText 按全文排版并报告完整内容高度,避免“第 36 行起全部消失”。 + * 原因:父布局传入 AT_MOST(一屏高) 时,DynamicLayout 只会排到该高度内的行数(约 35 行), + * 超出部分未进入 Layout,绘制时表现为整页消失。故在 AT_MOST 时先用 UNSPECIFIED 测一次, + * 让内部按全文排版,再以实际内容高度作为 measuredHeight。 + */ + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + int heightMode = View.MeasureSpec.getMode(heightSpec); + if (heightMode == View.MeasureSpec.AT_MOST) { + int unboundedHeight = View.MeasureSpec.makeMeasureSpec(0x3FFFFFFF, View.MeasureSpec.UNSPECIFIED); + super.onMeasure(widthSpec, unboundedHeight); + } else { + super.onMeasure(widthSpec, heightSpec); + } + Layout layout = getLayout(); + if (layout != null) { + int contentHeight = layout.getHeight() + getCompoundPaddingTop() + getCompoundPaddingBottom(); + setMeasuredDimension(getMeasuredWidth(), contentHeight); + } + } + /** * 处理触摸事件 *

@@ -190,15 +265,50 @@ public class NoteEditText extends EditText { y += getScrollY(); Layout layout = getLayout(); - int line = layout.getLineForVertical(y); - int off = layout.getOffsetForHorizontal(line, x); - Selection.setSelection(getText(), off); + if (layout != null) { + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + Selection.setSelection(getText(), off); + // 伪 Placeholder:若当前行内容为占位文案则清空该行 + post(new Runnable() { + @Override + public void run() { + checkAndClearPlaceholderLine(); + } + }); + } break; } return super.onTouchEvent(event); } + /** + * 若光标所在行内容完全等于 mPlaceholderLine1 或 mPlaceholderLine2,则清空该行。 + * 只删除占位文案,保留行尾换行符,避免下方时间行顶上来导致无法书写标题。 + */ + private void checkAndClearPlaceholderLine() { + if (TextUtils.isEmpty(mPlaceholderLine1) && TextUtils.isEmpty(mPlaceholderLine2)) return; + android.text.Editable text = getText(); + if (text == null) return; + Layout layout = getLayout(); + if (layout == null) return; + int selStart = getSelectionStart(); + if (selStart < 0) return; + int line = layout.getLineForOffset(selStart); + int lineStart = layout.getLineStart(line); + int lineEnd = layout.getLineEnd(line); + String lineContent = text.subSequence(lineStart, lineEnd).toString().trim(); + if (lineContent.equals(mPlaceholderLine1) || lineContent.equals(mPlaceholderLine2)) { + // 只替换占位文案,保留行尾换行符,避免下一行(如时间)顶上来 + int replaceEnd = lineEnd; + if (lineEnd > lineStart && lineEnd <= text.length() && text.charAt(lineEnd - 1) == '\n') { + replaceEnd = lineEnd - 1; + } + text.replace(lineStart, replaceEnd, ""); + } + } + /** * 键盘按下事件拦截 * 记录关键状态,实际逻辑在 onKeyUp 中执行。 @@ -314,4 +424,88 @@ public class NoteEditText extends EditText { } super.onCreateContextMenu(menu); } + + // ================= 富文本工具栏:加粗、字号变大、字号变小(仅运行时效果,不持久化) ================= + + /** + * 对选中区域应用或取消加粗 (StyleSpan BOLD)。 + * 若选区已全部加粗则取消加粗,否则应用加粗。 + */ + public void applyBoldToSelection() { + applySpanToSelection(new StyleSpan(Typeface.BOLD), StyleSpan.class, true); + } + + /** + * 对选中区域应用相对字号 1.2 倍 (RelativeSizeSpan)。 + */ + public void applyFontSizeUpToSelection() { + applyRelativeSizeToSelection(1.2f); + } + + /** + * 对选中区域应用相对字号 0.8 倍 (RelativeSizeSpan)。 + */ + public void applyFontSizeDownToSelection() { + applyRelativeSizeToSelection(0.8f); + } + + /** + * 对选中区域应用或移除指定倍率的 RelativeSizeSpan。 + * 若选区已有相同比例的 span 则移除(切换效果),否则添加。 + * + * @param proportion 相对字号倍率,如 1.2f 表示 1.2 倍 + */ + private void applyRelativeSizeToSelection(float proportion) { + android.text.Editable text = getText(); + if (text == null || !(text instanceof Spannable)) return; + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start < 0 || end < 0 || start == end) return; + if (start > end) { int t = start; start = end; end = t; } + Spannable sp = (Spannable) text; + RelativeSizeSpan[] existing = sp.getSpans(start, end, RelativeSizeSpan.class); + // 若选区已有相同比例的 RelativeSizeSpan 则移除,否则添加 + boolean hasSame = false; + for (RelativeSizeSpan s : existing) { + if (Math.abs(s.getSizeChange() - proportion) < 0.01f) { + sp.removeSpan(s); + hasSame = true; + } + } + if (!hasSame) { + sp.setSpan(new RelativeSizeSpan(proportion), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + /** + * 对选中区域应用或移除指定类型的 Span。 + * @param addSpan 要添加的 span 实例(仅用于“添加”分支) + * @param spanClass 要检测/移除的 span 类型 + * @param toggle 若为 true:若选区已全部为该 span 则移除,否则添加 + */ + private void applySpanToSelection(Object addSpan, Class spanClass, boolean toggle) { + android.text.Editable text = getText(); + if (text == null || !(text instanceof Spannable)) return; + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start < 0 || end < 0 || start == end) return; + if (start > end) { int t = start; start = end; end = t; } + Spannable sp = (Spannable) text; + Object[] existing = sp.getSpans(start, end, spanClass); + boolean allCovered = true; + for (Object s : existing) { + int sStart = sp.getSpanStart(s); + int sEnd = sp.getSpanEnd(s); + if (sStart > start || sEnd < end) { + allCovered = false; + break; + } + } + if (existing.length == 0) allCovered = false; + if (toggle && allCovered && existing.length > 0) { + for (Object s : existing) sp.removeSpan(s); + } else { + sp.setSpan(addSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } } \ No newline at end of file diff --git a/src/src/net/micode/notes/ui/NoteItemData.java b/src/app/src/main/java/net/micode/notes/ui/NoteItemData.java similarity index 96% rename from src/src/net/micode/notes/ui/NoteItemData.java rename to src/app/src/main/java/net/micode/notes/ui/NoteItemData.java index 68087a8..662a55d 100644 --- a/src/src/net/micode/notes/ui/NoteItemData.java +++ b/src/app/src/main/java/net/micode/notes/ui/NoteItemData.java @@ -39,6 +39,7 @@ public class NoteItemData { NoteColumns.TYPE, NoteColumns.WIDGET_ID, NoteColumns.WIDGET_TYPE, + NoteColumns.PINNED, }; // 列索引常量,对应 PROJECTION 数组 @@ -54,6 +55,7 @@ public class NoteItemData { private static final int TYPE_COLUMN = 9; private static final int WIDGET_ID_COLUMN = 10; private static final int WIDGET_TYPE_COLUMN = 11; + private static final int PINNED_COLUMN = 12; // 数据字段 private long mId; @@ -68,6 +70,7 @@ public class NoteItemData { private int mType; // 类型 (便签/文件夹) private int mWidgetId; private int mWidgetType; + private boolean mPinned; // 是否置顶 private String mName; // 联系人名字 (仅通话记录便签有效) private String mPhoneNumber; // 电话号码 (仅通话记录便签有效) @@ -99,6 +102,7 @@ public class NoteItemData { mType = cursor.getInt(TYPE_COLUMN); mWidgetId = cursor.getInt(WIDGET_ID_COLUMN); mWidgetType = cursor.getInt(WIDGET_TYPE_COLUMN); + mPinned = (cursor.getInt(PINNED_COLUMN) > 0); mPhoneNumber = ""; // 特殊逻辑:如果是通话记录文件夹下的便签,需要查找联系人名称 @@ -240,6 +244,10 @@ public class NoteItemData { return (mAlertDate > 0); } + public boolean isPinned() { + return mPinned; + } + public boolean isCallRecord() { return (mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber)); } diff --git a/src/app/src/main/java/net/micode/notes/ui/NoteReminderNotifier.java b/src/app/src/main/java/net/micode/notes/ui/NoteReminderNotifier.java new file mode 100644 index 0000000..0ff82b5 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/NoteReminderNotifier.java @@ -0,0 +1,80 @@ +package net.micode.notes.ui; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; + +import net.micode.notes.R; +import net.micode.notes.tool.DataUtils; + +/** + * 便签时间提醒通知:到点后发出一条通知,点击可打开闹钟弹窗。 + * 需在应用信息中开启「通知」权限(Android 13+)后才会显示。 + */ +public final class NoteReminderNotifier { + private static final String TAG = "NoteReminderNotifier"; + public static final String CHANNEL_ID = "note_reminder_channel"; + private static final int SNIPPET_MAX_LEN = 40; + + public static void show(Context context, long noteId, Uri noteUri) { + if (context == null || noteUri == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "POST_NOTIFICATIONS not granted"); + return; + } + } + String snippet; + try { + snippet = DataUtils.getSnippetById(context.getContentResolver(), noteId); + if (snippet == null) snippet = ""; + snippet = snippet.trim(); + if (snippet.length() > SNIPPET_MAX_LEN) { + snippet = snippet.substring(0, SNIPPET_MAX_LEN) + "…"; + } + } catch (Exception e) { + snippet = context.getString(R.string.note_reminder_default_text); + } + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + context.getString(R.string.note_reminder_channel_name), + NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(context.getString(R.string.note_reminder_channel_desc)); + nm.createNotificationChannel(channel); + } + Intent openIntent = new Intent(context, AlarmAlertActivity.class); + openIntent.setData(noteUri); + openIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pending = PendingIntent.getActivity( + context, + (int) (noteId % Integer.MAX_VALUE), + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.icon_app) + .setContentTitle(context.getString(R.string.note_reminder_title)) + .setContentText(snippet.isEmpty() ? context.getString(R.string.note_reminder_default_text) : snippet) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setAutoCancel(true) + .setContentIntent(pending); + try { + NotificationManagerCompat.from(context).notify((int) (noteId % Integer.MAX_VALUE), builder.build()); + } catch (SecurityException e) { + Log.e(TAG, "Cannot show notification: " + e.getMessage()); + } + } +} diff --git a/src/app/src/main/java/net/micode/notes/ui/NoteReminderReceiver.java b/src/app/src/main/java/net/micode/notes/ui/NoteReminderReceiver.java new file mode 100644 index 0000000..5705e20 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/NoteReminderReceiver.java @@ -0,0 +1,40 @@ +package net.micode.notes.ui; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +/** + * 便签时间提醒广播:到点后先发通知,再尝试打开闹钟弹窗。 + * 设置提醒时使用本 Receiver,确保到点有通知(需开启通知权限)。 + */ +public class NoteReminderReceiver extends BroadcastReceiver { + private static final String TAG = "NoteReminderReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + Uri data = intent.getData(); + if (data == null || data.getPathSegments() == null || data.getPathSegments().size() < 2) { + Log.e(TAG, "Alarm intent has no note data"); + return; + } + long noteId; + try { + noteId = Long.parseLong(data.getPathSegments().get(1)); + } catch (NumberFormatException e) { + Log.e(TAG, "Invalid note id in alarm intent", e); + return; + } + NoteReminderNotifier.show(context, noteId, data); + Intent alertIntent = new Intent(context, AlarmAlertActivity.class); + alertIntent.setData(data); + alertIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + try { + context.startActivity(alertIntent); + } catch (Exception e) { + Log.d(TAG, "Could not start AlarmAlertActivity: " + e.getMessage()); + } + } +} diff --git a/src/src/net/micode/notes/ui/NotesListActivity.java b/src/app/src/main/java/net/micode/notes/ui/NotesListActivity.java similarity index 90% rename from src/src/net/micode/notes/ui/NotesListActivity.java rename to src/app/src/main/java/net/micode/notes/ui/NotesListActivity.java index ffcdc0d..48456c7 100644 --- a/src/src/net/micode/notes/ui/NotesListActivity.java +++ b/src/app/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -27,22 +27,21 @@ import android.util.Log; import android.view.ActionMode; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; -import android.view.Display; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; -import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnCreateContextMenuListener; -import android.view.View.OnTouchListener; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.Button; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; import android.widget.EditText; import android.widget.ListView; import android.widget.PopupMenu; @@ -87,6 +86,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt private static final int MENU_FOLDER_DELETE = 0; private static final int MENU_FOLDER_VIEW = 1; private static final int MENU_FOLDER_CHANGE_NAME = 2; + private static final int MENU_FOLDER_PIN = 3; + private static final int MENU_FOLDER_UNPIN = 4; private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction"; @@ -104,11 +105,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt private NotesListAdapter mNotesListAdapter; private ListView mNotesListView; - private Button mAddNewNote; - - private boolean mDispatch; - private int mOriginY; - private int mDispatchY; + private FloatingActionButton mFab; private TextView mTitleBar; private long mCurrentFolderId; // 当前所在的文件夹 ID @@ -162,11 +159,13 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) { StringBuilder sb = new StringBuilder(); InputStream in = null; + InputStreamReader isr = null; + BufferedReader br = null; try { in = getResources().openRawResource(R.raw.introduction); if (in != null) { - InputStreamReader isr = new InputStreamReader(in); - BufferedReader br = new BufferedReader(isr); + isr = new InputStreamReader(in); + br = new BufferedReader(isr); char [] buf = new char[1024]; int len = 0; while ((len = br.read(buf)) > 0) { @@ -180,12 +179,14 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt e.printStackTrace(); return; } finally { - if(in != null) { - try { - in.close(); - } catch (IOException e) { - e.printStackTrace(); - } + if (br != null) { + try { br.close(); } catch (IOException e) { e.printStackTrace(); } + } + if (isr != null) { + try { isr.close(); } catch (IOException e) { e.printStackTrace(); } + } + if (in != null) { + try { in.close(); } catch (IOException e) { e.printStackTrace(); } } } @@ -220,12 +221,10 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt 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; + mFab = (FloatingActionButton) findViewById(R.id.btn_fab_new_note); + if (mFab != null) { + mFab.setOnClickListener(this); + } mTitleBar = (TextView) findViewById(R.id.tv_title_bar); mState = ListEditState.NOTE_LIST; mModeCallBack = new ModeCallback(); @@ -243,6 +242,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt public boolean onCreateActionMode(ActionMode mode, Menu menu) { getMenuInflater().inflate(R.menu.note_list_options, menu); menu.findItem(R.id.delete).setOnMenuItemClickListener(this); + menu.findItem(R.id.pin_to_top).setOnMenuItemClickListener(this); + menu.findItem(R.id.unpin).setOnMenuItemClickListener(this); mMoveMenu = menu.findItem(R.id.move); if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER || DataUtils.getUserFolderCount(mContentResolver) == 0) { @@ -254,7 +255,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt mActionMode = mode; mNotesListAdapter.setChoiceMode(true); mNotesListView.setLongClickable(false); - mAddNewNote.setVisibility(View.GONE); + if (mFab != null) mFab.setVisibility(View.GONE); // 自定义 ActionMode 的标题栏,植入全选下拉菜单 View customView = LayoutInflater.from(NotesListActivity.this).inflate( @@ -301,7 +302,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt public void onDestroyActionMode(ActionMode mode) { mNotesListAdapter.setChoiceMode(false); mNotesListView.setLongClickable(true); - mAddNewNote.setVisibility(View.VISIBLE); + if (mFab != null) mFab.setVisibility(View.VISIBLE); } public void finishActionMode() { @@ -341,6 +342,12 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt case R.id.move: startQueryDestinationFolders(); break; + case R.id.pin_to_top: + batchSetPinned(true); + break; + case R.id.unpin: + batchSetPinned(false); + break; default: return false; } @@ -348,63 +355,16 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * 新建便签按钮的触摸监听器 - *

- * 逻辑说明 (HACKME): - * "New Note" 按钮有一部分背景是透明的。为了让用户点到透明区域时能穿透点击到下方的列表项, - * 这里通过计算坐标,手动将事件 Dispatch 给 ListView。 - * 公式 y = -0.12x + 94 是根据 UI 设计图的形状拟合出来的。 - */ - private class NewNoteOnTouchListener implements OnTouchListener { - - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: { - Display display = getWindowManager().getDefaultDisplay(); - int screenHeight = display.getHeight(); - int newNoteViewHeight = mAddNewNote.getHeight(); - int start = screenHeight - newNoteViewHeight; - int eventY = start + (int) event.getY(); - if (mState == ListEditState.SUB_FOLDER) { - eventY -= mTitleBar.getHeight(); - start -= mTitleBar.getHeight(); - } - if (event.getY() < (event.getX() * (-0.12) + 94)) { - View view = mNotesListView.getChildAt(mNotesListView.getChildCount() - 1 - - mNotesListView.getFooterViewsCount()); - if (view != null && view.getBottom() > start - && (view.getTop() < (start + 94))) { - mOriginY = (int) event.getY(); - mDispatchY = eventY; - event.setLocation(event.getX(), mDispatchY); - mDispatch = true; - return mNotesListView.dispatchTouchEvent(event); - } - } - break; - } - case MotionEvent.ACTION_MOVE: { - if (mDispatch) { - mDispatchY += (int) event.getY() - mOriginY; - event.setLocation(event.getX(), mDispatchY); - return mNotesListView.dispatchTouchEvent(event); - } - break; - } - default: { - if (mDispatch) { - event.setLocation(event.getX(), mDispatchY); - mDispatch = false; - return mNotesListView.dispatchTouchEvent(event); - } - break; - } - } - return false; + private void batchSetPinned(boolean pinned) { + HashSet ids = mNotesListAdapter.getSelectedItemIds(); + if (ids == null || ids.isEmpty()) return; + if (DataUtils.batchSetPinned(mContentResolver, ids, pinned)) { + Toast.makeText(this, pinned ? getString(R.string.toast_pinned) : getString(R.string.toast_unpinned), + Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + mModeCallBack.finishActionMode(); } - - }; + } /** * 启动异步便签列表查询 (核心逻辑) @@ -422,7 +382,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] { String.valueOf(mCurrentFolderId) - }, NoteColumns.TYPE + " DESC," + NoteColumns.BG_COLOR_ID + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); + }, NoteColumns.PINNED + " DESC," + NoteColumns.TYPE + " DESC," + NoteColumns.BG_COLOR_ID + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); } private final class BackgroundQueryHandler extends AsyncQueryHandler { @@ -552,9 +512,10 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt startAsyncNotesListQuery(); // 刷新列表,查询子文件夹内容 if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { mState = ListEditState.CALL_RECORD_FOLDER; - mAddNewNote.setVisibility(View.GONE); + if (mFab != null) mFab.setVisibility(View.GONE); } else { mState = ListEditState.SUB_FOLDER; + if (mFab != null) mFab.setVisibility(View.VISIBLE); } if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { mTitleBar.setText(R.string.call_record_folder_name); @@ -565,12 +526,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } public void onClick(View v) { - switch (v.getId()) { - case R.id.btn_new_note: - createNewNote(); - break; - default: - break; + if (v.getId() == R.id.btn_fab_new_note) { + createNewNote(); } } @@ -677,7 +634,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt case CALL_RECORD_FOLDER: mCurrentFolderId = Notes.ID_ROOT_FOLDER; mState = ListEditState.NOTE_LIST; - mAddNewNote.setVisibility(View.VISIBLE); + if (mFab != null) mFab.setVisibility(View.VISIBLE); mTitleBar.setVisibility(View.GONE); startAsyncNotesListQuery(); break; @@ -713,6 +670,11 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt if (mFocusNoteDataItem != null) { menu.setHeaderTitle(mFocusNoteDataItem.getSnippet()); menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view); + if (mFocusNoteDataItem.isPinned()) { + menu.add(0, MENU_FOLDER_UNPIN, 0, R.string.menu_unpin); + } else { + menu.add(0, MENU_FOLDER_PIN, 0, R.string.menu_pin_to_top); + } 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); } @@ -754,6 +716,26 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt case MENU_FOLDER_CHANGE_NAME: showCreateOrModifyFolderDialog(false); break; + case MENU_FOLDER_PIN: + if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) { + HashSet ids = new HashSet(); + ids.add(mFocusNoteDataItem.getId()); + if (DataUtils.batchSetPinned(mContentResolver, ids, true)) { + Toast.makeText(this, getString(R.string.toast_pinned), Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + } + } + break; + case MENU_FOLDER_UNPIN: + if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) { + HashSet ids = new HashSet(); + ids.add(mFocusNoteDataItem.getId()); + if (DataUtils.batchSetPinned(mContentResolver, ids, false)) { + Toast.makeText(this, getString(R.string.toast_unpinned), Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + } + } + break; default: break; } diff --git a/src/src/net/micode/notes/ui/NotesListAdapter.java b/src/app/src/main/java/net/micode/notes/ui/NotesListAdapter.java similarity index 98% rename from src/src/net/micode/notes/ui/NotesListAdapter.java rename to src/app/src/main/java/net/micode/notes/ui/NotesListAdapter.java index 77c854c..ba34e75 100644 --- a/src/src/net/micode/notes/ui/NotesListAdapter.java +++ b/src/app/src/main/java/net/micode/notes/ui/NotesListAdapter.java @@ -98,6 +98,11 @@ public class NotesListAdapter extends CursorAdapter { notifyDataSetChanged(); // 刷新 UI 以显示勾选框变化 } + /** + * 判断当前是否处于批量多选模式。 + * + * @return true 表示多选模式,列表项显示复选框 + */ public boolean isInChoiceMode() { return mChoiceMode; } diff --git a/src/app/src/main/java/net/micode/notes/ui/NotesListFragment.java b/src/app/src/main/java/net/micode/notes/ui/NotesListFragment.java new file mode 100644 index 0000000..503853c --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/NotesListFragment.java @@ -0,0 +1,1070 @@ +package net.micode.notes.ui; + +import android.app.Activity; +import android.app.AlertDialog; +import android.appwidget.AppWidgetManager; +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.media.projection.MediaProjectionManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.view.ActionMode; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnCreateContextMenuListener; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.AdapterView.OnItemLongClickListener; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.gtask.remote.GTaskSyncService; +import net.micode.notes.model.WorkingNote; +import net.micode.notes.tool.BackupUtils; +import net.micode.notes.tool.DataUtils; +import net.micode.notes.tool.FloatingService; +import net.micode.notes.tool.PrivacySpaceManager; +import net.micode.notes.tool.ResourceParser; +import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute; +import net.micode.notes.widget.NoteWidgetProvider_2x; +import net.micode.notes.widget.NoteWidgetProvider_4x; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.HashSet; + +public class NotesListFragment extends Fragment implements OnClickListener, OnItemLongClickListener { + private static final int FOLDER_NOTE_LIST_QUERY_TOKEN = 0; + private static final int FOLDER_LIST_QUERY_TOKEN = 1; + private static final int MENU_FOLDER_DELETE = 0; + private static final int MENU_FOLDER_VIEW = 1; + private static final int MENU_FOLDER_CHANGE_NAME = 2; + private static final int MENU_FOLDER_PIN = 3; + private static final int MENU_FOLDER_UNPIN = 4; + private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction"; + + // === [新增] 灵感球相关常量 === + + // ========================== + + private enum ListEditState { + NOTE_LIST, SUB_FOLDER, CALL_RECORD_FOLDER, PRIVACY_SPACE + } + + private ListEditState mState; + private BackgroundQueryHandler mBackgroundQueryHandler; + private NotesListAdapter mNotesListAdapter; + private ListView mNotesListView; + private MaterialToolbar mToolbar; + private FloatingActionButton mFab; + private FloatingActionButton mFabBack; + private FloatingActionButton mFabExitPrivacy; + private TextView mTitleBar; + private long mCurrentFolderId; + private ContentResolver mContentResolver; + private ModeCallback mModeCallBack; + private static final String TAG = "NotesListFragment"; + private NoteItemData mFocusNoteDataItem; + + 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; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.note_list, container, false); + setHasOptionsMenu(true); // 允许 Fragment 处理菜单 + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + initResources(view); + setAppInfoFromRawRes(); + } + + @Override + public void onStart() { + super.onStart(); + startAsyncNotesListQuery(); + } + + // === [修改] onActivityResult 处理所有请求 === + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + // 只保留原本便签编辑返回刷新的逻辑 + if (resultCode == Activity.RESULT_OK + && (requestCode == REQUEST_CODE_OPEN_NODE || requestCode == REQUEST_CODE_NEW_NODE)) { + mNotesListAdapter.changeCursor(null); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void initResources(View view) { + mContentResolver = getActivity().getContentResolver(); + mBackgroundQueryHandler = new BackgroundQueryHandler(mContentResolver); + // 若进程存活且处于隐私空间(如配置变更后重建),恢复状态 + if (PrivacySpaceManager.isInPrivacySpace()) { + mCurrentFolderId = Notes.ID_PRIVACY_FOLDER; + mState = ListEditState.PRIVACY_SPACE; + applyPrivacySpaceTheme(view); + } else { + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mState = ListEditState.NOTE_LIST; + } + mNotesListView = (ListView) view.findViewById(R.id.notes_list); + mNotesListView.addFooterView(LayoutInflater.from(getActivity()).inflate(R.layout.note_list_footer, null), + null, false); + mNotesListView.setOnItemClickListener(new OnListItemClickListener()); + mNotesListView.setOnItemLongClickListener(this); + mNotesListAdapter = new NotesListAdapter(getActivity()); + mNotesListView.setAdapter(mNotesListAdapter); + + mToolbar = (MaterialToolbar) view.findViewById(R.id.toolbar_notes); + mFab = (FloatingActionButton) view.findViewById(R.id.btn_fab_new_note); + mFabBack = (FloatingActionButton) view.findViewById(R.id.btn_fab_back); + mFabExitPrivacy = (FloatingActionButton) view.findViewById(R.id.btn_fab_exit_privacy); + mTitleBar = (TextView) view.findViewById(R.id.tv_title_bar); + + if (mFabBack != null) { + mFabBack.setOnClickListener(v -> performBackToRoot()); + mFabBack.setVisibility(View.GONE); + } + if (mFabExitPrivacy != null) { + mFabExitPrivacy.setOnClickListener(v -> performExitPrivacySpace()); + mFabExitPrivacy.setVisibility(PrivacySpaceManager.isInPrivacySpace() ? View.VISIBLE : View.GONE); + } + if (mToolbar != null) { + mToolbar.setNavigationOnClickListener(v -> { + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).openDrawer(); + } + }); + } + if (mFab != null) { + mFab.setOnClickListener(v -> createNewNote()); + } + + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).setNotesListFragment(this); + } + if (!PrivacySpaceManager.isInPrivacySpace()) { + mState = ListEditState.NOTE_LIST; + } + mModeCallBack = new ModeCallback(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).setNotesListFragment(null); + } + } + + // === [新增] 检查并启动灵感球服务 === + + + // 处理菜单创建 + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + menu.clear(); + if (mState == ListEditState.PRIVACY_SPACE) { + inflater.inflate(R.menu.note_list_privacy, menu); + } else if (mState == ListEditState.NOTE_LIST) { + inflater.inflate(R.menu.note_list, menu); + menu.findItem(R.id.menu_sync).setTitle( + GTaskSyncService.isSyncing() ? R.string.menu_sync_cancel : R.string.menu_sync); + } else if (mState == ListEditState.SUB_FOLDER) { + inflater.inflate(R.menu.sub_folder, menu); + } else if (mState == ListEditState.CALL_RECORD_FOLDER) { + inflater.inflate(R.menu.call_record_folder, menu); + } + } + + // 处理菜单点击 + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + switch (item.getItemId()) { + + case R.id.menu_new_folder: { + showCreateOrModifyFolderDialog(true); + break; + } + case R.id.menu_export_text: { + exportNoteToText(); + break; + } + case R.id.menu_sync: { + if (isSyncMode()) { + if (TextUtils.equals(item.getTitle(), getString(R.string.menu_sync))) { + GTaskSyncService.startSync(getActivity()); + } else { + GTaskSyncService.cancelSync(getActivity()); + } + } else { + startPreferenceActivity(); + } + break; + } + case R.id.menu_setting: { + startPreferenceActivity(); + break; + } + case R.id.menu_new_note: { + createNewNote(); + break; + } + case R.id.menu_search: + onSearchRequested(); + break; + default: + break; + } + return true; + } + + // 搜索功能适配 + public boolean onSearchRequested() { + startActivity(new Intent(getActivity(), NotesListActivity.class).setAction(Intent.ACTION_SEARCH)); + return true; + } + + // 首次使用的介绍便签 + private void setAppInfoFromRawRes() { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity()); + if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) { + StringBuilder sb = new StringBuilder(); + InputStream in = null; + InputStreamReader isr = null; + BufferedReader br = null; + try { + in = getResources().openRawResource(R.raw.introduction); + if (in != null) { + isr = new InputStreamReader(in); + 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 (br != null) { + try { br.close(); } catch (IOException e) { e.printStackTrace(); } + } + if (isr != null) { + try { isr.close(); } catch (IOException e) { e.printStackTrace(); } + } + if (in != null) { + try { in.close(); } catch (IOException e) { e.printStackTrace(); } + } + } + + WorkingNote note = WorkingNote.createEmptyNote(getActivity(), 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; + } + } + } + + // --- 以下为原有的辅助类和方法,适配了 Context --- + + + + private class ModeCallback implements ListView.MultiChoiceModeListener, MenuItem.OnMenuItemClickListener { + private DropdownMenu mDropDownMenu; + private ActionMode mActionMode; + private MenuItem mMoveMenu; + + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + getActivity().getMenuInflater().inflate(R.menu.note_list_options, menu); + menu.findItem(R.id.delete).setOnMenuItemClickListener(this); + menu.findItem(R.id.pin_to_top).setOnMenuItemClickListener(this); + menu.findItem(R.id.unpin).setOnMenuItemClickListener(this); + mMoveMenu = menu.findItem(R.id.move); + if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER + || DataUtils.getUserFolderCount(mContentResolver) == 0) { + mMoveMenu.setVisible(false); + } else { + mMoveMenu.setVisible(true); + mMoveMenu.setOnMenuItemClickListener(this); + } + mActionMode = mode; + mNotesListAdapter.setChoiceMode(true); + mNotesListView.setLongClickable(false); + if (mFab != null) mFab.setVisibility(View.GONE); + + View customView = LayoutInflater.from(getActivity()).inflate( + R.layout.note_list_dropdown_menu, null); + mode.setCustomView(customView); + mDropDownMenu = new DropdownMenu(getActivity(), + (Button) customView.findViewById(R.id.selection_menu), + R.menu.note_list_dropdown); + mDropDownMenu.setOnDropdownMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ + public boolean onMenuItemClick(MenuItem item) { + mNotesListAdapter.selectAll(!mNotesListAdapter.isAllSelected()); + updateMenu(); + return true; + } + + }); + return true; + } + + private void updateMenu() { + int selectedCount = mNotesListAdapter.getSelectedCount(); + String format = getResources().getString(R.string.menu_select_title, selectedCount); + mDropDownMenu.setTitle(format); + MenuItem item = mDropDownMenu.findItem(R.id.action_select_all); + if (item != null) { + if (mNotesListAdapter.isAllSelected()) { + item.setChecked(true); + item.setTitle(R.string.menu_deselect_all); + } else { + item.setChecked(false); + item.setTitle(R.string.menu_select_all); + } + } + } + + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return false; + } + + public void onDestroyActionMode(ActionMode mode) { + mNotesListAdapter.setChoiceMode(false); + mNotesListView.setLongClickable(true); + if (mFab != null) mFab.setVisibility(View.VISIBLE); + } + + public void finishActionMode() { + mActionMode.finish(); + } + + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, + boolean checked) { + mNotesListAdapter.setCheckedItem(position, checked); + updateMenu(); + } + + public boolean onMenuItemClick(MenuItem item) { + if (mNotesListAdapter.getSelectedCount() == 0) { + Toast.makeText(getActivity(), getString(R.string.menu_select_none), + Toast.LENGTH_SHORT).show(); + return true; + } + + switch (item.getItemId()) { + case R.id.delete: + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getString(R.string.alert_title_delete)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setMessage(getString(R.string.alert_message_delete_notes, + mNotesListAdapter.getSelectedCount())); + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int which) { + batchDelete(); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + break; + case R.id.move: + startQueryDestinationFolders(); + break; + case R.id.pin_to_top: + batchSetPinned(true); + break; + case R.id.unpin: + batchSetPinned(false); + break; + default: + return false; + } + return true; + } + } + + private void batchSetPinned(boolean pinned) { + HashSet ids = mNotesListAdapter.getSelectedItemIds(); + if (ids == null || ids.isEmpty()) return; + if (DataUtils.batchSetPinned(mContentResolver, ids, pinned)) { + Toast.makeText(getActivity(), pinned ? getString(R.string.toast_pinned) : getString(R.string.toast_unpinned), + Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + mModeCallBack.finishActionMode(); + } + } + + private void startAsyncNotesListQuery() { + String selection; + String[] selectionArgs; + if (mState == ListEditState.PRIVACY_SPACE) { + selection = NORMAL_SELECTION; + selectionArgs = new String[] { String.valueOf(Notes.ID_PRIVACY_FOLDER) }; + } else if (mCurrentFolderId == Notes.ID_ROOT_FOLDER) { + selection = ROOT_FOLDER_SELECTION; + selectionArgs = new String[] { String.valueOf(mCurrentFolderId) }; + } else { + selection = NORMAL_SELECTION; + selectionArgs = new String[] { String.valueOf(mCurrentFolderId) }; + } + mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, + Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, selectionArgs, + NoteColumns.PINNED + " DESC," + NoteColumns.TYPE + " DESC," + NoteColumns.BG_COLOR_ID + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); + } + + private final class BackgroundQueryHandler extends AsyncQueryHandler { + public BackgroundQueryHandler(ContentResolver contentResolver) { + super(contentResolver); + } + + @Override + protected void onQueryComplete(int token, Object cookie, Cursor cursor) { + switch (token) { + case FOLDER_NOTE_LIST_QUERY_TOKEN: + mNotesListAdapter.changeCursor(cursor); + break; + case FOLDER_LIST_QUERY_TOKEN: + if (cursor != null && cursor.getCount() > 0) { + showFolderListMenu(cursor); + } else { + Log.e(TAG, "Query folder failed"); + } + break; + default: + return; + } + } + } + + private void showFolderListMenu(Cursor cursor) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.menu_title_select_folder); + final FoldersListAdapter adapter = new FoldersListAdapter(getActivity(), cursor); + builder.setAdapter(adapter, new DialogInterface.OnClickListener() { + + public void onClick(DialogInterface dialog, int which) { + DataUtils.batchMoveToFolder(mContentResolver, + mNotesListAdapter.getSelectedItemIds(), adapter.getItemId(which)); + Toast.makeText( + getActivity(), + getString(R.string.format_move_notes_to_folder, + mNotesListAdapter.getSelectedCount(), + adapter.getFolderName(getActivity(), which)), + Toast.LENGTH_SHORT).show(); + mModeCallBack.finishActionMode(); + } + }); + builder.show(); + } + + private void createNewNote() { + Intent intent = new Intent(getActivity(), 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 batchDelete() { + new AsyncTask>() { + protected HashSet doInBackground(Void... unused) { + HashSet widgets = mNotesListAdapter.getSelectedWidget(); + if (!isSyncMode()) { + if (DataUtils.batchDeleteNotes(mContentResolver, mNotesListAdapter + .getSelectedItemIds())) { + } else { + Log.e(TAG, "Delete notes error, should not happens"); + } + } else { + if (!DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter + .getSelectedItemIds(), Notes.ID_TRASH_FOLER)) { + Log.e(TAG, "Move notes to trash folder error, should not happens"); + } + } + return widgets; + } + + @Override + protected void onPostExecute(HashSet widgets) { + if (widgets != null) { + for (AppWidgetAttribute widget : widgets) { + if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID + && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { + updateWidget(widget.widgetId, widget.widgetType); + } + } + } + mModeCallBack.finishActionMode(); + } + }.execute(); + } + + private void deleteFolder(long folderId) { + if (folderId == Notes.ID_ROOT_FOLDER) { + Log.e(TAG, "Wrong folder id, should not happen " + folderId); + return; + } + + HashSet ids = new HashSet(); + ids.add(folderId); + HashSet widgets = DataUtils.getFolderNoteWidget(mContentResolver, + folderId); + if (!isSyncMode()) { + DataUtils.batchDeleteNotes(mContentResolver, ids); + } else { + DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_TRASH_FOLER); + } + if (widgets != null) { + for (AppWidgetAttribute widget : widgets) { + if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID + && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { + updateWidget(widget.widgetId, widget.widgetType); + } + } + } + } + + private void openNode(NoteItemData data) { + Intent intent = new Intent(getActivity(), NoteEditActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, data.getId()); + this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); + } + + private void openFolder(NoteItemData data) { + mCurrentFolderId = data.getId(); + startAsyncNotesListQuery(); + if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { + mState = ListEditState.CALL_RECORD_FOLDER; + if (mFab != null) mFab.setVisibility(View.GONE); + } else { + mState = ListEditState.SUB_FOLDER; + if (mFab != null) mFab.setVisibility(View.VISIBLE); + } + if (mFabBack != null) mFabBack.setVisibility(View.VISIBLE); + if (mToolbar != null) { + mToolbar.setTitle(data.getId() == Notes.ID_CALL_RECORD_FOLDER + ? getString(R.string.call_record_folder_name) : data.getSnippet()); + } + if (mTitleBar != null) { + mTitleBar.setText(data.getId() == Notes.ID_CALL_RECORD_FOLDER + ? getString(R.string.call_record_folder_name) : data.getSnippet()); + mTitleBar.setVisibility(View.VISIBLE); + } + } + + public void onClick(View v) { + if (v.getId() == R.id.btn_fab_new_note) { + createNewNote(); + } + } + + private void showSoftInput() { + InputMethodManager inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + } + } + + private void hideSoftInput(View view) { + InputMethodManager inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + private void showCreateOrModifyFolderDialog(final boolean create) { + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + View view = LayoutInflater.from(getActivity()).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; + } + } else { + etName.setText(""); + builder.setTitle(this.getString(R.string.menu_create_folder)); + } + + 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); + } + }); + + final AlertDialog 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(getActivity(), getString(R.string.folder_exist, name), + Toast.LENGTH_LONG).show(); + etName.setSelection(0, etName.length()); + return; + } + 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()) + }); + } + } 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); + } + dialog.dismiss(); + } + }); + + if (TextUtils.isEmpty(etName.getText())) { + positive.setEnabled(false); + } + etName.addTextChangedListener(new TextWatcher() { + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (TextUtils.isEmpty(etName.getText())) { + positive.setEnabled(false); + } else { + positive.setEnabled(true); + } + } + public void afterTextChanged(Editable s) { } + }); + } + + /** 由 MainActivity 在进入隐私空间时调用 */ + public void onEnterPrivacySpace() { + mCurrentFolderId = Notes.ID_PRIVACY_FOLDER; + mState = ListEditState.PRIVACY_SPACE; + if (getView() != null) { + applyPrivacySpaceTheme(getView()); + } + if (mToolbar != null) { + mToolbar.setTitle(R.string.privacy_space_title); + } + if (mTitleBar != null) { + mTitleBar.setVisibility(View.GONE); + } + if (mFabExitPrivacy != null) { + mFabExitPrivacy.setVisibility(View.VISIBLE); + } + startAsyncNotesListQuery(); + } + + /** 主动退出隐私空间 */ + private void performExitPrivacySpace() { + PrivacySpaceManager.exitPrivacySpace(); + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mState = ListEditState.NOTE_LIST; + if (getView() != null) { + restoreNormalTheme(getView()); + } + if (mToolbar != null) { + mToolbar.setTitle(R.string.notes_tab_title); + } + if (mFabExitPrivacy != null) { + mFabExitPrivacy.setVisibility(View.GONE); + } + startAsyncNotesListQuery(); + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).exitPrivacySpace(); + } + Toast.makeText(getActivity(), R.string.privacy_space_exit, Toast.LENGTH_SHORT).show(); + } + + private void restoreNormalTheme(View rootView) { + if (rootView == null) return; + int bgColor = ContextCompat.getColor(requireContext(), R.color.background_light); + rootView.setBackgroundColor(bgColor); + View listView = rootView.findViewById(R.id.notes_list); + if (listView != null) { + listView.setBackgroundColor(bgColor); + } + } + + private void applyPrivacySpaceTheme(View rootView) { + if (rootView == null) return; + int darkColor = ContextCompat.getColor(requireContext(), R.color.privacy_space_background); + rootView.setBackgroundColor(darkColor); + View listView = rootView.findViewById(R.id.notes_list); + if (listView != null) { + listView.setBackgroundColor(darkColor); + } + } + + /** + * 返回上一级便签列表(与 onBackPressed 中子文件夹/通话记录逻辑一致)。 + * 仅在子文件夹或通话记录文件夹内显示返回按钮时调用。 + */ + private void performBackToRoot() { + switch (mState) { + case SUB_FOLDER: + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mState = ListEditState.NOTE_LIST; + if (mFabBack != null) mFabBack.setVisibility(View.GONE); + startAsyncNotesListQuery(); + if (mToolbar != null) mToolbar.setTitle(R.string.notes_tab_title); + if (mTitleBar != null) mTitleBar.setVisibility(View.GONE); + break; + case CALL_RECORD_FOLDER: + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mState = ListEditState.NOTE_LIST; + if (mFabBack != null) mFabBack.setVisibility(View.GONE); + if (mFab != null) mFab.setVisibility(View.VISIBLE); + startAsyncNotesListQuery(); + if (mToolbar != null) mToolbar.setTitle(R.string.notes_tab_title); + if (mTitleBar != null) mTitleBar.setVisibility(View.GONE); + break; + default: + break; + } + } + + public boolean onBackPressed() { + switch (mState) { + case PRIVACY_SPACE: + return false; + case SUB_FOLDER: + performBackToRoot(); + return true; + case CALL_RECORD_FOLDER: + performBackToRoot(); + return true; + case NOTE_LIST: + default: + return false; + } + } + + /** 侧边栏「新建便签」入口 */ + public void createNewNoteFromDrawer() { + createNewNote(); + } + + /** 侧边栏「新建文件夹」入口 */ + public void showCreateOrModifyFolderDialogFromDrawer(boolean create) { + mFocusNoteDataItem = null; + showCreateOrModifyFolderDialog(create); + } + + /** 侧边栏「导出」入口 */ + public void exportNoteToTextFromDrawer() { + exportNoteToText(); + } + + /** 侧边栏「同步」入口 */ + public void handleSyncFromDrawer() { + if (isSyncMode()) { + if (GTaskSyncService.isSyncing()) { + GTaskSyncService.cancelSync(getActivity()); + } else { + GTaskSyncService.startSync(getActivity()); + } + } else { + startPreferenceActivity(); + } + } + + private void updateWidget(int appWidgetId, int appWidgetType) { + Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); + if (appWidgetType == Notes.TYPE_WIDGET_2X) { + intent.setClass(getActivity(), NoteWidgetProvider_2x.class); + } else if (appWidgetType == Notes.TYPE_WIDGET_4X) { + intent.setClass(getActivity(), NoteWidgetProvider_4x.class); + } else { + Log.e(TAG, "Unspported widget type"); + return; + } + + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { + appWidgetId + }); + + getActivity().sendBroadcast(intent); + getActivity().setResult(Activity.RESULT_OK, intent); + } + + 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); + if (mFocusNoteDataItem.isPinned()) { + menu.add(0, MENU_FOLDER_UNPIN, 0, R.string.menu_unpin); + } else { + menu.add(0, MENU_FOLDER_PIN, 0, R.string.menu_pin_to_top); + } + 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); + } + } + }; + + @Override + public boolean onContextItemSelected(@NonNull 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(getActivity()); + 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; + case MENU_FOLDER_PIN: + if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) { + HashSet ids = new HashSet(); + ids.add(mFocusNoteDataItem.getId()); + if (DataUtils.batchSetPinned(mContentResolver, ids, true)) { + Toast.makeText(getActivity(), getString(R.string.toast_pinned), Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + } + } + break; + case MENU_FOLDER_UNPIN: + if (mFocusNoteDataItem != null && mFocusNoteDataItem.getId() > 0) { + HashSet ids = new HashSet(); + ids.add(mFocusNoteDataItem.getId()); + if (DataUtils.batchSetPinned(mContentResolver, ids, false)) { + Toast.makeText(getActivity(), getString(R.string.toast_unpinned), Toast.LENGTH_SHORT).show(); + startAsyncNotesListQuery(); + } + } + break; + default: + break; + } + + return true; + } + + private void exportNoteToText() { + final BackupUtils backup = BackupUtils.getInstance(getActivity()); + new AsyncTask() { + + @Override + protected Integer doInBackground(Void... unused) { + return backup.exportToText(); + } + + @Override + protected void onPostExecute(Integer result) { + if (result == BackupUtils.STATE_SD_CARD_UNMOUONTED) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getActivity() + .getString(R.string.failed_sdcard_export)); + builder.setMessage(getActivity() + .getString(R.string.error_sdcard_unmounted)); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } else if (result == BackupUtils.STATE_SUCCESS) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getActivity() + .getString(R.string.success_sdcard_export)); + builder.setMessage(getActivity().getString( + R.string.format_exported_file_location, backup + .getExportedTextFileName(), backup.getExportedTextFileDir())); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } else if (result == BackupUtils.STATE_SYSTEM_ERROR) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getActivity() + .getString(R.string.failed_sdcard_export)); + builder.setMessage(getActivity() + .getString(R.string.error_sdcard_export)); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } + } + + }.execute(); + } + + private boolean isSyncMode() { + return NotesPreferenceActivity.getSyncAccountName(getActivity()).trim().length() > 0; + } + + private void startPreferenceActivity() { + Intent intent = new Intent(getActivity(), NotesPreferenceActivity.class); + startActivity(intent); + } + + private class OnListItemClickListener implements AdapterView.OnItemClickListener { + + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (view instanceof NotesListItem) { + NoteItemData item = ((NotesListItem) view).getItemData(); + if (mNotesListAdapter.isInChoiceMode()) { + if (item.getType() == Notes.TYPE_NOTE) { + position = position - mNotesListView.getHeaderViewsCount(); + mModeCallBack.onItemCheckedStateChanged(null, position, id, + !mNotesListAdapter.isSelectedItem(position)); + } + return; + } + + switch (mState) { + case NOTE_LIST: + if (item.getType() == Notes.TYPE_FOLDER + || item.getType() == Notes.TYPE_SYSTEM) { + openFolder(item); + } else if (item.getType() == Notes.TYPE_NOTE) { + openNode(item); + } else { + Log.e(TAG, "Wrong note type in NOTE_LIST"); + } + break; + case SUB_FOLDER: + case CALL_RECORD_FOLDER: + if (item.getType() == Notes.TYPE_NOTE) { + openNode(item); + } else { + Log.e(TAG, "Wrong note type in SUB_FOLDER"); + } + break; + case PRIVACY_SPACE: + if (item.getType() == Notes.TYPE_NOTE) { + openNode(item); + } + break; + default: + break; + } + } + } + + } + + 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); + } else { + Log.e(TAG, "startActionMode fails"); + } + } else if (mFocusNoteDataItem.getType() == Notes.TYPE_FOLDER) { + mNotesListView.setOnCreateContextMenuListener(mFolderOnCreateContextMenuListener); + } + } + return false; + } + 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"); + } + + + +} \ No newline at end of file diff --git a/src/src/net/micode/notes/ui/NotesListItem.java b/src/app/src/main/java/net/micode/notes/ui/NotesListItem.java similarity index 88% rename from src/src/net/micode/notes/ui/NotesListItem.java rename to src/app/src/main/java/net/micode/notes/ui/NotesListItem.java index 84b1571..8cc504b 100644 --- a/src/src/net/micode/notes/ui/NotesListItem.java +++ b/src/app/src/main/java/net/micode/notes/ui/NotesListItem.java @@ -8,6 +8,8 @@ package net.micode.notes.ui; import android.content.Context; import android.text.format.DateUtils; import android.view.View; + +import androidx.core.content.ContextCompat; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.LinearLayout; @@ -17,6 +19,7 @@ import net.micode.notes.R; import net.micode.notes.data.Notes; import net.micode.notes.tool.DataUtils; import net.micode.notes.tool.ResourceParser.NoteItemBgResources; +import net.micode.notes.tool.ResourceParser; /** * 便签列表项视图 (UI Component) @@ -34,6 +37,7 @@ public class NotesListItem extends LinearLayout { private TextView mCallName; // 联系人名称 (仅通话记录便签显示) private NoteItemData mItemData; private CheckBox mCheckBox; // 批量选择模式下的复选框 + private View mPinnedBar; // 置顶时左侧加深色条 public NotesListItem(Context context) { super(context); @@ -44,6 +48,7 @@ public class NotesListItem extends LinearLayout { mTime = (TextView) findViewById(R.id.tv_time); mCallName = (TextView) findViewById(R.id.tv_name); mCheckBox = (CheckBox) findViewById(android.R.id.checkbox); + mPinnedBar = findViewById(R.id.note_pinned_bar); } /** @@ -116,6 +121,17 @@ public class NotesListItem extends LinearLayout { // 设置相对时间显示 (例如 "刚刚", "昨天") mTime.setText(DateUtils.getRelativeTimeSpanString(data.getModifiedDate())); + // 置顶时显示左侧加深色条 + if (data.isPinned() && mPinnedBar != null) { + mPinnedBar.setVisibility(View.VISIBLE); + int colorResId = (data.getType() == Notes.TYPE_FOLDER || data.getType() == Notes.TYPE_SYSTEM) + ? ResourceParser.NoteItemBgResources.getPinnedBarColorRes(ResourceParser.YELLOW) + : ResourceParser.NoteItemBgResources.getPinnedBarColorRes(data.getBgColorId()); + mPinnedBar.setBackgroundColor(ContextCompat.getColor(context, colorResId)); + } else if (mPinnedBar != null) { + mPinnedBar.setVisibility(View.GONE); + } + // 设置动态背景 setBackground(data); } diff --git a/src/src/net/micode/notes/ui/NotesPreferenceActivity.java b/src/app/src/main/java/net/micode/notes/ui/NotesPreferenceActivity.java similarity index 100% rename from src/src/net/micode/notes/ui/NotesPreferenceActivity.java rename to src/app/src/main/java/net/micode/notes/ui/NotesPreferenceActivity.java diff --git a/src/app/src/main/java/net/micode/notes/ui/SplashActivity.java b/src/app/src/main/java/net/micode/notes/ui/SplashActivity.java new file mode 100644 index 0000000..8c35f3a --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/SplashActivity.java @@ -0,0 +1,168 @@ +package net.micode.notes.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.LinearInterpolator; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import net.micode.notes.R; + +/** + * 开场动画页 + * 简约高级感:深色背景 + Logo 缩放淡入 + 标题淡入 + 细线点缀,短暂停留后淡出并进入主界面。 + * 已做防闪退与 ANR:空指针检查、防重复跳转、安全超时、使用 post 替代 GlobalLayoutListener。 + */ +public class SplashActivity extends AppCompatActivity { + + private static final int PHASE_ENTER_MS = 900; + private static final int HOLD_MS = 1100; + private static final int PHASE_EXIT_MS = 450; + /** 最大等待时间,超时则直接进入主界面,防止卡死 */ + private static final int SAFETY_TIMEOUT_MS = 4000; + + private View mContentRoot; + private ImageView mLogo; + private TextView mTitle; + private View mAccent; + + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private boolean mEnterAnimationStarted; + private boolean mGoMainCalled; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_splash); + + mContentRoot = findViewById(R.id.splash_content); + mLogo = findViewById(R.id.splash_logo); + mTitle = findViewById(R.id.splash_title); + mAccent = findViewById(R.id.splash_accent); + + if (mContentRoot == null || mLogo == null || mTitle == null || mAccent == null) { + goToMain(); + return; + } + + if (mLogo != null) { + mLogo.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN); + } + + // 使用 post 在下一帧启动动画,避免与 GlobalLayoutListener 的 API 差异导致不触发 + mContentRoot.post(this::startEnterAnimation); + + // 安全超时:若动画或跳转异常,最多等待 SAFETY_TIMEOUT_MS 后强制进入主界面 + mHandler.postDelayed(this::goToMain, SAFETY_TIMEOUT_MS); + } + + private void startEnterAnimation() { + if (mEnterAnimationStarted || isFinishing()) return; + if (mLogo == null || mTitle == null || mAccent == null) { + goToMain(); + return; + } + mEnterAnimationStarted = true; + + // 1. Logo:缩放 + 淡入 + mLogo.setScaleX(0.82f); + mLogo.setScaleY(0.82f); + mLogo.setAlpha(0f); + + ObjectAnimator scaleX = ObjectAnimator.ofFloat(mLogo, View.SCALE_X, 0.82f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(mLogo, View.SCALE_Y, 0.82f, 1f); + ObjectAnimator alphaLogo = ObjectAnimator.ofFloat(mLogo, View.ALPHA, 0f, 1f); + scaleX.setDuration(520); + scaleY.setDuration(520); + alphaLogo.setDuration(420); + AccelerateDecelerateInterpolator interpolator = new AccelerateDecelerateInterpolator(); + scaleX.setInterpolator(interpolator); + scaleY.setInterpolator(interpolator); + alphaLogo.setInterpolator(interpolator); + scaleX.start(); + scaleY.start(); + alphaLogo.start(); + + // 2. 标题:延迟淡入 + 上移 + mTitle.setAlpha(0f); + mTitle.setTranslationY(12f); + mTitle.animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(280) + .setDuration(400) + .setInterpolator(interpolator) + .start(); + + // 3. 底部细线:淡入 + mAccent.setAlpha(0f); + mAccent.animate() + .alpha(1f) + .setStartDelay(460) + .setDuration(320) + .setInterpolator(new LinearInterpolator()) + .start(); + + // 4. 停留后退场并跳转 + mHandler.postDelayed(this::startExitAnimation, PHASE_ENTER_MS + HOLD_MS); + } + + private void startExitAnimation() { + if (isFinishing() || mGoMainCalled) return; + if (mContentRoot == null) { + goToMain(); + return; + } + mContentRoot.animate() + .alpha(0f) + .setDuration(PHASE_EXIT_MS) + .setInterpolator(new AccelerateDecelerateInterpolator()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (mContentRoot != null) { + mContentRoot.animate().setListener(null); + } + goToMain(); + } + }) + .start(); + } + + private void goToMain() { + if (mGoMainCalled) return; + mGoMainCalled = true; + mHandler.removeCallbacksAndMessages(null); + + if (isFinishing()) return; + + try { + startActivity(new Intent(this, MainActivity.class)); + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); + } catch (Exception e) { + overridePendingTransition(0, 0); + } + finish(); + } + + @Override + protected void onDestroy() { + mHandler.removeCallbacksAndMessages(null); + mContentRoot = null; + mLogo = null; + mTitle = null; + mAccent = null; + super.onDestroy(); + } +} diff --git a/src/app/src/main/java/net/micode/notes/ui/TodoEditActivity.java b/src/app/src/main/java/net/micode/notes/ui/TodoEditActivity.java new file mode 100644 index 0000000..4757b93 --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/TodoEditActivity.java @@ -0,0 +1,215 @@ +package net.micode.notes.ui; + +/** + * 待办编辑/新建页面 + *

+ * 支持新增或编辑待办事项,可设置时间提醒(DatePicker + TimePicker)或地点提醒(Geofence)。 + * 保存时调用 TodoReminderManager 注册 AlarmManager/Geofencing 提醒。 + *

+ * + * @see TodoItem + * @see TodoReminderManager + * @see TodoDao + */ +import android.Manifest; +import android.app.DatePickerDialog; +import android.app.TimePickerDialog; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import android.widget.EditText; + +import net.micode.notes.R; +import net.micode.notes.data.TodoDao; +import net.micode.notes.todo.TodoItem; +import net.micode.notes.todo.TodoReminderManager; + +import java.util.Calendar; + +public class TodoEditActivity extends AppCompatActivity { + private static final String EXTRA_TODO_ID = "todo_id"; + + private EditText mEtContent; + private Button mBtnAddReminder; + private TextView mTvReminderInfo; + private Button mBtnSave; + + private TodoDao mTodoDao; + private TodoItem mItem; + private boolean mIsEdit; + + private final ActivityResultLauncher mLocationPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> { + if (Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))) { + applyMockLocation(); + } else { + Toast.makeText(this, "需要位置权限才能设置地点提醒", Toast.LENGTH_SHORT).show(); + } + }); + + public static Intent newIntent(Context context) { + return new Intent(context, TodoEditActivity.class); + } + + public static Intent newIntent(Context context, long todoId) { + Intent i = new Intent(context, TodoEditActivity.class); + i.putExtra(EXTRA_TODO_ID, todoId); + return i; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_todo_edit); + if (getSupportActionBar() != null) getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + mTodoDao = new TodoDao(this); + Intent intent = getIntent(); + long todoId = (intent != null) ? intent.getLongExtra(EXTRA_TODO_ID, 0) : 0; + mIsEdit = todoId > 0; + mItem = mIsEdit ? mTodoDao.getById(todoId) : new TodoItem(); + if (mItem == null) mItem = new TodoItem(); + + mEtContent = findViewById(R.id.et_todo_content); + mBtnAddReminder = findViewById(R.id.btn_add_reminder); + mTvReminderInfo = findViewById(R.id.tv_reminder_info); + mBtnSave = findViewById(R.id.btn_save); + + if (mIsEdit) mEtContent.setText(mItem.getContent()); + updateReminderInfo(); + + mBtnAddReminder.setOnClickListener(v -> showReminderOptions()); + mBtnSave.setOnClickListener(v -> save()); + } + + @Override + public boolean onSupportNavigateUp() { + finish(); + return true; + } + + private void showReminderOptions() { + String[] options = new String[]{ + getString(R.string.todo_reminder_time), + getString(R.string.todo_reminder_location), + getString(R.string.todo_reminder_none) + }; + new android.app.AlertDialog.Builder(this) + .setTitle(R.string.todo_add_reminder) + .setItems(options, (dialog, which) -> { + if (which == 0) pickTime(); + else if (which == 1) pickLocation(); + else clearReminder(); + }) + .show(); + } + + private void clearReminder() { + mItem.setReminderType(TodoItem.REMINDER_NONE); + mItem.setReminderTimestamp(0); + mItem.setLatitude(0); + mItem.setLongitude(0); + mItem.setLocationName(""); + updateReminderInfo(); + } + + private void pickTime() { + Calendar cal = Calendar.getInstance(); + if (mItem.getReminderTimestamp() > 0) cal.setTimeInMillis(mItem.getReminderTimestamp()); + + DatePickerDialog dateDialog = new DatePickerDialog(this, + (v, year, month, dayOfMonth) -> { + cal.set(year, month, dayOfMonth); + TimePickerDialog timeDialog = new TimePickerDialog(this, + (tv, hour, minute) -> { + cal.set(Calendar.HOUR_OF_DAY, hour); + cal.set(Calendar.MINUTE, minute); + cal.set(Calendar.SECOND, 0); + mItem.setReminderType(TodoItem.REMINDER_TIME); + mItem.setReminderTimestamp(cal.getTimeInMillis()); + mItem.setLatitude(0); + mItem.setLongitude(0); + mItem.setLocationName(""); + updateReminderInfo(); + }, + cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), + DateFormat.is24HourFormat(this)); + timeDialog.show(); + }, + cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)); + dateDialog.show(); + } + + private void pickLocation() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + mLocationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + }); + } else { + mLocationPermissionLauncher.launch(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}); + } + } else { + applyMockLocation(); + } + } + + private void applyMockLocation() { + // 模拟选点:返回虚拟坐标和地点名称(无真实地图 SDK) + mItem.setReminderType(TodoItem.REMINDER_LOCATION); + mItem.setReminderTimestamp(0); + mItem.setLatitude(39.9042); + mItem.setLongitude(116.4074); + mItem.setLocationName("附近超市"); + updateReminderInfo(); + Toast.makeText(this, "已设置地点提醒:附近超市", Toast.LENGTH_SHORT).show(); + } + + private void updateReminderInfo() { + if (mItem.getReminderType() == TodoItem.REMINDER_TIME && mItem.getReminderTimestamp() > 0) { + mTvReminderInfo.setVisibility(android.view.View.VISIBLE); + mTvReminderInfo.setText("时间提醒: " + DateFormat.format("yyyy-MM-dd HH:mm", mItem.getReminderTimestamp())); + } else if (mItem.getReminderType() == TodoItem.REMINDER_LOCATION) { + mTvReminderInfo.setVisibility(android.view.View.VISIBLE); + mTvReminderInfo.setText("地点提醒: " + mItem.getLocationName()); + } else { + mTvReminderInfo.setVisibility(android.view.View.GONE); + } + } + + private void save() { + String content = mEtContent.getText() != null ? mEtContent.getText().toString().trim() : ""; + if (TextUtils.isEmpty(content)) { + Toast.makeText(this, "请输入待办内容", Toast.LENGTH_SHORT).show(); + return; + } + mItem.setContent(content); + if (mItem.getCreatedTime() == 0) mItem.setCreatedTime(System.currentTimeMillis()); + + if (mIsEdit && mItem.getId() > 0) { + mTodoDao.update(mItem); + } else { + long id = mTodoDao.insert(mItem); + mItem.setId(id); + } + + TodoReminderManager.scheduleReminders(this, mItem); + setResult(RESULT_OK); + finish(); + } +} diff --git a/src/app/src/main/java/net/micode/notes/ui/TodoFragment.java b/src/app/src/main/java/net/micode/notes/ui/TodoFragment.java new file mode 100644 index 0000000..7e2dc0f --- /dev/null +++ b/src/app/src/main/java/net/micode/notes/ui/TodoFragment.java @@ -0,0 +1,238 @@ +package net.micode.notes.ui; + +/** + * 待办事项列表 Fragment + *

+ * 主界面底部导航「待办」页,展示未完成与已完成待办,支持增删改查、时间/地点提醒。 + * 提供游戏化反馈:全部完成后触发震动、提示音、撒花动画。 + * 长按进入多选模式,可批量删除。 + *

+ * + * @see TodoAdapter + * @see TodoEditActivity + * @see TodoReminderManager + */ +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import net.micode.notes.R; +import net.micode.notes.data.TodoDao; +import net.micode.notes.todo.TodoReminderManager; +import net.micode.notes.todo.ConfettiView; +import net.micode.notes.todo.TodoAdapter; +import net.micode.notes.todo.TodoItem; + +import java.util.ArrayList; +import java.util.List; + +public class TodoFragment extends Fragment { + private static final int REQUEST_EDIT = 2001; + + private RecyclerView mRecyclerView; + private TodoAdapter mAdapter; + private FloatingActionButton mFab; + private MaterialToolbar mToolbar; + private ConfettiView mConfettiView; + + private TodoDao mTodoDao; + private List mUndoneList = new ArrayList<>(); + private List mDoneList = new ArrayList<>(); + + private ActionModeCallback mActionModeCallback; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_todo, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mTodoDao = new TodoDao(requireContext()); + mRecyclerView = view.findViewById(R.id.recycler_todo); + mFab = view.findViewById(R.id.fab_add_todo); + mToolbar = view.findViewById(R.id.toolbar_todo); + mConfettiView = view.findViewById(R.id.confetti_view); + + mRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + mAdapter = new TodoAdapter(); + mRecyclerView.setAdapter(mAdapter); + + mAdapter.setOnItemClickListener(new TodoAdapter.OnItemClickListener() { + @Override + public void onItemClick(TodoItem item, int position) { + if (!mAdapter.isChoiceMode()) { + Intent intent = TodoEditActivity.newIntent(requireContext(), item.getId()); + startActivityForResult(intent, REQUEST_EDIT); + } + } + + @Override + public void onCheckChanged(TodoItem item, boolean checked) { + if (mAdapter.isChoiceMode()) return; + if (item == null || item.getId() <= 0) return; + item.setDone(checked); + mTodoDao.update(item); + refreshData(); + if (checked) { + checkAndTriggerGamification(); + } + } + }); + + mAdapter.setOnItemLongClickListener((item, position) -> { + if (!mAdapter.isChoiceMode() && mActionModeCallback == null) { + mActionModeCallback = new ActionModeCallback(); + requireActivity().startActionMode(mActionModeCallback); + mAdapter.setChoiceMode(true); + mAdapter.setChecked(position, true); + mFab.setVisibility(View.GONE); + } + }); + + mFab.setOnClickListener(v -> { + if (getActivity() == null || !isAdded()) return; + Intent intent = new Intent(getActivity(), TodoEditActivity.class); + startActivityForResult(intent, REQUEST_EDIT); + }); + + if (mToolbar != null && getActivity() instanceof MainActivity) { + mToolbar.setNavigationOnClickListener(v -> ((MainActivity) getActivity()).openDrawer()); + } + + mConfettiView.setOnClickListener(v -> mConfettiView.setVisibility(View.GONE)); + + refreshData(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_EDIT && resultCode == Activity.RESULT_OK) { + refreshData(); + } + } + + private void refreshData() { + mUndoneList = mTodoDao.getUndone(); + mDoneList = mTodoDao.getDone(); + mAdapter.setData(mUndoneList, mDoneList); + } + + private void checkAndTriggerGamification() { + int undoneCount = mTodoDao.getUndoneCount(); + if (undoneCount == 0) { + triggerGamification(); + } + } + + private void triggerGamification() { + vibrate(); + playSuccessSound(); + showConfetti(); + } + + private void vibrate() { + try { + if (getContext() == null) return; + Vibrator v = (Vibrator) getContext().getSystemService(android.content.Context.VIBRATOR_SERVICE); + if (v != null && v.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + v.vibrate(VibrationEffect.createOneShot(150, VibrationEffect.DEFAULT_AMPLITUDE)); + } else { + v.vibrate(150); + } + } + } catch (Exception e) { /* ignore */ } + } + + /** 撒花时的短促有力提示音(系统 ToneGenerator,无需 raw 资源) */ + private static final int SUCCESS_TONE_DURATION_MS = 120; + + private void playSuccessSound() { + try { + Context ctx = getContext(); + if (ctx == null) return; + ToneGenerator tone = new ToneGenerator(AudioManager.STREAM_NOTIFICATION, 85); + tone.startTone(ToneGenerator.TONE_PROP_ACK, SUCCESS_TONE_DURATION_MS); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + try { + tone.release(); + } catch (Exception ignored) { } + }, SUCCESS_TONE_DURATION_MS + 80); + } catch (Exception e) { /* 无扬声器或权限时静默 */ } + } + + private void showConfetti() { + if (mConfettiView != null) { + mConfettiView.post(() -> { + mConfettiView.start(); + }); + } + } + + private class ActionModeCallback implements android.view.ActionMode.Callback { + @Override + public boolean onCreateActionMode(android.view.ActionMode mode, android.view.Menu menu) { + requireActivity().getMenuInflater().inflate(R.menu.todo_list_options, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(android.view.ActionMode mode, android.view.Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(android.view.ActionMode mode, android.view.MenuItem item) { + if (item.getItemId() == R.id.delete) { + List ids = mAdapter.getSelectedIds(); + if (ids.isEmpty()) { + Toast.makeText(requireContext(), R.string.menu_select_none, Toast.LENGTH_SHORT).show(); + return true; + } + new android.app.AlertDialog.Builder(requireContext()) + .setTitle(R.string.alert_title_delete) + .setMessage(getString(R.string.todo_delete_confirm, ids.size())) + .setPositiveButton(android.R.string.ok, (d, w) -> { + for (long id : ids) TodoReminderManager.cancelAllForTodo(requireContext(), id); + mTodoDao.deleteByIds(ids); + refreshData(); + mode.finish(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + return true; + } + + @Override + public void onDestroyActionMode(android.view.ActionMode mode) { + mAdapter.setChoiceMode(false); + mFab.setVisibility(View.VISIBLE); + mActionModeCallback = null; + } + } +} diff --git a/src/src/net/micode/notes/widget/NoteWidgetProvider.java b/src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider.java similarity index 100% rename from src/src/net/micode/notes/widget/NoteWidgetProvider.java rename to src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider.java diff --git a/src/src/net/micode/notes/widget/NoteWidgetProvider_2x.java b/src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider_2x.java similarity index 100% rename from src/src/net/micode/notes/widget/NoteWidgetProvider_2x.java rename to src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider_2x.java diff --git a/src/src/net/micode/notes/widget/NoteWidgetProvider_4x.java b/src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider_4x.java similarity index 100% rename from src/src/net/micode/notes/widget/NoteWidgetProvider_4x.java rename to src/app/src/main/java/net/micode/notes/widget/NoteWidgetProvider_4x.java diff --git a/src/app/src/main/res/color/bottom_nav_color.xml b/src/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 0000000..aeb73c4 --- /dev/null +++ b/src/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/res/color/primary_text_dark.xml b/src/app/src/main/res/color/primary_text_dark.xml similarity index 100% rename from src/res/color/primary_text_dark.xml rename to src/app/src/main/res/color/primary_text_dark.xml diff --git a/src/res/color/secondary_text_dark.xml b/src/app/src/main/res/color/secondary_text_dark.xml similarity index 100% rename from src/res/color/secondary_text_dark.xml rename to src/app/src/main/res/color/secondary_text_dark.xml diff --git a/src/res/drawable-hdpi/bg_btn_set_color.png b/src/app/src/main/res/drawable-hdpi/bg_btn_set_color.png similarity index 100% rename from src/res/drawable-hdpi/bg_btn_set_color.png rename to src/app/src/main/res/drawable-hdpi/bg_btn_set_color.png diff --git a/src/res/drawable-hdpi/bg_color_btn_mask.png b/src/app/src/main/res/drawable-hdpi/bg_color_btn_mask.png similarity index 100% rename from src/res/drawable-hdpi/bg_color_btn_mask.png rename to src/app/src/main/res/drawable-hdpi/bg_color_btn_mask.png diff --git a/src/res/drawable-hdpi/call_record.png b/src/app/src/main/res/drawable-hdpi/call_record.png similarity index 100% rename from src/res/drawable-hdpi/call_record.png rename to src/app/src/main/res/drawable-hdpi/call_record.png diff --git a/src/res/drawable-hdpi/clock.png b/src/app/src/main/res/drawable-hdpi/clock.png similarity index 100% rename from src/res/drawable-hdpi/clock.png rename to src/app/src/main/res/drawable-hdpi/clock.png diff --git a/src/res/drawable-hdpi/delete.png b/src/app/src/main/res/drawable-hdpi/delete.png similarity index 100% rename from src/res/drawable-hdpi/delete.png rename to src/app/src/main/res/drawable-hdpi/delete.png diff --git a/src/res/drawable-hdpi/dropdown_icon.9.png b/src/app/src/main/res/drawable-hdpi/dropdown_icon.9.png similarity index 100% rename from src/res/drawable-hdpi/dropdown_icon.9.png rename to src/app/src/main/res/drawable-hdpi/dropdown_icon.9.png diff --git a/src/res/drawable-hdpi/edit_blue.9.png b/src/app/src/main/res/drawable-hdpi/edit_blue.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_blue.9.png rename to src/app/src/main/res/drawable-hdpi/edit_blue.9.png diff --git a/src/res/drawable-hdpi/edit_green.9.png b/src/app/src/main/res/drawable-hdpi/edit_green.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_green.9.png rename to src/app/src/main/res/drawable-hdpi/edit_green.9.png diff --git a/src/res/drawable-hdpi/edit_red.9.png b/src/app/src/main/res/drawable-hdpi/edit_red.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_red.9.png rename to src/app/src/main/res/drawable-hdpi/edit_red.9.png diff --git a/src/res/drawable-hdpi/edit_title_blue.9.png b/src/app/src/main/res/drawable-hdpi/edit_title_blue.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_title_blue.9.png rename to src/app/src/main/res/drawable-hdpi/edit_title_blue.9.png diff --git a/src/res/drawable-hdpi/edit_title_green.9.png b/src/app/src/main/res/drawable-hdpi/edit_title_green.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_title_green.9.png rename to src/app/src/main/res/drawable-hdpi/edit_title_green.9.png diff --git a/src/res/drawable-hdpi/edit_title_red.9.png b/src/app/src/main/res/drawable-hdpi/edit_title_red.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_title_red.9.png rename to src/app/src/main/res/drawable-hdpi/edit_title_red.9.png diff --git a/src/res/drawable-hdpi/edit_title_white.9.png b/src/app/src/main/res/drawable-hdpi/edit_title_white.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_title_white.9.png rename to src/app/src/main/res/drawable-hdpi/edit_title_white.9.png diff --git a/src/res/drawable-hdpi/edit_title_yellow.9.png b/src/app/src/main/res/drawable-hdpi/edit_title_yellow.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_title_yellow.9.png rename to src/app/src/main/res/drawable-hdpi/edit_title_yellow.9.png diff --git a/src/res/drawable-hdpi/edit_white.9.png b/src/app/src/main/res/drawable-hdpi/edit_white.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_white.9.png rename to src/app/src/main/res/drawable-hdpi/edit_white.9.png diff --git a/src/res/drawable-hdpi/edit_yellow.9.png b/src/app/src/main/res/drawable-hdpi/edit_yellow.9.png similarity index 100% rename from src/res/drawable-hdpi/edit_yellow.9.png rename to src/app/src/main/res/drawable-hdpi/edit_yellow.9.png diff --git a/src/res/drawable-hdpi/font_large.png b/src/app/src/main/res/drawable-hdpi/font_large.png similarity index 100% rename from src/res/drawable-hdpi/font_large.png rename to src/app/src/main/res/drawable-hdpi/font_large.png diff --git a/src/res/drawable-hdpi/font_normal.png b/src/app/src/main/res/drawable-hdpi/font_normal.png similarity index 100% rename from src/res/drawable-hdpi/font_normal.png rename to src/app/src/main/res/drawable-hdpi/font_normal.png diff --git a/src/res/drawable-hdpi/font_size_selector_bg.9.png b/src/app/src/main/res/drawable-hdpi/font_size_selector_bg.9.png similarity index 100% rename from src/res/drawable-hdpi/font_size_selector_bg.9.png rename to src/app/src/main/res/drawable-hdpi/font_size_selector_bg.9.png diff --git a/src/res/drawable-hdpi/font_small.png b/src/app/src/main/res/drawable-hdpi/font_small.png similarity index 100% rename from src/res/drawable-hdpi/font_small.png rename to src/app/src/main/res/drawable-hdpi/font_small.png diff --git a/src/res/drawable-hdpi/font_super.png b/src/app/src/main/res/drawable-hdpi/font_super.png similarity index 100% rename from src/res/drawable-hdpi/font_super.png rename to src/app/src/main/res/drawable-hdpi/font_super.png diff --git a/src/res/drawable-hdpi/icon_app.png b/src/app/src/main/res/drawable-hdpi/icon_app.png similarity index 100% rename from src/res/drawable-hdpi/icon_app.png rename to src/app/src/main/res/drawable-hdpi/icon_app.png diff --git a/src/res/drawable-hdpi/list_background.png b/src/app/src/main/res/drawable-hdpi/list_background.png similarity index 100% rename from src/res/drawable-hdpi/list_background.png rename to src/app/src/main/res/drawable-hdpi/list_background.png diff --git a/src/res/drawable-hdpi/list_blue_down.9.png b/src/app/src/main/res/drawable-hdpi/list_blue_down.9.png similarity index 100% rename from src/res/drawable-hdpi/list_blue_down.9.png rename to src/app/src/main/res/drawable-hdpi/list_blue_down.9.png diff --git a/src/res/drawable-hdpi/list_blue_middle.9.png b/src/app/src/main/res/drawable-hdpi/list_blue_middle.9.png similarity index 100% rename from src/res/drawable-hdpi/list_blue_middle.9.png rename to src/app/src/main/res/drawable-hdpi/list_blue_middle.9.png diff --git a/src/res/drawable-hdpi/list_blue_single.9.png b/src/app/src/main/res/drawable-hdpi/list_blue_single.9.png similarity index 100% rename from src/res/drawable-hdpi/list_blue_single.9.png rename to src/app/src/main/res/drawable-hdpi/list_blue_single.9.png diff --git a/src/res/drawable-hdpi/list_blue_up.9.png b/src/app/src/main/res/drawable-hdpi/list_blue_up.9.png similarity index 100% rename from src/res/drawable-hdpi/list_blue_up.9.png rename to src/app/src/main/res/drawable-hdpi/list_blue_up.9.png diff --git a/src/res/drawable-hdpi/list_folder.9.png b/src/app/src/main/res/drawable-hdpi/list_folder.9.png similarity index 100% rename from src/res/drawable-hdpi/list_folder.9.png rename to src/app/src/main/res/drawable-hdpi/list_folder.9.png diff --git a/src/res/drawable-hdpi/list_footer_bg.9.png b/src/app/src/main/res/drawable-hdpi/list_footer_bg.9.png similarity index 100% rename from src/res/drawable-hdpi/list_footer_bg.9.png rename to src/app/src/main/res/drawable-hdpi/list_footer_bg.9.png diff --git a/src/res/drawable-hdpi/list_green_down.9.png b/src/app/src/main/res/drawable-hdpi/list_green_down.9.png similarity index 100% rename from src/res/drawable-hdpi/list_green_down.9.png rename to src/app/src/main/res/drawable-hdpi/list_green_down.9.png diff --git a/src/res/drawable-hdpi/list_green_middle.9.png b/src/app/src/main/res/drawable-hdpi/list_green_middle.9.png similarity index 100% rename from src/res/drawable-hdpi/list_green_middle.9.png rename to src/app/src/main/res/drawable-hdpi/list_green_middle.9.png diff --git a/src/res/drawable-hdpi/list_green_single.9.png b/src/app/src/main/res/drawable-hdpi/list_green_single.9.png similarity index 100% rename from src/res/drawable-hdpi/list_green_single.9.png rename to src/app/src/main/res/drawable-hdpi/list_green_single.9.png diff --git a/src/res/drawable-hdpi/list_green_up.9.png b/src/app/src/main/res/drawable-hdpi/list_green_up.9.png similarity index 100% rename from src/res/drawable-hdpi/list_green_up.9.png rename to src/app/src/main/res/drawable-hdpi/list_green_up.9.png diff --git a/src/res/drawable-hdpi/list_red_down.9.png b/src/app/src/main/res/drawable-hdpi/list_red_down.9.png similarity index 100% rename from src/res/drawable-hdpi/list_red_down.9.png rename to src/app/src/main/res/drawable-hdpi/list_red_down.9.png diff --git a/src/res/drawable-hdpi/list_red_middle.9.png b/src/app/src/main/res/drawable-hdpi/list_red_middle.9.png similarity index 100% rename from src/res/drawable-hdpi/list_red_middle.9.png rename to src/app/src/main/res/drawable-hdpi/list_red_middle.9.png diff --git a/src/res/drawable-hdpi/list_red_single.9.png b/src/app/src/main/res/drawable-hdpi/list_red_single.9.png similarity index 100% rename from src/res/drawable-hdpi/list_red_single.9.png rename to src/app/src/main/res/drawable-hdpi/list_red_single.9.png diff --git a/src/res/drawable-hdpi/list_red_up.9.png b/src/app/src/main/res/drawable-hdpi/list_red_up.9.png similarity index 100% rename from src/res/drawable-hdpi/list_red_up.9.png rename to src/app/src/main/res/drawable-hdpi/list_red_up.9.png diff --git a/src/res/drawable-hdpi/list_white_down.9.png b/src/app/src/main/res/drawable-hdpi/list_white_down.9.png similarity index 100% rename from src/res/drawable-hdpi/list_white_down.9.png rename to src/app/src/main/res/drawable-hdpi/list_white_down.9.png diff --git a/src/res/drawable-hdpi/list_white_middle.9.png b/src/app/src/main/res/drawable-hdpi/list_white_middle.9.png similarity index 100% rename from src/res/drawable-hdpi/list_white_middle.9.png rename to src/app/src/main/res/drawable-hdpi/list_white_middle.9.png diff --git a/src/res/drawable-hdpi/list_white_single.9.png b/src/app/src/main/res/drawable-hdpi/list_white_single.9.png similarity index 100% rename from src/res/drawable-hdpi/list_white_single.9.png rename to src/app/src/main/res/drawable-hdpi/list_white_single.9.png diff --git a/src/res/drawable-hdpi/list_white_up.9.png b/src/app/src/main/res/drawable-hdpi/list_white_up.9.png similarity index 100% rename from src/res/drawable-hdpi/list_white_up.9.png rename to src/app/src/main/res/drawable-hdpi/list_white_up.9.png diff --git a/src/res/drawable-hdpi/list_yellow_down.9.png b/src/app/src/main/res/drawable-hdpi/list_yellow_down.9.png similarity index 100% rename from src/res/drawable-hdpi/list_yellow_down.9.png rename to src/app/src/main/res/drawable-hdpi/list_yellow_down.9.png diff --git a/src/res/drawable-hdpi/list_yellow_middle.9.png b/src/app/src/main/res/drawable-hdpi/list_yellow_middle.9.png similarity index 100% rename from src/res/drawable-hdpi/list_yellow_middle.9.png rename to src/app/src/main/res/drawable-hdpi/list_yellow_middle.9.png diff --git a/src/res/drawable-hdpi/list_yellow_single.9.png b/src/app/src/main/res/drawable-hdpi/list_yellow_single.9.png similarity index 100% rename from src/res/drawable-hdpi/list_yellow_single.9.png rename to src/app/src/main/res/drawable-hdpi/list_yellow_single.9.png diff --git a/src/res/drawable-hdpi/list_yellow_up.9.png b/src/app/src/main/res/drawable-hdpi/list_yellow_up.9.png similarity index 100% rename from src/res/drawable-hdpi/list_yellow_up.9.png rename to src/app/src/main/res/drawable-hdpi/list_yellow_up.9.png diff --git a/src/res/drawable-hdpi/menu_delete.png b/src/app/src/main/res/drawable-hdpi/menu_delete.png similarity index 100% rename from src/res/drawable-hdpi/menu_delete.png rename to src/app/src/main/res/drawable-hdpi/menu_delete.png diff --git a/src/res/drawable-hdpi/menu_move.png b/src/app/src/main/res/drawable-hdpi/menu_move.png similarity index 100% rename from src/res/drawable-hdpi/menu_move.png rename to src/app/src/main/res/drawable-hdpi/menu_move.png diff --git a/src/res/drawable-hdpi/new_note_normal.png b/src/app/src/main/res/drawable-hdpi/new_note_normal.png similarity index 100% rename from src/res/drawable-hdpi/new_note_normal.png rename to src/app/src/main/res/drawable-hdpi/new_note_normal.png diff --git a/src/res/drawable-hdpi/new_note_pressed.png b/src/app/src/main/res/drawable-hdpi/new_note_pressed.png similarity index 100% rename from src/res/drawable-hdpi/new_note_pressed.png rename to src/app/src/main/res/drawable-hdpi/new_note_pressed.png diff --git a/src/res/drawable-hdpi/note_edit_color_selector_panel.png b/src/app/src/main/res/drawable-hdpi/note_edit_color_selector_panel.png similarity index 100% rename from src/res/drawable-hdpi/note_edit_color_selector_panel.png rename to src/app/src/main/res/drawable-hdpi/note_edit_color_selector_panel.png diff --git a/src/res/drawable-hdpi/notification.png b/src/app/src/main/res/drawable-hdpi/notification.png similarity index 100% rename from src/res/drawable-hdpi/notification.png rename to src/app/src/main/res/drawable-hdpi/notification.png diff --git a/src/res/drawable-hdpi/search_result.png b/src/app/src/main/res/drawable-hdpi/search_result.png similarity index 100% rename from src/res/drawable-hdpi/search_result.png rename to src/app/src/main/res/drawable-hdpi/search_result.png diff --git a/src/res/drawable-hdpi/selected.png b/src/app/src/main/res/drawable-hdpi/selected.png similarity index 100% rename from src/res/drawable-hdpi/selected.png rename to src/app/src/main/res/drawable-hdpi/selected.png diff --git a/src/res/drawable-hdpi/title_alert.png b/src/app/src/main/res/drawable-hdpi/title_alert.png similarity index 100% rename from src/res/drawable-hdpi/title_alert.png rename to src/app/src/main/res/drawable-hdpi/title_alert.png diff --git a/src/res/drawable-hdpi/title_bar_bg.9.png b/src/app/src/main/res/drawable-hdpi/title_bar_bg.9.png similarity index 100% rename from src/res/drawable-hdpi/title_bar_bg.9.png rename to src/app/src/main/res/drawable-hdpi/title_bar_bg.9.png diff --git a/src/res/drawable-hdpi/widget_2x_blue.png b/src/app/src/main/res/drawable-hdpi/widget_2x_blue.png similarity index 100% rename from src/res/drawable-hdpi/widget_2x_blue.png rename to src/app/src/main/res/drawable-hdpi/widget_2x_blue.png diff --git a/src/res/drawable-hdpi/widget_2x_green.png b/src/app/src/main/res/drawable-hdpi/widget_2x_green.png similarity index 100% rename from src/res/drawable-hdpi/widget_2x_green.png rename to src/app/src/main/res/drawable-hdpi/widget_2x_green.png diff --git a/src/res/drawable-hdpi/widget_2x_red.png b/src/app/src/main/res/drawable-hdpi/widget_2x_red.png similarity index 100% rename from src/res/drawable-hdpi/widget_2x_red.png rename to src/app/src/main/res/drawable-hdpi/widget_2x_red.png diff --git a/src/res/drawable-hdpi/widget_2x_white.png b/src/app/src/main/res/drawable-hdpi/widget_2x_white.png similarity index 100% rename from src/res/drawable-hdpi/widget_2x_white.png rename to src/app/src/main/res/drawable-hdpi/widget_2x_white.png diff --git a/src/res/drawable-hdpi/widget_2x_yellow.png b/src/app/src/main/res/drawable-hdpi/widget_2x_yellow.png similarity index 100% rename from src/res/drawable-hdpi/widget_2x_yellow.png rename to src/app/src/main/res/drawable-hdpi/widget_2x_yellow.png diff --git a/src/res/drawable-hdpi/widget_4x_blue.png b/src/app/src/main/res/drawable-hdpi/widget_4x_blue.png similarity index 100% rename from src/res/drawable-hdpi/widget_4x_blue.png rename to src/app/src/main/res/drawable-hdpi/widget_4x_blue.png diff --git a/src/res/drawable-hdpi/widget_4x_green.png b/src/app/src/main/res/drawable-hdpi/widget_4x_green.png similarity index 100% rename from src/res/drawable-hdpi/widget_4x_green.png rename to src/app/src/main/res/drawable-hdpi/widget_4x_green.png diff --git a/src/res/drawable-hdpi/widget_4x_red.png b/src/app/src/main/res/drawable-hdpi/widget_4x_red.png similarity index 100% rename from src/res/drawable-hdpi/widget_4x_red.png rename to src/app/src/main/res/drawable-hdpi/widget_4x_red.png diff --git a/src/res/drawable-hdpi/widget_4x_white.png b/src/app/src/main/res/drawable-hdpi/widget_4x_white.png similarity index 100% rename from src/res/drawable-hdpi/widget_4x_white.png rename to src/app/src/main/res/drawable-hdpi/widget_4x_white.png diff --git a/src/res/drawable-hdpi/widget_4x_yellow.png b/src/app/src/main/res/drawable-hdpi/widget_4x_yellow.png similarity index 100% rename from src/res/drawable-hdpi/widget_4x_yellow.png rename to src/app/src/main/res/drawable-hdpi/widget_4x_yellow.png diff --git a/src/app/src/main/res/drawable/bg_floating_ball.xml b/src/app/src/main/res/drawable/bg_floating_ball.xml new file mode 100644 index 0000000..181dcad --- /dev/null +++ b/src/app/src/main/res/drawable/bg_floating_ball.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/app/src/main/res/drawable/ic_add_24dp.xml b/src/app/src/main/res/drawable/ic_add_24dp.xml new file mode 100644 index 0000000..ea55c4e --- /dev/null +++ b/src/app/src/main/res/drawable/ic_add_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_arrow_back_24dp.xml b/src/app/src/main/res/drawable/ic_arrow_back_24dp.xml new file mode 100644 index 0000000..4e13e4a --- /dev/null +++ b/src/app/src/main/res/drawable/ic_arrow_back_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_edit_note_24dp.xml b/src/app/src/main/res/drawable/ic_edit_note_24dp.xml new file mode 100644 index 0000000..7e51a3c --- /dev/null +++ b/src/app/src/main/res/drawable/ic_edit_note_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_exit_privacy_24dp.xml b/src/app/src/main/res/drawable/ic_exit_privacy_24dp.xml new file mode 100644 index 0000000..99b02d7 --- /dev/null +++ b/src/app/src/main/res/drawable/ic_exit_privacy_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/app/src/main/res/drawable/ic_export_24dp.xml b/src/app/src/main/res/drawable/ic_export_24dp.xml new file mode 100644 index 0000000..5c3bfe4 --- /dev/null +++ b/src/app/src/main/res/drawable/ic_export_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_folder_24dp.xml b/src/app/src/main/res/drawable/ic_folder_24dp.xml new file mode 100644 index 0000000..591115a --- /dev/null +++ b/src/app/src/main/res/drawable/ic_folder_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_menu_24dp.xml b/src/app/src/main/res/drawable/ic_menu_24dp.xml new file mode 100644 index 0000000..ecb13cc --- /dev/null +++ b/src/app/src/main/res/drawable/ic_menu_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_search_24dp.xml b/src/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 0000000..0a6ea2c --- /dev/null +++ b/src/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_settings_24dp.xml b/src/app/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 0000000..5aa8318 --- /dev/null +++ b/src/app/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/app/src/main/res/drawable/ic_sync_24dp.xml b/src/app/src/main/res/drawable/ic_sync_24dp.xml new file mode 100644 index 0000000..3633fd0 --- /dev/null +++ b/src/app/src/main/res/drawable/ic_sync_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/res/drawable/new_note.xml b/src/app/src/main/res/drawable/new_note.xml similarity index 100% rename from src/res/drawable/new_note.xml rename to src/app/src/main/res/drawable/new_note.xml diff --git a/src/app/src/main/res/drawable/splash_accent_line.xml b/src/app/src/main/res/drawable/splash_accent_line.xml new file mode 100644 index 0000000..3c5bf62 --- /dev/null +++ b/src/app/src/main/res/drawable/splash_accent_line.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/src/app/src/main/res/drawable/splash_background.xml b/src/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 0000000..90642cb --- /dev/null +++ b/src/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/res/layout/account_dialog_title.xml b/src/app/src/main/res/layout/account_dialog_title.xml similarity index 100% rename from src/res/layout/account_dialog_title.xml rename to src/app/src/main/res/layout/account_dialog_title.xml diff --git a/src/app/src/main/res/layout/activity_main.xml b/src/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..5a6601a --- /dev/null +++ b/src/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/src/app/src/main/res/layout/activity_splash.xml b/src/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..6be03d3 --- /dev/null +++ b/src/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/app/src/main/res/layout/activity_todo_edit.xml b/src/app/src/main/res/layout/activity_todo_edit.xml new file mode 100644 index 0000000..9185c33 --- /dev/null +++ b/src/app/src/main/res/layout/activity_todo_edit.xml @@ -0,0 +1,49 @@ + + + + + + + +