#6

Merged
prwohun7f merged 1 commits from zhaoxin_branch into master 5 hours ago

@ -14,245 +14,260 @@
* limitations under the License.
*/
// 包声明:归属小米便签UI模块作为便签提醒触发时的核心弹窗页面
// 包声明:归属小米便签UI模块便签提醒功能的最终展示页,是提醒触发时用户感知的核心页面
package net.micode.notes.ui;
// 导入安卓Activity核心类页面基础类
// 安卓页面核心基类,所有页面的父类,提供页面生命周期、窗口管理、组件交互等基础能力
import android.app.Activity;
// 导入安卓对话框类:展示便签提醒内容和操作按钮
// 安卓系统对话框核心类,用于展示标准化的弹窗,承载提醒内容与交互按钮,是本页面的核心展示载体
import android.app.AlertDialog;
// 导入安卓上下文类:访问系统服务、资源
// 安卓上下文核心类,提供系统服务获取、资源访问、组件通信等基础能力
import android.content.Context;
// 导入安卓对话框事件类:处理按钮点击、对话框关闭
// 安卓对话框事件回调相关类,处理按钮点击、对话框关闭等交互事件,实现页面的核心交互逻辑
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.content.DialogInterface.OnDismissListener;
// 导入安卓意图类:跳转便签编辑页、接收提醒所属便签ID
// 安卓意图核心类,用于页面跳转、数据传递,此处用于跳转便签编辑页并携带便签ID
import android.content.Intent;
// 导入安卓音频管理类:控制提醒铃声的音频流类型
// 安卓音频管理核心类,用于指定音频流类型,适配系统音量与静音规则,控制提醒铃声的播放策略
import android.media.AudioManager;
// 导入安卓媒体播放类:播放提醒铃声(系统闹钟铃声)
// 安卓媒体播放核心类,用于加载、播放、停止系统铃声资源,实现提醒音效的播放功能
import android.media.MediaPlayer;
// 导入安卓铃声管理类:获取系统默认闹钟铃声
// 安卓铃声管理核心类用于获取系统默认的闹钟铃声Uri统一访问系统铃声资源
import android.media.RingtoneManager;
// 导入安卓URI类标识铃声资源、便签ID
// 安卓统一资源标识类,用于标记铃声资源地址、便签数据的唯一标识
import android.net.Uri;
// 导入安卓Bundle类保存页面状态此处未使用
// 安卓页面状态保存类,用于横竖屏切换等场景的页面数据恢复,本页面未使用
import android.os.Bundle;
// 导入安卓电源管理类:判断屏幕是否亮屏,控制屏幕唤醒
// 安卓电源管理核心类,用于获取设备屏幕的亮灭状态,判断当前是亮屏/熄屏/锁屏状态
import android.os.PowerManager;
// 导入安卓系统设置类:获取铃声静音模式配置
// 安卓系统设置核心类,用于读取系统的铃声静音模式配置,适配不同的系统音频策略
import android.provider.Settings;
// 导入安卓窗口相关类:设置窗口标记(唤醒屏幕、锁屏显示等)
// 安卓窗口管理相关类,用于配置页面窗口的显示属性,实现锁屏显示、屏幕唤醒、常亮等核心功能
import android.view.Window;
import android.view.WindowManager;
// 导入小米便签资源类:引用字符串、布局等资源
// 小米便签资源常量类引用字符串、布局、颜色等本地资源统一管理资源ID
import net.micode.notes.R;
// 导入便签数据常量类定义便签类型、ContentURI等
// 便签应用核心数据常量类定义便签类型、ContentURI等全局常量用于数据合法性校验
import net.micode.notes.data.Notes;
// 导入数据工具类:查询便签摘要、判断便签是否存在
// 便签数据工具类,封装数据库查询相关的通用方法,提供便签摘要查询、便签存在性校验等能力
import net.micode.notes.tool.DataUtils;
// 导入IO异常类处理媒体播放器的IO错误
// Java IO异常类捕获媒体播放器加载铃声资源时的IO错误保证程序健壮性
import java.io.IOException;
/**
* 便
*
* 1.
* - /
* -
* 2.
* - Intent便ID便60
* - 便便
* 3.
* -
* -
* 4.
* -
* - 便便
*
* - 便
* -
* 便
* Activity
* OnClickListener + OnDismissListener
* 便便
* ++便使
*
*
* 1. //
* 2. /穿
* 3. 使
* 4.
*
* 1. Intent便IDIDAlarmReceiver
* 2. 便ID便
* 3. 60
* 4. 便便便
*
* 1. Uri使
* 2.
* 3.
* 4.
* 5.
* 便
* 1. 便
* 2. +便/
* 3. 便便
* 4.
* &
* 1. 穿
* 2.
* 3.
* 4. 使
* 5.
*
* AlarmManager广 AlarmReceiver广 +便 便 + + / 便
*
* - 便便/
* -
* -
*/
public class AlarmAlertActivity extends Activity implements OnClickListener, OnDismissListener {
// 提醒所属便签的ID从Intent的Data字段解析标识当前提醒对应的便签
// ======================== 成员变量区 ========================
/** 当前提醒绑定的便签唯一ID核心标识所有数据操作均基于此ID */
private long mNoteId;
// 便签摘要文本用于弹窗展示最大长度限制为60字符
/** 便签的文本摘要内容,用于弹窗展示,做了长度限制处理 */
private String mSnippet;
// 便签摘要预览最大长度:超过该长度则裁剪并添加省略标记
/** 便签摘要预览的最大字符长度,超过该长度自动裁剪并添加省略号,保证展示美观 */
private static final int SNIPPET_PREW_MAX_LEN = 60;
// 媒体播放器:用于播放系统闹钟铃声,循环播放提醒音
/** 媒体播放器实例,用于加载和播放系统闹钟铃声,实现听觉提醒 */
MediaPlayer mPlayer;
/**
* 便ID
* @param savedInstanceState 使
*
*
* @param savedInstanceState 使
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 隐藏页面标题栏:仅展示对话框,无需原生标题栏
// 核心配置:隐藏原生的页面标题栏,本页面仅展示对话框,无需标题栏,极简展示
requestWindowFeature(Window.FEATURE_NO_TITLE);
// 1. 窗口显示控制:确保弹窗在锁屏/熄屏时可见
// ========== 第一步:窗口属性配置,实现锁屏显示、屏幕唤醒等核心能力 ==========
final Window win = getWindow();
// 添加标记:锁屏时仍显示窗口(基础可见性)
// 必加窗口标记:允许窗口在设备锁屏状态下依然显示,是锁屏提醒的基础配置
win.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
// 熄屏状态下:额外添加唤醒/常亮标记
// 智能判断屏幕状态:仅在熄屏时添加额外的唤醒与常亮标记,亮屏时不做修改
if (!isScreenOn()) {
win.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON // 保持屏幕常亮
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON // 唤醒屏幕
| WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON // 允许屏幕锁定时显示
| WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); // 适配装饰布局
win.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // 保持屏幕常亮,直到弹窗关闭
win.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); // 强制唤醒熄屏的屏幕,核心保障用户能看到提醒
win.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);// 允许屏幕在亮屏状态下依然保持锁定,兼顾安全性
win.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); // 适配系统的装饰布局,避免弹窗内容被遮挡
}
// 2. 解析Intent获取提醒所属便签ID并查询摘要
// ========== 第二步解析Intent数据获取便签ID并查询摘要内容 ==========
Intent intent = getIntent();
try {
// 从Intent的Data字段解析便签IDData格式content://notes/note/[id]
// 核心解析逻辑从Intent的Data字段中解析出便签IDData字段的格式为 content://notes/note/[id]
mNoteId = Long.valueOf(intent.getData().getPathSegments().get(1));
// 根据便签ID查询摘要文本
// 根据便签ID查询数据库,获取便签的文本摘要
mSnippet = DataUtils.getSnippetById(this.getContentResolver(), mNoteId);
// 摘要长度裁剪超过60字符则截取前60位并添加省略标记
// 摘要内容规范化处理超过60字符则截取前60位并添加省略号保证弹窗展示效果
mSnippet = mSnippet.length() > SNIPPET_PREW_MAX_LEN ? mSnippet.substring(0,
SNIPPET_PREW_MAX_LEN) + getResources().getString(R.string.notelist_string_info)
: mSnippet;
} catch (IllegalArgumentException e) {
// 解析ID失败如格式错误打印异常并结束页面
// 异常处理ID解析失败如格式错误、数据异常打印日志并直接返回不展示任何内容
e.printStackTrace();
return;
}
// 3. 初始化媒体播放器,判断便签是否存在并执行后续逻辑
// ========== 第三步:初始化媒体播放器,校验便签有效性并执行核心逻辑 ==========
mPlayer = new MediaPlayer();
// 仅当便签仍存在于数据库中时,展示弹窗并播放铃声
// 关键校验:仅当该便签真实存在于数据库且为普通便签类型时,才展示弹窗和播放铃声
if (DataUtils.visibleInNoteDatabase(getContentResolver(), mNoteId, Notes.TYPE_NOTE)) {
showActionDialog(); // 展示提醒弹窗
playAlarmSound(); // 播放提醒铃声
showActionDialog(); // 展示提醒弹窗,核心交互载体
playAlarmSound(); // 播放系统闹钟铃声,核心听觉提醒
} else {
// 便签已删除:直接结束页面,不展示任何内容
// 便签已被删除或类型非法:直接关闭页面,无任何展示与播放,避免无效操作
finish();
}
}
/**
*
* @return booleantrue=false=/
*
* @return boolean true=/ false=/
*/
private boolean isScreenOn() {
// 获取电源管理系统服务
// 获取系统电源管理服务实例
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
// 返回屏幕亮屏状态
// 返回当前屏幕的亮灭状态
return pm.isScreenOn();
}
/**
*
*
* - URI
* -
* -
*
* Uri
*/
private void playAlarmSound() {
// 获取系统默认闹钟铃声的URI
// 第一步获取系统默认的闹钟铃声Uri使用系统级铃声资源适配用户个性化设置
Uri url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM);
// 获取系统静音模式配置:判断闹钟流是否受静音影响
// 第二步:读取系统铃声静音模式配置,判断闹钟音频流是否受静音规则影响,适配系统策略
int silentModeStreams = Settings.System.getInt(getContentResolver(),
Settings.System.MODE_RINGER_STREAMS_AFFECTED, 0);
// 设置音频流类型:适配静音模式
// 第三步:设置音频流类型,核心适配逻辑,保证铃声播放遵循系统音量规则
if ((silentModeStreams & (1 << AudioManager.STREAM_ALARM)) != 0) {
mPlayer.setAudioStreamType(silentModeStreams);
} else {
// 默认使用闹钟音频流STREAM_ALARM,优先级高于普通媒体流
// 默认使用闹钟专属音频流,优先级高于普通媒体流,不会被媒体音量影响
mPlayer.setAudioStreamType(AudioManager.STREAM_ALARM);
}
// 初始化并启动媒体播放器
// 第四步:初始化媒体播放器并启动循环播放,捕获所有可能的异常,保证程序稳定
try {
mPlayer.setDataSource(this, url); // 设置铃声数据源
mPlayer.prepare(); // 准备播放器(同步)
mPlayer.setLooping(true); // 设置循环播放(持续提醒)
mPlayer.start(); // 启动播放
mPlayer.setDataSource(this, url); // 设置铃声资源的数据源
mPlayer.prepare(); // 同步准备播放器,加载铃声资源
mPlayer.setLooping(true); // 核心配置:循环播放铃声,直到用户手动关闭
mPlayer.start(); // 启动铃声播放,触发听觉提醒
} catch (IllegalArgumentException e) {
// 参数错误异常:打印堆栈,不中断流程
e.printStackTrace();
} catch (SecurityException e) {
// 权限异常(如铃声文件无访问权限):打印堆栈
e.printStackTrace();
} catch (IllegalStateException e) {
// 播放器状态异常如重复prepare打印堆栈
e.printStackTrace();
} catch (IOException e) {
// IO异常如铃声文件不存在打印堆栈
e.printStackTrace();
}
}
/**
* 便
*
* - 便
* - +便
* -
* 便
* 便
*/
private void showActionDialog() {
AlertDialog.Builder dialog = new AlertDialog.Builder(this);
dialog.setTitle(R.string.app_name); // 弹窗标题:应用名称
dialog.setMessage(mSnippet); // 弹窗内容:便签摘要
dialog.setPositiveButton(R.string.notealert_ok, this); // 确认按钮
dialog.setTitle(R.string.app_name); // 弹窗标题:固定为应用名称,简洁明了
dialog.setMessage(mSnippet); // 弹窗核心内容:便签的文本摘要,展示提醒的核心信息
dialog.setPositiveButton(R.string.notealert_ok, this); // 确认按钮,绑定点击事件监听器
// 亮屏状态下:额外显示“进入便签”按钮(负向按钮)
// 智能按钮适配:仅在屏幕亮屏时展示「进入便签」按钮,熄屏/锁屏时不展示,贴合操作场景
if (isScreenOn()) {
dialog.setNegativeButton(R.string.notealert_enter, this);
}
// 展示弹窗并绑定关闭监听器
// 展示弹窗并绑定关闭监听器,无论何种方式关闭弹窗,均触发统一的收尾逻辑
dialog.show().setOnDismissListener(this);
}
/**
*
* @param dialog
* @param which BUTTON_POSITIVE/ NEGATIVE
*
* @param dialog
* @param which 便
*/
public void onClick(DialogInterface dialog, int which) {
switch (which) {
case DialogInterface.BUTTON_NEGATIVE:
// “进入便签”按钮跳转到便签编辑页传递便签ID
// 点击「进入便签」按钮:跳转至该便签的编辑页面,精准定位到对应内容
Intent intent = new Intent(this, NoteEditActivity.class);
intent.setAction(Intent.ACTION_VIEW); // 设置动作:查看/编辑便签
intent.putExtra(Intent.EXTRA_UID, mNoteId); // 传递便签ID
intent.setAction(Intent.ACTION_VIEW); // 设置跳转动作:查看/编辑便签
intent.putExtra(Intent.EXTRA_UID, mNoteId); // 传递便签ID目标页面根据ID查询并展示内容
startActivity(intent);
break;
default:
// “确认”按钮无额外操作对话框关闭由OnDismissListener处理
// 点击「确认」按钮无额外业务逻辑仅关闭弹窗收尾逻辑由OnDismissListener统一处理
break;
}
}
/**
*
* @param dialog
*
*
* @param dialog
*/
public void onDismiss(DialogInterface dialog) {
stopAlarmSound(); // 停止提醒铃声,释放媒体资源
finish(); // 结束页面
stopAlarmSound(); // 核心收尾:停止铃声播放并释放媒体资源,杜绝内存泄漏与音频残留
finish(); // 关闭当前页面,释放所有资源,页面生命周期结束
}
/**
*
*
*
*
*/
private void stopAlarmSound() {
if (mPlayer != null) {
mPlayer.stop(); // 停止播放
mPlayer.release(); // 释放播放器资源
mPlayer = null; // 置空引用便于GC回收
mPlayer.stop(); // 立即停止铃声播放
mPlayer.release(); // 释放播放器的所有资源,包括音频通道、内存等
mPlayer = null; // 置空引用便于GC回收,彻底杜绝内存泄漏
}
}
}

@ -14,100 +14,128 @@
* limitations under the License.
*/
// 包声明:归属小米便签UI模块作为便签提醒的初始化广播接收器
// 包声明:归属小米便签UI模块便签提醒功能的初始化核心广播接收器,负责重启恢复所有未过期提醒
package net.micode.notes.ui;
// 导入安卓闹钟管理类:用于注册/设置便签提醒闹钟
// 安卓系统闹钟服务核心类,用于注册、设置、取消定时闹钟,是便签提醒的核心调度组件
import android.app.AlarmManager;
// 导入安卓延迟意图类封装广播Intent供AlarmManager触发
// 安卓延迟意图核心类,封装广播/页面跳转意图交由AlarmManager在指定时间触发核心桥梁类
import android.app.PendingIntent;
// 导入安卓广播接收器核心类:接收初始化广播(如开机/应用启动)
// 安卓广播核心基类,继承此类实现广播监听与处理能力,为本类的核心父类
import android.content.BroadcastReceiver;
// 导入安卓ContentURI工具类拼接便签ID到Intent的Data字段标识提醒所属便签
// 安卓ContentURI拼接工具类用于将Uri和数据ID拼接生成唯一标识Uri便于数据精准匹配
import android.content.ContentUris;
// 导入安卓上下文类提供ContentResolver、系统服务等访问能力
// 安卓上下文核心类,提供系统服务获取、内容解析器访问、组件通信等基础能力
import android.content.Context;
// 导入安卓意图类创建指向AlarmReceiver的广播意图
// 安卓意图核心类,组件间通信的核心载体,用于封装广播意图并携带数据
import android.content.Intent;
// 导入安卓数据库游标类:查询未过期的便签提醒数据
// 安卓数据库游标核心类,用于遍历查询数据库返回的结果集,读取便签提醒数据
import android.database.Cursor;
// 导入便签数据常量类定义ContentURI、字段、便签类型等核心常量
// 便签应用核心数据常量类定义数据库Uri、便签类型、字段名等全局常量统一管理
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
/**
* 便广
* 便广
* BroadcastReceiver 广广
* 便/便
* AlarmManager
*
* 1. /广便
* 2. 便AlarmManager
* 3. /便
*
* - 便TYPE_NOTE/
* - 使AlarmManager.RTC_WAKEUP
* 1. /广广广广
* 2. 广ContentResolver便
* 3. +便便TYPE_NOTE//
* 4. 广AlarmManager
* 5. 便/
* &
* 1. 便
* 2. 便IDIO
* 3. ContentUris便IDIntentData便
* 4. 使AlarmManager.RTC_WAKEUP/CPU
* 5. Cursor
* 6. 广
*
* -
* - 便TYPE_NOTETYPE_FOLDER
* - 便ALERTED_DATE0便
*
* 便 AlarmManager / 广 AlarmReceiver
*/
public class AlarmInitReceiver extends BroadcastReceiver {
/**
* IO
*
* - NoteColumns.ID便ID便
* - NoteColumns.ALERTED_DATE便>0
*
* IO
*
*/
private static final String [] PROJECTION = new String [] {
NoteColumns.ID, // 0: 便签ID
NoteColumns.ALERTED_DATE // 1: 提醒时间戳
NoteColumns.ID, // 数组索引0便签的唯一主键ID用于绑定提醒与便签、生成唯一Intent标识
NoteColumns.ALERTED_DATE // 数组索引1便签设置的提醒时间戳毫秒级用于设置闹钟的触发时间
};
// 投影数组对应的列索引常量简化Cursor取值避免硬编码索引
private static final int COLUMN_ID = 0; // 便签ID列索引
private static final int COLUMN_ALERTED_DATE = 1; // 提醒时间戳列索引
// 投影数组列索引常量【硬编码优化】
// 设计目的:将数组索引封装为常量,替代代码中的硬编码数字,提升代码可读性、可维护性,避免索引写错导致的程序异常
private static final int COLUMN_ID = 0; // 对应PROJECTION[0]便签ID列索引
private static final int COLUMN_ALERTED_DATE = 1; // 对应PROJECTION[1],提醒时间戳列索引
/**
* 广便AlarmManager
* @param context 广访ContentResolver
* @param intent 广Intent广广
* 广广
* +
* @param context 广ContentResolverAlarmManagerIntent
* @param intent 广广
*/
@Override
public void onReceive(Context context, Intent intent) {
// 1. 获取当前时间戳:作为筛选“未过期提醒”的阈值(仅处理提醒时间>当前时间的便签)
// 第一步:获取当前系统时间戳,作为筛选「未过期提醒」的核心阈值,只处理提醒时间在当前时间之后的记录
long currentDate = System.currentTimeMillis();
// 2. 查询ContentResolver获取所有未过期的普通便签提醒
// 查询条件:提醒时间>当前时间 + 便签类型为普通便签TYPE_NOTE
// 第二步通过ContentResolver查询便签数据库获取所有符合条件的有效提醒数据
// 核心查询参数说明:
// 1. uri查询地址 → Notes.CONTENT_NOTE_URI 普通便签的专属Uri精准定位查询表
// 2. projection查询字段 → 自定义的PROJECTION数组仅查ID和提醒时间戳
// 3. selection查询条件 → 双重过滤:提醒时间>当前时间 且 便签类型为普通便签,精准筛选有效数据
// 4. selectionArgs条件参数 → 传入当前时间戳的字符串形式防止SQL注入符合安卓安全规范
// 5. sortOrder排序规则 → null使用数据库默认排序无需额外排序提升查询效率
Cursor c = context.getContentResolver().query(Notes.CONTENT_NOTE_URI,
PROJECTION, // 要查询的字段
NoteColumns.ALERTED_DATE + ">? AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE, // 查询条件
new String[] { String.valueOf(currentDate) }, // 条件参数(当前时间戳)
null); // 排序规则(默认)
PROJECTION,
NoteColumns.ALERTED_DATE + ">? AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE,
new String[] { String.valueOf(currentDate) },
null);
// 3. 遍历Cursor为每个未过期的提醒注册闹钟
if (c != null) {
// 游标移动到第一条数据(有未过期提醒时进入循环)
// 第三步:遍历查询结果,为每个有效提醒重新注册闹钟
if (c != null) { // 游标非空校验:避免查询结果为空时的空指针异常,代码健壮性保障
// 游标移动到第一条数据,存在有效提醒时进入循环遍历逻辑
if (c.moveToFirst()) {
do {
// 3.1 获取当前便签的提醒时间戳和ID
long alertDate = c.getLong(COLUMN_ALERTED_DATE); // 提醒触发时间
long noteId = c.getLong(COLUMN_ID); // 便签唯一ID
// 3.1 从游标中读取当前行的核心数据便签ID、提醒触发时间戳
long alertDate = c.getLong(COLUMN_ALERTED_DATE); // 该便签的提醒触发时间,毫秒级
long noteId = c.getLong(COLUMN_ID); // 便签唯一主键ID
// 3.2 创建广播Intent指向AlarmReceiver提醒触发时的处理接收器
// 3.2 创建广播意图指定意图的目标为AlarmReceiver即提醒时间到达时需要触发的广播接收器
Intent sender = new Intent(context, AlarmReceiver.class);
// 将便签ID附加到Intent的Data字段标识该提醒所属的便签便于AlarmReceiver识别
// 核心关键将便签ID拼接至Intent的Data字段生成唯一的Uri标识精准绑定该提醒与对应便签
// 作用AlarmReceiver接收到广播时可通过该Uri解析出便签ID进而查询便签详情展示提醒内容无串号风险
sender.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId));
// 3.3 创建PendingIntent封装广播Intent供AlarmManager触发
// 参数说明context=上下文requestCode=请求码此处为0intent=广播意图flags=标记(默认)
// 3.3 创建PendingIntent延迟意图封装上述广播意图交由AlarmManager管理在指定时间触发广播
// PendingIntent.getBroadcast参数说明上下文、请求码(此处传0即可)、待封装的广播意图、flags标记(默认0)
// 核心作用PendingIntent是一种授权意图允许系统在未来的指定时间以当前应用的身份发送该广播是闹钟触发的核心载体
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, 0);
// 3.4 获取AlarmManager系统服务注册提醒闹钟
// 3.4 获取系统AlarmManager服务实例系统级的闹钟调度核心服务
AlarmManager alermManager = (AlarmManager) context
.getSystemService(Context.ALARM_SERVICE);
// 设置闹钟RTC_WAKEUP基于系统时间触发时唤醒设备、提醒时间、PendingIntent
// 3.5 核心操作:重新注册闹钟,完成提醒恢复
// alermManager.set参数说明
// 1. type → AlarmManager.RTC_WAKEUP 最核心的闹钟类型基于系统绝对时间触发时唤醒设备CPU/屏幕,保证提醒必达
// 2. triggerAtTime → 闹钟触发的具体时间即从数据库读取的提醒时间戳alertDate
// 3. operation → 待触发的PendingIntent延迟意图触发时发送广播至AlarmReceiver
alermManager.set(AlarmManager.RTC_WAKEUP, alertDate, pendingIntent);
} while (c.moveToNext()); // 遍历所有未过期的提醒
} while (c.moveToNext()); // 循环遍历游标,处理所有有效提醒数据
}
// 4. 关闭Cursor释放数据库资源避免内存泄漏
// 第四步:必须关闭游标,释放数据库资源与内存,杜绝内存泄漏,安卓数据库操作的强制规范
c.close();
}
}

@ -14,38 +14,50 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块作为便签提醒功能的核心广播接收器
// 包声明:归属小米便签UI模块便签提醒功能的广播接收核心类承接闹钟触发逻辑
package net.micode.notes.ui;
// 导入安卓广播接收器核心类:接收系统/应用发送的广播事件
// 安卓广播核心基类,继承此类实现广播接收能力,监听系统/应用发送的广播事件
import android.content.BroadcastReceiver;
// 导入安卓上下文类提供应用运行环境启动Activity
// 安卓上下文核心类,提供应用运行环境、组件启动、资源访问等基础能力
import android.content.Context;
// 导入安卓意图类:用于启动提醒页面,传递数据
// 安卓意图核心类,用于组件间通信、页面跳转、数据传递的核心载体
import android.content.Intent;
/**
* 便广
* 便广广
* BroadcastReceiver 广广
* 便
*
* 1. 便广AlarmManager
* 2. 广AlarmAlertActivity便
*
* - 广ActivityFLAG_ACTIVITY_NEW_TASK
* 使便
* 1. AlarmManager广广便
* 2. 广Intent便便ID
* 3. Intent
* 4. 便
* &
* 1.
* 2. BroadcastReceiver ActivityActivity FLAG_ACTIVITY_NEW_TASK
* 3. 广Intent
* 4. 广onReceive
* 5. 便广广
*
* 便 AlarmManager 广 广 AlarmAlertActivity 便/
* 使便广
*/
public class AlarmReceiver extends BroadcastReceiver {
/**
* 广广
* @param context 广Activity
* @param intent 广Intent便ID
* 广广
* 广
* @param context 广Activity
* @param intent 广便
*/
@Override
public void onReceive(Context context, Intent intent) {
// 1. 修改Intent的目标类为提醒弹窗页面AlarmAlertActivity
// 第一步为当前Intent指定跳转的目标页面将广播意图转为页面跳转意图
intent.setClass(context, AlarmAlertActivity.class);
// 2. 添加新任务标记BroadcastReceiver无Activity任务栈必须添加该标记才能启动Activity
// 第二步添加必选的新任务启动标记解决广播无任务栈启动Activity的上下文问题规避运行时异常
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 3. 启动提醒弹窗页面,展示便签提醒内容
// 第三步:启动提醒弹窗页面,完成广播事件的最终转发,展示便签提醒内容
context.startActivity(intent);
}
}

@ -14,157 +14,176 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块自定义日期时间选择控件核心类
// 包声明:归属小米便签UI模块日期时间选择的核心自定义复合控件供弹窗调用
package net.micode.notes.ui;
// 导入日期格式化工具类:处理星期/日期的文本展示
// Java文本格式化工具类获取系统的上午/下午文本标识,适配多语言环境
import java.text.DateFormatSymbols;
// 导入日历类:核心的日期时间管理,处理年/月/日/时/分的计算与联动
// Java核心日历工具类全局唯一的时间数据载体处理所有年月日时分的计算、联动、转换逻辑
import java.util.Calendar;
// 导入资源类引用布局文件datetime_picker.xml
// 小米便签资源文件引用获取布局、字符串等资源ID常量
import net.micode.notes.R;
// 导入安卓上下文类:创建控件、加载资源
// 安卓系统核心类 - 上下文,提供控件创建、资源加载、布局填充的运行环境
import android.content.Context;
// 导入日期格式化工具判断系统24小时制设置
// 安卓系统日期格式化工具类提供系统24小时制判断、日期文本格式化能力
import android.text.format.DateFormat;
// 导入视图相关类:布局容器、视图可见性控制
// 安卓视图体系核心类,视图基础属性配置、可见性控制、布局容器核心父类
import android.view.View;
import android.widget.FrameLayout;
// 导入数字选择器:核心的日期/时间选择UI组件
// 安卓数字滚轮选择器本控件的核心子组件实现年月日时分的滚轮滑动选择UI
import android.widget.NumberPicker;
/**
*
*
* 1. 7/AM/PMNumberPicker
* 2. 590+1230+1
* 3. 24/12
* 4. ////
* 5. /
* 使便DateTimePickerDialogUI
* UI
* FrameLayout
* 便DateTimePickerDialog使
* +++
*
* 1. 4NumberPickerUI
* 2. Calendar
* 3. 590+1230+112
* 4. 24/12
* 5. 7. 便
* 6. /
* 7.
* 8.
* 9.
*
* 使DateTimePickerDialog便
*/
public class DateTimePicker extends FrameLayout {
// ======================== 基础常量 - 控件默认状态 ========================
/** 控件默认启用状态:初始为启用 */
// ======================== 基础常量区 - 控件默认配置与固定数值 ========================
/** 控件默认启用状态:初始化时默认所有选择器均可交互 */
private static final boolean DEFAULT_ENABLE_STATE = true;
// ======================== 常量 - 时间数值范围 ========================
/** 半天的小时数12小时制的核心数值AM/PM分界 */
// ======================== 常量 - 时间单位核心数值 ========================
/** 半天小时数12小时制的核心分界值上午/下午的小时数上限 */
private static final int HOURS_IN_HALF_DAY = 12;
/** 全天小时数24小时制的核心数值 */
/** 全天小时数24小时制的核心数值,一天的总小时数 */
private static final int HOURS_IN_ALL_DAY = 24;
/** 一周的天数日期选择器展示近7天 */
/** 一周天数:日期选择器固定展示的天数,不可修改,适配短周期提醒业务 */
private static final int DAYS_IN_ALL_WEEK = 7;
// ======================== 常量 - NumberPicker取值范围 ========================
/** 日期选择器最小值0对应近7天的起始索引 */
// ======================== 常量 - NumberPicker滚轮选择器 取值范围约束 ========================
/** 日期选择器-最小值固定为0对应近7天列表的第一条数据 */
private static final int DATE_SPINNER_MIN_VAL = 0;
/** 日期选择器最大值6对应近7天的结束索引DAYS_IN_ALL_WEEK - 1 */
/** 日期选择器-最大值固定为6对应近7天列表的最后一条数据数值等于天数减1 */
private static final int DATE_SPINNER_MAX_VAL = DAYS_IN_ALL_WEEK - 1;
/** 24小时制-小时选择器最小值0凌晨0点 */
/** 24小时制-小时选择器-最小值凌晨0点时间起点 */
private static final int HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW = 0;
/** 24小时制-小时选择器最大值23深夜23点 */
/** 24小时制-小时选择器-最大值深夜23点时间终点 */
private static final int HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW = 23;
/** 12小时制-小时选择器最小值1上午/下午1点 */
/** 12小时制-小时选择器-最小值:上午/下午的1点无0点概念 */
private static final int HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW = 1;
/** 12小时制-小时选择器最大值12上午/下午12点 */
/** 12小时制-小时选择器-最大值:上午/下午的12点封顶数值 */
private static final int HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW = 12;
/** 分钟选择器最小值0整点 */
/** 分钟选择器-最小值整点0分分钟起点 */
private static final int MINUT_SPINNER_MIN_VAL = 0;
/** 分钟选择器最大值59整点前1分钟 */
/** 分钟选择器-最大值整点前1分钟59分分钟终点 */
private static final int MINUT_SPINNER_MAX_VAL = 59;
/** 上午/下午选择器最小值0AM/上午) */
/** 上下午选择器-最小值0对应上午/AM */
private static final int AMPM_SPINNER_MIN_VAL = 0;
/** 上午/下午选择器最大值1PM/下午) */
/** 上下午选择器-最大值1对应下午/PM */
private static final int AMPM_SPINNER_MAX_VAL = 1;
// ======================== 成员变量 - UI组件 ========================
/** 日期选择器展示近7天的日期格式如“12.23 星期二”) */
// ======================== 成员变量 - UI核心组件【所有子选择器,全局持用避免重复查找】 ========================
/** 日期滚轮选择器核心展示近7天的格式化日期文本如「01.15 周四」,支持滑动切换日期 */
private final NumberPicker mDateSpinner;
/** 小时选择器根据24/12小时制展示不同范围的小时数 */
/** 小时滚轮选择器根据24/12小时制展示对应范围的小时数核心时间选择组件 */
private final NumberPicker mHourSpinner;
/** 分钟选择器0~59的分钟数选择 */
/** 分钟滚轮选择器:固定0~59的分钟数选择,支持长按快速滚动,核心时间选择组件 */
private final NumberPicker mMinuteSpinner;
/** 上午/下午选择器仅12小时制显示0=AM/上午1=PM/下午 */
/** 上下午滚轮选择器仅12小时制显示0=上午/AM1=下午/PM适配12小时制的时间展示 */
private final NumberPicker mAmPmSpinner;
// ======================== 成员变量 - 日期时间状态 ========================
/** 核心日历对象:存储当前选中的日期时间,处理所有时间计算/联动 */
// ======================== 成员变量区 - 核心状态与数据载体【全局核心数据,控件的大脑】 ========================
/** 核心日历对象:全局唯一的时间数据载体,存储当前选中的完整年月日时分信息,所有选择操作最终同步至此,所有外部获取操作均来源于此,保证数据唯一可信 */
private Calendar mDate;
/** 日期选择器展示文本数组存储近7天的格式化日期文本如“12.23 星期二”) */
/** 日期展示文本数组缓存近7天的格式化日期文本供日期选择器展示使用避免重复计算 */
private String[] mDateDisplayValues = new String[DAYS_IN_ALL_WEEK];
/** 上午/下午标记true=AM/上午false=PM/下午仅12小时制有效 */
/** 上下午状态标记true=上午/AMfalse=下午/PM仅在12小时制下生效控制时间计算与展示 */
private boolean mIsAm;
/** 24小时制标记true=24小时制false=12小时制 */
/** 24小时制状态标记true=启用24小时制隐藏上下午选择器;false=启用12小时制,显示上下午选择器 */
private boolean mIs24HourView;
/** 控件启用状态true=启用false=禁用所有选择器 */
/** 控件整体启用状态标记true=所有选择器可交互false=所有选择器禁用,统一管控交互权限 */
private boolean mIsEnabled = DEFAULT_ENABLE_STATE;
/** 初始化标记true=控件正在初始化,避免初始化时触发不必要的回调 */
/** 初始化状态标记true=控件正在初始化屏蔽所有回调触发false=初始化完成,正常响应所有操作与回调,防止初始化阶段的无效数据通知 */
private boolean mInitialising;
// ======================== 成员变量 - 回调监听 ========================
/** 日期时间变化回调监听器:外部实现,接收时间变化通知 */
// ======================== 成员变量区 - 回调通信接口 ========================
/** 日期时间变化回调监听器:外部实现该接口,接收控件的时间变化通知,是控件与外部通信的唯一桥梁 */
private OnDateTimeChangedListener mOnDateTimeChangedListener;
// ======================== 成员变量 - NumberPicker值变化监听器 ========================
/** 日期选择器值变化监听器:处理日期切换,更新日历对象并触发回调 */
// ======================== 成员变量区 - 滚轮选择器 值变化监听器【所有选择器的核心交互逻辑,内部闭环】 ========================
/**
*
*
* 77
*/
private NumberPicker.OnValueChangeListener mOnDateChangedListener = new NumberPicker.OnValueChangeListener() {
@Override
public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
// 调整日历的天数(新值-旧值为天数偏移量)
// 计算日期偏移量:新值-旧值 即为需要增减的天数,直接同步至核心日历对象
mDate.add(Calendar.DAY_OF_YEAR, newVal - oldVal);
// 更新日期选择器的展示文本
// 刷新日期选择器的展示文本,保证文本与选中日期一致
updateDateControl();
// 触发日期时间变化回调
// 触发全局时间变化回调,向外通知日期已更新
onDateTimeChanged();
}
};
/** 小时选择器值变化监听器处理小时切换包含边界联动小时→日期和12/24小时制适配 */
/**
*
* 24/12
*
* 1. 121112 +11211 -11112
* 2. 24230 +1023 -1
* 3. 24
*/
private NumberPicker.OnValueChangeListener mOnHourChangedListener = new NumberPicker.OnValueChangeListener() {
@Override
public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
boolean isDateChanged = false; // 日期是否变化标记
Calendar cal = Calendar.getInstance();
boolean isDateChanged = false; // 日期是否发生变化标记位,默认无变化
Calendar cal = Calendar.getInstance(); // 临时日历对象,用于处理日期偏移
// 12小时制下的小时边界处理
// ========== 12小时制 小时边界特殊处理 ==========
if (!mIs24HourView) {
// 场景1下午11点→12点 → 日期+1
// 场景1下午状态下小时从11→12触发日期+1跨天
if (!mIsAm && oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) {
cal.setTimeInMillis(mDate.getTimeInMillis());
cal.add(Calendar.DAY_OF_YEAR, 1);
isDateChanged = true;
}
// 场景2上午12点→11点 → 日期-1
// 场景2上午状态下小时从12→11触发日期-1跨天
else if (mIsAm && oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1) {
cal.setTimeInMillis(mDate.getTimeInMillis());
cal.add(Calendar.DAY_OF_YEAR, -1);
isDateChanged = true;
}
// 小时11→12 或 12→11 时,切换上午/下午标记
// 场景3小时在11和12之间切换时自动翻转上下午状态核心联动逻辑
if (oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY ||
oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1) {
mIsAm = !mIsAm;
updateAmPmControl(); // 更新上午/下午选择器
updateAmPmControl(); // 同步刷新上下午选择器的选中状态
}
}
// 24小时制下的小时边界处理
// ========== 24小时制 小时边界特殊处理 ==========
else {
// 场景123点→0点 → 日期+1
// 场景1小时从23→0触发日期+1跨天一天的结束
if (oldVal == HOURS_IN_ALL_DAY - 1 && newVal == 0) {
cal.setTimeInMillis(mDate.getTimeInMillis());
cal.add(Calendar.DAY_OF_YEAR, 1);
isDateChanged = true;
}
// 场景20点→23点 → 日期-1
// 场景2小时从0→23触发日期-1跨天一天的开始
else if (oldVal == 0 && newVal == HOURS_IN_ALL_DAY - 1) {
cal.setTimeInMillis(mDate.getTimeInMillis());
cal.add(Calendar.DAY_OF_YEAR, -1);
@ -172,13 +191,13 @@ public class DateTimePicker extends FrameLayout {
}
}
// 计算并设置最终的小时数适配12/24小时制
// ========== 统一处理将选中的小时数转换为24小时制同步至核心日历对象 ==========
int newHour = mHourSpinner.getValue() % HOURS_IN_HALF_DAY + (mIsAm ? 0 : HOURS_IN_HALF_DAY);
mDate.set(Calendar.HOUR_OF_DAY, newHour);
// 触发时间变化回调
// 触发全局时间变化回调,向外通知小时已更新
onDateTimeChanged();
// 日期变化时,更新日历的年//
// ========== 日期发生变化时,同步更新核心日历对象的年月日 ==========
if (isDateChanged) {
setCurrentYear(cal.get(Calendar.YEAR));
setCurrentMonth(cal.get(Calendar.MONTH));
@ -187,218 +206,232 @@ public class DateTimePicker extends FrameLayout {
}
};
/** 分钟选择器值变化监听器:处理分钟切换,包含边界联动(分钟→小时→日期) */
/**
*
*
* 590 +1059 -1
* 12
*/
private NumberPicker.OnValueChangeListener mOnMinuteChangedListener = new NumberPicker.OnValueChangeListener() {
@Override
public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
int minValue = mMinuteSpinner.getMinValue();
int maxValue = mMinuteSpinner.getMaxValue();
int offset = 0; // 小时偏移量
int offset = 0; // 小时偏移量,默认无偏移
// 场景159分→0分 → 小时+1
// 场景1分钟从59→0触发小时+1分钟到顶进位到小时
if (oldVal == maxValue && newVal == minValue) {
offset += 1;
}
// 场景20分→59分 → 小时-1
// 场景2分钟从0→59触发小时-1分钟到底退位到小时
else if (oldVal == minValue && newVal == maxValue) {
offset -= 1;
}
// 小时需要偏移时,调整日历并更新相关选择器
// ========== 小时需要偏移时,执行联动逻辑 ==========
if (offset != 0) {
mDate.add(Calendar.HOUR_OF_DAY, offset); // 调整小时
mHourSpinner.setValue(getCurrentHour()); // 更新小时选择器
updateDateControl(); // 更新日期选择器(小时偏移可能导致日期变化)
mDate.add(Calendar.HOUR_OF_DAY, offset); // 同步更新核心日历对象的小时数
mHourSpinner.setValue(getCurrentHour()); // 刷新小时选择器的选中值
updateDateControl(); // 刷新日期选择器,小时偏移可能导致日期变化
// 根据新小时数更新上午/下午标记
// 根据新小时数更新上下午状态标记
int newHour = getCurrentHourOfDay();
if (newHour >= HOURS_IN_HALF_DAY) {
mIsAm = false;
} else {
mIsAm = true;
}
updateAmPmControl(); // 更新上午/下午选择器
mIsAm = newHour < HOURS_IN_HALF_DAY;
updateAmPmControl(); // 同步刷新上下午选择器的选中状态
}
// 设置最终的分钟数
// ========== 统一处理:将选中的分钟数同步至核心日历对象 ==========
mDate.set(Calendar.MINUTE, newVal);
// 触发时间变化回调
// 触发全局时间变化回调,向外通知分钟已更新
onDateTimeChanged();
}
};
/** 上午/下午选择器值变化监听器:切换上午/下午调整小时数±12小时 */
/**
* 12
* ±12
* +12-12
*/
private NumberPicker.OnValueChangeListener mOnAmPmChangedListener = new NumberPicker.OnValueChangeListener() {
@Override
public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
// 切换上午/下午标记
// 翻转上下午状态标记
mIsAm = !mIsAm;
// 调整小时数:上午→下午 +12小时下午→上午 -12小时
// 根据新状态,调整核心日历对象的小时数,保证时间正确
if (mIsAm) {
mDate.add(Calendar.HOUR_OF_DAY, -HOURS_IN_HALF_DAY);
} else {
mDate.add(Calendar.HOUR_OF_DAY, HOURS_IN_HALF_DAY);
}
// 更新上午/下午选择器展示
// 刷新上下午选择器的展示状态
updateAmPmControl();
// 触发时间变化回调
// 触发全局时间变化回调,向外通知上下午已切换
onDateTimeChanged();
}
};
// ======================== 回调接口 - 日期时间变化通知 ========================
// ======================== 内部回调接口 - 日期时间变化通知【标准化通信协议】 ========================
/**
*
* DateTimePickerDialog
*
*
*
*/
public interface OnDateTimeChangedListener {
/**
*
* @param view DateTimePicker
* @param year
* @param month Calendar.MONTH0=111=12
* @param dayOfMonth
* @param hourOfDay 240~23
* @param minute 0~59
*
* @param view DateTimePicker
* @param year 2026
* @param month Calendar011112
* @param dayOfMonth 15
* @param hourOfDay 240~23使
* @param minute 0~59
*/
void onDateTimeChanged(DateTimePicker view, int year, int month,
int dayOfMonth, int hourOfDay, int minute);
}
// ======================== 构造方法 ========================
// ======================== 构造方法区 - 三级重载构造,满足不同初始化需求【核心初始化入口】 ========================
/**
* 1使24
* @param context
* 1
* 使24
* @param context
*/
public DateTimePicker(Context context) {
this(context, System.currentTimeMillis());
}
/**
* 224
* @param context
* @param date
* 2
* 24
* @param context
* @param date
*/
public DateTimePicker(Context context, long date) {
this(context, date, DateFormat.is24HourFormat(context));
}
/**
* 324
* @param context
* @param date
* @param is24HourView 使24
* 3
* ++++++
* @param context
* @param date
* @param is24HourView 24true=false=12
*/
public DateTimePicker(Context context, long date, boolean is24HourView) {
super(context);
mDate = Calendar.getInstance(); // 初始化核心日历对象
mInitialising = true; // 标记进入初始化阶段(避免触发不必要的回调)
// 初始化上午/下午标记根据当前小时数判断≥12为下午
mDate = Calendar.getInstance(); // 初始化核心日历对象,默认加载系统当前时间
mInitialising = true; // 标记进入初始化阶段,屏蔽所有回调触发,防止无效数据通知
// 初始化上下午状态标记根据当前小时数判断≥12为下午<12为上午
mIsAm = getCurrentHourOfDay() >= HOURS_IN_HALF_DAY;
// 加载控件布局datetime_picker.xml
// 第一步填充控件的核心布局将xml布局文件加载至当前FrameLayout根容器
inflate(context, R.layout.datetime_picker, this);
// 初始化日期选择器
// 第二步初始化所有子滚轮选择器绑定控件ID配置基础属性与监听器
// 初始化日期选择器:配置取值范围+绑定值变化监听器
mDateSpinner = (NumberPicker) findViewById(R.id.date);
mDateSpinner.setMinValue(DATE_SPINNER_MIN_VAL);
mDateSpinner.setMaxValue(DATE_SPINNER_MAX_VAL);
mDateSpinner.setOnValueChangedListener(mOnDateChangedListener);
// 初始化小时选择器
// 初始化小时选择器仅绑定值变化监听器取值范围在24/12小时制设置时动态配置
mHourSpinner = (NumberPicker) findViewById(R.id.hour);
mHourSpinner.setOnValueChangedListener(mOnHourChangedListener);
// 初始化分钟选择器
// 初始化分钟选择器:配置取值范围+设置长按快速滚动间隔+绑定值变化监听器,细节体验优化
mMinuteSpinner = (NumberPicker) findViewById(R.id.minute);
mMinuteSpinner.setMinValue(MINUT_SPINNER_MIN_VAL);
mMinuteSpinner.setMaxValue(MINUT_SPINNER_MAX_VAL);
mMinuteSpinner.setLongPressUpdateInterval(100); // 长按快速调整的间隔100ms
mMinuteSpinner.setLongPressUpdateInterval(100); // 长按滚动间隔100ms滚动更快更流畅
mMinuteSpinner.setOnValueChangedListener(mOnMinuteChangedListener);
// 初始化上午/下午选择器
String[] stringsForAmPm = new DateFormatSymbols().getAmPmStrings(); // 获取系统AM/PM文本适配多语言
// 初始化上下午选择器:配置取值范围+加载系统原生的AM/PM文本适配多语言+绑定值变化监听
String[] stringsForAmPm = new DateFormatSymbols().getAmPmStrings();
mAmPmSpinner = (NumberPicker) findViewById(R.id.amPm);
mAmPmSpinner.setMinValue(AMPM_SPINNER_MIN_VAL);
mAmPmSpinner.setMaxValue(AMPM_SPINNER_MAX_VAL);
mAmPmSpinner.setDisplayedValues(stringsForAmPm); // 设置AM/PM展示文本
mAmPmSpinner.setDisplayedValues(stringsForAmPm);
mAmPmSpinner.setOnValueChangedListener(mOnAmPmChangedListener);
// 更新控件到初始状态
// 第三步刷新所有子选择器的初始展示状态保证UI与初始数据一致
updateDateControl();
updateHourControl();
updateAmPmControl();
// 设置24小时制状态
// 第四步配置24/12小时制状态完成制式适配
set24HourView(is24HourView);
// 设置初始时间
// 第五步:设置控件的初始选中时间,同步至所有选择器与核心日历对象
setCurrentDate(date);
// 设置控件启用状态
// 第六步:配置控件的整体启用状态,默认启用
setEnabled(isEnabled());
// 初始化完成,取消标记
// 第七步:初始化完成,解除初始化标记,控件进入正常工作状态,可响应所有操作与回调
mInitialising = false;
}
// ======================== 重写方法 - 控件启用/禁用 ========================
// ======================== 重写父类方法 - 控件整体启用/禁用【统一状态管控】 ========================
/**
* /
* @param enabled true=false=
* FrameLayoutsetEnabled/
*
*
* @param enabled true=false=
*/
@Override
public void setEnabled(boolean enabled) {
if (mIsEnabled == enabled) {
return; // 状态未变化,直接返回
return;
}
super.setEnabled(enabled);
// 同步所有选择器的启用状态
// 同步所有选择器的启用状态
mDateSpinner.setEnabled(enabled);
mMinuteSpinner.setEnabled(enabled);
mHourSpinner.setEnabled(enabled);
mAmPmSpinner.setEnabled(enabled);
// 更新启用状态标记
// 更新全局启用状态标记
mIsEnabled = enabled;
}
/**
*
* @return true=false=
* FrameLayoutisEnabled
* @return true=false=
*/
@Override
public boolean isEnabled() {
return mIsEnabled;
}
// ======================== 公共方法 - 日期时间获取/设置 ========================
// ======================== 公共方法区 - 日期时间 取值/赋值 标准化API【外部核心调用接口最全】 ========================
/**
*
* @return
*
* @return
*/
public long getCurrentDateInTimeMillis() {
return mDate.getTimeInMillis();
}
/**
*
* @param date
*
*
* @param date
*/
public void setCurrentDate(long date) {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(date);
// 解析时间戳为年/月/日/时/分,调用重载方法设置
// 解析时间戳为标准化的年月日时分,调用重载方法完成赋值
setCurrentDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE));
}
/**
* ////
* @param year
* @param month Calendar.MONTH0=1
* @param dayOfMonth
* @param hourOfDay 240~23
* @param minute 0~59
*
*
* @param year 2026
* @param month Calendar0=1
* @param dayOfMonth 1~31
* @param hourOfDay 240~23
* @param minute 0~59
*/
public void setCurrentDate(int year, int month,
int dayOfMonth, int hourOfDay, int minute) {
@ -411,7 +444,7 @@ public class DateTimePicker extends FrameLayout {
/**
*
* @return 2025
* @return 2026
*/
public int getCurrentYear() {
return mDate.get(Calendar.YEAR);
@ -419,21 +452,21 @@ public class DateTimePicker extends FrameLayout {
/**
*
* @param year 2025
*
* @param year 2026
*/
public void setCurrentYear(int year) {
// 初始化中或年份未变化时,直接返回
if (!mInitialising && year == getCurrentYear()) {
return;
}
mDate.set(Calendar.YEAR, year);
updateDateControl(); // 更新日期选择器展示
onDateTimeChanged(); // 触发时间变化回调
updateDateControl();
onDateTimeChanged();
}
/**
*
* @return Calendar.MONTH0=111=12
* @return Calendar011112
*/
public int getCurrentMonth() {
return mDate.get(Calendar.MONTH);
@ -441,216 +474,212 @@ public class DateTimePicker extends FrameLayout {
/**
*
* @param month Calendar.MONTH0=1
*
* @param month Calendar0=1
*/
public void setCurrentMonth(int month) {
// 初始化中或月份未变化时,直接返回
if (!mInitialising && month == getCurrentMonth()) {
return;
}
mDate.set(Calendar.MONTH, month);
updateDateControl(); // 更新日期选择器展示
onDateTimeChanged(); // 触发时间变化回调
updateDateControl();
onDateTimeChanged();
}
/**
*
* @return 1~31
*
* @return 1~31
*/
public int getCurrentDay() {
return mDate.get(Calendar.DAY_OF_MONTH);
}
/**
*
* @param dayOfMonth 1~31
*
*
* @param dayOfMonth 1~31
*/
public void setCurrentDay(int dayOfMonth) {
// 初始化中或日期未变化时,直接返回
if (!mInitialising && dayOfMonth == getCurrentDay()) {
return;
}
mDate.set(Calendar.DAY_OF_MONTH, dayOfMonth);
updateDateControl(); // 更新日期选择器展示
onDateTimeChanged(); // 触发时间变化回调
updateDateControl();
onDateTimeChanged();
}
/**
* 240~23
* @return 0~23
* 24
* @return 0~23
*/
public int getCurrentHourOfDay() {
return mDate.get(Calendar.HOUR_OF_DAY);
}
/**
* 24/12
* @return 240~23121~12
*
* 24/12使
* @return 240~23121~12
*/
private int getCurrentHour() {
if (mIs24HourView){
return getCurrentHourOfDay(); // 24小时制直接返回
return getCurrentHourOfDay();
} else {
// 12小时制转换0点→12点13点→1点依此类推
int hour = getCurrentHourOfDay();
if (hour > HOURS_IN_HALF_DAY) {
return hour - HOURS_IN_HALF_DAY;
} else {
return hour == 0 ? HOURS_IN_HALF_DAY : hour;
}
// 12小时制特殊转换0点→12点13点→1点保证选择器展示正确
return hour > HOURS_IN_HALF_DAY ? hour - HOURS_IN_HALF_DAY : (hour == 0 ? HOURS_IN_HALF_DAY : hour);
}
}
/**
* 240~23
* @param hourOfDay 0~23
* 24
* 24/12
*
* @param hourOfDay 240~23
*/
public void setCurrentHour(int hourOfDay) {
// 初始化中或小时未变化时,直接返回
if (!mInitialising && hourOfDay == getCurrentHourOfDay()) {
return;
}
mDate.set(Calendar.HOUR_OF_DAY, hourOfDay);
// 12小时制下,更新上午/下午标记和选择器
// 12小时制下的特殊处理:转换小时数+更新上下午状态
if (!mIs24HourView) {
if (hourOfDay >= HOURS_IN_HALF_DAY) {
mIsAm = false; // ≥12为下午
if (hourOfDay > HOURS_IN_HALF_DAY) {
hourOfDay -= HOURS_IN_HALF_DAY; // 转换为12小时制13→114→2...
}
mIsAm = false;
hourOfDay = hourOfDay > HOURS_IN_HALF_DAY ? hourOfDay - HOURS_IN_HALF_DAY : hourOfDay;
} else {
mIsAm = true; // <12为上午
if (hourOfDay == 0) {
hourOfDay = HOURS_IN_HALF_DAY; // 0点→12点
}
mIsAm = true;
hourOfDay = hourOfDay == 0 ? HOURS_IN_HALF_DAY : hourOfDay;
}
updateAmPmControl(); // 更新上午/下午选择器
updateAmPmControl();
}
// 设置小时选择器的值
mHourSpinner.setValue(hourOfDay);
// 触发时间变化回调
onDateTimeChanged();
}
/**
*
* @return 0~59
*
* @return 0~59
*/
public int getCurrentMinute() {
return mDate.get(Calendar.MINUTE);
}
/**
*
* @param minute 0~59
*
*
* @param minute 0~59
*/
public void setCurrentMinute(int minute) {
// 初始化中或分钟未变化时,直接返回
if (!mInitialising && minute == getCurrentMinute()) {
return;
}
mMinuteSpinner.setValue(minute); // 设置分钟选择器的值
mDate.set(Calendar.MINUTE, minute); // 更新日历对象
onDateTimeChanged(); // 触发时间变化回调
mMinuteSpinner.setValue(minute);
mDate.set(Calendar.MINUTE, minute);
onDateTimeChanged();
}
// ======================== 公共方法 - 24小时制适配 ========================
// ======================== 公共方法 - 24小时制适配【系统级适配能力】 ========================
/**
* 24
* 24
* @return true=24false=12
*/
public boolean is24HourView () {
return mIs24HourView;
}
public boolean is24HourView () {
return mIs24HourView;
}
/**
* 24/12
* @param is24HourView true=24AM/PMfalse=12AM/PM
* 24/12
*
*
* @param is24HourView true=24false=12
*/
public void set24HourView(boolean is24HourView) {
if (mIs24HourView == is24HourView) {
return; // 状态未变化,直接返回
}
mIs24HourView = is24HourView;
// 显示/隐藏上午/下午选择器
public void set24HourView(boolean is24HourView) {
if (mIs24HourView == is24HourView) {
return;
}
mIs24HourView = is24HourView;
// 控制上下午选择器的可见性
mAmPmSpinner.setVisibility(is24HourView ? View.GONE : View.VISIBLE);
// 获取当前小时数(用于重置选择器)
int hour = getCurrentHourOfDay();
// 新小时选择器的取值范围
// 新小时选择器的取值范围
updateHourControl();
// 重新设置小时数(适配新的制式)
// 重新适配并设置小时数,保证展示正确
setCurrentHour(hour);
// 更新上午/下午选择器状态
// 刷新上下午选择器的状态
updateAmPmControl();
}
}
// ======================== 内部方法 - 控件更新 ========================
// ======================== 内部私有方法 - 控件UI刷新【核心UI更新逻辑,内部闭环】 ========================
/**
* 712.23
*
* 7
* MM.dd EEEE. 01.15
*/
private void updateDateControl() {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(mDate.getTimeInMillis());
// 计算近7天的起始日期(当前日期 - 4天确保中间为当前日期
// 计算近7天的起始日期当前日期向前推4天保证选中日期在列表中间位置交互更友好
cal.add(Calendar.DAY_OF_YEAR, -DAYS_IN_ALL_WEEK / 2 - 1);
mDateSpinner.setDisplayedValues(null); // 清空原有展示文本
// 生成7天的格式化日期文本
mDateSpinner.setDisplayedValues(null); // 清空原有文本,避免残留
// 循环生成7天的格式化日期文本
for (int i = 0; i < DAYS_IN_ALL_WEEK; ++i) {
cal.add(Calendar.DAY_OF_YEAR, 1);
// 格式化:月.日 星期如“12.23 星期二”)
mDateDisplayValues[i] = (String) DateFormat.format("MM.dd EEEE", cal);
}
// 设置日期选择器的展示文本
// 将生成的文本数组设置日期选择器
mDateSpinner.setDisplayedValues(mDateDisplayValues);
// 设置默认选中中间项(当前日期)
// 默认选中列表中间项,即当前日期
mDateSpinner.setValue(DAYS_IN_ALL_WEEK / 2);
mDateSpinner.invalidate(); // 刷新选择器UI
mDateSpinner.invalidate(); // 强制刷新UI,保证展示生效
}
/**
* /
*
* 24UI
*/
private void updateAmPmControl() {
if (mIs24HourView) {
mAmPmSpinner.setVisibility(View.GONE); // 24小时制隐藏
mAmPmSpinner.setVisibility(View.GONE);
} else {
// 设置选中项0=AM/上午1=PM/下午
// 设置选中项0=上午/AM1=下午/PM
int index = mIsAm ? Calendar.AM : Calendar.PM;
mAmPmSpinner.setValue(index);
mAmPmSpinner.setVisibility(View.VISIBLE); // 12小时制显示
mAmPmSpinner.setVisibility(View.VISIBLE);
}
}
/**
* 24/12
*
* 24
*/
private void updateHourControl() {
if (mIs24HourView) {
// 24小时制0~23
mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW);
mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW);
} else {
// 12小时制1~12
mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW);
mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW);
}
}
// ======================== 公共方法 - 回调设置 ========================
private void updateHourControl() {
if (mIs24HourView) {
mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW);
mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW);
} else {
mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW);
mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW);
}
}
// ======================== 公共方法区 - 回调监听器绑定【通信入口】 ========================
/**
*
* @param callback null
*
* @param callback OnDateTimeChangedListenernull
*/
public void setOnDateTimeChangedListener(OnDateTimeChangedListener callback) {
mOnDateTimeChangedListener = callback;
}
// ======================== 内部方法 - 回调触发 ========================
// ======================== 内部私有方法 - 回调触发【核心通信逻辑,内部闭环】 ========================
/**
* ////
*
*
* null
*/
private void onDateTimeChanged() {
if (mOnDateTimeChangedListener != null) {

@ -14,144 +14,160 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块封装日期时间选择对话框集成DateTimePicker控件
// 包声明:归属小米便签UI模块日期时间选择弹窗的核心封装类集成自定义时间选择控件
package net.micode.notes.ui;
// 导入日历类:管理选中的日期时间,处理年/月/日/时/分的设置
// Java日历工具类核心时间处理载体存储用户选中的年月日时分秒提供时间的赋值与转换能力
import java.util.Calendar;
// 导入小米便签资源类:引用对话框按钮文本、布局等资源
// 小米便签资源文件引用获取字符串、布局等资源ID常量
import net.micode.notes.R;
// 导入自定义日期时间选择控件:核心的日期时间选择UI
// 自定义日期时间选择核心控件,本弹窗的核心内容视图,提供年月日时分滚轮选择UI
import net.micode.notes.ui.DateTimePicker;
import net.micode.notes.ui.DateTimePicker.OnDateTimeChangedListener;
// 导入安卓对话框相关类基础AlertDialog、对话框点击事件
// 安卓系统弹窗核心类,本类的父类,提供弹窗的基础展示与按钮配置能力
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
// 导入安卓日期格式化工具类判断24小时制、格式化日期时间文本
// 安卓系统日期格式化工具类提供24小时制判断、时间戳转友好文本的格式化能力
import android.text.format.DateFormat;
import android.text.format.DateUtils;
/**
*
* AlertDialog
* 1. DateTimePickerUI
* 2. Calendar
* 3. 24
* 4.
* 使便/
*
* AlertDialog
* 便便DateTimePicker
* +++API
*
* 1. DateTimePicker
* 2. Calendar
* 3. 24/12
* 4.
* 5.
* 6.
* 7. 便使
* 便便
*/
public class DateTimePickerDialog extends AlertDialog implements OnClickListener {
// 选中的日期时间对象:存储用户选择的年/月/日/时/分秒固定为0
// 核心时间数据载体:存储用户当前选中的完整日期时间,初始化时获取系统当前时间,秒数固定置0
private Calendar mDate = Calendar.getInstance();
// 是否使用24小时制适配系统设置影响时间显示格式
// 24小时制标记位适配系统设置决定时间的展示格式24小时/12小时带上午下午
private boolean mIs24HourView;
// 日期时间选择完成的回调监听器:外部实现,接收最终选中的时间戳
// 时间选择完成的回调监听器:外部实现该接口接收最终选中的时间戳,核心通信桥梁
private OnDateTimeSetListener mOnDateTimeSetListener;
// 核心日期时间选择控件:承载年/月/日/时/分的选择UI
// 自定义日期时间选择控件本弹窗的核心内容视图提供滚轮式年月日时分选择UI业务核心控件
private DateTimePicker mDateTimePicker;
/**
*
* NoteEditActivity
*
*
*
*/
public interface OnDateTimeSetListener {
/**
*
* @param dialog /
* @param date 0
*
* @param dialog
* @param date 0
*/
void OnDateTimeSet(AlertDialog dialog, long date);
}
/**
*
* @param context
* @param date
*
* +++++
*
* @param context
* @param date
*/
public DateTimePickerDialog(Context context, long date) {
super(context);
// 1. 创建自定义日期时间选择控件,设置为对话框的核心视图
// 第一步:创建自定义的日期时间选择控件,作为弹窗的核心内容视图,替换原生弹窗的默认布局
mDateTimePicker = new DateTimePicker(context);
setView(mDateTimePicker);
// 2. 绑定DateTimePicker的时间变化监听实时更新选中的Calendar对象
// 第二步:为时间选择控件绑定实时变化监听器,核心联动逻辑
// 监听用户在滚轮上的每一次选择操作实时同步选中的时间数据到Calendar对象中
mDateTimePicker.setOnDateTimeChangedListener(new OnDateTimeChangedListener() {
public void onDateTimeChanged(DateTimePicker view, int year, int month,
int dayOfMonth, int hourOfDay, int minute) {
// 更新Calendar对象的年/月/日/时/分
// 实时更新核心时间载体:将滚轮选中的年月日时分赋值到Calendar对象
mDate.set(Calendar.YEAR, year);
mDate.set(Calendar.MONTH, month);
mDate.set(Calendar.DAY_OF_MONTH, dayOfMonth);
mDate.set(Calendar.HOUR_OF_DAY, hourOfDay);
mDate.set(Calendar.MINUTE, minute);
// 实时更新对话框标题(格式化显示当前选中的时间)
// 实时更新弹窗标题:将最新选中的时间格式化后展示在弹窗顶部,给用户即时反馈
updateTitle(mDate.getTimeInMillis());
}
});
// 3. 初始化选中的日期时间设置初始时间戳秒固定为0仅保留到分钟级
// 第三步:初始化选中的时间数据,设置默认选中的时间戳,统一时间精度
mDate.setTimeInMillis(date);
mDate.set(Calendar.SECOND, 0);
// 将初始时间设置到DateTimePicker控件
mDate.set(Calendar.SECOND, 0); // 强制置空秒数,仅保留到分钟级,符合业务使用场景
// 将初始化的时间数据同步到时间选择控件,保证弹窗打开时展示正确的默认时间
mDateTimePicker.setCurrentDate(mDate.getTimeInMillis());
// 4. 设置对话框按钮:确认(触发回调)、取消(无操作)
setButton(context.getString(R.string.datetime_dialog_ok), this); // 确认按钮绑定当前类的onClick
setButton2(context.getString(R.string.datetime_dialog_cancel), (OnClickListener)null); // 取消按钮无操作
// 第四步:配置弹窗的底部按钮,固化交互逻辑,符合用户操作习惯
setButton(context.getString(R.string.datetime_dialog_ok), this); // 确定按钮,绑定当前类的点击事件
setButton2(context.getString(R.string.datetime_dialog_cancel), (OnClickListener)null); // 取消按钮,无业务逻辑,点击仅关闭弹窗
// 5. 适配系统24小时制设置获取系统是否启用24小时制设置到对话框
// 第五步适配系统全局的时间显示规则自动获取系统是否开启24小时制保证体验一致性
set24HourView(DateFormat.is24HourFormat(this.getContext()));
// 6. 初始化对话框标题:格式化显示初始时间
// 第六步:初始化弹窗标题,将默认时间格式化后展示,完成弹窗的最终初始化
updateTitle(mDate.getTimeInMillis());
}
/**
* 使24
* @param is24HourView true=24false=12/
*
* 24
* @param is24HourView true=使24false=使12
*/
public void set24HourView(boolean is24HourView) {
mIs24HourView = is24HourView;
}
/**
*
* @param callBack OnDateTimeSetListenerNoteEditActivity
*
*
* @param callBack OnDateTimeSetListener
*/
public void setOnDateTimeSetListener(OnDateTimeSetListener callBack) {
mOnDateTimeSetListener = callBack;
}
/**
*
* @param date
*
*
*
* @param date
*/
private void updateTitle(long date) {
// 定义日期时间格式化标记:显示年、日期、时间
// 定义时间格式化的标记位组合,指定需要展示的时间维度:年 + 月日 + 时分
int flag =
DateUtils.FORMAT_SHOW_YEAR | // 显示年份
DateUtils.FORMAT_SHOW_DATE | // 显示日期(月/日)
DateUtils.FORMAT_SHOW_TIME; // 显示时间(时/分)
// 适配24小时制覆盖标记FORMAT_24HOUR无论true/false仅控制显示格式
DateUtils.FORMAT_SHOW_YEAR | // 强制展示年份如「2026年」
DateUtils.FORMAT_SHOW_DATE | // 强制展示日期如「1月15日」
DateUtils.FORMAT_SHOW_TIME; // 强制展示时间如「15:30」或「下午3:30」
// 根据24小时制标记位追加对应的格式化规则自动切换显示格式
flag |= mIs24HourView ? DateUtils.FORMAT_24HOUR : DateUtils.FORMAT_24HOUR;
// 格式化时间戳为文本,设置为对话框标题
// 将时间戳格式化后设置为弹窗的标题文本完成UI更新
setTitle(DateUtils.formatDateTime(this.getContext(), date, flag));
}
/**
*
*
* @param arg0 DialogInterface
* @param arg1 DialogInterface.BUTTON_POSITIVE
*
* OnClickListener
*
* @param arg0
* @param arg1 /
*/
public void onClick(DialogInterface arg0, int arg1) {
// 有回调监听器时,触发回调并传递选中的时间戳
// 空值安全校验:避免未绑定回调监听器时触发空指针异常
if (mOnDateTimeSetListener != null) {
// 触发外部回调,传递弹窗实例和最终选中的时间戳,完成时间选择的业务闭环
mOnDateTimeSetListener.OnDateTimeSet(this, mDate.getTimeInMillis());
}
}

@ -14,95 +14,109 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块封装下拉菜单的核心逻辑简化PopupMenu的使
// 包声明:归属小米便签UI模块下拉菜单功能的统一封装工具类全局复
package net.micode.notes.ui;
// 导入安卓上下文类提供应用运行环境创建PopupMenu、加载资源
// 安卓系统核心类 - 上下文,提供资源加载、控件创建的运行环境,必须依赖项
import android.content.Context;
// 导入安卓菜单相关类:管理下拉菜单的选项和菜单项
// 安卓菜单体系核心类Menu管理菜单整体结构MenuItem表示单个菜单项
import android.view.Menu;
import android.view.MenuItem;
// 导入安卓视图相关类:处理按钮点击事件
// 安卓视图体系核心类,处理视图点击事件、视图基础属性配置
import android.view.View;
import android.view.View.OnClickListener;
// 导入安卓按钮控件类:作为下拉菜单的触发按钮
// 安卓按钮控件,作为下拉菜单的触发载体,本类核心绑定控件
import android.widget.Button;
// 导入安卓PopupMenu类核心的下拉菜单控件
// 安卓原生下拉菜单核心控件,实现弹窗式菜单展示,本类封装的核心原生控件
import android.widget.PopupMenu;
// 安卓下拉菜单的菜单项点击事件监听器,监听菜单选项的点击行为
import android.widget.PopupMenu.OnMenuItemClickListener;
// 导入小米便签资源类:引用下拉按钮的背景资源
// 小米便签资源文件引用获取图标、布局等资源ID常量
import net.micode.notes.R;
/**
*
*
* 1. PopupMenuButton使
* 2.
* 3.
* 使便
* UI
* AndroidPopupMenu+Button
* 便
*
* 1. PopupMenuAPI
* 2. UI
* 3.
* 4. --
* 5. 使
* 便
*/
public class DropdownMenu {
// 下拉菜单的触发按钮:点击该按钮显示下拉菜单,统一设置背景样式
// 下拉菜单的触发按钮,全局持用引用:点击该按钮即可弹出下拉菜单,统一配置背景样式
private Button mButton;
// 核心下拉菜单控件:承载菜单选项,控制菜单的显示/隐藏
// Android原生下拉菜单核心控件本类封装的核心对象负责菜单的弹出、收起、承载菜单项
private PopupMenu mPopupMenu;
// PopupMenu对应的Menu对象用于查找菜单项、管理菜单内容
// PopupMenu对应的菜单容器对象:用于动态查找、修改、管理菜单项的状态(显示/隐藏/禁用等)
private Menu mMenu;
/**
* +
* @param context PopupMenu
* @param button
* @param menuId IDR.menu.xxx
*
* +++
*
* @param context PopupMenu
* @param button
* @param menuId IDR.menu.menu_sort
*/
public DropdownMenu(Context context, Button button, int menuId) {
// 保存触发按钮引用
// 保存触发按钮的全局用,供后续设置文本、复用控件使
mButton = button;
// 设置按钮背景为下拉图标(统一样式)
// 统一设置触发按钮的背景样式:加载内置的下拉箭头图标,保证所有下拉按钮视觉统一
mButton.setBackgroundResource(R.drawable.dropdown_icon);
// 创建PopupMenu关联上下文和触发按钮菜单显示在按钮下方
// 创建原生PopupMenu对象绑定上下文和触发按钮指定菜单弹出的锚点位置
mPopupMenu = new PopupMenu(context, mButton);
// 获取PopupMenu对应的Menu对象用于后续菜单管理
// 获取PopupMenu内部的Menu容器对象缓存引用供后续菜单项查找使用避免重复获取
mMenu = mPopupMenu.getMenu();
// 加载菜单布局资源到Menu中初始化下拉菜单的选项
// 通过菜单解析器将指定的菜单布局资源加载到Menu容器中完成菜单项的初始化展示
mPopupMenu.getMenuInflater().inflate(menuId, mMenu);
// 绑定按钮点击事件:点击按钮显示下拉菜单
// 为触发按钮绑定点击事件监听器:点击按钮时,自动弹出下拉菜单,核心触发逻辑
mButton.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
mPopupMenu.show(); // 显示下拉菜单
// 弹出下拉菜单,菜单默认展示在触发按钮的下方,对齐方式由系统原生适配
mPopupMenu.show();
}
});
}
/**
*
*
* @param listener OnMenuItemClickListener
*
*
* ID
* @param listener onMenuItemClick
*/
public void setOnDropdownMenuItemClickListener(OnMenuItemClickListener listener) {
// 仅当PopupMenu初始化完成时绑定监听器
// 空值安全校验避免PopupMenu未初始化完成时绑定监听器导致空指针异常
if (mPopupMenu != null) {
// 将外部传入的监听器绑定到PopupMenu所有菜单项的点击事件都会回调该监听器
mPopupMenu.setOnMenuItemClickListener(listener);
}
}
/**
* IDMenuItem
* //
* @param id IDR.id.menu_sort_by_time
* @return MenuItemnull
* IDMenuItem
* ///
*
* @param id IDR.id.menu_sort_by_timeR.id.menu_move_folder
* @return MenuItem null
*/
public MenuItem findItem(int id) {
// 直接通过缓存的Menu对象查找菜单项高效无冗余
return mMenu.findItem(id);
}
/**
*
*
* @param title
*
*
*
* @param title
*/
public void setTitle(CharSequence title) {
// 直接为触发按钮设置文本内容更新UI展示
mButton.setText(title);
}
}

@ -14,137 +14,162 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块作为文件夹列表如“移动便签”弹窗的核心适配器
// 包声明:归属小米便签UI模块是文件夹列表展示的核心数据适配器
package net.micode.notes.ui;
// 导入安卓上下文类:提供应用运行环境(创建列表项、加载资源)
// 安卓系统核心类 - 上下文,提供资源加载、视图创建的运行环境
import android.content.Context;
// 导入安卓数据库游标类:存储文件夹数据库查询结果
// 安卓数据库游标类,承载文件夹的数据库查询结果集,适配器的核心数据源
import android.database.Cursor;
// 导入安卓视图相关类:创建/绑定列表项视图
// 安卓视图体系核心类,用于创建和装载列表子项视图
import android.view.View;
import android.view.ViewGroup;
// 导入安卓Cursor适配器类适配Cursor数据的列表适配器基
// 安卓游标适配器基类适配Cursor数据源的列表专用适配器本类核心父
import android.widget.CursorAdapter;
// 导入安卓线性布局类:作为文件夹列表项的根布局
// 安卓线性布局,作为自定义列表项的根布局容器
import android.widget.LinearLayout;
// 导入安卓文本视图类:展示文件夹名称
// 安卓文本控件,用于展示文件夹名称文本内容
import android.widget.TextView;
// 导入小米便签资源类:引用布局、字符串资源(根文件夹显示文本)
// 小米便签资源文件引用获取布局、字符串等资源id
import net.micode.notes.R;
// 导入便签数据常量类定义文件夹ID根文件夹、字段等常量
// 小米便签核心数据常量定义文件夹id、数据库字段名等全局常量
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
/**
*
* CursorAdapter
* 1. ID
* 2. CursorFolderListItem
* 3.
* 4.
* 使便
*
* CursorAdapter Cursor
* 便便
*
* 1. IO
* 2. Cursor
* 3.
* 4.
* 5. findViewById
* -
*/
public class FoldersListAdapter extends CursorAdapter {
/**
* IO
*
* - NoteColumns.IDIDNotes.ID_ROOT_FOLDER
* - NoteColumns.SNIPPETSNIPPET
*
*
*
*/
public static final String [] PROJECTION = {
NoteColumns.ID, // 0: 文件夹唯一ID
NoteColumns.SNIPPET // 1: 文件夹名称
NoteColumns.ID, // 数组索引0 - 文件夹的唯一ID数据库主键
NoteColumns.SNIPPET // 数组索引1 - 文件夹的名称,用于列表展示
};
// 投影数组对应的列索引常量简化Cursor取值避免硬编码索引
public static final int ID_COLUMN = 0; // 文件夹ID列索引
public static final int NAME_COLUMN = 1; // 文件夹名称列索引
/**
*
*
* Cursor.getInt(ID_COLUMN) Cursor.getInt(0)
*/
public static final int ID_COLUMN = 0; // 文件夹ID对应的游标列索引
public static final int NAME_COLUMN = 1; // 文件夹名称对应的游标列索引
/**
*
* @param context
* @param c CursorID
* CursorAdapter
* @param context
* @param c ID
*/
public FoldersListAdapter(Context context, Cursor c) {
super(context, c);
// TODO Auto-generated constructor stub
}
/**
*
* @param context
* @param cursor Cursor使
* @param parent ListView
* @return ViewFolderListItem
*
*
* bindView
* @param context
* @param cursor 使
* @param parent ListView
* @return View
*/
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
// 创建自定义文件夹列表项视图FolderListItem
// 创建自定义的文件夹列表项视图,内部已完成布局加载和控件绑定
return new FolderListItem(context);
}
/**
* CursorUI
*
* -
* - 使Cursor
* -
* @param view FolderListItem
* @param context
* @param cursor CursorID
*
* /
*
* 1. ID=Notes.ID_ROOT_FOLDER
* 2.
* 3. FolderListItem
* @param view newView
* @param context
* @param cursor
*/
@Override
public void bindView(View view, Context context, Cursor cursor) {
// 仅处理FolderListItem类型的视图防止类型错误
// 视图类型校验,保证类型安全,避免视图强转异常
if (view instanceof FolderListItem) {
// 处理文件夹名称:根文件夹替换为“移动到上级文件夹”,普通文件夹用原始名称
String folderName = (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context
.getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN);
// 调用列表项的bind方法设置文件夹名称
// 读取当前游标中的文件夹ID判断是否为根文件夹
long folderId = cursor.getLong(ID_COLUMN);
String folderName;
// 根文件夹特殊处理:替换展示文本;普通文件夹使用原始名称
if (folderId == Notes.ID_ROOT_FOLDER) {
folderName = context.getString(R.string.menu_move_parent_folder);
} else {
folderName = cursor.getString(NAME_COLUMN);
}
// 调用自定义视图的绑定方法,将处理后的文件夹名称设置到文本控件
((FolderListItem) view).bind(folderName);
}
}
/**
*
* Cursor
* @param context
* @param position
* @return String
*
*
* bindView
* @param context
* @param position 0
* @return String
*/
public String getFolderName(Context context, int position) {
// 获取指定位置的Cursor
// 根据位置获取对应的游标对象,游标已自动定位到对应行
Cursor cursor = (Cursor) getItem(position);
// 同bindView逻辑根文件夹替换名称普通文件夹用原始名称
return (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context
.getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN);
long folderId = cursor.getLong(ID_COLUMN);
// 根文件夹判断与名称适配逻辑与bindView完全一致
return folderId == Notes.ID_ROOT_FOLDER ? context.getString(R.string.menu_move_parent_folder)
: cursor.getString(NAME_COLUMN);
}
/**
*
* LinearLayout
*
* LinearLayout 线
*
* bind
* 访private 使
*/
private class FolderListItem extends LinearLayout {
// 文件夹名称文本控件:展示处理后的文件夹名称
// 列表项核心控件:展示文件夹名称的文本控件,全局缓存避免重复查找
private TextView mName;
/**
*
* @param context
*
* +
* @param context
*/
public FolderListItem(Context context) {
super(context);
// 加载文件夹列表项布局res/layout/folder_list_item.xml到当前LinearLayout
// 将文件夹列表项的布局文件填充到当前的LinearLayout根布局中
inflate(context, R.layout.folder_list_item, this);
// 初始化文件夹名称文本控件
// 查找并缓存文件夹名称文本控件,后续直接复用,提升性能
mName = (TextView) findViewById(R.id.tv_folder_name);
}
/**
*
* @param name
*
*
* @param name /
*/
public void bind(String name) {
mName.setText(name);

@ -14,309 +14,324 @@
* limitations under the License.
*/
// 包声明归属小米便签的UI模块自定义EditText适配便签编辑的特殊交互逻辑
// 包声明归属小米便签的UI模块该类是便签编辑页的核心自定义输入控件
package net.micode.notes.ui;
// 导入安卓上下文:提供应用运行环境
// 安卓系统上下文:提供应用运行环境与系统服务访问能力
import android.content.Context;
// 导入安卓图形矩形类:用于焦点变化时的区域计算
// 安卓图形矩形类:用于焦点切换时的焦点区域坐标计算与传递
import android.graphics.Rect;
// 导入安卓文本布局类:处理文本的行/列定位(触摸光标定位)
// 安卓文本布局核心类:管理文本的排版、行高、行列定位,核心支撑触摸光标精准定位
import android.text.Layout;
// 导入安卓文本选择类:控制文本光标位置
// 安卓文本选择工具类:用于手动设置文本光标位置、选中文本区域
import android.text.Selection;
// 导入安卓富文本类处理包含URLSpan的富文本
// 安卓富文本标记接口:标识带格式的文本(如包含链接、颜色的文本),本类核心处理该类型文本
import android.text.Spanned;
// 导入安卓文本工具类:判空等文本操作
// 安卓文本工具类:提供字符串判空、文本处理等安全高效的工具方法
import android.text.TextUtils;
// 导入安卓URLSpan类处理文本中的链接电话/网页/邮箱)
// 安卓文本链接样式类:封装文本中的超链接数据,包含链接地址与点击事件
import android.text.style.URLSpan;
// 导入安卓属性集类自定义View构造参数
// 安卓属性集类:承载布局xml中配置的控件属性自定义View必备构造参数
import android.util.AttributeSet;
// 导入安卓日志类:输出编辑框相关日志
// 安卓日志工具类:输出编辑框相关的调试日志,便于问题排查
import android.util.Log;
// 导入安卓上下文菜单类:自定义链接的上下文菜单
// 安卓上下文菜单类:构建长按文本弹出的菜单容器
import android.view.ContextMenu;
// 导入安卓按键事件类:处理回车/删除等按键交互
// 安卓按键事件类:封装物理按键/软键盘按键的事件信息,用于监听回车、删除键
import android.view.KeyEvent;
// 导入安卓菜单项类:构建上下文菜单的选项
// 安卓菜单项类:上下文菜单单个选项对象
import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
// 导入安卓触摸事件类:处理触摸定位光标
// 安卓触摸事件类:封装屏幕触摸的坐标、动作类型,用于重写触摸定位光标逻辑
import android.view.MotionEvent;
// 导入安卓编辑框类自定义EditText的父类
// 安卓原生编辑框本自定义控件的父类继承所有原生EditText基础能力
import android.widget.EditText;
// 导入小米便签资源类:引用字符串资源(链接菜单文本)
// 小米便签资源类:引用项目中的字符串、图片等资源文件,此处核心用链接菜单的文本资源
import net.micode.notes.R;
// 导入集合类存储scheme与菜单文本的映射关系
// Java集合类HashMap存储链接协议与菜单文本的映射关系实现协议与文案解耦
import java.util.HashMap;
import java.util.Map;
/**
* 便EditText
* EditText
* 1. 便
* -
* -
* 2.
* 3. //
* 4.
* 5.
* 便 EditText
* Android EditText便
* Activity
*
* 1. /
* 2.
* 3.
* 4. /
* 5.
* 6. EditText
*/
public class NoteEditText extends EditText {
// 日志标签:用于编辑框相关日志输出
// 日志打印的TAG标识固定值便于过滤该控件的所有日志信息
private static final String TAG = "NoteEditText";
// 当前编辑框在多编辑框列表中的索引(用于增删回调)
// 核心成员变量:当前编辑框在【多编辑框列表】中的索引值
// 作用用于向Activity回调增删操作时标识当前操作的编辑框位置核心支撑多框联动
private int mIndex;
// 删除键按下前的光标起始位置(用于判断是否触发编辑框删除)
// 核心成员变量:删除键按下瞬间的光标起始位置
// 作用在onKeyDown中记录、onKeyUp中校验判断是否触发「删除当前编辑框」的业务逻辑
private int mSelectionStartBeforeDelete;
// 链接Scheme常量匹配文本中的不同类型链接
private static final String SCHEME_TEL = "tel:" ; // 电话链接前缀
private static final String SCHEME_HTTP = "http:" ; // 网页链接前缀
private static final String SCHEME_EMAIL = "mailto:" ;// 邮箱链接前缀
// ========== 常量区文本链接的协议头Scheme常量 ==========
// 定义安卓原生支持的三大核心链接协议前缀用于匹配文本中的URLSpan链接类型
private static final String SCHEME_TEL = "tel:"; // 电话链接协议头 → 匹配电话号码链接
private static final String SCHEME_HTTP = "http:"; // 网页链接协议头 → 匹配http/https网页链接
private static final String SCHEME_EMAIL = "mailto:";// 邮箱链接协议头 → 匹配邮件发送链接
/**
* Scheme
* //
* (Scheme) ID
*
*
*/
private static final Map<String, Integer> sSchemaActionResMap = new HashMap<String, Integer>();
static {
// 初始化映射关系:Scheme -> 菜单文本资源ID
sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel); // 电话链接 → “拨打电话”
sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web); // 网页链接 → “打开网页”
sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email);// 邮箱链接 → “发送邮件”
// 初始化映射关系:协议头 → 对应的菜单文本资源ID
sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel); // 电话链接 → 展示「拨打电话」
sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web); // 网页链接 → 展示「打开网页」
sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email); // 邮箱链接 → 展示「发送邮件」
}
/**
*
* NoteEditActivity
*
* NoteEditActivity
* UI
*/
public interface OnTextViewChangeListener {
/**
*
* + +
* @param index
* @param text
*
* + +
* @param index
* @param text
*/
void onEditTextDelete(int index, String text);
/**
*
*
* @param index +1
* @param text
*
*
* @param index +1
* @param text
*/
void onEditTextEnter(int index, String text);
/**
* /
* /
* /
* /
* @param index
* @param hasText true=false=
* @param hasText truefalse
*/
void onTextChange(int index, boolean hasText);
}
// 编辑框状态变化监听器由Activity实现
// 成员变量回调接口的实例对象由上层Activity通过set方法注入
private OnTextViewChangeListener mOnTextViewChangeListener;
/**
*
* @param context
*
* @param context
*/
public NoteEditText(Context context) {
super(context, null);
// 默认索引为0首个编辑框
mIndex = 0;
mIndex = 0; // 默认索引为0代表首个编辑框后续可通过setIndex重新赋值
}
/**
*
* @param index
*
* @param index
*/
public void setIndex(int index) {
mIndex = index;
}
/**
*
* @param listener OnTextViewChangeListenerNoteEditActivity
*
* @param listener OnTextViewChangeListenerActivity
*/
public void setOnTextViewChangeListener(OnTextViewChangeListener listener) {
mOnTextViewChangeListener = listener;
}
/**
*
* xml
* View
* @param context
* @param attrs
* @param attrs xml
*/
public NoteEditText(Context context, AttributeSet attrs) {
super(context, attrs, android.R.attr.editTextStyle);
}
/**
*
*
* View
* @param context
* @param attrs
* @param defStyle
* @param defStyle ID
*/
public NoteEditText(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// TODO Auto-generated constructor stub
}
/**
*
* /
* @param event
* @return boolean
*
* EditText
*
* @param event
* @return boolean
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 1. 计算触摸的相对坐标(扣除内边距,加上滚动偏移)
case MotionEvent.ACTION_DOWN: // 只处理「按下」动作,是触摸的起始事件
// 步骤1计算触摸点的【文本相对坐标】- 修正内边距与滚动偏移的影响
int x = (int) event.getX();
int y = (int) event.getY();
x -= getTotalPaddingLeft();
y -= getTotalPaddingTop();
x += getScrollX();
y += getScrollY();
x -= getTotalPaddingLeft(); // 减去左侧总内边距得到文本区域内的X坐标
y -= getTotalPaddingTop(); // 减去顶部总内边距得到文本区域内的Y坐标
x += getScrollX(); // 加上横向滚动偏移,适配文本横向滚动的场景
y += getScrollY(); // 加上纵向滚动偏移,适配文本纵向滚动的场景
// 2. 根据坐标获取文本的行和偏移量
Layout layout = getLayout();
int line = layout.getLineForVertical(y); // 触摸位置的文本行
int off = layout.getOffsetForHorizontal(line, x); // 该行的水平偏移量
// 步骤2通过文本布局对象将坐标转换为文本的行列信息
Layout layout = getLayout(); // 获取当前编辑框的文本布局管理器
int line = layout.getLineForVertical(y); // 根据Y坐标获取触摸到的文本行号
int off = layout.getOffsetForHorizontal(line, x); // 根据行号+X坐标获取该行的字符偏移量
// 3. 设置光标到触摸位置
// 步骤3手动设置光标到精准的触摸位置核心修复原生偏移问题
Selection.setSelection(getText(), off);
break;
}
// 交给父类处理剩余触摸逻辑(如滑动、长按)
// 必须调用父类方法:保留原生的滑动、长按、双击等所有触摸逻辑,只做增量优化
return super.onTouchEvent(event);
}
/**
* /
* @param keyCode KEYCODE_ENTER/KEYCODE_DEL
* @param event
* @return boolean
* /
*
*
* @param keyCode //
* @param event
* @return boolean false=onKeyUptrue=
*/
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_ENTER:
// 有监听器时返回false交给onKeyUp处理分割文本+新增编辑框)
// 回车键按下有监听器则返回false交给onKeyUp处理分割+新增逻辑,无则走原生逻辑
if (mOnTextViewChangeListener != null) {
return false;
}
break;
case KeyEvent.KEYCODE_DEL:
// 记录删除键按下前的光标位置用于onKeyUp判断是否触发删除编辑框
// 删除键按下记录此时的光标起始位置供onKeyUp校验是否触发删除编辑框逻辑
mSelectionStartBeforeDelete = getSelectionStart();
break;
default:
break;
}
// 交给父类处理其他按键逻辑
// 调用父类方法:处理所有其他按键的原生逻辑,无侵入式修改
return super.onKeyDown(keyCode, event);
}
/**
* /
*
*
*
* @param keyCode
* @param event
* @return boolean
* @param event
* @return boolean true=false=
*/
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
switch(keyCode) {
case KeyEvent.KEYCODE_DEL:
// 有监听器时处理删除逻辑
// ========== 删除键抬起:处理「删除编辑框」核心逻辑 ==========
if (mOnTextViewChangeListener != null) {
// 触发条件:光标在起始位置 + 非首个编辑框索引≠0
// 触发条件【三重校验,缺一不可】:
// 1. 光标在文本的起始位置02. 当前编辑框不是第一个编辑框索引≠03. 有回调监听器
if (0 == mSelectionStartBeforeDelete && mIndex != 0) {
// 回调删除当前编辑框
// 回调上层Activity执行删除当前编辑框的逻辑,并传递当前文本内容
mOnTextViewChangeListener.onEditTextDelete(mIndex, getText().toString());
return true; // 消费事件,避免父类处理
return true; // 消费事件,避免原生删除逻辑执行,防止文本内容错乱
}
} else {
// 无监听器时输出日志提示
// 无监听器时打印日志,便于调试排查问题
Log.d(TAG, "OnTextViewChangeListener was not seted");
}
break;
case KeyEvent.KEYCODE_ENTER:
// 有监听器时处理回车逻辑
// ========== 回车键抬起:处理「分割文本+新增编辑框」核心逻辑 ==========
if (mOnTextViewChangeListener != null) {
// 获取光标起始位置
int selectionStart = getSelectionStart();
// 分割文本:光标后的内容作为新增编辑框的文本
int selectionStart = getSelectionStart(); // 获取当前光标位置
// 步骤1分割文本 → 光标后的内容作为新增编辑框的初始化文本
String text = getText().subSequence(selectionStart, length()).toString();
// 保留光标前的内容在当前编辑框
// 步骤2更新当前编辑框文本 → 只保留光标前的内容,完成文本分割
setText(getText().subSequence(0, selectionStart));
// 回调新增编辑框(索引为当前+1
// 步骤3回调上层Activity → 在当前索引+1的位置新增编辑框并传递分割后的文本
mOnTextViewChangeListener.onEditTextEnter(mIndex + 1, text);
} else {
// 无监听器时输出日志提示
Log.d(TAG, "OnTextViewChangeListener was not seted");
}
break;
default:
break;
}
// 交给父类处理其他按键逻辑
// 调用父类方法:处理其他按键的原生抬起逻辑,兼容所有原生功能
return super.onKeyUp(keyCode, event);
}
/**
* /
* @param focused
* @param direction
* @param previouslyFocusedRect
*
* /
*
* +
* @param focused true=false=
* @param direction
* @param previouslyFocusedRect
*/
@Override
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
if (mOnTextViewChangeListener != null) {
// 失焦且文本为空:回调隐藏选项;否则回调显示选项
// 核心判断:失焦 并且 文本为空 → 回调隐藏按钮;否则回调显示按钮
if (!focused && TextUtils.isEmpty(getText())) {
mOnTextViewChangeListener.onTextChange(mIndex, false);
} else {
mOnTextViewChangeListener.onTextChange(mIndex, true);
}
}
// 交给父类处理焦点变化
// 调用父类方法:执行原生的焦点变化逻辑,如光标显示/隐藏、背景色变化等
super.onFocusChanged(focused, direction, previouslyFocusedRect);
}
/**
*
* URLSpanScheme
* @param menu
*
*
*
* //
* @param menu
*/
@Override
protected void onCreateContextMenu(ContextMenu menu) {
// 仅处理富文本Spanned类型的文本
// 前置判断:只有文本是【富文本(Spanned)】类型时,才处理链接识别逻辑
if (getText() instanceof Spanned) {
// 获取光标选中的起始/结束位置
// 步骤1获取当前光标选中的文本区域兼容正选/反选两种情况
int selStart = getSelectionStart();
int selEnd = getSelectionEnd();
int min = Math.min(selStart, selEnd); // 选中区域的起始位置
int max = Math.max(selStart, selEnd); // 选中区域的结束位置
// 修正选中范围确保min≤max
int min = Math.min(selStart, selEnd);
int max = Math.max(selStart, selEnd);
// 获取选中区域内的所有URLSpan链接
// 步骤2从选中区域中提取所有的URLSpan链接对象
final URLSpan[] urls = ((Spanned) getText()).getSpans(min, max, URLSpan.class);
// 仅处理单个链接的情况(避免多链接冲突)
// 步骤3仅处理「单个链接」的场景避免多链接冲突保证菜单的唯一性
if (urls.length == 1) {
// 默认菜单文本资源ID未知链接
int defaultResId = 0;
// 匹配链接的Scheme,获取对应的菜单文本
int defaultResId = 0; // 初始化菜单文本资源ID
// 步骤4遍历协议映射表匹配当前链接的协议类型,获取对应的菜单文本
for(String schema: sSchemaActionResMap.keySet()) {
if(urls[0].getURL().indexOf(schema) >= 0) {
defaultResId = sSchemaActionResMap.get(schema);
@ -324,23 +339,23 @@ public class NoteEditText extends EditText {
}
}
// 未匹配到已知Scheme使用“其他链接”文本
// 兜底处理:未匹配到已知协议时,显示「其他链接」的默认文本
if (defaultResId == 0) {
defaultResId = R.string.note_link_other;
}
// 添加菜单选项并设置点击事件
// 步骤5向菜单中添加自定义选项并绑定点击事件
menu.add(0, 0, 0, defaultResId).setOnMenuItemClickListener(
new OnMenuItemClickListener() {
public boolean onMenuItemClick(MenuItem item) {
// 触发URLSpan的点击事件跳转链接拨打电话/打开网页/发送邮件)
// 核心逻辑触发URLSpan原生的点击事件 → 自动跳转对应链接(拨打电话/打开网页/发送邮件)
urls[0].onClick(NoteEditText.this);
return true;
return true; // 消费菜单点击事件,避免后续处理
}
});
}
}
// 交给父类创建默认上下文菜单
// 必须调用父类方法:无链接时创建原生默认菜单(复制、粘贴、剪切、选择全部等),完整兼容原生功能
super.onCreateContextMenu(menu);
}
}

@ -14,174 +14,187 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块作为便签列表项的核心数据模型解析并封装Cursor中的便签数据
// 包声明:归属小米便签UI模块为列表页提供标准化的便签/文件夹数据模型,封装数据库游标解析逻辑
package net.micode.notes.ui;
// 导入安卓上下文类用于获取ContentResolver、查询联系人信息
// 安卓上下文:提供系统服务访问能力,用于联系人查询、内容解析器获取
import android.content.Context;
// 导入安卓数据库游标类:存储便签数据库查询结果,作为数据解析源
// 安卓数据库游标:承载数据库查询结果集,是本类核心的数据解析源
import android.database.Cursor;
// 导入安卓文本工具类:处理字符串判空、替换等操作
// 安卓文本工具类:提供字符串判空、文本处理等安全操作方法
import android.text.TextUtils;
// 导入联系人数据类:根据手机号查询联系人名称
// 联系人数据工具类:根据手机号匹配系统通讯录中的联系人姓名
import net.micode.notes.data.Contact;
// 导入便签数据常量类定义便签类型、特殊文件夹ID通话记录文件夹常量
// 便签核心常量类定义便签类型、文件夹ID、字段名等全局常量
import net.micode.notes.data.Notes;
// 便签数据库列名常量:数据库表的字段名称枚举,避免硬编码字符串
import net.micode.notes.data.Notes.NoteColumns;
// 导入数据工具类:获取通话记录手机号、格式化数据等
// 便签数据工具类:提供通话记录手机号查询、数据格式化等通用能力
import net.micode.notes.tool.DataUtils;
/**
* 便
* 便便
* MVCModelUI
*
* 1. 便PROJECTION
* 2. Cursor便/ID
* 3.
* 4. //便
* 5. 访getter
* 1.
* 2. Cursor便/
* 3. 便
* 4. UI
* 5. getter
* + + +
*/
public class NoteItemData {
/**
* 便
* 便/
*
* IO
* 便/
*/
static final String [] PROJECTION = new String [] {
NoteColumns.ID, // 0: 便签/文件夹唯一ID
NoteColumns.ALERTED_DATE, // 1: 提醒时间戳0无提醒)
NoteColumns.BG_COLOR_ID, // 2: 背景色ID
NoteColumns.CREATED_DATE, // 3: 创建时间戳
NoteColumns.HAS_ATTACHMENT, // 4: 是否有附件0=无1=有
NoteColumns.MODIFIED_DATE, // 5: 最后修改时间戳
NoteColumns.NOTES_COUNT, // 6: 文件夹内便签数量(仅文件夹有效)
NoteColumns.PARENT_ID, // 7: 父文件夹ID根文件夹为Notes.ID_ROOT_FOLDER
NoteColumns.SNIPPET, // 8: 便签摘要/文件夹名称
NoteColumns.TYPE, // 9: 类型Notes.TYPE_NOTE/TYPE_FOLDER/TYPE_SYSTEM
NoteColumns.WIDGET_ID, // 10: 关联的小部件ID无效为AppWidgetManager.INVALID_APPWIDGET_ID
NoteColumns.WIDGET_TYPE, // 11: 关联的小部件类型2x/4x对应Notes.TYPE_WIDGET_2X/4X
static final String[] PROJECTION = new String[]{
NoteColumns.ID, // 0: 便签/文件夹唯一主键ID
NoteColumns.ALERTED_DATE, // 1: 提醒时间戳0表无提醒)
NoteColumns.BG_COLOR_ID, // 2: 背景色ID,用于列表项背景着色
NoteColumns.CREATED_DATE, // 3: 创建时间戳UTC毫秒值
NoteColumns.HAS_ATTACHMENT, // 4: 是否包含附件 0-无 1-有(图片/音频等
NoteColumns.MODIFIED_DATE, // 5: 最后修改时间戳,列表页优先展示该时间
NoteColumns.NOTES_COUNT, // 6: 文件夹内包含的便签数量,仅文件夹类型有效
NoteColumns.PARENT_ID, // 7: 父文件夹ID根目录为Notes.ID_ROOT_FOLDER
NoteColumns.SNIPPET, // 8: 便签内容摘要/文件夹名称,短文本展示用
NoteColumns.TYPE, // 9: 数据类型,区分便签/文件夹/系统项
NoteColumns.WIDGET_ID, // 10: 关联的桌面小部件ID无效则为默认值
NoteColumns.WIDGET_TYPE // 11: 关联小部件尺寸类型 2x/4x
};
// 投影数组对应的列索引常量简化Cursor取值避免硬编码索引
private static final int ID_COLUMN = 0; // 便签/文件夹ID列索引
private static final int ALERTED_DATE_COLUMN = 1; // 提醒时间列索引
private static final int BG_COLOR_ID_COLUMN = 2; // 背景色ID列索引
private static final int CREATED_DATE_COLUMN = 3; // 创建时间列索引
private static final int HAS_ATTACHMENT_COLUMN = 4; // 是否有附件列索引
private static final int MODIFIED_DATE_COLUMN = 5; // 最后修改时间列索引
private static final int NOTES_COUNT_COLUMN = 6; // 文件夹内便签数量列索引
private static final int PARENT_ID_COLUMN = 7; // 父文件夹ID列索引
private static final int SNIPPET_COLUMN = 8; // 摘要/文件夹名称列索引
private static final int TYPE_COLUMN = 9; // 类型列索引
private static final int WIDGET_ID_COLUMN = 10; // 小部件ID列索引
private static final int WIDGET_TYPE_COLUMN = 11; // 小部件类型列索引
// 核心数据字段:与投影数组一一对应
private long mId; // 便签/文件夹唯一ID
private long mAlertDate; // 提醒时间戳(>0表示有提醒
private int mBgColorId; // 背景色ID用于列表项背景渲染
private long mCreatedDate; // 创建时间戳
private boolean mHasAttachment; // 是否有附件(图片/音频等)
private long mModifiedDate; // 最后修改时间戳(用于列表项时间展示)
private int mNotesCount; // 文件夹内便签数量仅TYPE_FOLDER有效
private long mParentId; // 父文件夹ID通话记录项为Notes.ID_CALL_RECORD_FOLDER
private String mSnippet; // 便签摘要/文件夹名称(清理了勾选标记后的纯文本)
private int mType; // 类型Notes.TYPE_NOTE/TYPE_FOLDER/TYPE_SYSTEM
private int mWidgetId; // 关联的小部件ID无效为INVALID_APPWIDGET_ID
private int mWidgetType; // 关联的小部件类型2x/4x
private String mName; // 通话记录项的联系人名称(无则显示手机号)
private String mPhoneNumber; // 通话记录项的手机号仅父ID为通话记录文件夹有效
// 列表位置状态字段:用于适配列表项背景渲染
private boolean mIsLastItem; // 是否为列表最后一项
private boolean mIsFirstItem; // 是否为列表第一项
private boolean mIsOnlyOneItem; // 是否为列表唯一一项
private boolean mIsOneNoteFollowingFolder; // 是否为文件夹后的唯一便签项
private boolean mIsMultiNotesFollowingFolder;// 是否为文件夹后的多个便签项的第一项
/**
* PROJECTION
* +
*
*/
private static final int ID_COLUMN = 0;
private static final int ALERTED_DATE_COLUMN = 1;
private static final int BG_COLOR_ID_COLUMN = 2;
private static final int CREATED_DATE_COLUMN = 3;
private static final int HAS_ATTACHMENT_COLUMN = 4;
private static final int MODIFIED_DATE_COLUMN = 5;
private static final int NOTES_COUNT_COLUMN = 6;
private static final int PARENT_ID_COLUMN = 7;
private static final int SNIPPET_COLUMN = 8;
private static final int TYPE_COLUMN = 9;
private static final int WIDGET_ID_COLUMN = 10;
private static final int WIDGET_TYPE_COLUMN = 11;
// ===================== 核心业务数据字段 - 与投影数组一一映射 =====================
// 访问规则全部私有仅通过getter方法访问无setter数据只读保证一致性
private long mId; // 便签/文件夹唯一ID数据库主键
private long mAlertDate; // 提醒时间戳,>0表示该便签设置了提醒
private int mBgColorId; // 背景色ID对应预设的颜色值列表项渲染用
private long mCreatedDate; // 便签创建时间戳
private boolean mHasAttachment; // 是否包含附件数据库int转Java布尔值贴合业务语义
private long mModifiedDate; // 最后修改时间戳,列表页展示的核心时间字段
private int mNotesCount; // 文件夹内便签数量仅TYPE_FOLDER类型有效
private long mParentId; // 父文件夹ID通话记录便签固定为通话记录文件夹ID
private String mSnippet; // 便签纯文本摘要/文件夹名称,已清理勾选标记
private int mType; // 数据类型Notes.TYPE_NOTE/文件夹/系统项
private int mWidgetId; // 关联桌面小部件ID无效则为INVALID_APPWIDGET_ID
private int mWidgetType; // 关联小部件尺寸类型2x/4x两种规格
// ===================== 通话记录专属扩展字段 =====================
private String mName; // 通话记录联系人姓名,无则显示手机号
private String mPhoneNumber; // 通话记录对应的手机号,仅通话便签有值
// ===================== 列表位置状态字段 - 纯UI渲染支撑 =====================
// 作用:标记当前项在列表中的位置,用于适配不同的背景样式(圆角/分割线/边距等)
private boolean mIsLastItem; // 是否为列表最后一条数据
private boolean mIsFirstItem; // 是否为列表第一条数据
private boolean mIsOnlyOneItem; // 是否为列表中唯一的一条数据
private boolean mIsOneNoteFollowingFolder; // 文件夹后的唯一一条便签项
private boolean mIsMultiNotesFollowingFolder;// 文件夹后的第一条便签(后续还有更多项)
/**
* Cursor便/
* @param context ContentResolver
* @param cursor 便Cursor
* Cursor
*
* @param context
* @param cursor /
*/
public NoteItemData(Context context, Cursor cursor) {
// 1. 解析Cursor核心字段与投影数组索引一一对应
// 第一步:解析数据库核心字段,与投影数组索引一一对应,基础数据初始化
mId = cursor.getLong(ID_COLUMN);
mAlertDate = cursor.getLong(ALERTED_DATE_COLUMN);
mBgColorId = cursor.getInt(BG_COLOR_ID_COLUMN);
mCreatedDate = cursor.getLong(CREATED_DATE_COLUMN);
// 转换int为boolean0=无附件,>0=有附件
mHasAttachment = (cursor.getInt(HAS_ATTACHMENT_COLUMN) > 0) ? true : false;
// 数据库存储为int(0/1)转换为业务语义更清晰的boolean类型
mHasAttachment = cursor.getInt(HAS_ATTACHMENT_COLUMN) > 0;
mModifiedDate = cursor.getLong(MODIFIED_DATE_COLUMN);
mNotesCount = cursor.getInt(NOTES_COUNT_COLUMN);
mParentId = cursor.getLong(PARENT_ID_COLUMN);
// 解析摘要文本,并清理勾选框标记标签,仅保留纯文本内容,优化展示效果
mSnippet = cursor.getString(SNIPPET_COLUMN);
// 清理摘要中的勾选标记TAG_CHECKED/TAG_UNCHECKED仅保留纯文本
mSnippet = mSnippet.replace(NoteEditActivity.TAG_CHECKED, "").replace(
NoteEditActivity.TAG_UNCHECKED, "");
mType = cursor.getInt(TYPE_COLUMN);
mWidgetId = cursor.getInt(WIDGET_ID_COLUMN);
mWidgetType = cursor.getInt(WIDGET_TYPE_COLUMN);
// 2. 初始化通话记录相关字段仅父ID为通话记录文件夹时解析
// 第二步:通话记录便签专属解析逻辑,仅父文件夹为通话记录文件夹时触发
mPhoneNumber = "";
if (mParentId == Notes.ID_CALL_RECORD_FOLDER) {
// 根据便签ID查询对应的通话记录手机号
// 根据便签ID查询关联的通话手机号
mPhoneNumber = DataUtils.getCallNumberByNoteId(context.getContentResolver(), mId);
// 有手机号时,查询对应的联系人名称
// 手机号非空时,查询系统通讯录匹配联系人姓名
if (!TextUtils.isEmpty(mPhoneNumber)) {
mName = Contact.getContact(context, mPhoneNumber);
// 无联系人名称时,使用手机号作为名称
// 无匹配联系人时,手机号作为联系人名称兜底展示
if (mName == null) {
mName = mPhoneNumber;
}
}
}
// 3. 兜底:联系人名称为空时设为空字符串,避免空指针
// 第三步:空值兜底处理,防止空指针异常,生产级代码必备容错逻辑
if (mName == null) {
mName = "";
}
// 4. 判断当前项在列表中的位置状态(用于背景渲染)
// 第四步自动判断当前项在列表中的位置状态为UI渲染提供数据支撑
checkPostion(cursor);
}
/**
* Cursor
*
* 1. //
* 2. 便/
* @param cursor 便Cursor
* Cursor
*
* Cursor
* @param cursor
*/
private void checkPostion(Cursor cursor) {
// 初始化基础位置状态
mIsLastItem = cursor.isLast() ? true : false; // 是否为最后一项
mIsFirstItem = cursor.isFirst() ? true : false; // 是否为第一项
mIsOnlyOneItem = (cursor.getCount() == 1); // 是否为唯一一项
// 初始化文件夹后便签项状态为false
// 初始化基础位置状态:首项、尾项、唯一项
mIsLastItem = cursor.isLast();
mIsFirstItem = cursor.isFirst();
mIsOnlyOneItem = cursor.getCount() == 1;
// 初始化文件夹子项状态为默认值false
mIsMultiNotesFollowingFolder = false;
mIsOneNoteFollowingFolder = false;
// 仅处理普通便签且非第一项的情况:判断是否为文件夹后的便签项
// 核心业务判断:仅处理【普通便签】且【非列表首项】的场景
if (mType == Notes.TYPE_NOTE && !mIsFirstItem) {
// 记录当前位置
int position = cursor.getPosition();
// 移动Cursor到上一项判断上一项是否为文件夹/系统项
// 记录当前游标位置,用于后续回位,防止位置丢失
int currentPosition = cursor.getPosition();
// 游标上移一行,判断上一项是否为【文件夹】或【系统项】
if (cursor.moveToPrevious()) {
if (cursor.getInt(TYPE_COLUMN) == Notes.TYPE_FOLDER
|| cursor.getInt(TYPE_COLUMN) == Notes.TYPE_SYSTEM) {
// 上一项是文件夹/系统项:判断当前项后是否还有更多项
if (cursor.getCount() > (position + 1)) {
// 有更多项:标记为“文件夹后的多个便签项的第一项”
int prevItemType = cursor.getInt(TYPE_COLUMN);
if (prevItemType == Notes.TYPE_FOLDER || prevItemType == Notes.TYPE_SYSTEM) {
// 上一项是文件夹/系统项 → 当前项是文件夹的子项第一条
if (cursor.getCount() > currentPosition + 1) {
// 后续还有更多数据 → 标记为多子项的第一条
mIsMultiNotesFollowingFolder = true;
} else {
// 无更多项:标记为“文件夹后的唯一便签项”
// 后续无数据 → 标记为文件夹后的唯一子项
mIsOneNoteFollowingFolder = true;
}
}
// 移动Cursor回原位置避免影响后续操作
// 游标回位到原位置,必须执行,否则会导致后续数据解析错位
if (!cursor.moveToNext()) {
// 移动失败时抛出异常防止Cursor位置错乱
// 回位失败时主动抛异常快速定位问题避免隐性bug
throw new IllegalStateException("cursor move to previous but can't move back");
}
}
@ -189,178 +202,182 @@ public class NoteItemData {
}
/**
* 便
* @return booleantrue=false=
* 便
* @return true- false-
*/
public boolean isOneFollowingFolder() {
return mIsOneNoteFollowingFolder;
}
/**
* 便
* @return booleantrue=false=
* 便
* @return true- false-
*/
public boolean isMultiFollowingFolder() {
return mIsMultiNotesFollowingFolder;
}
/**
*
* @return booleantrue=false=
*
* @return true- false-
*/
public boolean isLast() {
return mIsLastItem;
}
/**
*
* @return String/
* 访
* @return /
*/
public String getCallName() {
return mName;
}
/**
*
* @return booleantrue=false=
*
* @return true- false-
*/
public boolean isFirst() {
return mIsFirstItem;
}
/**
*
* @return booleantrue=false=
*
* @return true- false-
*/
public boolean isSingle() {
return mIsOnlyOneItem;
}
/**
* 便/ID
* @return longID
* 访便/ID
* @return IDlong
*/
public long getId() {
return mId;
}
/**
*
* @return long>0
* 访
* @return UTC0
*/
public long getAlertDate() {
return mAlertDate;
}
/**
*
* @return long
* 访
* @return UTC
*/
public long getCreatedDate() {
return mCreatedDate;
}
/**
*
* @return booleantrue=false=
* 便/
* @return true- false-
*/
public boolean hasAttachment() {
return mHasAttachment;
}
/**
*
* @return long
* 访
* @return UTC
*/
public long getModifiedDate() {
return mModifiedDate;
}
/**
* ID
* @return intID
* 访ID
* @return ID
*/
public int getBgColorId() {
return mBgColorId;
}
/**
* ID
* @return longIDNotes.ID_CALL_RECORD_FOLDER
* 访ID
* @return IDID
*/
public long getParentId() {
return mParentId;
}
/**
* 便TYPE_FOLDER
* @return int便
* 访便
* @return 便
*/
public int getNotesCount() {
return mNotesCount;
}
/**
* IDgetParentId
* @return longID
* getParentId()
*
* @return ID
*/
public long getFolderId () {
public long getFolderId() {
return mParentId;
}
/**
* 便/
* @return intNotes.TYPE_NOTE/TYPE_FOLDER/TYPE_SYSTEM
* 访
* @return Notes.TYPE_NOTE//
*/
public int getType() {
return mType;
}
/**
*
* @return int2x/4xNotes.TYPE_WIDGET_2X/4X
* 访
* @return 2x/4x
*/
public int getWidgetType() {
return mWidgetType;
}
public int getWidgetType() {
return mWidgetType;
}
/**
* ID
* @return intIDAppWidgetManager.INVALID_APPWIDGET_ID
* 访ID
* @return IDINVALID_APPWIDGET_ID
*/
public int getWidgetId() {
return mWidgetId;
}
public int getWidgetId() {
return mWidgetId;
}
/**
* 便/
* @return String/
* 访/
* @return
*/
public String getSnippet() {
return mSnippet;
}
/**
*
* @return booleantrue=mAlertDate>0false=
* 便
* 0
* @return true- false-
*/
public boolean hasAlert() {
return (mAlertDate > 0);
return mAlertDate > 0;
}
/**
*
* @return booleantrue=IDfalse=
* 便
* +
* @return true-便 false-便/
*/
public boolean isCallRecord() {
return (mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber));
return mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber);
}
/**
* Cursor便
* NotesListAdapterNoteItemData
* @param cursor 便Cursor
* @return int便Notes.TYPE_NOTE/TYPE_FOLDER/TYPE_SYSTEM
* Cursor便
*
*
* @param cursor
* @return 便
*/
public static int getNoteType(Cursor cursor) {
return cursor.getInt(TYPE_COLUMN);

@ -14,151 +14,170 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块作为便签列表页的核心适配器绑定Cursor数据到列表项
// 包声明:小米便签 核心UI模块该包承载应用所有可视化交互页面及配套适配器本类为列表页核心数据适配桥梁
package net.micode.notes.ui;
// 导入安卓上下文类:提供应用运行环境
// -------------------------- 安卓系统核心依赖包 - 上下文/数据库/日志/视图/适配器能力 --------------------------
// 安卓应用全局上下文:提供资源访问、视图创建、数据解析等基础能力,适配器必备依赖
import android.content.Context;
// 导入安卓数据库游标类:存储便签数据库查询结果
// 安卓数据库游标核心类:封装数据库查询结果集,承载便签数据表的查询数据,列表展示的核心数据来源
import android.database.Cursor;
// 导入安卓日志类:输出适配器相关日志(异常/调试)
// 安卓系统日志工具类:输出适配器运行的调试/错误日志,便于问题定位与线上排查
import android.util.Log;
// 导入安卓视图相关类:创建/绑定列表项视图
// 安卓视图体系核心类:视图创建、视图容器的核心父类,适配列表项的创建与挂载
import android.view.View;
import android.view.ViewGroup;
// 导入安卓Cursor适配器类适配Cursor数据的列表适配器基
// 安卓游标适配器基类专为Cursor数据设计的列表适配器封装数据绑定、视图复用、数据变化监听等核心能力本类的核心父
import android.widget.CursorAdapter;
// 导入便签数据常量类定义便签类型、特殊ID根文件夹等常量
// -------------------------- 小米便签业务层核心依赖 - 数据常量/集合工具 --------------------------
// 小米便签数据层核心常量类:定义便签/文件夹/小部件的类型、特殊ID、业务状态等全局核心常量
import net.micode.notes.data.Notes;
// 导入集合相关类:管理选中项的状态、统计选中数量
// Java集合框架相关类封装选中项状态的存储、遍历、统计支撑批量选择模式的核心逻辑
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
/**
* 便
* CursorAdapter
* 1. 便CursorNotesListItem
* 2. /
* 3. 便ID
* 4. 便
* 便
* <p>
* {CursorAdapter}MVCUI{NotesListActivity}/Cursor{NotesListItem}
* Cursor
* Cursor///
* 便
* CursorAdapterHashMap
*
* </p>
*/
public class NotesListAdapter extends CursorAdapter {
// 日志标签:用于适配器相关日志输出,便于问题定位
/** 日志常量标签:适配器相关日志的统一标识,便于日志过滤与问题定位 */
private static final String TAG = "NotesListAdapter";
// 应用上下文用于创建列表项、解析Cursor数据
/** 应用上下文对象:用于创建列表项视图、解析业务数据,全局复用避免多次创建 */
private Context mContext;
// 选中项状态映射Key=列表项位置Value=是否选中(选择模式下)
/** 选中项状态映射容器核心数据结构Key=列表项的索引位置Value=该位置的选中状态,支撑选择模式的核心存储 */
private HashMap<Integer, Boolean> mSelectedIndex;
// 普通便签总数仅统计TYPE_NOTE类型的项用于判断是否全选
/** 普通便签数量统计:仅统计{Notes.TYPE_NOTE}类型的项,排除文件夹/通话记录等类型,用于全选状态的判定依据 */
private int mNotesCount;
// 选择模式标记true=批量选择模式false=普通浏览模式
/** 选择模式状态标记true=开启批量选择模式批量操作false=关闭选择模式(普通浏览),控制列表项勾选框的显隐 */
private boolean mChoiceMode;
/**
* 便
* 便ID
* 便
* 便便
*
*/
public static class AppWidgetAttribute {
public int widgetId; // 小部件唯一标识ID
public int widgetType; // 小部件类型2x/4x对应Notes.TYPE_WIDGET_2X/4X
/** 小部件系统唯一标识ID桌面小部件的注册ID用于精准定位目标小部件 */
public int widgetId;
/** 小部件类型标识区分2x/4x两种尺寸的便签小部件对应{Notes.TYPE_WIDGET_2X}/{Notes.TYPE_WIDGET_4X} */
public int widgetType;
};
/**
*
* @param context
*
* 便
* Cursor{changeCursor}
* @param context
*/
public NotesListAdapter(Context context) {
// 父类构造:上下文+初始Cursornull后续通过changeCursor设置
// 父类构造:传入上下文+空CursorCursor数据后续动态绑定保证初始化灵活性
super(context, null);
// 初始化选中项状态映射空HashMap
// 初始化选中项状态映射容器空HashMap保证初始无选中项
mSelectedIndex = new HashMap<Integer, Boolean>();
// 保存上下文引用
// 保存应用上下文引用,供后续视图创建与数据解析使
mContext = context;
// 初始化普通便签总数为0
// 重置普通便签统计数量为0初始无数据状态
mNotesCount = 0;
}
/**
* 便
* @param context
* @param cursor Cursor使
* @param parent ListView
* @return ViewNotesListItem
*
*
* {NotesListItem}
* @param context
* @param cursor 使
* @param parent ListView
* @return View
*/
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
// 创建自定义列表项视图NotesListItem
// 创建小米便签自定义列表项视图,作为列表的最小展示单元
return new NotesListItem(context);
}
/**
* CursorUI
* @param view NotesListItem
* @param context
* @param cursor Cursor便
*
*
* Cursor{NoteItemData}
*
* @param view {NotesListItem}
* @param context
* @param cursor 便
*/
@Override
public void bindView(View view, Context context, Cursor cursor) {
// 仅处理NotesListItem类型的视图防止类型错误
// 类型安全校验仅处理自定义的NotesListItem视图防止视图类型错误导致崩溃
if (view instanceof NotesListItem) {
// 根据Cursor创建便签数据模型NoteItemData
// 将Cursor数据库数据解析为业务数据模型封装所有展示所需的业务字段
NoteItemData itemData = new NoteItemData(context, cursor);
// 调用列表项的bind方法绑定数据+适配选择模式+设置选中状态
// 调用列表项的核心绑定方法,完成数据渲染+选择模式适配+选中状态赋值
((NotesListItem) view).bind(context, itemData, mChoiceMode,
isSelectedItem(cursor.getPosition()));
}
}
/**
*
* @param position
* @param checked
*
* /
*
* @param position
* @param checked true=false=
*/
public void setCheckedItem(final int position, final boolean checked) {
// 更新选中状态映射
// 更新选中状态映射容器,保存当前位置的选中状态
mSelectedIndex.put(position, checked);
// 通知ListView数据变化刷新UI
// 通知列表数据发生变化,触发视图刷新,展示最新的选中状态
notifyDataSetChanged();
}
/**
*
* @return booleantrue=false=
*
* @return boolean true=false=
*/
public boolean isInChoiceMode() {
return mChoiceMode;
}
/**
*
* /
*
*
* @param mode true=false=
*/
public void setChoiceMode(boolean mode) {
// 清空原有选中项状态(切换模式时重置选择)
// 清空所有选中项状态,重置选择容器为初始状态
mSelectedIndex.clear();
// 更新选择模式标记
// 更新选择模式标记,控制后续视图绑定的逻辑分支
mChoiceMode = mode;
}
/**
* /
* 便TYPE_NOTE
* @param checked true=false=
* /
* {Notes.TYPE_NOTE}便/
*
* @param checked true=false=
*/
public void selectAll(boolean checked) {
// 获取当前绑定的Cursor
// 获取当前绑定的数据库游标,遍历所有列表项数据
Cursor cursor = getCursor();
// 遍历所有列表项
for (int i = 0; i < getCount(); i++) {
// 移动Cursor到当前位置
// 移动游标到当前列表项的位置,匹配对应数据
if (cursor.moveToPosition(i)) {
// 仅处理普通便签类型
// 仅处理普通便签类型,过滤文件夹等非选择
if (NoteItemData.getNoteType(cursor) == Notes.TYPE_NOTE) {
// 设置当前位置的选中状态
// 批量设置当前位置的选中状态
setCheckedItem(i, checked);
}
}
@ -166,22 +185,24 @@ public class NotesListAdapter extends CursorAdapter {
}
/**
* 便ID
* @return HashSet<Long>便IDID
* 便ID
* /便ID
* ID
* @return HashSet<Long> 便ID
*/
public HashSet<Long> getSelectedItemIds() {
HashSet<Long> itemSet = new HashSet<Long>();
// 遍历所有选中的位置
// 遍历所有已记录的选中状态位置
for (Integer position : mSelectedIndex.keySet()) {
// 仅处理选中状态为true的
// 仅处理选中状态为true的有效
if (mSelectedIndex.get(position) == true) {
// 获取当前位置的便签ID
// 获取当前位置对应的便签ID
Long id = getItemId(position);
// 过滤根文件夹ID无效项日志提示
// 过滤系统根文件夹的无效ID避免业务操作异常
if (id == Notes.ID_ROOT_FOLDER) {
Log.d(TAG, "Wrong item id, should not happen");
} else {
// 添加有效ID到集合
// 将有效ID添加至结果集合
itemSet.add(id);
}
}
@ -190,32 +211,34 @@ public class NotesListAdapter extends CursorAdapter {
}
/**
*
* @return HashSet<AppWidgetAttribute>null
*
* 便ID
* null
* @return HashSet<AppWidgetAttribute> null
*/
public HashSet<AppWidgetAttribute> getSelectedWidget() {
HashSet<AppWidgetAttribute> itemSet = new HashSet<AppWidgetAttribute>();
// 遍历所有选中的位置
// 遍历所有已记录的选中状态位置
for (Integer position : mSelectedIndex.keySet()) {
// 仅处理选中状态为true的
// 仅处理选中状态为true的有效
if (mSelectedIndex.get(position) == true) {
// 获取当前位置的Cursor
// 获取当前位置对应的数据库游标
Cursor c = (Cursor) getItem(position);
if (c != null) {
// 创建小部件属性对象
// 创建小部件属性对象,封装核心数据
AppWidgetAttribute widget = new AppWidgetAttribute();
// 根据Cursor创建便签数据模型
// 将游标数据解析为业务模型,提取小部件关联属性
NoteItemData item = new NoteItemData(mContext, c);
// 提取小部件ID和类型
widget.widgetId = item.getWidgetId();
widget.widgetType = item.getWidgetType();
// 添加集合
// 添加至结果集合
itemSet.add(widget);
/**
* CursorCursor
* Cursor
* CursorCursorAdapter
*/
} else {
// 无效Cursor输出错误日志并返回null
// 游标无效时输出错误日志返回null标记异常状态
Log.e(TAG, "Invalid cursor");
return null;
}
@ -225,17 +248,17 @@ public class NotesListAdapter extends CursorAdapter {
}
/**
*
* @return int便
* 便
* true
* @return int 便0
*/
public int getSelectedCount() {
// 获取所有选中状态值
// 获取所有已记录的选中状态值集合
Collection<Boolean> values = mSelectedIndex.values();
// 无状态值时返回0
if (null == values) {
return 0;
}
// 遍历状态值,统计选中true的数量
// 遍历状态值,统计选中状态为true的项数
Iterator<Boolean> iter = values.iterator();
int count = 0;
while (iter.hasNext()) {
@ -247,61 +270,68 @@ public class NotesListAdapter extends CursorAdapter {
}
/**
*
* @return booleantrue=>0便false=
*
* 0 便
*
* @return boolean true=便false=//便
*/
public boolean isAllSelected() {
int checkedCount = getSelectedCount();
// 选中数非0且等于普通便签总数判定为全选
return (checkedCount != 0 && checkedCount == mNotesCount);
}
/**
*
* @param position
* @return booleantrue=false=null
*
*
* @param position
* @return boolean true=false=/
*/
public boolean isSelectedItem(final int position) {
// 状态为null时视为未选中
// 状态容器中无该位置记录时,默认返回未选中
if (null == mSelectedIndex.get(position)) {
return false;
}
// 返回当前位置的选中状态
// 返回该位置的实际选中状态
return mSelectedIndex.get(position);
}
/**
* 便
* Cursor便
*
* //
* 便
*
*/
@Override
protected void onContentChanged() {
super.onContentChanged();
// 重新计普通便签
// 数据变化后重新计普通便签数
calcNotesCount();
}
/**
* CursorCursor便
* @param cursor Cursor
*
*
* 便
* @param cursor
*/
@Override
public void changeCursor(Cursor cursor) {
super.changeCursor(cursor);
// 重新计普通便签
// 更换游标后重新计普通便签数
calcNotesCount();
}
/**
* 便TYPE_NOTE
* Cursor便
* 便
* {Notes.TYPE_NOTE}便
*
*/
private void calcNotesCount() {
// 重置总数为0
// 重置计数为0避免累计统计错误
mNotesCount = 0;
// 遍历所有列表项
// 遍历适配器绑定的所有列表项数据
for (int i = 0; i < getCount(); i++) {
// 获取当前位置的Cursor
// 获取当前位置对应的数据库游标
Cursor c = (Cursor) getItem(i);
if (c != null) {
// 仅统计普通便签类型的项
@ -309,7 +339,7 @@ public class NotesListAdapter extends CursorAdapter {
mNotesCount++;
}
} else {
// 无效Cursor输出错误日志并终止计算
// 游标无效时输出错误日志,终止统计避免数据异常
Log.e(TAG, "Invalid cursor");
return;
}

@ -14,64 +14,70 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块负责便签列表页的单个列表项渲染
// 包声明:小米便签 核心UI模块该包承载应用所有可视化交互页面及自定义UI组件本类为列表页核心子项组件
package net.micode.notes.ui;
// 导入安卓上下文类:提供应用运行环境(资源访问、样式加载)
// -------------------------- 安卓系统核心依赖包 - 上下文/视图/布局/基础控件能力 --------------------------
// 安卓应用全局上下文:提供资源访问、样式加载、布局渲染等基础能力,自定义控件必备依赖
import android.content.Context;
// 导入安卓日期工具类格式化相对时间如“1分钟前”“昨天”
// 安卓系统时间格式化工具类:提供相对时间格式化能力,将时间戳转为「几分钟前/昨天/上周」等友好展示格式
import android.text.format.DateUtils;
// 导入安卓视图类:控制控件可见性状态
// 安卓视图体系核心父类:控制控件的显示/隐藏、可见性状态、视图属性等核心操作
import android.view.View;
// 导入安卓复选框类:选择模式下展示的勾选框
// 安卓复选框控件:列表选择模式下的核心勾选组件,用于便签的批量操作场景
import android.widget.CheckBox;
// 导入安卓图片视图类:展示提醒图标、通话记录图标
// 安卓图片展示控件:承载提醒图标、通话记录图标等所有图片类展示内容
import android.widget.ImageView;
// 导入安卓线性布局类:作为列表项的根布局
// 安卓线性布局容器:本类的父布局,提供横向/纵向的线性控件排列能力,作为列表项的根布局
import android.widget.LinearLayout;
// 导入安卓文本视图类:展示标题、时间、通话名称等文本
// 安卓文本展示控件:承载所有文本类展示内容,如标题、时间、通话名称等
import android.widget.TextView;
// 导入小米便签资源类:引用布局、字符串、样式、图片等资源
// -------------------------- 小米便签业务层核心依赖 - 资源/数据/工具适配 --------------------------
// 小米便签资源常量类统一管理布局、字符串、样式、图片等所有本地资源ID引用
import net.micode.notes.R;
// 导入便签数据常量类:定义便签/文件夹类型、特殊ID通话记录文件夹常量
// 小米便签数据层核心常量类:定义便签/文件夹的类型、特殊ID、业务状态等全局核心常量
import net.micode.notes.data.Notes;
// 导入数据工具类:格式化便签摘要文本
// 小米便签数据格式化工具类:封装便签摘要文本的格式化处理逻辑,统一文本展示规则
import net.micode.notes.tool.DataUtils;
// 导入资源解析工具类:获取便签/文件夹的背景资源ID
// 小米便签资源解析工具类:封装便签/文件夹的背景资源适配逻辑,提供不同状态的背景资源获取能力
import net.micode.notes.tool.ResourceParser.NoteItemBgResources;
/**
* 便
* LinearLayout便NotesListActivity
* 1. UI
* 2. 便便///UI
* 3. /
* 4. 便
* 便
* <p>
* {LinearLayout}线MVCUI{NotesListActivity}
* 便UI
* 便/UI
* bind
*
* </p>
*/
public class NotesListItem extends LinearLayout {
// 提醒/类型图标:展示闹钟提醒、通话记录等图标
/** 功能图标控件:展示闹钟提醒、通话记录等业务类型图标,不同场景展示对应功能标识 */
private ImageView mAlert;
// 标题文本:展示便签摘要、文件夹名称(含数量)、通话记录标题
/** 主标题文本控件:核心文本展示区,适配展示便签摘要、文件夹名称+数量、通话记录内容等核心信息 */
private TextView mTitle;
// 时间文本展示便签最后修改的相对时间如“5分钟前”
/** 时间文本控件:展示便签/文件夹的最后修改时间,统一格式化为相对友好时间格式 */
private TextView mTime;
// 通话名称文本:仅通话记录项展示来电/去电联系人名称
/** 通话名称文本控件:通话记录专属展示区,仅通话记录项展示来电/去电的联系人名称,其他场景隐藏 */
private TextView mCallName;
// 列表项绑定的便签数据模型
/** 数据模型载体:保存当前列表项绑定的业务数据,用于后续视图刷新与数据获取 */
private NoteItemData mItemData;
// 选择模式下的勾选框:批量操作时展示
/** 选择复选框控件:批量操作模式专属控件,用于勾选待操作的便签,非选择模式下默认隐藏 */
private CheckBox mCheckBox;
/**
*
* @param context
*
*
*
* @param context
*/
public NotesListItem(Context context) {
super(context);
// 加载列表项布局res/layout/note_item.xml到当前LinearLayout
// 加载列表项的基础布局文件将xml布局解析为当前线性布局的子视图
inflate(context, R.layout.note_item, this);
// 初始化各UI控件
// 绑定布局内所有子控件通过ID精准获取并赋值给成员变量
mAlert = (ImageView) findViewById(R.id.iv_alert_icon);
mTitle = (TextView) findViewById(R.id.tv_title);
mTime = (TextView) findViewById(R.id.tv_time);
@ -80,14 +86,16 @@ public class NotesListItem extends LinearLayout {
}
/**
* 便UI/
* @param context /
* @param data 便NoteItemData
* @param choiceMode
* @param checked
*
* //
*
* @param context
* @param data
* @param choiceMode true=false=
* @param checked true=false=
*/
public void bind(Context context, NoteItemData data, boolean choiceMode, boolean checked) {
// 选择模式且为普通便签:显示勾选框并设置勾选状态;否则隐藏勾选框
// 选择模式适配逻辑:仅普通便签在选择模式下展示勾选框,其他类型/模式均隐藏,防止误操作文件夹
if (choiceMode && data.getType() == Notes.TYPE_NOTE) {
mCheckBox.setVisibility(View.VISIBLE);
mCheckBox.setChecked(checked);
@ -95,26 +103,28 @@ public class NotesListItem extends LinearLayout {
mCheckBox.setVisibility(View.GONE);
}
// 保存当前绑定的数据模型
// 缓存当前绑定的业务数据模型,供后续背景设置与外部数据获取使用
mItemData = data;
// 分支1通话记录文件夹特殊ID
// ===== 分支一通话记录专属文件夹系统特殊固定ID独立的展示样式 =====
if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) {
mCallName.setVisibility(View.GONE); // 隐藏通话名称
mAlert.setVisibility(View.VISIBLE); // 显示类型图标
mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); // 设置标题主样式
// 标题:通话记录文件夹名称 + 文件夹内文件数量(格式化)
mCallName.setVisibility(View.GONE);
mAlert.setVisibility(View.VISIBLE);
mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem);
// 标题展示规则:固定文件夹名称 + 文件夹内的通话记录数量,格式化展示
mTitle.setText(context.getString(R.string.call_record_folder_name)
+ context.getString(R.string.format_folder_files_count, data.getNotesCount()));
mAlert.setImageResource(R.drawable.call_record); // 设置通话记录图标
// 展示通话记录专属图标,明确业务类型标识
mAlert.setImageResource(R.drawable.call_record);
}
// 分支2通话记录项父ID为通话记录文件夹
// ===== 分支二:通话记录子项(归属通话记录文件夹),通话类专属展示样式 =====
else if (data.getParentId() == Notes.ID_CALL_RECORD_FOLDER) {
mCallName.setVisibility(View.VISIBLE); // 显示通话名称
mCallName.setText(data.getCallName()); // 设置通话联系人名称
mTitle.setTextAppearance(context,R.style.TextAppearanceSecondaryItem); // 设置标题次要样式
mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet())); // 格式化显示便签摘要
// 有提醒:显示闹钟图标;无提醒:隐藏图标
mCallName.setVisibility(View.VISIBLE);
mCallName.setText(data.getCallName());
mTitle.setTextAppearance(context,R.style.TextAppearanceSecondaryItem);
// 标题展示格式化后的通话记录摘要内容,保证文本展示的规范性
mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet()));
// 提醒状态适配:有闹钟提醒则展示闹钟图标,无则隐藏
if (data.hasAlert()) {
mAlert.setImageResource(R.drawable.clock);
mAlert.setVisibility(View.VISIBLE);
@ -122,23 +132,22 @@ public class NotesListItem extends LinearLayout {
mAlert.setVisibility(View.GONE);
}
}
// 分支3普通文件夹/普通便签(非通话记录相关)
// ===== 分支三:普通业务类型(普通文件夹/普通便签),通用展示样式 =====
else {
mCallName.setVisibility(View.GONE); // 隐藏通话名称
mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); // 设置标题主样式
mCallName.setVisibility(View.GONE);
mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem);
// 子分支3.1普通文件夹
// 子分支1普通文件夹类型,展示文件夹名称+包含便签数量
if (data.getType() == Notes.TYPE_FOLDER) {
// 标题:文件夹名称 + 文件夹内文件数量(格式化)
mTitle.setText(data.getSnippet()
+ context.getString(R.string.format_folder_files_count,
data.getNotesCount()));
mAlert.setVisibility(View.GONE); // 隐藏图标
mAlert.setVisibility(View.GONE);
}
// 子分支3.2普通便签
// 子分支2普通便签类型,展示便签核心摘要内容
else {
mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet())); // 格式化显示便签摘要
// 有提醒:显示闹钟图标;无提醒:隐藏图标
mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet()));
// 提醒状态适配:有闹钟提醒展示闹钟图标,无则隐藏
if (data.hasAlert()) {
mAlert.setImageResource(R.drawable.clock);
mAlert.setVisibility(View.VISIBLE);
@ -147,47 +156,51 @@ public class NotesListItem extends LinearLayout {
}
}
}
// 设置最后修改时间格式化为相对时间如“1小时前”
// 统一设置最后修改时间:将时间戳转为「几分钟前/昨天」等相对友好的时间格式,提升用户体验
mTime.setText(DateUtils.getRelativeTimeSpanString(data.getModifiedDate()));
// 根据数据模型设置列表项背景
// 根据当前绑定的数据模型,完成列表项背景样式的精准适配
setBackground(data);
}
/**
* 便
* 便/便///
* @param data 便
*
* 便
* 便///+ ID
* 使
* @param data ID
*/
private void setBackground(NoteItemData data) {
// 获取便签背景色ID
// 获取当前数据模型的背景色标识ID作为背景资源匹配的核心依据
int id = data.getBgColorId();
// 普通便签:根据列表位置适配不同背景资源
// 普通便签的背景适配逻辑:多场景精准匹配,保证列表连贯的视觉效果
if (data.getType() == Notes.TYPE_NOTE) {
if (data.isSingle() || data.isOneFollowingFolder()) {
// 单独项/文件夹下唯一项:使用“单独项”背景
// 场景1列表中唯一项 / 文件夹下的唯一项 → 使用独立完整背景
setBackgroundResource(NoteItemBgResources.getNoteBgSingleRes(id));
} else if (data.isLast()) {
// 列表最后一项:使用“最后项”背景
// 场景2列表中的最后一项 → 使用底部收尾样式背景
setBackgroundResource(NoteItemBgResources.getNoteBgLastRes(id));
} else if (data.isFirst() || data.isMultiFollowingFolder()) {
// 列表第一项/文件夹下多项的第一项:使用“第一项”背景
// 场景3列表中的第一项 / 文件夹下的多项首项 → 使用顶部起始样式背景
setBackgroundResource(NoteItemBgResources.getNoteBgFirstRes(id));
} else {
// 列表中间项:使用“普通项”背景
// 场景4列表中的中间项 → 使用标准连贯样式背景
setBackgroundResource(NoteItemBgResources.getNoteBgNormalRes(id));
}
}
// 文件夹(含通话记录文件夹):使用文件夹统一背景
// 文件夹类型统一适配逻辑:所有文件夹(含通话记录文件夹)使用固定背景样式
else {
setBackgroundResource(NoteItemBgResources.getFolderBgRes());
}
}
/**
* 便
* @return NoteItemData
*
* 访//
*
* @return NoteItemData
*/
public NoteItemData getItemData() {
return mItemData;

@ -14,149 +14,176 @@
* limitations under the License.
*/
// 包声明:归属小米便签的UI模块负责便签应用的设置页面逻辑
// 包声明:小米便签 核心UI业务模块承载应用所有可视化交互页面本类为应用设置页核心实现
package net.micode.notes.ui;
// 导入安卓账号相关类用于Google账号的获取与管理
// -------------------------- 安卓系统核心依赖包 - 账号/页面/弹窗/广播/数据存储能力 --------------------------
// 安卓账号体系核心类封装Google账号的账号名、账号类型等基础信息载体
import android.accounts.Account;
// 安卓账号管理核心服务类:系统级账号管理器,负责设备上所有账号的查询、管理、鉴权等操作
import android.accounts.AccountManager;
// 导入安卓ActionBar类设置顶部导航栏返回按钮
// 安卓顶部导航栏核心类配置页面导航样式、返回按钮、标题等ActionBar相关属性
import android.app.ActionBar;
// 导入安卓对话框类:用于账号选择、更改确认等弹窗
// 安卓系统弹窗核心类:构建标准化的对话框,承载账号选择、确认提示等交互弹窗
import android.app.AlertDialog;
// 导入安卓广播接收器类:接收同步服务的状态广播
// 安卓广播核心组件:监听系统/应用内部的广播消息,本类用于监听同步服务状态变更广播
import android.content.BroadcastReceiver;
// 导入安卓内容值类:用于更新便签数据库的同步相关字段
// 安卓数据封装类封装键值对数据用于ContentProvider执行数据库字段更新操作
import android.content.ContentValues;
// 导入安卓上下文类:提供应用运行环境
// 安卓应用全局上下文:提供资源访问、组件通信、偏好设置读写等核心基础能力
import android.content.Context;
// 导入安卓对话框点击监听类:处理弹窗选项的点击事件
// 安卓对话框交互核心接口:监听弹窗选项的点击事件,处理用户选择逻辑
import android.content.DialogInterface;
// 导入安卓意图类:用于页面跳转、广播过滤
// 安卓组件通信核心类:封装页面跳转指令、广播指令、参数传递,实现跨组件通信
import android.content.Intent;
// 导入安卓意图过滤器类:筛选需要接收的广播
// 安卓广播过滤核心类:筛选需要监听的广播动作,精准匹配目标广播消息
import android.content.IntentFilter;
// 导入安卓共享偏好设置类:存储便签的偏好配置(同步账号、最后同步时间等)
// 安卓轻量级存储核心类:键值对持久化存储,用于保存应用偏好配置,非数据库存储
import android.content.SharedPreferences;
// 导入安卓Bundle类保存Activity状态
// 安卓页面状态存储类:保存页面销毁重建时的临时数据,保证页面状态不丢失
import android.os.Bundle;
// 导入安卓偏好设置相关类构建设置页面的UI组件
// 安卓系统偏好设置组件构建设置页面的标准化UI控件封装设置项的展示与交互逻辑
import android.preference.Preference;
import android.preference.Preference.OnPreferenceClickListener;
import android.preference.PreferenceActivity;
import android.preference.PreferenceCategory;
// 导入安卓文本工具类:处理字符串判空、相等判断
// 安卓文本工具类:封装字符串判空、内容对比等常用操作,避免空指针与硬编码判断
import android.text.TextUtils;
// 导入安卓日期格式化类:格式化最后同步时间
// 安卓系统日期格式化工具类:标准化格式化时间戳为指定样式的字符串,适配多语言展示
import android.text.format.DateFormat;
// 导入安卓布局填充类加载自定义布局设置页面header、对话框标题等
// 安卓布局加载核心类将xml布局文件解析为Java视图对象加载自定义布局与弹窗样式
import android.view.LayoutInflater;
// 导入安卓菜单相关类处理ActionBar的菜单点击
// 安卓页面菜单核心类配置ActionBar右侧菜单的创建与点击事件处理
import android.view.Menu;
import android.view.MenuItem;
// 导入安卓视图相关类操作按钮、文本框等UI控件
// 安卓视图体系核心类:操作所有可视化控件的父类,实现控件的点击、赋值、显隐等操作
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
// 导入安卓吐司类:显示操作结果提示
// 安卓轻量级提示组件:展示短时操作结果提示,无焦点不阻塞用户交互
import android.widget.Toast;
// 导入小米便签资源类引用字符串、布局、偏好设置xml等资源
// -------------------------- 小米便签业务层核心依赖 - 资源/数据/同步服务 --------------------------
// 小米便签资源常量类统一管理布局、字符串、颜色、样式等所有本地资源ID引用
import net.micode.notes.R;
// 导入便签数据常量类定义便签URI、字段等核心常量
// 小米便签数据层核心常量类定义便签ContentProvider URI、数据表字段、业务常量等核心配置
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
// 导入GTask同步服务类处理便签与Google Task的同步逻辑
// 小米便签核心同步服务类封装便签与Google Task的双向同步逻辑提供同步启停、状态查询等核心能力
import net.micode.notes.gtask.remote.GTaskSyncService;
/**
* 便Activity
* PreferenceActivity
* 1. Google//便Google Task
* 2.
* 3. /
* 4. 广UI
* 5. 便
* 便 Activity
* <p>
* {PreferenceActivity}MVCUI便
* GoogleGTask
* Google///
* 广便
* SharedPreferences广
* 线线Google
* </p>
*/
public class NotesPreferenceActivity extends PreferenceActivity {
/** 偏好设置文件名:所有便签偏好配置存储在该文件下 */
/**
* SharedPreferences
*/
public static final String PREFERENCE_NAME = "notes_preferences";
/** 偏好设置Key存储当前绑定的Google同步账号名 */
/**
* - Google
*/
public static final String PREFERENCE_SYNC_ACCOUNT_NAME = "pref_key_account_name";
/** 偏好设置Key存储最后一次同步的时间戳 */
/**
* -
*/
public static final String PREFERENCE_LAST_SYNC_TIME = "pref_last_sync_time";
/** 偏好设置Key控制便签背景色是否随机显示 */
/**
* - 便便
*/
public static final String PREFERENCE_SET_BG_COLOR_KEY = "pref_key_bg_random_appear";
/** 私有偏好设置Key同步账号相关的偏好分类标识 */
/**
* - KeyUI
*/
private static final String PREFERENCE_SYNC_ACCOUNT_KEY = "pref_sync_account_key";
/** 私有常量添加账号时的权限过滤Key */
/**
* - Google
*/
private static final String AUTHORITIES_FILTER_KEY = "authorities";
/** 账号相关的偏好分类组件:承载账号选择的偏好项 */
/**
*
*/
private PreferenceCategory mAccountCategory;
/** 广播接收器接收GTask同步服务的状态广播同步中/同步完成) */
/**
* 广GTaskSyncService广/UI
*/
private GTaskReceiver mReceiver;
/** 原始Google账号数组用于对比是否新增了账号 */
/**
* Google
*/
private Account[] mOriAccounts;
/** 标记位:是否触发了添加新账号的操作 */
/**
*
*/
private boolean mHasAddedAccount;
/**
* Activity
* ActionBar广header
* @param icicle ActivityBundle
*
*
* ActionBar广
*
* @param icicle Bundle
*/
@Override
protected void onCreate(Bundle icicle) {
super.onCreate(icicle);
/* 使用应用图标作为导航开启ActionBar的返回按钮 */
/* 配置ActionBar导航启用左上角返回按钮跳转回便签列表页 */
getActionBar().setDisplayHomeAsUpEnabled(true);
// 从xml资源加载偏好设置页面的UI结构res/xml/preferences.xml
// 从xml资源加载设置页面的标准化偏好配置UI结构页面主体内容初始化
addPreferencesFromResource(R.xml.preferences);
// 获取账号相关的偏好分类组件
// 根据标识获取账号同步分类容器控件,完成核心控件绑定
mAccountCategory = (PreferenceCategory) findPreference(PREFERENCE_SYNC_ACCOUNT_KEY);
// 初始化同步广播接收器
// 初始化同步状态广播接收器,注册监听同步服务的状态变更广播
mReceiver = new GTaskReceiver();
IntentFilter filter = new IntentFilter();
// 过滤GTask同步服务的广播动作
filter.addAction(GTaskSyncService.GTASK_SERVICE_BROADCAST_NAME);
// 注册广播接收器,监听同步状态变化
registerReceiver(mReceiver, filter);
// 初始化原始账号数组为null
// 初始化账号缓存数组与状态标记位,默认无账号无新增操作
mOriAccounts = null;
// 加载设置页面的header布局并添加到列表顶部
mHasAddedAccount = false;
// 加载自定义页面头部布局并添加至列表顶部,丰富页面视觉层级
View header = LayoutInflater.from(this).inflate(R.layout.settings_header, null);
getListView().addHeaderView(header, null, true);
}
/**
* Activity
* GoogleUI
*
*
* GoogleUI
*
*/
@Override
protected void onResume() {
super.onResume();
// 若触发了添加账号操作,检查是否有新账号并自动绑定
// 检测添加账号标记位,处理新增账号的自动绑定逻辑
if (mHasAddedAccount) {
// 获取当前设备的Google账号列表
Account[] accounts = getGoogleAccounts();
// 对比原始账号数组,判断是否新增了账号
// 对比操作前后的账号数量,判断是否有新账号添加成功
if (mOriAccounts != null && accounts.length > mOriAccounts.length) {
for (Account accountNew : accounts) {
boolean found = false;
// 遍历原始账号,判断新账号是否已存在
// 遍历原始账号列表,过滤已存在的账号
for (Account accountOld : mOriAccounts) {
if (TextUtils.equals(accountOld.name, accountNew.name)) {
found = true;
break;
}
}
// 找到新增账号,自动绑定为同步账号
// 匹配到新增账号,自动完成绑定并终止遍历
if (!found) {
setSyncAccount(accountNew.name);
break;
@ -165,17 +192,18 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
}
// 刷新设置页面的UI账号偏好、同步按钮、同步状态
// 统一刷新页面所有UI组件状态保证数据与视图一致
refreshUI();
}
/**
* Activity
* 广
*
* 退
* 广
*/
@Override
protected void onDestroy() {
// 注销同步广播接收器
// 安全注销广播接收器,判空避免空指针异常
if (mReceiver != null) {
unregisterReceiver(mReceiver);
}
@ -183,34 +211,34 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
*
* /
*
*
*
*/
private void loadAccountPreference() {
// 清空账号分类下的原有偏好项,避免重复添加
// 清空分类容器内原有配置项避免重复添加导致的UI重复展示问题
mAccountCategory.removeAll();
// 创建账号选择的偏好项
// 新建账号选择偏好配置项,初始化展示文案与交互行为
Preference accountPref = new Preference(this);
// 获取当前绑定的默认同步账号
final String defaultAccount = getSyncAccountName(this);
// 设置偏好项标题和摘要
accountPref.setTitle(getString(R.string.preferences_account_title));
accountPref.setSummary(getString(R.string.preferences_account_summary));
// 设置偏好项点击事件
// 绑定配置项点击事件,处理账号选择与变更的核心交互逻辑
accountPref.setOnPreferenceClickListener(new OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference preference) {
// 同步中不允许更改账号
// 同步中禁止账号操作,防止同步数据与账号信息不一致导致异常
if (!GTaskSyncService.isSyncing()) {
// 未绑定账号:显示账号选择对话框
if (TextUtils.isEmpty(defaultAccount)) {
// 未绑定账号:展示账号选择弹窗,供用户选择绑定
showSelectAccountAlertDialog();
} else {
// 已绑定账号:显示更改账号的确认对话框(提示风险)
// 已绑定账号:展示账号变更确认弹窗,提示风险并提供操作选项
showChangeAccountConfirmAlertDialog();
}
} else {
// 同步中提示无法更改账号
// 同步中操作拦截,展示友好的吐司提示
Toast.makeText(NotesPreferenceActivity.this,
R.string.preferences_toast_cannot_change_account, Toast.LENGTH_SHORT)
.show();
@ -219,25 +247,23 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
});
// 将账号偏好项添加到账号分类组件中
// 将配置项添加至分类容器完成UI渲染
mAccountCategory.addPreference(accountPref);
}
/**
*
*
* 1. /
* 2.
* 3.
*
* /
*
*/
private void loadSyncButton() {
// 获取同步按钮和最后同步时间文本框
// 获取页面同步按钮与同步状态文本控件,完成视图绑定
Button syncButton = (Button) findViewById(R.id.preference_sync_button);
TextView lastSyncTimeView = (TextView) findViewById(R.id.prefenerece_sync_status_textview);
// 根据同步状态设置按钮状态
// 根据同步服务状态,动态配置按钮行为与文本
if (GTaskSyncService.isSyncing()) {
// 同步中:按钮文本取消同步,点击触发取消同步
// 同步中状态:按钮文本为取消同步,点击触发同步终止逻辑
syncButton.setText(getString(R.string.preferences_button_sync_cancel));
syncButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
@ -245,7 +271,7 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
});
} else {
// 未同步:按钮文本立即同步,点击触发同步
// 未同步状态:按钮文本为立即同步,点击触发同步启动逻辑
syncButton.setText(getString(R.string.preferences_button_sync_immediately));
syncButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
@ -253,33 +279,33 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
});
}
// 无绑定账号时禁用同步按钮
// 无绑定账号时禁用同步按钮,避免无账号同步的无效操作
syncButton.setEnabled(!TextUtils.isEmpty(getSyncAccountName(this)));
// 设置最后同步时间/同步进度
// 配置同步状态文本展示逻辑,区分同步中与历史同步记录
if (GTaskSyncService.isSyncing()) {
// 同步中:显示同步进度文本
// 同步中:展示实时同步进度文案
lastSyncTimeView.setText(GTaskSyncService.getProgressString());
lastSyncTimeView.setVisibility(View.VISIBLE);
} else {
// 未同步:获取最后同步时间戳
// 未同步:读取最后同步时间戳,展示格式化的历史同步记录
long lastSyncTime = getLastSyncTime(this);
if (lastSyncTime != 0) {
// 有同步记录:格式化并显示最后同步时间
lastSyncTimeView.setText(getString(R.string.preferences_last_sync_time,
DateFormat.format(getString(R.string.preferences_last_sync_time_format),
lastSyncTime)));
lastSyncTimeView.setVisibility(View.VISIBLE);
} else {
// 无同步记录:隐藏时间文本
// 无同步记录:隐藏状态文本,简化页面展示
lastSyncTimeView.setVisibility(View.GONE);
}
}
}
/**
* UI
* /
* UI
*
*
*/
private void refreshUI() {
loadAccountPreference();
@ -287,203 +313,179 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
*
*
* 1. Google
* 2.
* 3.
* Google
* Google
*
*/
private void showSelectAccountAlertDialog() {
AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
// 加载自定义对话框标题布局
View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null);
TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title);
titleTextView.setText(getString(R.string.preferences_dialog_select_account_title));
TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle);
subtitleTextView.setText(getString(R.string.preferences_dialog_select_account_tips));
dialogBuilder.setCustomTitle(titleView);
// 隐藏默认的PositiveButton通过单选列表选择账号
dialogBuilder.setPositiveButton(null, null);
// 获取设备的Google账号列表
Account[] accounts = getGoogleAccounts();
// 获取当前绑定的账号
String defAccount = getSyncAccountName(this);
// 记录当前账号数组,用于后续对比是否新增账号
mOriAccounts = accounts;
// 初始化标记位:未添加新账号
mHasAddedAccount = false;
// 有Google账号时构建单选列表
if (accounts.length > 0) {
CharSequence[] items = new CharSequence[accounts.length];
final CharSequence[] itemMapping = items;
int checkedItem = -1;
int index = 0;
// 遍历账号,构建列表项并标记当前绑定账号
for (Account account : accounts) {
if (TextUtils.equals(account.name, defAccount)) {
checkedItem = index;
}
items[index++] = account.name;
}
// 设置单选列表选择后绑定账号并刷新UI
dialogBuilder.setSingleChoiceItems(items, checkedItem,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
setSyncAccount(itemMapping[which].toString());
dialog.dismiss();
refreshUI();
}
});
}
// 加载“添加新账号”的视图并添加到对话框
View addAccountView = LayoutInflater.from(this).inflate(R.layout.add_account_text, null);
dialogBuilder.setView(addAccountView);
// 显示对话框
final AlertDialog dialog = dialogBuilder.show();
// 设置“添加新账号”点击事件:跳转系统添加账号页面
addAccountView.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// 标记触发了添加账号操作
mHasAddedAccount = true;
Intent intent = new Intent("android.settings.ADD_ACCOUNT_SETTINGS");
// 过滤仅显示Google账号添加选项
intent.putExtra(AUTHORITIES_FILTER_KEY, new String[] {
"gmail-ls"
});
// 启动添加账号页面(无返回值)
startActivityForResult(intent, -1);
dialog.dismiss();
}
});
}
private void showSelectAccountAlertDialog() {
AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
// 加载自定义弹窗标题布局,初始化弹窗头部文案
View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null);
TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title);
titleTextView.setText(getString(R.string.preferences_dialog_select_account_title));
TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle);
subtitleTextView.setText(getString(R.string.preferences_dialog_select_account_tips));
dialogBuilder.setCustomTitle(titleView);
dialogBuilder.setPositiveButton(null, null);
// 获取设备上所有Google账号构建账号选择列表
Account[] accounts = getGoogleAccounts();
String defAccount = getSyncAccountName(this);
// 缓存当前账号列表,用于后续新增账号判断
mOriAccounts = accounts;
mHasAddedAccount = false;
// 存在Google账号时构建单选列表供用户选择
if (accounts.length > 0) {
CharSequence[] items = new CharSequence[accounts.length];
final CharSequence[] itemMapping = items;
int checkedItem = -1;
int index = 0;
for (Account account : accounts) {
if (TextUtils.equals(account.name, defAccount)) {
checkedItem = index;
}
items[index++] = account.name;
}
// 绑定列表选择事件,选择后完成账号绑定并关闭弹窗
dialogBuilder.setSingleChoiceItems(items, checkedItem,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
setSyncAccount(itemMapping[which].toString());
dialog.dismiss();
refreshUI();
}
});
}
// 加载添加新账号的自定义视图,提供账号新增入口
View addAccountView = LayoutInflater.from(this).inflate(R.layout.add_account_text, null);
dialogBuilder.setView(addAccountView);
// 展示弹窗并绑定添加账号点击事件,跳转系统账号添加页面
final AlertDialog dialog = dialogBuilder.show();
addAccountView.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
mHasAddedAccount = true;
Intent intent = new Intent("android.settings.ADD_ACCOUNT_SETTINGS");
intent.putExtra(AUTHORITIES_FILTER_KEY, new String[] {
"gmail-ls"
});
startActivityForResult(intent, -1);
dialog.dismiss();
}
});
}
/**
*
*
* 1.
* 2.
* 3. /
* /
* //
*
*/
private void showChangeAccountConfirmAlertDialog() {
AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
// 加载自定义对话框标题布局
View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null);
TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title);
// 标题显示当前绑定的账号名
titleTextView.setText(getString(R.string.preferences_dialog_change_account_title,
getSyncAccountName(this)));
TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle);
// 副标题提示更改账号的风险
subtitleTextView.setText(getString(R.string.preferences_dialog_change_account_warn_msg));
dialogBuilder.setCustomTitle(titleView);
// 构建对话框选项:更改账号、移除账号、取消
CharSequence[] menuItemArray = new CharSequence[] {
getString(R.string.preferences_menu_change_account),
getString(R.string.preferences_menu_remove_account),
getString(R.string.preferences_menu_cancel)
};
// 设置选项点击事件
dialogBuilder.setItems(menuItemArray, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0) {
// 选择“更改账号”:显示账号选择对话框
showSelectAccountAlertDialog();
} else if (which == 1) {
// 选择“移除账号”:清理绑定的账号信息
removeSyncAccount();
refreshUI();
}
// which==2为取消无操作
}
});
dialogBuilder.show();
}
private void showChangeAccountConfirmAlertDialog() {
AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
// 加载自定义弹窗标题布局,展示当前绑定账号与风险提示
View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null);
TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title);
titleTextView.setText(getString(R.string.preferences_dialog_change_account_title,
getSyncAccountName(this)));
TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle);
subtitleTextView.setText(getString(R.string.preferences_dialog_change_account_warn_msg));
dialogBuilder.setCustomTitle(titleView);
// 构建弹窗操作选项,绑定点击事件处理不同操作逻辑
CharSequence[] menuItemArray = new CharSequence[] {
getString(R.string.preferences_menu_change_account),
getString(R.string.preferences_menu_remove_account),
getString(R.string.preferences_menu_cancel)
};
dialogBuilder.setItems(menuItemArray, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
if (which == 0) {
// 选择更改账号:跳转账号选择弹窗
showSelectAccountAlertDialog();
} else if (which == 1) {
// 选择移除账号:执行账号解绑逻辑
removeSyncAccount();
refreshUI();
}
}
});
dialogBuilder.show();
}
/**
* Google
* @return Account[]com.google
* Google
* GoogleGoogle
*
* @return Account[] com.google
*/
private Account[] getGoogleAccounts() {
AccountManager accountManager = AccountManager.get(this);
// 根据账号类型筛选Google账号
return accountManager.getAccountsByType("com.google");
}
/**
*
*
* 1. SharedPreferences
* 2.
* 3. 便GTaskGTASK_IDSYNC_ID
* 4.
* @param account Google
* Google
* 便
*
* @param account Google
*/
private void setSyncAccount(String account) {
// 账号未变化时不执行操作
if (!getSyncAccountName(this).equals(account)) {
// 获取偏好设置编辑器
SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
// 存储新账号名(空账号则存空字符串)
if (account != null) {
editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, account);
} else {
editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, "");
}
editor.commit();
// 清理最后同步时间
setLastSyncTime(this, 0);
// 异步清理本地便签的GTask同步信息避免阻塞主线程
new Thread(new Runnable() {
public void run() {
ContentValues values = new ContentValues();
// 清空GTASK_ID和SYNC_ID
values.put(NoteColumns.GTASK_ID, "");
values.put(NoteColumns.SYNC_ID, 0);
// 更新所有便签的同步字段
getContentResolver().update(Notes.CONTENT_NOTE_URI, values, null, null);
}
}).start();
// 提示账号设置成功
Toast.makeText(NotesPreferenceActivity.this,
getString(R.string.preferences_toast_success_set_accout, account),
Toast.LENGTH_SHORT).show();
}
}
private void setSyncAccount(String account) {
// 账号未发生变化时,直接返回避免无效操作
if (!getSyncAccountName(this).equals(account)) {
// 写入偏好配置,持久化存储绑定的账号名
SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
if (account != null) {
editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, account);
} else {
editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, "");
}
editor.commit();
// 账号变更后清空历史同步时间,保证同步记录的准确性
setLastSyncTime(this, 0);
// 异步线程清理本地便签的同步关联字段,避免主线程阻塞导致页面卡顿
new Thread(new Runnable() {
public void run() {
ContentValues values = new ContentValues();
values.put(NoteColumns.GTASK_ID, "");
values.put(NoteColumns.SYNC_ID, 0);
getContentResolver().update(Notes.CONTENT_NOTE_URI, values, null, null);
}
}).start();
// 展示账号绑定成功的友好提示
Toast.makeText(NotesPreferenceActivity.this,
getString(R.string.preferences_toast_success_set_accout, account),
Toast.LENGTH_SHORT).show();
}
}
/**
*
*
* 1. SharedPreferences
* 2. 便GTask
* Google
* 便
* 便
*/
private void removeSyncAccount() {
// 获取偏好设置编辑器
// 读取偏好配置并清理账号与同步时间相关配置项
SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
// 移除同步账号名
if (settings.contains(PREFERENCE_SYNC_ACCOUNT_NAME)) {
editor.remove(PREFERENCE_SYNC_ACCOUNT_NAME);
}
// 移除最后同步时间
if (settings.contains(PREFERENCE_LAST_SYNC_TIME)) {
editor.remove(PREFERENCE_LAST_SYNC_TIME);
}
editor.commit();
// 异步清理本地便签的GTask同步信息
// 异步线程清理本地便签的同步关联字段,释放系统资源
new Thread(new Runnable() {
public void run() {
ContentValues values = new ContentValues();
@ -495,9 +497,10 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
*
* @param context
* @return String
* Google
*
* @param context 访
* @return String
*/
public static String getSyncAccountName(Context context) {
SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME,
@ -506,9 +509,10 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
*
* @param context
* @param time
*
*
* @param context 访
* @param time
*/
public static void setLastSyncTime(Context context, long time) {
SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME,
@ -519,9 +523,10 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
*
* @param context
* @return long0
*
*
* @param context 访
* @return long 0
*/
public static long getLastSyncTime(Context context) {
SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME,
@ -530,21 +535,24 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
* 广GTask广
* UI
* 广GTask广
*
* /广UI
* 宿
*/
private class GTaskReceiver extends BroadcastReceiver {
/**
* 广
* @param context 广
* @param intent
* 广广
* 广UI
* @param context 广
* @param intent 广
*/
@Override
public void onReceive(Context context, Intent intent) {
// 刷新设置页面UI
// 刷新页面所有UI控件,同步最新状态
refreshUI();
// 同步中:更新同步进度文本
// 同步中状态:更新实时同步进度文本
if (intent.getBooleanExtra(GTaskSyncService.GTASK_SERVICE_BROADCAST_IS_SYNCING, false)) {
TextView syncStatus = (TextView) findViewById(R.id.prefenerece_sync_status_textview);
syncStatus.setText(intent
@ -555,17 +563,17 @@ public class NotesPreferenceActivity extends PreferenceActivity {
}
/**
* ActionBar
* 便Activity
* @param item
* @return boolean
* ActionBar
* 便Activity
*
* @param item
* @return boolean
*/
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
// 构建跳转到便签列表页的意图
// 构建返回主页面的意图,清除顶部栈保证页面唯一性
Intent intent = new Intent(this, NotesListActivity.class);
// 清除顶部Activity栈避免返回时重复创建
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent);
return true;

@ -14,222 +14,231 @@
* limitations under the License.
*/
// 包声明:归属小米便签桌面小部件模块,定义所有尺寸便签小部件的通用基
// 包声明:小米便签 桌面小部件功能模块,该包统管所有小部件基类与不同规格的实现子
package net.micode.notes.widget;
// 导入安卓延迟意图类:用于小部件点击事件的异步意图触发
// -------------------------- 安卓系统核心依赖包 - 桌面小部件基础能力 --------------------------
// 安卓延迟意图类:小部件跨进程点击事件核心载体,桌面进程通过该类触发应用内页面跳转,异步执行意图逻辑
import android.app.PendingIntent;
// 导入安卓小部件管理器类:管理小部件的生命周期(更新、删除、创建)
// 安卓系统桌面小部件核心管理类:负责小部件的创建、更新、销毁、状态维护等全生命周期系统调度
import android.appwidget.AppWidgetManager;
// 导入安卓小部件提供者基类:所有桌面小部件的系统基类
// 安卓系统小部件基类:所有桌面小部件的标准父类,封装系统层小部件生命周期回调方法
import android.appwidget.AppWidgetProvider;
// 导入安卓内容值类用于封装ContentProvider的更新数据
// 安卓数据封装类封装ContentProvider的更新数据键值对用于便签数据的字段更新操作
import android.content.ContentValues;
// 导入安卓上下文类提供应用运行环境资源访问、ContentResolver获取等
// 安卓应用全局上下文提供资源访问、ContentResolver获取、系统服务调用等核心能力小部件核心依赖
import android.content.Context;
// 导入安卓意图类:用于组件间通信(小部件点击跳转页面)
// 安卓组件通信核心类:封装页面跳转指令与传递参数,用于小部件点击后的页面跳转逻辑
import android.content.Intent;
// 导入安卓数据库游标类:存储数据库查询结果
// 安卓数据库游标类承载ContentProvider的查询结果集按需读取便签数据用完需手动释放资源
import android.database.Cursor;
// 导入安卓日志类:输出小部件相关日志(异常/调试)
// 安卓系统日志工具类:输出调试与异常日志,便于小部件功能的问题定位与线上排查
import android.util.Log;
// 导入安卓远程视图类用于渲染桌面小部件的UI小部件运行在桌面进程需远程视图
// 安卓远程视图类小部件核心UI载体因小部件运行在桌面系统进程需通过该类跨进程渲染UI布局与数据
import android.widget.RemoteViews;
// 导入小米便签资源类:引用布局、字符串、图片等资源
// -------------------------- 小米便签业务层核心依赖 - 资源/数据/页面/工具 --------------------------
// 小米便签资源常量类统一管理布局、字符串、图片、颜色等所有本地资源ID引用
import net.micode.notes.R;
// 导入便签数据常量类定义便签URI、字段、意图参数等核心常量
// 小米便签数据层核心常量类定义便签URI、意图参数、业务状态、小部件类型等全局核心常量数据层与UI层通用
import net.micode.notes.data.Notes;
// 导入便签列常量子类:简化便签表字段的引用
// 小米便签数据表字段子类:简化便签数据库表的列名引用,避免硬编码,提升代码可维护性
import net.micode.notes.data.Notes.NoteColumns;
// 导入资源解析工具类:解析小部件背景资源
// 小米便签资源解析工具类:封装多规格小部件背景资源的映射适配逻辑,统一提供不同尺寸的背景资源获取能力
import net.micode.notes.tool.ResourceParser;
// 导入便签编辑页面:小部件点击跳转的目标页面
// 小米便签核心业务页面:便签新建/编辑页面,小部件点击跳转的核心目标页面
import net.micode.notes.ui.NoteEditActivity;
// 导入便签列表页面:隐私模式下小部件点击跳转的目标页面
// 小米便签核心业务页面:便签列表展示页面,隐私模式下小部件点击的跳转目标页面
import net.micode.notes.ui.NotesListActivity;
/**
* 便
* AppWidgetProvider便
* 1. 便
* 2. UI
* 3. 2x/4x
* 便
* <p>
* {AppWidgetProvider}MVCUI便
*
* {NoteWidgetProvider_2x}/{NoteWidgetProvider_4x}
* /UI/
*
* </p>
*/
public abstract class NoteWidgetProvider extends AppWidgetProvider {
/**
* 便
* 便IDID便
* 便
*
*/
public static final String [] PROJECTION = new String [] {
NoteColumns.ID, // 便签唯一标识
NoteColumns.BG_COLOR_ID, // 便签背景色ID
NoteColumns.SNIPPET // 便签内容摘要
NoteColumns.ID, // 便签数据表-主键ID唯一标识单条便签数据
NoteColumns.BG_COLOR_ID, // 便签数据表-背景色标识ID,用于匹配小部件对应背景资源
NoteColumns.SNIPPET // 便签数据表-内容摘要小部件UI上展示的核心文本内容
};
// 投影数组对应的列索引常量简化Cursor取值
public static final int COLUMN_ID = 0; // 便签ID列索引
public static final int COLUMN_BG_COLOR_ID = 1; // 背景色ID列索引
public static final int COLUMN_SNIPPET = 2; // 摘要列索引
// 投影数组对应的列索引常量:固化查询结果集的字段下标简化Cursor取值逻辑,避免硬编码索引值导致的错误
public static final int COLUMN_ID = 0; // 投影数组中-便签ID列索引
public static final int COLUMN_BG_COLOR_ID = 1; // 投影数组中-背景色ID列索引
public static final int COLUMN_SNIPPET = 2; // 投影数组中-便签摘要列索引
// 日志标签:用于小部件相关日志输出,便于问题定位
// 日志统一标签:小部件模块所有日志输出的固定标识,便于日志过滤与问题精准定位
private static final String TAG = "NoteWidgetProvider";
/**
*
* 便便WIDGET_ID
* @param context
* @param appWidgetIds ID
*
* 便
* 便便WIDGET_ID
*
* @param context ContentResolver
* @param appWidgetIds ID
*/
@Override
public void onDeleted(Context context, int[] appWidgetIds) {
// 构建ContentValues将WIDGET_ID设为无效值INVALID_APPWIDGET_ID
// 构建数据更新载体:封装需要更新的字段与对应值,将关联标识置为系统无效值
ContentValues values = new ContentValues();
values.put(NoteColumns.WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
// 遍历所有被删除的小部件ID更新关联便签的WIDGET_ID
// 遍历所有待删除的小部件ID逐一对关联便签执行数据更新操作
for (int i = 0; i < appWidgetIds.length; i++) {
context.getContentResolver().update(Notes.CONTENT_NOTE_URI, // 便签ContentProvider的URI
values, // 要更新的字段和值
NoteColumns.WIDGET_ID + "=?", // 更新条件WIDGET_ID匹配当前删除的ID
new String[] { String.valueOf(appWidgetIds[i])}); // 条件参数防止SQL注入
context.getContentResolver().update(Notes.CONTENT_NOTE_URI,
values,
NoteColumns.WIDGET_ID + "=?",
new String[] { String.valueOf(appWidgetIds[i])});
}
}
/**
* ID便
* widgetId + 便PARENT_IDID
* @param context ContentResolver
* @param widgetId ID
* @return Cursor便IDnull
* ID便
* ID + 便便
* @param context ContentResolver
* @param widgetId ID
* @return Cursor 便null
*/
private Cursor getNoteWidgetInfo(Context context, int widgetId) {
return context.getContentResolver().query(Notes.CONTENT_NOTE_URI, // 便签查询URI
PROJECTION, // 查询的字段投影
NoteColumns.WIDGET_ID + "=? AND " + NoteColumns.PARENT_ID + "<>?", // 查询条件
new String[] { String.valueOf(widgetId), String.valueOf(Notes.ID_TRASH_FOLER) }, // 条件参数
null); // 排序规则(无)
return context.getContentResolver().query(Notes.CONTENT_NOTE_URI,
PROJECTION,
NoteColumns.WIDGET_ID + "=? AND " + NoteColumns.PARENT_ID + "<>?",
new String[] { String.valueOf(widgetId), String.valueOf(Notes.ID_TRASH_FOLER) },
null);
}
/**
*
* privacyMode
* @param context
* @param appWidgetManager
* @param appWidgetIds ID
*
* UI
* @param context
* @param appWidgetManager UI
* @param appWidgetIds ID
*/
protected void update(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
update(context, appWidgetManager, appWidgetIds, false);
}
/**
* UI
* @param context
* @param appWidgetManager UI
* @param appWidgetIds ID
* @param privacyMode
*
* UI
* /便
* @param context 访
* @param appWidgetManager UI
* @param appWidgetIds ID
* @param privacyMode true=()false=()
*/
private void update(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds,
boolean privacyMode) {
// 遍历所有需要更新的小部件ID逐个处理
// 遍历所有待更新的小部件ID逐个完成独立的更新逻辑处理
for (int i = 0; i < appWidgetIds.length; i++) {
// 过滤无效的小部件IDINVALID_APPWIDGET_ID
// 过滤无效的小部件ID,跳过无意义的更新操作,提升执行效率
if (appWidgetIds[i] != AppWidgetManager.INVALID_APPWIDGET_ID) {
// 初始化默认背景ID(从资源解析工具获取全局默认背景)
// 初始化默认背景ID:从资源工具类获取应用全局默认的便签背景标识
int bgId = ResourceParser.getDefaultBgId(context);
// 初始化便签摘要(默认空字符串)
// 初始化便签摘要:默认空字符串,防止空指针异常
String snippet = "";
// 构建小部件点击意图:默认跳转便签编辑页
// 构建默认跳转意图:无关联便签时,跳转至便签编辑页执行新建操作
Intent intent = new Intent(context, NoteEditActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); // 防止重复创建Activity
intent.putExtra(Notes.INTENT_EXTRA_WIDGET_ID, appWidgetIds[i]); // 携带小部件ID参数
intent.putExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, getWidgetType()); // 携带小部件类型(子类实现)
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); // 启动模式:栈顶复用,避免重复创建页面实例
intent.putExtra(Notes.INTENT_EXTRA_WIDGET_ID, appWidgetIds[i]); // 携带小部件ID参数,供编辑页关联使用
intent.putExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, getWidgetType()); // 携带小部件类型,由子类实现差异化适配
// 查询当前小部件关联的便签信息
// 查询当前小部件关联的有效便签数据
Cursor c = getNoteWidgetInfo(context, appWidgetIds[i]);
if (c != null && c.moveToFirst()) {
// 异常日志同一个widgetId关联多个便签数据异常
// 异常日志埋点同一小部件ID关联多条便签数据属于数据异常场景输出错误日志便于排查
if (c.getCount() > 1) {
Log.e(TAG, "Multiple message with same widget id:" + appWidgetIds[i]);
c.close();
return;
}
// 从Cursor中获取便签摘要和背景色ID
// 从结果集中解析业务数据,赋值给本地变量用于后续视图渲染
snippet = c.getString(COLUMN_SNIPPET);
bgId = c.getInt(COLUMN_BG_COLOR_ID);
// 携带便签ID参数跳转编辑页时定位到该便签
// 携带便签ID参数跳转编辑页时直接定位到当前关联的便签内容
intent.putExtra(Intent.EXTRA_UID, c.getLong(COLUMN_ID));
// 设置意图动作:查看已有便签
// 修改意图动作:由「新建」改为「查看/编辑」已有便签
intent.setAction(Intent.ACTION_VIEW);
} else {
// 无关联便签时:显示默认提示文本
// 无关联便签数据时:展示应用默认的空内容提示文本
snippet = context.getResources().getString(R.string.widget_havenot_content);
// 设置意图动作:新建便签
// 修改意图动作:执行新建便签的业务逻辑
intent.setAction(Intent.ACTION_INSERT_OR_EDIT);
}
// 关闭Cursor释放数据库资源
// 安全关闭游标,释放数据库连接资源,防止内存泄漏
if (c != null) {
c.close();
}
// 创建远程视图加载子类定义的布局2x/4x
// 创建远程视图实例加载子类实现的规格专属布局完成跨进程UI初始化
RemoteViews rv = new RemoteViews(context.getPackageName(), getLayoutId());
// 设置小部件背景图使用子类定义的背景资源2x/4x
// 为远程视图设置背景资源:加载子类实现的规格专属背景,完成尺寸与背景的精准适配
rv.setImageViewResource(R.id.widget_bg_image, getBgResourceId(bgId));
// 携带背景ID参数跳转编辑页时同步背景
// 携带背景ID参数跳转编辑页时同步使用当前小部件的背景样式
intent.putExtra(Notes.INTENT_EXTRA_BACKGROUND_ID, bgId);
/**
* PendingIntent
*
* 1. 便
* 2. 便便
*
* PendingIntent
*/
PendingIntent pendingIntent = null;
if (privacyMode) {
// 隐私模式:设置提示文本(“访问模式下隐藏内容”)
// 隐私模式逻辑:隐藏便签真实内容,展示隐私提示文本
rv.setTextViewText(R.id.widget_text,
context.getString(R.string.widget_under_visit_mode));
// 构建跳转列表页的PendingIntent
// 隐私模式跳转:点击后跳转至便签列表页面,而非编辑页
pendingIntent = PendingIntent.getActivity(context, appWidgetIds[i], new Intent(
context, NotesListActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
} else {
// 普通模式:设置便签摘要文本
// 普通模式逻辑:展示解析后的便签摘要文本
rv.setTextViewText(R.id.widget_text, snippet);
// 构建跳转编辑页的PendingIntentFLAG_UPDATE_CURRENT更新已有Intent的参数
// 普通模式跳转:点击后跳转至便签编辑页,携带完整业务参数
pendingIntent = PendingIntent.getActivity(context, appWidgetIds[i], intent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
// 绑定小部件文本区域的点击事件触发上述PendingIntent
// 为小部件核心文本区域绑定点击事件,触发上述构建的延迟意图
rv.setOnClickPendingIntent(R.id.widget_text, pendingIntent);
// 通过小部件管理器更新当前小部件的UI
// 通过系统管理器完成最终的UI刷新将渲染完成的远程视图同步至桌面小部件
appWidgetManager.updateAppWidget(appWidgetIds[i], rv);
}
}
}
/**
* ID
* 2x/4xID
* @param bgId ID
* @return ID
* ID
* /
* @param bgId 便ID
* @return int DrawableID
*/
protected abstract int getBgResourceId(int bgId);
/**
* ID
* 2x/4xID
* @return ID
* ID
* UI
* @return int ID
*/
protected abstract int getLayoutId();
/**
*
* 2x/4xNotes.TYPE_WIDGET_2X/4X
* @return
*
*
*
* @return int Notes.TYPE_WIDGET_2X / Notes.TYPE_WIDGET_4X
*/
protected abstract int getWidgetType();
}

@ -14,70 +14,66 @@
* limitations under the License.
*/
// 包声明:归属小米便签的桌面小部件模块
// 包声明:小米便签 桌面小部件功能模块,该包下统管所有尺寸规格的便签桌面小组件实现类
package net.micode.notes.widget;
// 导入必要的安卓系统类:用于管理桌面小部件
import android.appwidget.AppWidgetManager;
// 导入安卓上下文类:提供应用运行环境的全局信息
import android.content.Context;
// 导入小米便签的资源类:用于引用布局等资源
import net.micode.notes.R;
// 导入便签数据常量类:定义小部件类型等常量
import net.micode.notes.data.Notes;
// 导入资源解析工具类:用于解析小部件背景资源
import net.micode.notes.tool.ResourceParser;
/**
* 2x便
* NoteWidgetProvider2x
* 便 2X
* NoteWidgetProviderMVCUI2X
* 2X
*
*/
public class NoteWidgetProvider_2x extends NoteWidgetProvider {
/**
*
* 2xupdate
* @param context
* @param appWidgetManager
* @param appWidgetIds ID
*
* 便
* update2X便
* @param context 访
* @param appWidgetManager
* @param appWidgetIds 2XID
*/
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// 调用父类的update方法执行2x尺寸小部件的更新核心逻辑
super.update(context, appWidgetManager, appWidgetIds);
}
/**
* ID
* 2xID
* @return 2xID
* 2XID
* 2X便
* @return int 2XIDR.layout.widget_2x
*/
@Override
protected int getLayoutId() {
// 返回R.layout.widget_2x2x尺寸小部件的布局文件ID
return R.layout.widget_2x;
}
/**
* ID
* ID2xID
* @param bgId ID
* @return 2xID
* 2XID
* ID
* UI
* @param bgId IDNotes
* @return int 2XDrawableID
*/
@Override
protected int getBgResourceId(int bgId) {
// 调用ResourceParser工具类获取2x尺寸小部件对应的背景资源ID
return ResourceParser.WidgetBgResources.getWidget2xBgResource(bgId);
}
/**
*
* 2x便
* @return 2xNotes.TYPE_WIDGET_2X
*
* NotesProvider便
* 便-
* @return int 2XNotes.TYPE_WIDGET_2X
*/
@Override
protected int getWidgetType() {
// 返回Notes.TYPE_WIDGET_2X2x尺寸便签小部件的类型标识常量
return Notes.TYPE_WIDGET_2X;
}
}

@ -14,70 +14,70 @@
* limitations under the License.
*/
// 包声明:归属小米便签的桌面小部件模块,统一管理不同尺寸的便签小部件
// 包声明:小米便签 桌面小部件功能模块,该包统一承载所有尺寸规格的便签桌面小组件实现类
package net.micode.notes.widget;
// 导入安卓系统小部件管理类:用于管理桌面小部件的创建、更新、删除等生命周期
// 安卓系统核心依赖:桌面小部件全生命周期调度的核心管理类,负责小部件的创建、更新、销毁等系统操作
import android.appwidget.AppWidgetManager;
// 导入安卓上下文类:提供应用运行时的环境信息(如资源访问、组件通信)
// 安卓系统核心依赖:应用全局上下文,提供资源访问、系统服务调用、组件通信的基础能力,小部件初始化必备
import android.content.Context;
// 导入小米便签资源类:引用布局、颜色等本地资源
// 小米便签业务依赖应用资源常量类统一管理所有布局、样式等资源ID引用
import net.micode.notes.R;
// 导入便签数据常量类:定义小部件类型、便签状态等核心常量
// 小米便签业务依赖:数据层核心常量类,定义便签及小部件的类型、状态等全局业务枚举常量
import net.micode.notes.data.Notes;
// 导入资源解析工具类:专门处理小部件背景资源的解析与匹配
// 小米便签业务依赖:资源解析工具类,封装多规格小部件背景资源的映射适配逻辑,统一提供背景资源获取能力
import net.micode.notes.tool.ResourceParser;
/**
* 4x便
* NoteWidgetProvider4x
* 4x
* 便 4X
* NoteWidgetProviderMVCUI4X
* 4X
*
*/
public class NoteWidgetProvider_4x extends NoteWidgetProvider {
/**
*
* 4x便update
* @param context 访
* @param appWidgetManager
* @param appWidgetIds 4xID
*
* 4X便
* 4Xupdate4X便
* @param context 访
* @param appWidgetManager
* @param appWidgetIds 4XID
*/
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// 调用父类核心更新方法复用通用更新逻辑适配4x尺寸小部件
super.update(context, appWidgetManager, appWidgetIds);
}
/**
* 4xID
* @Override
* @return 4xIDR.layout.widget_4x
* 4XID
* 便4XUI4便
* @return int 4XIDR.layout.widget_4x
*/
protected int getLayoutId() {
// 返回widget_4x布局ID该布局定义了4x尺寸小部件的UI结构
return R.layout.widget_4x;
}
/**
*
* ID4xID
* @param bgId
* @return 4xID
* ID4XID
* UI
* 便
* @param bgId ID
* @return int 4XDrawableID
*/
@Override
protected int getBgResourceId(int bgId) {
// 调用工具类方法获取4x尺寸小部件对应的背景资源ID
return ResourceParser.WidgetBgResources.getWidget4xBgResource(bgId);
}
/**
*
* 4x便
* @return 4xNotes.TYPE_WIDGET_4X
* 4X
* NotesProvider便
* -
* @return int 4XNotes.TYPE_WIDGET_4X
*/
@Override
protected int getWidgetType() {
// 返回Notes中定义的4x小部件类型常量用于业务层识别小部件尺寸
return Notes.TYPE_WIDGET_4X;
}
}
Loading…
Cancel
Save