diff --git a/app/src/main/kotlin/net/micode/notes/ui/AlarmAlertActivity.kt b/app/src/main/kotlin/net/micode/notes/ui/AlarmAlertActivity.kt index 0ca58b5..22d8889 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/AlarmAlertActivity.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/AlarmAlertActivity.kt @@ -34,27 +34,88 @@ import net.micode.notes.data.Notes import net.micode.notes.data.NotesRepository import java.io.IOException +/** + * 闹钟提醒Activity + * + * 当用户为便签设置时间提醒后,系统在指定时间启动此Activity。 + * + * 主要功能: + * 1. 在锁屏界面或亮屏状态下显示提醒对话框 + * 2. 播放系统闹钟铃声,循环播放直至用户响应 + * 3. 提供"知道了"(关闭提醒)和"查看"(进入便签编辑页)两种响应方式 + * 4. 自动处理便签已被删除的情况 + * + * 继承关系: + * - Activity:标准的Android活动组件 + * - DialogInterface.OnClickListener:处理对话框按钮点击事件 + * - DialogInterface.OnDismissListener:处理对话框关闭事件 + * + * @author MiCode Open Source Community + */ class AlarmAlertActivity : Activity(), DialogInterface.OnClickListener, DialogInterface.OnDismissListener { + + // ==================== 成员变量 ==================== + + /** + * 触发提醒的便签ID + * 从Intent中获取,用于查询便签内容和后续跳转 + */ private var mNoteId: Long = 0 + + /** + * 便签内容摘要 + * 显示在对话框中的文本内容,超过60字符时会截断并添加"…" + */ private var mSnippet: String? = null + + /** + * 媒体播放器实例 + * 用于播放系统闹钟铃声,可为null(当播放失败或未初始化时) + */ var mPlayer: MediaPlayer? = null + // ==================== 生命周期方法 ==================== + + /** + * Activity创建时的回调方法 + * + * 执行流程: + * 1. 配置窗口属性(支持锁屏显示、点亮屏幕) + * 2. 从Intent中提取便签ID(支持EXTRA_UID和URI两种方式) + * 3. 异步查询便签摘要内容 + * 4. 验证便签是否仍存在且可见 + * 5. 若便签有效则显示对话框并播放铃声,否则直接关闭 + * + * @param savedInstanceState 保存的实例状态(本Activity未使用) + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // 移除标题栏,使提醒界面更简洁 requestWindowFeature(Window.FEATURE_NO_TITLE) + // 配置窗口以支持闹钟提醒的特殊显示需求 configureWindowForAlarm() val intent = intent try { + // 提取便签ID - 支持两种数据传递方式 + // 方式1:直接从Intent的EXTRA_UID extra中获取 + // 方式2:从Intent的URI路径中解析(格式:content://.../noteId) mNoteId = intent.getLongExtra(Intent.EXTRA_UID, 0L).takeIf { it > 0L } ?: intent.data?.pathSegments?.getOrNull(1)?.toLongOrNull() ?: throw IllegalArgumentException("Missing note id in alarm intent") + + // 创建数据仓库实例,用于访问数据库 val repository = NotesRepository(applicationContext) + + // 从数据库获取便签摘要内容 + // 使用runBlocking同步执行协程,简化异步处理(数据量小,影响可接受) val snippet = runBlocking(Dispatchers.IO) { repository.getSnippetById(mNoteId).orEmpty() } + + // 处理摘要过长的情况:截断并添加省略号标记 mSnippet = if (snippet.length > SNIPPET_PREW_MAX_LEN) snippet.substring( 0, @@ -63,108 +124,241 @@ class AlarmAlertActivity : Activity(), DialogInterface.OnClickListener, else snippet } catch (e: IllegalArgumentException) { + // 无法获取有效的便签ID,打印错误并结束Activity e.printStackTrace() return } + // 初始化媒体播放器 mPlayer = MediaPlayer() + + // 检查便签是否仍然存在且可见(可能在闹钟触发前已被删除) val visible = runBlocking(Dispatchers.IO) { NotesRepository(applicationContext).isVisibleNote(mNoteId, Notes.TYPE_NOTE) } + if (visible) { + // 便签有效:显示提醒对话框并播放闹钟铃声 showActionDialog() playAlarmSound() } else { + // 便签已不存在(已被删除或移入回收站),直接关闭Activity finish() } } + // ==================== 属性方法 ==================== + + /** + * 判断屏幕是否处于亮屏/交互状态 + * + * 用于决定是否显示"查看"按钮: + * - 屏幕亮起时,用户可以方便地点击"查看"进入便签编辑页 + * - 屏幕锁定时,仅显示"知道了"按钮,简化用户操作 + * + * 注意:使用isInteractive而非isScreenOn,因为isInteractive更能准确反映用户可交互状态 + * + * @return true表示屏幕亮起且用户可交互,false表示屏幕已关闭 + */ private val isScreenOn: Boolean get() = (getSystemService(POWER_SERVICE) as PowerManager).isInteractive + // ==================== 窗口配置方法 ==================== + + /** + * 配置窗口属性,确保闹钟提醒能够正常显示 + * + * 需要实现的特性: + * 1. 锁屏状态下显示 - 即使手机被锁定,提醒界面也能显示 + * 2. 点亮屏幕 - 如果手机处于待机黑屏状态,自动点亮屏幕 + * 3. 保持屏幕常亮 - 提醒期间防止屏幕自动熄灭 + * + * Android版本兼容性处理: + * - API 27 (Android 8.1) 及以上:使用 setShowWhenLocked() 和 setTurnScreenOn() 方法 + * - API 26 及以下:使用传统的 WindowManager.LayoutParams flags + * + * 注意:低版本API使用了废弃的FLAG_SHOW_WHEN_LOCKED等标志,这是版本兼容的需要 + */ @Suppress("DEPRECATION") private fun configureWindowForAlarm() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(true) - setTurnScreenOn(true) - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // Android 8.1+:使用推荐的新版API + setShowWhenLocked(true) // 允许在锁屏界面上方显示 + setTurnScreenOn(true) // 允许点亮屏幕 + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // 保持屏幕常亮 return } + // Android 8.0及以下:使用传统的window flag组合实现相同效果 window.addFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or - WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or // 保持屏幕常亮 + WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or // 允许屏幕亮起时锁屏 + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or // 锁屏时显示 + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON // 点亮屏幕 ) } + // ==================== 闹钟铃声相关方法 ==================== + + /** + * 播放系统闹钟铃声 + * + * 铃声获取方式: + * - 通过 RingtoneManager.getActualDefaultRingtoneUri() 获取系统默认闹钟铃声 + * - 使用 TYPE_ALARM 类型,获取闹钟类别铃声(而非通知或铃声) + * + * 音频属性配置: + * - USAGE_ALARM:标识为闹钟用途,系统会给予较高优先级 + * - CONTENT_TYPE_SONIFICATION:内容类型为提示音/通知音 + * + * 播放设置: + * - isLooping = true:循环播放,确保用户不会因短暂错过而失效 + * + * 异常处理: + * 捕获多种异常(IllegalArgumentException、SecurityException等), + * 仅打印堆栈信息而不影响提醒的显示,确保闹钟提醒的基本功能可用 + */ private fun playAlarmSound() { + // 获取系统默认闹钟铃声的URIs val url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM) - val player = mPlayer ?: return + val player = mPlayer ?: return // 如果MediaPlayer未初始化,静默返回 + + // 配置音频属性,明确标识为闹钟用途 player.setAudioAttributes( AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) // 用途:闹钟 + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) // 内容类型:提示音 .build() ) + + // 设置数据源并开始播放 try { - player.setDataSource(this, url) - player.prepare() - player.isLooping = true - player.start() + player.setDataSource(this, url) // 设置音频数据源 + player.prepare() // 准备播放(同步准备) + player.isLooping = true // 循环播放 + player.start() // 开始播放 } catch (e: IllegalArgumentException) { - // TODO Auto-generated catch block + // 参数异常(如URI格式错误) e.printStackTrace() } catch (e: SecurityException) { - // TODO Auto-generated catch block + // 安全异常(如缺少读取铃声的权限) e.printStackTrace() } catch (e: IllegalStateException) { - // TODO Auto-generated catch block + // 状态异常(MediaPlayer处于错误状态) e.printStackTrace() } catch (e: IOException) { + // IO异常(铃声文件无法访问) e.printStackTrace() } } + + /** + * 停止闹钟铃声并释放资源 + * + * 该方法在对话框关闭时调用,确保: + * 1. 停止当前正在播放的铃声 + * 2. 释放MediaPlayer占用的系统资源 + * 3. 将引用置为null,辅助垃圾回收 + * + * 注意:该方法需要安全处理player为null的情况 + */ + private fun stopAlarmSound() { + mPlayer?.let { player -> + player.stop() // 停止播放 + player.release() // 释放资源 + mPlayer = null // 辅助GC + }, + } + // ==================== 对话框相关方法 ==================== + + /** + * 显示提醒对话框 + * + * 对话框配置: + * - 标题:应用名称("便签") + * - 消息内容:便签摘要(mSnippet) + * - "知道了"按钮(PositiveButton):始终显示,点击后仅关闭对话框 + * - "查看"按钮(NegativeButton):仅在屏幕亮起时显示 + * + * "查看"按钮的条件显示逻辑: + * 屏幕亮起时,用户可以舒适地阅读和点击,因此显示"查看"按钮 + * 屏幕锁定时,用户可能只是短暂查看,显示"知道了"更简洁 + * + * 监听器设置: + * - OnClickListener:处理按钮点击(本Activity实现) + * - OnDismissListener:处理对话框关闭(本Activity实现) + */ private fun showActionDialog() { val dialog = 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 (this.isScreenOn) { - dialog.setNegativeButton(R.string.notealert_enter, this) + // 屏幕亮起时才显示"查看"按钮 + dialog.setNegativeButton(R.string.notealert_enter, this) // 按钮:查看 } - dialog.show().setOnDismissListener(this) + dialog.show().setOnDismissListener(this) // 显示对话框并设置关闭监听器 } + // ==================== 回调接口实现 ==================== + + /** + * 对话框按钮点击事件处理 + * + * 按钮类型: + * - BUTTON_NEGATIVE("查看"按钮):启动NoteEditActivity,跳转到便签编辑页面 + * - BUTTON_POSITIVE("知道了"按钮):无需额外处理,对话框关闭即可 + * + * 跳转逻辑: + * 创建Intent,设置ACTION_VIEW动作,并将便签ID通过EXTRA_UID传递给编辑页面 + * + * @param dialog 触发点击事件的对话框 + * @param which 被点击的按钮类型(DialogInterface.BUTTON_POSITIVE 或 BUTTON_NEGATIVE) + */ override fun onClick(dialog: DialogInterface?, which: Int) { when (which) { DialogInterface.BUTTON_NEGATIVE -> { + // 用户点击"查看"按钮:跳转到便签编辑Activity val intent = Intent(this, NoteEditActivity::class.java) - intent.setAction(Intent.ACTION_VIEW) - intent.putExtra(Intent.EXTRA_UID, mNoteId) - startActivity(intent) + intent.setAction(Intent.ACTION_VIEW) // 动作:查看 + intent.putExtra(Intent.EXTRA_UID, mNoteId) // 传递便签ID + startActivity(intent) // 启动Activity } - + // BUTTON_POSITIVE("知道了"按钮)不需要任何处理 else -> {} } } - + + /** + * 对话框关闭事件处理 + * + * 无论用户通过何种方式关闭对话框(点击按钮、返回键、点击外部), + * 都会执行以下操作: + * 1. 停止闹钟铃声并释放MediaPlayer资源 + * 2. 关闭当前Activity + * + * 这确保了提醒界面不会残留,资源被及时释放 + * + * @param dialog 被关闭的对话框 + */ override fun onDismiss(dialog: DialogInterface?) { - stopAlarmSound() - finish() - } - - private fun stopAlarmSound() { - mPlayer?.let { player -> - player.stop() - player.release() - mPlayer = null - } + stopAlarmSound() // 停止铃声并释放资源 + finish() // 结束Activity } + // ==================== 伴生对象 ==================== + companion object { + /** + * 便签摘要显示的最大长度(字符数) + * + * 超过该长度的便签内容会被截断,并在末尾添加省略号(R.string.notelist_string_info) + * 设置为60字符的原因: + * - 手机屏幕宽度通常可显示约20-30个中文字符 + * - 60字符约等于2-3行文本,在对话框中显示比例合适 + * - 既能让用户了解便签内容,又不会因内容过长导致对话框过大 + */ private const val SNIPPET_PREW_MAX_LEN = 60 } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/AlarmInitReceiver.kt b/app/src/main/kotlin/net/micode/notes/ui/AlarmInitReceiver.kt index e390a65..c13ae9b 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/AlarmInitReceiver.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/AlarmInitReceiver.kt @@ -25,28 +25,103 @@ import kotlinx.coroutines.runBlocking import net.micode.notes.data.NotesRepository import net.micode.notes.tool.PendingIntentCompat +/** + * 闹钟初始化广播接收器 + * + * 该 BroadcastReceiver 专门用于监听系统启动完成的广播,并在设备重启后 + * 重新注册所有有效的闹钟提醒。这是必要的,因为系统的闹钟设置不会在设备重启后自动保留。 + * + * 主要功能: + * 1. 监听系统启动完成事件(ACTION_BOOT_COMPLETED) + * 2. 查询数据库中的所有未过期闹钟提醒 + * 3. 为每个有效的闹钟重新创建 PendingIntent 并注册到 AlarmManager + * + * 工作流程: + * 设备重启 → 系统发送 BOOT_COMPLETED 广播 → 本接收器被触发 → + * 查询未来所有未触发的闹钟 → 逐个重新设置 AlarmManager 提醒 + * + * 为什么需要这个组件: + * - AlarmManager 中设置的闹钟是易失性的,设备重启后会全部丢失 + * - 用户设置的所有提醒(例如明天上午9点提醒写便签)需要在重启后仍然生效 + * - 通过监听开机广播,我们可以恢复所有的闹钟设置,提供持久化的提醒体验 + * + * @author MiCode Open Source Community + */ class AlarmInitReceiver : BroadcastReceiver() { + + /** + * 广播接收器的回调方法 + * + * 当设备发送匹配的广播时,系统会调用此方法。本接收器只关心系统启动完成的广播。 + * + * 执行逻辑: + * 1. 验证广播的 Action 是否为 ACTION_BOOT_COMPLETED(系统启动完成) + * 2. 从数据库查询所有未来需要触发且尚未触发的闹钟提醒 + * 3. 遍历每个闹钟,创建对应的 PendingIntent 并重新注册到 AlarmManager + * + * 注意: + * - 查询数据库使用 runBlocking + Dispatchers.IO 是合理的,因为这个接收器 + * 需要尽快完成工作,且查询操作相对快速(索引查询) + * - 使用 RTC_WAKEUP 标志,确保在设备休眠时也能唤醒并触发提醒 + * + * @param context 接收器运行时的上下文环境 + * @param intent 包含广播信息的 Intent 对象,用于判断广播类型 + */ override fun onReceive(context: Context, intent: Intent?) { + // 第一步:验证广播类型 + // 只有系统启动完成的广播才需要处理 + // SYSTEM_USER_ADDED、MY_PACKAGE_REPLACED 等广播会被忽略 if (intent?.action != Intent.ACTION_BOOT_COMPLETED) { - return + return // 不是开机广播,直接返回 } + // 第二步:获取当前时间戳 + // 用于过滤出未来需要触发的闹钟(alertedDate > currentDate) val currentDate = System.currentTimeMillis() + + // 第三步:从数据库查询所有未来的闹钟提醒 + // 使用 runBlocking 同步执行协程,因为 BroadcastReceiver 的 onReceive 方法 + // 必须在有限时间内返回(Android 框架限制为约10秒),同步执行更可控 val alarms = runBlocking(Dispatchers.IO) { + // 创建 NotesRepository 实例,通过 applicationContext 避免内存泄漏 NotesRepository(context.applicationContext).getFutureAlertNotes(currentDate) } + + // 第四步:获取 AlarmManager 系统服务 + // AlarmManager 负责管理设备上的闹钟和定时任务 val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + // 第五步:遍历所有未触发的闹钟并重新设置 alarms.forEach { alarm -> + // 5.1 创建 Intent,用于在闹钟触发时启动 AlarmReceiver + // Intent 携带便签 ID(EXTRA_UID),以便 AlarmReceiver 知道哪条便签触发了提醒 val sender = Intent(context, AlarmReceiver::class.java).apply { - putExtra(Intent.EXTRA_UID, alarm.noteId) + putExtra(Intent.EXTRA_UID, alarm.noteId) // 存储便签ID } + + // 5.2 创建 PendingIntent + // PendingIntent 允许 AlarmManager 在未来某个时间点以当前应用的身份启动 Intent + // 参数说明: + // - context: 上下文 + // - requestCode: 0(所有闹钟使用相同的 requestCode,因为 noteId 存储在 Intent 中用于区分) + // - intent: 要执行的 Intent + // - flags: 使用 PendingIntentCompat.immutableFlag() 确保不可变性(Android 12+ 要求) val pendingIntent = PendingIntent.getBroadcast( context, - 0, - sender, - PendingIntentCompat.immutableFlag() + 0, // requestCode,设为0即可 + sender, // 要包装的 Intent + PendingIntentCompat.immutableFlag() // flag:保证 PendingIntent 不可变 ) + + // 5.3 将闹钟设置到 AlarmManager + // 参数说明: + // - type: AlarmManager.RTC_WAKEUP - 使用真实时间(RTC),并在设备休眠时唤醒 CPU + // - triggerAtMillis: alarm.alertedDate - 闹钟触发的时间点(毫秒时间戳) + // - operation: pendingIntent - 触发时要执行的 PendingIntent alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.alertedDate, pendingIntent) } + + // 注:本接收器不需要调用 abortBroadcast(),也不需要在 onReceive 中启动长时间运行的操作 + // 所有操作均为同步且轻量级,符合 BroadcastReceiver 的最佳实践 } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/AlarmReceiver.kt b/app/src/main/kotlin/net/micode/notes/ui/AlarmReceiver.kt index 1c17b78..d98bd0b 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/AlarmReceiver.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/AlarmReceiver.kt @@ -19,10 +19,67 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +/** + * 闹钟提醒广播接收器 + * + * 该 BroadcastReceiver 作为闹钟触发时的入口点,负责将系统闹钟事件转化为 + * 可见的 Activity 界面。它是 AlarmManager 和实际 UI 显示之间的桥梁。 + * + * 主要功能: + * 1. 接收 AlarmManager 在指定时间触发的系统广播 + * 2. 将广播 Intent 转换为启动 AlarmAlertActivity 的 Intent + * 3. 添加 FLAG_ACTIVITY_NEW_TASK 标志启动 Activity + * + * 工作流程: + * AlarmManager 触发 → 系统发送广播 → AlarmReceiver.onReceive() 被调用 → + * 重新设置 Intent 的目标组件 → 启动 AlarmAlertActivity 显示提醒界面 + * + * 为什么需要这个组件: + * - AlarmManager 只能发送广播或启动 Service,不能直接启动 Activity + * - 从广播接收器启动 Activity 需要 FLAG_ACTIVITY_NEW_TASK 标志 + * - 分离 AlarmReceiver 和 AlarmAlertActivity 可以实现职责单一: + * * AlarmReceiver:处理系统级闹钟事件,轻量级转发 + * * AlarmAlertActivity:显示 UI 界面,处理用户交互 + * + * @author MiCode Open Source Community + */ class AlarmReceiver : BroadcastReceiver() { + + /** + * 广播接收器的回调方法 + * + * 当 AlarmManager 设定的时间到达时,系统会调用此方法。 + * + * 执行逻辑: + * 1. 将传入的 Intent 重新设置目标组件为 AlarmAlertActivity + * 2. 添加 FLAG_ACTIVITY_NEW_TASK 标志(从非 Activity 上下文启动 Activity 的必要标志) + * 3. 启动 AlarmAlertActivity,显示提醒界面 + * + * 注意: + * - 本方法执行时间应当极短,仅做必要的 Intent 转换和 Activity 启动 + * - 不要在此方法中执行耗时操作(如数据库查询、网络请求等) + * - Intent 中已经包含了便签 ID(通过 EXTRA_UID 在设置闹钟时存入), + * 这些数据会随着 Intent 自动传递给 AlarmAlertActivity + * + * @param context 接收器运行时的上下文环境,用于启动 Activity + * @param intent 系统广播传递的 Intent 对象,包含闹钟触发信息 + * (其中含有之前通过 PendingIntent 设置的所有 extra 数据, + * 如 EXTRA_UID 保存的便签 ID) + */ override fun onReceive(context: Context, intent: Intent) { + // 第一步:重新设置 Intent 的目标组件 + // 原始 Intent 来自 PendingIntent,其目标组件可能是隐式的或未设置 + // 显式设置为 AlarmAlertActivity,确保系统知道要启动哪个 Activity intent.setClass(context, AlarmAlertActivity::class.java) + + // 第二步:添加 NEW_TASK 标志 + // 因为 BroadcastReceiver 不属于 Activity 上下文,启动 Activity 时 + // 必须使用 FLAG_ACTIVITY_NEW_TASK 标志,否则系统会抛出异常 + // 该标志会创建一个新的任务栈来承载这个 Activity intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + // 第三步:启动闹钟提醒 Activity + // AlarmAlertActivity 将接管后续的 UI 显示和用户交互逻辑 context.startActivity(intent) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NoteEditActivity.kt b/app/src/main/kotlin/net/micode/notes/ui/NoteEditActivity.kt index bfbd80e..06f9d43 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NoteEditActivity.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NoteEditActivity.kt @@ -34,44 +34,126 @@ import net.micode.notes.tool.ResourceParser import net.micode.notes.tool.defaultPreferences import net.micode.notes.widget.NoteWidgetUpdater +/** + * 便签编辑活动 + * + * 这是整个便签应用的核心编辑界面,负责便签的创建、编辑、删除、分享等功能。 + * + * 主要功能: + * 1. 便签编辑:支持普通文本和待办清单两种编辑模式 + * 2. 便签管理:新建、保存、删除便签 + * 3. 提醒功能:设置/取消闹钟提醒 + * 4. 分享导出:分享文本、导出TXT、生成图片 + * 5. 桌面快捷方式:将便签添加到桌面 + * 6. 个性化设置:背景颜色、字体大小 + * + * 技术特点: + * - 使用 Jetpack Compose 构建 UI + * - 使用 ViewModel 管理数据 + * - 支持 Activity 重启后恢复状态 + * - 支持多种 Intent 启动模式(查看/新建) + * + * @author MiCode Open Source Community + */ class NoteEditActivity : ComponentActivity() { + + // ==================== 成员变量 ==================== + + /** + * 当前正在编辑的工作便签 + * WorkingNote 封装了便签的数据操作逻辑 + */ private lateinit var workingNote: WorkingNote + + /** + * 共享偏好设置实例 + * 存储用户的个性化设置(如字体大小) + */ private lateinit var sharedPrefs: SharedPreferences + + /** + * UI 状态(使用 Compose 的状态管理) + * mutableStateOf 会自动触发 UI 重组 + */ private var uiState by mutableStateOf(NoteEditUiState()) + + /** + * 删除确认对话框是否可见 + */ private var deleteDialogVisible by mutableStateOf(false) + + /** + * 提醒设置对话框状态 + * null 表示不显示对话框 + */ private var reminderDialogState by mutableStateOf(null) + /** + * 便签编辑页面的 ViewModel + * 负责处理导出、生成长图等耗时操作 + */ private val noteEditViewModel: NoteEditViewModel by lazy { ViewModelProvider(this)[NoteEditViewModel::class.java] } + + /** + * 便签数据仓库 + * 提供数据库访问接口 + */ private val notesRepository: NotesRepository by lazy { NotesRepository(applicationContext) } + // ==================== 生命周期方法 ==================== + + /** + * Activity 创建时的回调 + * + * 初始化流程: + * 1. 配置窗口(支持边到边显示) + * 2. 加载用户偏好设置 + * 3. 根据 Intent 初始化 Activity 状态(新建/编辑便签) + * 4. 注册返回键回调(保存后退出) + * 5. 设置 Compose UI 界面 + * + * @param savedInstanceState 保存的实例状态,用于 Activity 重建后恢复便签 ID + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // 配置窗口:让内容延伸到系统栏区域(边到边显示) WindowCompat.setDecorFitsSystemWindows(window, false) + + // 获取共享偏好设置实例 sharedPrefs = defaultPreferences() + + // 判断是否有保存的状态(如屏幕旋转后恢复) + // 如果有,从 savedInstanceState 中恢复便签 ID 并构建 Intent val initialIntent = if (savedInstanceState?.containsKey(Intent.EXTRA_UID) == true) { Intent(Intent.ACTION_VIEW).apply { putExtra(Intent.EXTRA_UID, savedInstanceState.getLong(Intent.EXTRA_UID)) } } else { - intent + intent // 使用原始 Intent } + // 初始化 Activity 状态(加载便签数据) + // 返回 false 表示初始化失败(如便签不存在),需要关闭 Activity if (!initActivityState(initialIntent)) { finish() return } + // 注册返回键回调 + // 用户按返回键时,先保存便签再退出 onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - saveNote() - finish() + saveNote() // 保存便签内容 + finish() // 关闭 Activity } }) + // 设置 Compose UI setContent { MaterialTheme { NoteEditScreen( @@ -119,92 +201,158 @@ class NoteEditActivity : ComponentActivity() { } } + /** + * 处理新的 Intent(当 Activity 已存在时) + * + * 应用场景: + * - 从桌面快捷方式打开便签 + * - 从其他应用通过 Intent 打开 + * - 通知栏点击提醒 + * + * @param intent 新的 Intent 对象 + */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + // 重新初始化 Activity 状态 if (initActivityState(intent)) { + // 同步 UI 状态 syncUiStateFromWorkingNote() } } + /** + * Activity 恢复显示时的回调 + * + * 确保 UI 显示最新的便签内容 + */ override fun onResume() { super.onResume() syncUiStateFromWorkingNote(uiState.content) } + /** + * Activity 暂停时的回调 + * + * 自动保存便签内容,防止数据丢失 + */ override fun onPause() { super.onPause() saveNote() } + /** + * 保存 Activity 状态(用于屏幕旋转等配置变更) + * + * @param outState 用于保存状态的 Bundle + */ override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) + // 确保便签已保存到数据库 if (!workingNote.existInDatabase()) { saveNote() } + // 保存便签 ID 以便重建时恢复 outState.putLong(Intent.EXTRA_UID, workingNote.noteId) } + // ==================== Activity 状态初始化 ==================== + + /** + * 初始化 Activity 状态 + * + * 根据 Intent 的 Action 类型决定操作: + * - ACTION_VIEW:打开已有便签进行查看/编辑 + * - ACTION_INSERT_OR_EDIT:创建新便签或编辑现有便签 + * + * 此方法处理两种场景: + * 1. 查看已有便签:检查便签是否存在,加载数据 + * 2. 新建便签:创建新的空便签,支持传入文件夹ID、小部件ID等参数 + * + * @param intent 启动 Activity 的 Intent + * @return true 表示初始化成功,false 表示失败(会关闭 Activity) + */ private fun initActivityState(intent: Intent): Boolean { + // 处理 ACTION_VIEW:查看已有便签 @Suppress("DEPRECATION") val note = if (intent.action == Intent.ACTION_VIEW) { + // 获取便签 ID var noteId = intent.getLongExtra(Intent.EXTRA_UID, 0) + // 验证便签是否可见(未被删除且不在回收站) val visible = runBlocking(Dispatchers.IO) { notesRepository.isVisibleNote(noteId, Notes.TYPE_NOTE) } if (!visible) { + // 便签不存在,跳转到列表页并提示错误 startActivity(Intent(this, NotesListActivity::class.java)) showToast(R.string.error_note_not_exist) return false } + // 配置软键盘:初始隐藏,调整布局大小 window.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE ) + + // 加载便签数据 loadWorkingNote(noteId) ?: return false + + // 处理 ACTION_INSERT_OR_EDIT:新建或编辑便签 } else if (intent.action == Intent.ACTION_INSERT_OR_EDIT) { - val folderId = intent.getLongExtra(Notes.INTENT_EXTRA_FOLDER_ID, 0) + // 获取可选参数 + val folderId = intent.getLongExtra(Notes.INTENT_EXTRA_FOLDER_ID, 0) // 目标文件夹 ID val widgetId = intent.getIntExtra( Notes.INTENT_EXTRA_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID - ) + ) // 来源小部件 ID val widgetType = intent.getIntExtra( Notes.INTENT_EXTRA_WIDGET_TYPE, Notes.TYPE_WIDGET_INVALIDE - ) + ) // 小部件类型 val bgResId = intent.getIntExtra( Notes.INTENT_EXTRA_BACKGROUND_ID, ResourceParser.getDefaultBgId(this) - ) + ) // 背景颜色资源 ID - val phoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER) - val callDate = intent.getLongExtra(Notes.INTENT_EXTRA_CALL_DATE, 0) + // 通话便签相关数据 + val phoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER) // 电话号码 + val callDate = intent.getLongExtra(Notes.INTENT_EXTRA_CALL_DATE, 0) // 通话时间 + + // 创建便签(可能是通话便签或普通便签) val createdNote = if (callDate != 0L && phoneNumber != null) { + // 通话便签:先查询是否已存在相同通话的便签 val noteId = runBlocking(Dispatchers.IO) { notesRepository.getNoteIdByPhoneNumberAndCallDate(phoneNumber, callDate) } if (noteId > 0) { + // 已存在则加载现有便签 loadWorkingNote(noteId) ?: return false } else { + // 不存在则创建新的通话便签 WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId).apply { convertToCallNote(phoneNumber, callDate) } } } else { + // 普通便签:直接创建空便签 WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId) } + // 配置软键盘:显示键盘,调整布局大小 window.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) createdNote + } else { + // 不支持的 Action Log.e(TAG, "Intent not specified action, should not support") return false } + // 保存便签引用并同步 UI 状态 workingNote = note deleteDialogVisible = false reminderDialogState = null @@ -212,6 +360,12 @@ class NoteEditActivity : ComponentActivity() { return true } + /** + * 根据 ID 加载工作便签 + * + * @param noteId 便签 ID + * @return WorkingNote 实例,加载失败时返回 null + */ private fun loadWorkingNote(noteId: Long): WorkingNote? { return runCatching { WorkingNote.load(this, noteId) } .onFailure { error -> @@ -220,33 +374,53 @@ class NoteEditActivity : ComponentActivity() { .getOrNull() } + // ==================== UI 状态同步 ==================== + + /** + * 同步 UI 状态 + * + * 将 WorkingNote 中的数据同步到 Compose UI 状态中 + * + * @param contentOverride 可选的覆盖内容(用于保留用户未保存的输入) + */ private fun syncUiStateFromWorkingNote(contentOverride: String? = null) { + // 获取字体大小设置(默认值使用资源文件中定义的默认字体) val fontSizeId = sharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE) - .takeIf { it < 4 } + .takeIf { it < 4 } // 确保值在有效范围内(0-3) ?: ResourceParser.BG_DEFAULT_FONT_SIZE + // 更新 UI 状态 uiState = NoteEditUiState( - content = contentOverride ?: workingNote.content.orEmpty(), - bgColorId = workingNote.bgColorId, - bgResId = workingNote.bgColorResId, - titleBgResId = workingNote.titleBgResId, - fontSizeId = fontSizeId, - modifiedDate = if (workingNote.modifiedDate > 0L) { + content = contentOverride ?: workingNote.content.orEmpty(), // 便签内容 + bgColorId = workingNote.bgColorId, // 背景颜色 ID + bgResId = workingNote.bgColorResId, // 背景资源 ID + titleBgResId = workingNote.titleBgResId, // 标题栏背景资源 ID + fontSizeId = fontSizeId, // 字体大小 ID + modifiedDate = if (workingNote.modifiedDate > 0L) { // 最后修改时间 workingNote.modifiedDate } else { System.currentTimeMillis() }, - alertDate = workingNote.alertDate, - isChecklistMode = workingNote.checkListMode == TextNote.MODE_CHECK_LIST + alertDate = workingNote.alertDate, // 提醒时间 + isChecklistMode = workingNote.checkListMode == TextNote.MODE_CHECK_LIST // 是否为清单模式 ) } + // ==================== 清单模式转换 ==================== + + /** + * 切换清单模式 + * + * 在普通文本模式和待办清单模式之间切换 + * - 切换到清单模式:为每行文本添加复选框符号(□) + * - 切换到普通模式:移除复选框符号 + */ private fun toggleChecklistMode() { val enableChecklist = workingNote.checkListMode != TextNote.MODE_CHECK_LIST val updatedContent = if (enableChecklist) { - toChecklistText(uiState.content) + toChecklistText(uiState.content) // 转换为清单格式 } else { - fromChecklistText(uiState.content) + fromChecklistText(uiState.content) // 转换为普通格式 } workingNote.checkListMode = if (enableChecklist) TextNote.MODE_CHECK_LIST else 0 uiState = uiState.copy( @@ -255,48 +429,82 @@ class NoteEditActivity : ComponentActivity() { ) } + /** + * 将普通文本转换为清单文本 + * + * 格式:每行前添加 "□" 符号 + * + * @param text 普通文本 + * @return 清单格式文本 + */ private fun toChecklistText(text: String): String { val normalized = fromChecklistText(text) return normalized.lineSequence() - .filter { it.isNotBlank() } + .filter { it.isNotBlank() } // 过滤空行 .joinToString("\n") { "$TAG_UNCHECKED ${it.trim()}" } } + /** + * 将清单文本转换为普通文本 + * + * 移???每行前的 "□" 或 "√" 符号 + * + * @param text 清单格式文本 + * @return 普通文本 + */ private fun fromChecklistText(text: String): String { return text.lineSequence() .joinToString("\n") { line -> line.removePrefix("$TAG_CHECKED ") .removePrefix("$TAG_UNCHECKED ") } - .trimEnd() + .trimEnd() // 移除末尾空行 } + // ==================== 便签删除操作 ==================== + + /** + * 删除当前便签 + * + * 执行删除逻辑: + * 1. 从数据库删除便签记录 + * 2. 标记 WorkingNote 为已删除 + * 3. 更新桌面小部件(如果关联了小部件) + */ private fun deleteCurrentNote() { if (workingNote.existInDatabase()) { val id = workingNote.noteId val deleted = if (id != Notes.ID_ROOT_FOLDER.toLong()) { runBlocking(Dispatchers.IO) { - notesRepository.deleteNotes(setOf(id)) + notesRepository.deleteNotes(setOf(id)) // 删除便签 } } else { - false + false // 根文件夹不能删除 } if (!deleted) { Log.e(TAG, "Delete note failed") } } - workingNote.markDeleted() - updateWidgetIfNeeded() + workingNote.markDeleted() // 标记为已删除 + updateWidgetIfNeeded() // 更新小部件 } + /** + * 请求删除当前便签(显示确认对话框) + * + * 根据用户设置决定是否显示确认对话框 + */ private fun requestDeleteCurrentNote() { if (NotesPreferences.isDeleteConfirmationEnabled(this)) { - deleteDialogVisible = true + deleteDialogVisible = true // 显示确认对话框 } else { - confirmDeleteCurrentNote() + confirmDeleteCurrentNote() // 直接删除 } } + /** + * 确认删除当前便签 + */ private fun confirmDeleteCurrentNote() { deleteDialogVisible = false setResult(RESULT_OK) @@ -304,14 +512,30 @@ class NoteEditActivity : ComponentActivity() { finish() } + // ==================== 提醒功能 ==================== + + /** + * 清除提醒 + * + * 取消已设置的闹钟提醒 + */ private fun clearReminder() { reminderDialogState = null - workingNote.setAlertDate(0) - updateAlarm(0, false) + workingNote.setAlertDate(0) // 清除提醒时间 + updateAlarm(0, false) // 取消闹钟 uiState = uiState.copy(alertDate = 0L) } + /** + * 更新闹钟设置 + * + * 使用 AlarmManager 设置或取消闹钟 + * + * @param date 提醒时间(毫秒时间戳) + * @param set true 表示设置闹钟,false 表示取消闹钟 + */ private fun updateAlarm(date: Long, set: Boolean) { + // 确保便签已保存到数据库 if (!workingNote.existInDatabase()) { saveNote() } @@ -320,6 +544,7 @@ class NoteEditActivity : ComponentActivity() { return } + // 创建 PendingIntent val intent = Intent(this, AlarmReceiver::class.java).apply { putExtra(Intent.EXTRA_UID, workingNote.noteId) } @@ -327,8 +552,10 @@ class NoteEditActivity : ComponentActivity() { this, 0, intent, - PendingIntentCompat.immutableFlag() + PendingIntentCompat.immutableFlag() // Android 12+ 需要声明可变性 ) + + // 设置或取消闹钟 val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager if (!set) { alarmManager.cancel(pendingIntent) @@ -337,17 +564,47 @@ class NoteEditActivity : ComponentActivity() { } } + // ==================== 便签创建与管理 ==================== + + /** + * 创建新便签 + * + * 保存当前便签后,启动一个新的 NoteEditActivity 用于创建便签 + */ private fun createNewNote() { saveNote() finish() startActivity( Intent(this, NoteEditActivity::class.java).apply { action = Intent.ACTION_INSERT_OR_EDIT - putExtra(Notes.INTENT_EXTRA_FOLDER_ID, workingNote.folderId) + putExtra(Notes.INTENT_EXTRA_FOLDER_ID, workingNote.folderId) // 在同一文件夹下创建 } ) } + /** + * 保存便签 + * + * @return true 保存成功,false 保存失败 + */ + private fun saveNote(): Boolean { + workingNote.setWorkingText(uiState.content) // 设置内容 + val saved = workingNote.saveNote() // 保存到数据库 + if (saved) { + setResult(RESULT_OK) // 设置成功结果 + uiState = uiState.copy(modifiedDate = workingNote.modifiedDate) // 更新修改时间 + updateWidgetIfNeeded() // 更新小部件 + } + return saved + } + + // ==================== 分享与导出功能 ==================== + + /** + * 分享当前便签 + * + * 使用系统分享功能,支持发送到其他应用 + */ private fun shareCurrentNote() { val text = currentNoteTextForShare val intent = Intent(Intent.ACTION_SEND).apply { @@ -363,6 +620,11 @@ class NoteEditActivity : ComponentActivity() { } } + /** + * 导出当前便签为 TXT 文件 + * + * 将便签内容以纯文本格式保存到存储设备 + */ private fun exportCurrentNoteAsTxt() { val noteText = currentNoteTextForExport if (noteText.isBlank()) { @@ -375,6 +637,11 @@ class NoteEditActivity : ComponentActivity() { } } + /** + * 保存当前便签为长图片 + * + * 将便签内容生成为图片并保存到相册 + */ private fun saveCurrentNoteAsLongImage() { val noteText = currentNoteTextForLongImage if (noteText.isBlank()) { @@ -395,8 +662,19 @@ class NoteEditActivity : ComponentActivity() { } } + // ==================== 桌面快捷方式 ==================== + + /** + * 创建桌面快捷方式 + * + * 将当前便签以快捷方式的形式添加到桌面 + * + * @deprecated Android 26+ 不再支持使用广播添加快捷方式, + * 应使用 ShortcutManager 框架 + */ @Suppress("DEPRECATION") private fun sendToDesktop() { + // 确保便签已保存(需要有效的 noteId) if (!workingNote.existInDatabase()) { saveNote() } @@ -406,50 +684,60 @@ class NoteEditActivity : ComponentActivity() { return } + // 创建快捷方式 Intent val sender = Intent() val shortcutIntent = Intent(this, NoteEditActivity::class.java).apply { action = Intent.ACTION_VIEW putExtra(Intent.EXTRA_UID, workingNote.noteId) } - sender.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent) - sender.putExtra(Intent.EXTRA_SHORTCUT_NAME, makeShortcutIconTitle(uiState.content)) + + // 设置快捷方式属性 + sender.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent) // 点击后打开的 Intent + sender.putExtra(Intent.EXTRA_SHORTCUT_NAME, makeShortcutIconTitle(uiState.content)) // 快捷方式名称 sender.putExtra( Intent.EXTRA_SHORTCUT_ICON_RESOURCE, - Intent.ShortcutIconResource.fromContext(this, R.drawable.icon_app) + Intent.ShortcutIconResource.fromContext(this, R.drawable.icon_app) // 图标 ) - sender.putExtra("duplicate", true) - sender.action = "com.android.launcher.action.INSTALL_SHORTCUT" + sender.putExtra("duplicate", true) // 允许重复创建 + sender.action = "com.android.launcher.action.INSTALL_SHORTCUT" // 安装快捷方式的广播 + + // 发送广播创建快捷方式 sendBroadcast(sender) showToast(R.string.info_note_enter_desktop) } - private fun saveNote(): Boolean { - workingNote.setWorkingText(uiState.content) - val saved = workingNote.saveNote() - if (saved) { - setResult(RESULT_OK) - uiState = uiState.copy(modifiedDate = workingNote.modifiedDate) - updateWidgetIfNeeded() - } - return saved - } - + // ==================== 小部件更新 ==================== + + /** + * 更新桌面小部件 + * + * 如果当前便签关联了桌面小部件,则更新小部件显示 + */ private fun updateWidgetIfNeeded() { if (workingNote.widgetId == AppWidgetManager.INVALID_APPWIDGET_ID || workingNote.widgetType == Notes.TYPE_WIDGET_INVALIDE ) { - return + return // 未关联小部件 } NoteWidgetUpdater.updateWidget(this, workingNote.widgetId, workingNote.widgetType) } + // ==================== 辅助方法与属性 ==================== + + /** + * 获取当前编辑器的文本大小(像素) + * + * 根据用户设置的字体大小 ID 计算实际像素值 + * + * @return 文本大小(像素) + */ private fun currentEditorTextSize(): Float { val spValue = when (uiState.fontSizeId) { - ResourceParser.TEXT_SMALL -> 15 - ResourceParser.TEXT_MEDIUM -> 18 - ResourceParser.TEXT_LARGE -> 22 - ResourceParser.TEXT_SUPER -> 26 - else -> 18 + ResourceParser.TEXT_SMALL -> 15 // 小号字体 + ResourceParser.TEXT_MEDIUM -> 18 // 正常字体 + ResourceParser.TEXT_LARGE -> 22 // 大号字体 + ResourceParser.TEXT_SUPER -> 26 // 超大字体 + else -> 18 // 默认正常字体 } return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, @@ -458,15 +746,32 @@ class NoteEditActivity : ComponentActivity() { ) } + /** + * 用于导出的便签文本(转换为普通文本) + * + * 导出时使用纯文本格式,移除清单符号 + */ private val currentNoteTextForExport: String get() = fromChecklistText(uiState.content) + /** + * 用于分享的便签文本(保持原始格式) + */ private val currentNoteTextForShare: String get() = uiState.content + /** + * 用于生成长图片的便签文本(保持原始格式) + */ private val currentNoteTextForLongImage: String get() = uiState.content + /** + * 生成快捷方式标题 + * + * @param content 便签内容 + * @return 截断后的标题(最多10个字符) + */ private fun makeShortcutIconTitle(content: String): String { val title = content.replace(TAG_CHECKED, "").replace(TAG_UNCHECKED, "") return if (title.length > SHORTCUT_ICON_TITLE_MAX_LEN) { @@ -476,15 +781,33 @@ class NoteEditActivity : ComponentActivity() { } } + /** + * 显示 Toast 消息 + * + * @param resId 字符串资源 ID + * @param duration 显示时长(Toast.LENGTH_SHORT 或 Toast.LENGTH_LONG) + */ private fun showToast(resId: Int, duration: Int = Toast.LENGTH_SHORT) { Toast.makeText(this, resId, duration).show() } + // ==================== 伴生对象 ==================== + companion object { - private const val TAG = "NoteEditActivity" - private const val PREFERENCE_FONT_SIZE = "pref_font_size" - private const val SHORTCUT_ICON_TITLE_MAX_LEN = 10 + private const val TAG = "NoteEditActivity" // 日志标签 + private const val PREFERENCE_FONT_SIZE = "pref_font_size" // 字体大小偏好设置的 Key + private const val SHORTCUT_ICON_TITLE_MAX_LEN = 10 // 快捷方式标题最大长度 + + /** + * 已选中复选框符号(√) + * 用于清单模式中表示已完成的项目 + */ val TAG_CHECKED: String = '\u221A'.toString() + + /** + * 未选中复选框符号(□) + * 用于清单模式中表示未完成的项目 + */ val TAG_UNCHECKED: String = '\u25A1'.toString() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NoteEditScreen.kt b/app/src/main/kotlin/net/micode/notes/ui/NoteEditScreen.kt index 36c4733..101d38d 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NoteEditScreen.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NoteEditScreen.kt @@ -58,6 +58,20 @@ import java.util.Calendar import java.util.Date import android.text.format.DateFormat as AndroidDateFormat +/** + * 便签编辑界面的 UI 状态数据类 + * + * 使用不可变数据类存储界面的所有状态,便于 Compose 重组和状态管理 + * + * @property content 便签文本内容 + * @property bgColorId 背景颜色 ID(对应 ResourceParser 中的常量) + * @property bgResId 背景图片资源 ID(0 表示不使用图片背景) + * @property titleBgResId 标题栏背景图片资源 ID + * @property fontSizeId 字体大小 ID + * @property modifiedDate 最后修改时间(毫秒时间戳) + * @property alertDate 提醒时间(0 表示未设置提醒) + * @property isChecklistMode 是否为待办清单模式 + */ data class NoteEditUiState( val content: String = "", val bgColorId: Int = ResourceParser.BG_DEFAULT_COLOR, @@ -69,17 +83,55 @@ data class NoteEditUiState( val isChecklistMode: Boolean = false ) +/** + * 提醒对话框的 UI 状态数据类 + * + * @property initialDate 初始显示的日期时间(毫秒时间戳) + */ data class ReminderDialogUiState( val initialDate: Long = System.currentTimeMillis() ) -private val HeaderActionColor = Color(0xFF7A6A43) -private val HeaderTitleColor = Color(0xFF231C12) -private val HeaderSubtitleColor = Color(0xAA3D3427) -private val EditorControlSurfaceColor = Color(0xF0E5D6A8) -private val EditorTextColor = Color(0xFF2A261F) -private val EditorHintColor = Color(0x88544E43) +// ==================== 主题颜色常量 ==================== +private val HeaderActionColor = Color(0xFF7A6A43) // 顶部栏操作按钮文字颜色 +private val HeaderTitleColor = Color(0xFF231C12) // 顶部栏标题文字颜色 +private val HeaderSubtitleColor = Color(0xAA3D3427) // 顶部栏副标题文字颜色(半透明) +private val EditorControlSurfaceColor = Color(0xF0E5D6A8) // 底部控制面板背景色 +private val EditorTextColor = Color(0xFF2A261F) // 编辑器文字颜色 +private val EditorHintColor = Color(0x88544E43) // 编辑器占位提示文字颜色(半透明) +/** + * 便签编辑主屏幕 + * + * 这是便签编辑界面的核心 Composable 组件,负责: + * 1. 显示便签编辑区域(支持普通文本和清单模式) + * 2. 顶部操作栏(返回、更多菜单、标题、修改时间) + * 3. 提醒信息显示 + * 4. 底部控制面板(背景颜色、字体大小选择) + * 5. 删除确认对话框 + * 6. 提醒设置对话框 + * + * @param state 当前 UI 状态 + * @param deleteDialogVisible 删除确认对话框是否可见 + * @param reminderDialogState 提醒对话框状态(null 表示不可见) + * @param onBack 返回按钮点击回调 + * @param onContentChange 内容变更回调 + * @param onNewNote 新建便签回调 + * @param onDelete 删除便签回调(触发前) + * @param onDismissDeleteDialog 关闭删除对话框回调 + * @param onConfirmDelete 确认删除回调 + * @param onToggleChecklist 切换清单模式回调 + * @param onShare 分享便签回调 + * @param onExportText 导出为文本回调 + * @param onSaveLongImage 保存为长图回调 + * @param onSendToDesktop 发送到桌面回调 + * @param onSetReminder 设置提醒回调 + * @param onDismissReminderDialog 关闭提醒对话框回调 + * @param onConfirmReminder 确认提醒回调(返回选中的时间戳) + * @param onClearReminder 清除提醒回调 + * @param onSelectBackground 选择背景颜色回调 + * @param onSelectFontSize 选择字体大小回调 + */ @Composable fun NoteEditScreen( state: NoteEditUiState, @@ -103,13 +155,17 @@ fun NoteEditScreen( onSelectBackground: (Int) -> Unit, onSelectFontSize: (Int) -> Unit ) { + // 根布局:填充整个屏幕 Box(modifier = Modifier.fillMaxSize()) { + // 背景层:优先使用图片背景,否则使用默认黄色背景 if (state.bgResId != 0) { + // 使用图片背景(如旧版的主题背景) ResourceDrawableBackground( resId = state.bgResId, modifier = Modifier.fillMaxSize() ) } else { + // 默认背景色(米黄色) Box( modifier = Modifier .fillMaxSize() @@ -117,7 +173,9 @@ fun NoteEditScreen( ) } + // 主内容列 Column(modifier = Modifier.fillMaxSize()) { + // 顶部操作栏 NoteEditTopBar( state = state, onBack = onBack, @@ -132,6 +190,7 @@ fun NoteEditScreen( onClearReminder = onClearReminder ) + // 提醒信息条(仅当设置了提醒时显示) if (state.alertDate > 0L) { Surface( color = Color(0xCCFFF7E3), @@ -146,12 +205,14 @@ fun NoteEditScreen( .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { + // 提醒图标 LegacyAssetIcon( resId = R.drawable.title_alert, contentDescription = null, modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.size(10.dp)) + // 相对时间文本(如"5分钟后"、"明天"等) Text( text = DateUtils.getRelativeTimeSpanString( state.alertDate, @@ -161,17 +222,20 @@ fun NoteEditScreen( modifier = Modifier.weight(1f), color = HeaderSubtitleColor ) + // 删除提醒按钮 RemoveReminderAction(onClick = onClearReminder) } } } + // 便签编辑区域 Column( modifier = Modifier - .weight(1f) + .weight(1f) // 占据剩余空间 .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 6.dp) ) { + // 顶部装饰线(模仿旧版纸张效果) LegacyAssetIcon( resId = R.drawable.bg_color_btn_mask, contentDescription = null, @@ -179,12 +243,14 @@ fun NoteEditScreen( .fillMaxWidth() .height(7.dp) ) + + // 文本输入框容器 Box( modifier = Modifier .weight(1f) .fillMaxWidth() ) { - val scrollState = rememberScrollState() + val scrollState = rememberScrollState() // 滚动状态 BasicTextField( value = state.content, onValueChange = onContentChange, @@ -198,21 +264,24 @@ fun NoteEditScreen( .padding(horizontal = 18.dp, vertical = 14.dp) .verticalScroll(scrollState), decorationBox = { innerTextField -> + // 空内容时显示占位提示文本 if (state.content.isEmpty()) { Text( text = if (state.isChecklistMode) { - stringResource(R.string.checklist_placeholder) + stringResource(R.string.checklist_placeholder) // "每行一项…" } else { - stringResource(R.string.note_editor_placeholder) + stringResource(R.string.note_editor_placeholder) // "写点什么…" }, color = EditorHintColor, fontSize = fontSizeFor(state.fontSizeId) ) } - innerTextField() + innerTextField() // 实际输入框 } ) } + + // 底部装饰线 LegacyAssetIcon( resId = R.drawable.bg_color_btn_mask, contentDescription = null, @@ -222,6 +291,7 @@ fun NoteEditScreen( ) } + // 底部控制面板(背景颜色 + 字体大小) EditorControls( state = state, onSelectBackground = onSelectBackground, @@ -229,6 +299,7 @@ fun NoteEditScreen( ) } + // 删除确认对话框 if (deleteDialogVisible) { AlertDialog( onDismissRequest = onDismissDeleteDialog, @@ -247,6 +318,7 @@ fun NoteEditScreen( ) } + // 提醒设置对话框 reminderDialogState?.let { dialogState -> ReminderDialog( state = dialogState, @@ -257,6 +329,26 @@ fun NoteEditScreen( } } +/** + * 顶部操作栏 + * + * 包含: + * - 返回按钮 + * - 应用标题和最后修改时间 + * - 更多菜单(新建、删除、切换清单模式、分享、导出、生成图片、发送到桌面、提醒设置) + * + * @param state 当前 UI 状态 + * @param onBack 返回回调 + * @param onNewNote 新建便签回调 + * @param onDelete 删除回调 + * @param onToggleChecklist 切换清单模式回调 + * @param onShare 分享回调 + * @param onExportText 导出文本回调 + * @param onSaveLongImage 保存长图回调 + * @param onSendToDesktop 发送到桌面回调 + * @param onSetReminder 设置提醒回调 + * @param onClearReminder 清除提醒回调 + */ @Composable private fun NoteEditTopBar( state: NoteEditUiState, @@ -271,8 +363,9 @@ private fun NoteEditTopBar( onSetReminder: () -> Unit, onClearReminder: () -> Unit ) { - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } // 更多菜单是否展开 Box(modifier = Modifier.fillMaxWidth()) { + // 背景层 if (state.titleBgResId != 0) { ResourceDrawableBackground( resId = state.titleBgResId, @@ -282,21 +375,25 @@ private fun NoteEditTopBar( Box( modifier = Modifier .matchParentSize() - .background(Color(0xDDECDCB6)) + .background(Color(0xDDECDCB6)) // 默认半透明米色 ) } + // 顶部栏内容 Row( modifier = Modifier .fillMaxWidth() - .statusBarsPadding() + .statusBarsPadding() // 适配状态栏高度 .padding(horizontal = 18.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { + // 返回按钮 HeaderActionText( text = stringResource(R.string.action_back), onClick = onBack ) + + // 标题区域 Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.app_name), @@ -313,6 +410,8 @@ private fun NoteEditTopBar( color = HeaderSubtitleColor ) } + + // 更多菜单按钮 Box { HeaderActionText( text = stringResource(R.string.action_more), @@ -322,6 +421,7 @@ private fun NoteEditTopBar( expanded = expanded, onDismissRequest = { expanded = false } ) { + // 新建便签 DropdownMenuItem( text = { Text(text = stringResource(R.string.notelist_menu_new)) }, onClick = { @@ -329,6 +429,7 @@ private fun NoteEditTopBar( onNewNote() } ) + // 删除 DropdownMenuItem( leadingIcon = { LegacyAssetIcon( @@ -343,14 +444,15 @@ private fun NoteEditTopBar( onDelete() } ) + // 切换清单模式(文字会动态变化) DropdownMenuItem( text = { Text( text = stringResource( if (state.isChecklistMode) { - R.string.menu_normal_mode + R.string.menu_normal_mode // "退出清单模式" } else { - R.string.menu_list_mode + R.string.menu_list_mode // "进入清单模式" } ) ) @@ -360,6 +462,7 @@ private fun NoteEditTopBar( onToggleChecklist() } ) + // 分享 DropdownMenuItem( text = { Text(text = stringResource(R.string.menu_share)) }, onClick = { @@ -367,6 +470,7 @@ private fun NoteEditTopBar( onShare() } ) + // 导出为 TXT DropdownMenuItem( text = { Text(text = stringResource(R.string.menu_export_as_txt)) }, onClick = { @@ -374,6 +478,7 @@ private fun NoteEditTopBar( onExportText() } ) + // 保存为图片 DropdownMenuItem( text = { Text(text = stringResource(R.string.menu_generate_long_image)) }, onClick = { @@ -381,6 +486,7 @@ private fun NoteEditTopBar( onSaveLongImage() } ) + // 发送到桌面(快捷方式) DropdownMenuItem( leadingIcon = { LegacyAssetIcon( @@ -395,6 +501,7 @@ private fun NoteEditTopBar( onSendToDesktop() } ) + // 提醒设置(文字会动态变化) DropdownMenuItem( leadingIcon = { LegacyAssetIcon( @@ -407,9 +514,9 @@ private fun NoteEditTopBar( Text( text = stringResource( if (state.alertDate > 0L) { - R.string.menu_remove_remind + R.string.menu_remove_remind // "删除提醒" } else { - R.string.menu_alert + R.string.menu_alert // "提醒我" } ) ) @@ -429,6 +536,14 @@ private fun NoteEditTopBar( } } +/** + * 顶部栏操作按钮(文字按钮) + * + * 圆角矩形背景,点击时触发回调 + * + * @param text 按钮文字 + * @param onClick 点击回调 + */ @Composable private fun HeaderActionText( text: String, @@ -450,6 +565,13 @@ private fun HeaderActionText( } } +/** + * 删除提醒操作按钮 + * + * 显示在提醒信息条右侧,点击后清除提醒 + * + * @param onClick 点击回调 + */ @Composable private fun RemoveReminderAction(onClick: () -> Unit) { Row( @@ -474,6 +596,16 @@ private fun RemoveReminderAction(onClick: () -> Unit) { } } +/** + * 提醒设置对话框 + * + * 使用原生 Android 的 DatePicker 和 TimePicker 控件 + * 用户选择日期和时间后,回调返回完整的时间戳 + * + * @param state 对话框状态(包含初始日期) + * @param onDismiss 关闭对话框回调 + * @param onConfirm 确认回调(返回选中的毫秒时间戳) + */ @Composable private fun ReminderDialog( state: ReminderDialogUiState, @@ -481,26 +613,33 @@ private fun ReminderDialog( onConfirm: (Long) -> Unit ) { val context = LocalContext.current + // 根据初始日期初始化 Calendar val initialCalendar = remember(state.initialDate) { Calendar.getInstance().apply { timeInMillis = state.initialDate } } + // 年 var year by remember(state.initialDate) { mutableIntStateOf(initialCalendar.get(Calendar.YEAR)) } + // 月(Calendar 中 0 表示一月) var month by remember(state.initialDate) { mutableIntStateOf(initialCalendar.get(Calendar.MONTH)) } + // 日 var dayOfMonth by remember(state.initialDate) { mutableIntStateOf(initialCalendar.get(Calendar.DAY_OF_MONTH)) } + // 小时(24小时制) var selectedHourOfDay by remember(state.initialDate) { mutableIntStateOf(initialCalendar.get(Calendar.HOUR_OF_DAY)) } + // 分钟 var selectedMinute by remember(state.initialDate) { mutableIntStateOf(initialCalendar.get(Calendar.MINUTE)) } + // 是否为24小时制(根据系统设置) val is24Hour = remember(context) { AndroidDateFormat.is24HourFormat(context) } AlertDialog( @@ -508,6 +647,7 @@ private fun ReminderDialog( title = { Text(text = stringResource(R.string.menu_alert)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // 日期选择器 AndroidView( factory = { viewContext -> android.widget.DatePicker(viewContext).apply { @@ -523,6 +663,7 @@ private fun ReminderDialog( }, modifier = Modifier.fillMaxWidth() ) + // 时间选择器 AndroidView( factory = { viewContext -> android.widget.TimePicker(viewContext).apply { @@ -551,6 +692,7 @@ private fun ReminderDialog( confirmButton = { TextButton( onClick = { + // 构建选中的时间戳 val calendar = Calendar.getInstance().apply { set(Calendar.YEAR, year) set(Calendar.MONTH, month) @@ -574,6 +716,17 @@ private fun ReminderDialog( ) } +/** + * 底部控制面板 + * + * 包含: + * - 背景颜色选择器(5种颜色:黄、蓝、白、绿、红) + * - 字体大小选择器(4档:小、正常、大、超大) + * + * @param state 当前 UI 状态 + * @param onSelectBackground 选择背景颜色回调 + * @param onSelectFontSize 选择字体大小回调 + */ @Composable private fun EditorControls( state: NoteEditUiState, @@ -583,20 +736,22 @@ private fun EditorControls( Surface( modifier = Modifier.fillMaxWidth(), color = EditorControlSurfaceColor, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) // 顶部圆角 ) { Column( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() + .navigationBarsPadding() // 适配导航栏高度 .padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // 背景颜色选择行 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { + // 调色板图标 Box( modifier = Modifier .size(width = 38.dp, height = 44.dp), @@ -609,6 +764,7 @@ private fun EditorControls( ) } + // 颜色选项容器(带背景图片) Box( modifier = Modifier .weight(1f) @@ -620,6 +776,7 @@ private fun EditorControls( modifier = Modifier.fillMaxSize(), fillBounds = true ) + // 5个颜色选项 Row( modifier = Modifier .fillMaxSize() @@ -643,14 +800,15 @@ private fun EditorControls( .border( width = if (state.bgColorId == bgId) 2.dp else 1.dp, color = if (state.bgColorId == bgId) { - Color(0xFF6A4A1E) + Color(0xFF6A4A1E) // 深棕色边框(选中) } else { - Color(0x66443A2D) + Color(0x66443A2D) // 浅棕色边框(未选中) }, shape = RoundedCornerShape(8.dp) ) .clickable { onSelectBackground(bgId) } ) { + // 选中标记(对勾图标) if (state.bgColorId == bgId) { LegacyAssetIcon( resId = R.drawable.selected, @@ -667,6 +825,7 @@ private fun EditorControls( } } + // 字体大小选择行 Box( modifier = Modifier .fillMaxWidth() @@ -700,11 +859,13 @@ private fun EditorControls( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp) ) { + // 字体大小图标(A 带大小标识) LegacyAssetIcon( resId = option.drawableRes, contentDescription = stringResource(option.labelRes), modifier = Modifier.size(width = 24.dp, height = 28.dp) ) + // 文字标签 Text( text = stringResource(option.labelRes), fontSize = 10.sp, @@ -713,6 +874,7 @@ private fun EditorControls( color = Color(0xFF5A5348) ) } + // 选中标记 if (state.fontSizeId == option.fontId) { LegacyAssetIcon( resId = R.drawable.selected, @@ -731,6 +893,11 @@ private fun EditorControls( } } +/** + * 字体选项列表 + * + * @return 包含四档字体选项的列表 + */ @Composable private fun fontOptions(): List { return listOf( @@ -741,12 +908,30 @@ private fun fontOptions(): List { ) } +/** + * 字体选项数据类 + * + * @param fontId 字体大小 ID + * @param drawableRes 图标资源 ID + * @param labelRes 文字标签资源 ID + */ private data class FontOption( val fontId: Int, @param:DrawableRes val drawableRes: Int, @param:StringRes val labelRes: Int ) +/** + * 传统资源图标(使用 AndroidView 加载旧版资源) + * + * 为了兼容旧版 UI 资源,使用 ImageView 来显示传统的 drawable 资源 + * 这些资源可能是 .9.png 或普通图片 + * + * @param resId 图片资源 ID + * @param contentDescription 内容描述(无障碍) + * @param modifier Modifier + * @param fillBounds 是否填充边界(true 使用 FIT_XY,false 使用 FIT_CENTER) + */ @Composable private fun LegacyAssetIcon( @DrawableRes resId: Int, @@ -781,19 +966,31 @@ private fun LegacyAssetIcon( ) } +/** + * 根据字体大小 ID 获取对应的文字大小(sp) + * + * @param fontSizeId 字体大小 ID + * @return 对应的字体大小(sp 单位) + */ private fun fontSizeFor(fontSizeId: Int) = when (fontSizeId) { ResourceParser.TEXT_SMALL -> 15.sp ResourceParser.TEXT_MEDIUM -> 18.sp ResourceParser.TEXT_LARGE -> 22.sp ResourceParser.TEXT_SUPER -> 26.sp - else -> 18.sp + else -> 18.sp // 默认正常字体 } +/** + * 根据背景颜色 ID 获取对应的颜色值 + * + * @param bgColorId 背景颜色 ID(ResourceParser 中的常量) + * @return 对应的 Color 对象 + */ private fun colorForBackground(bgColorId: Int) = when (bgColorId) { - ResourceParser.YELLOW -> Color(0xFFEECF68) - ResourceParser.BLUE -> Color(0xFF8DB6E9) - ResourceParser.WHITE -> Color(0xFFF5F2E8) - ResourceParser.GREEN -> Color(0xFFA1C88C) - ResourceParser.RED -> Color(0xFFE59882) - else -> Color(0xFFEECF68) -} + ResourceParser.YELLOW -> Color(0xFFEECF68) // 黄色 + ResourceParser.BLUE -> Color(0xFF8DB6E9) // 蓝色 + ResourceParser.WHITE -> Color(0xFFF5F2E8) // 白色 + ResourceParser.GREEN -> Color(0xFFA1C88C) // 绿色 + ResourceParser.RED -> Color(0xFFE59882) // 红色 + else -> Color(0xFFEECF68) // 默认黄色 +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NoteEditViewModel.kt b/app/src/main/kotlin/net/micode/notes/ui/NoteEditViewModel.kt index 1c12b20..7d3a32f 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NoteEditViewModel.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NoteEditViewModel.kt @@ -29,21 +29,75 @@ import java.io.FileOutputStream import java.io.IOException import kotlin.math.max +/** + * 便签编辑页面的 ViewModel + * + * 负责处理便签编辑页面中的耗时操作,避免在主线程中执行导致界面卡顿。 + * 主要功能包括: + * 1. 导出便签为 TXT 文件 + * 2. 将便签内容生成长图片并保存 + * + * 技术特点: + * - 使用 Kotlin Coroutines 处理异步操作 + * - 支持 Android 10+ 的 MediaStore API 和低版本的直接文件访问 + * - 生成图片时使用 StaticLayout 进行文本排版 + * - 自动处理内存回收,避免内存泄漏 + * + * @author MiCode Open Source Community + */ class NoteEditViewModel : ViewModel() { + + // ==================== 公共 API ==================== + + /** + * 导出当前便签为 TXT 文件 + * + * 将便签的文本内容以纯文本格式保存到设备的下载目录中。 + * 文件名格式:note_yyyyMMdd_HHmmss.txt + * + * 执行流程: + * 1. 在 IO 线程中执行文件写入操作 + * 2. 写入完成后通过回调返回结果 + * 3. 根据 Android 版本使用不同的存储方式 + * + * @param context 上下文(用于获取应用目录和 ContentResolver) + * @param noteText 要导出的便签文本内容 + * @param onComplete 完成回调,参数 Boolean 表示是否导出成功 + */ fun exportCurrentNoteAsTxt( context: Context, noteText: String, onComplete: (Boolean) -> Unit ) { - val appContext = context.applicationContext + val appContext = context.applicationContext // 使用 ApplicationContext 避免内存泄漏 viewModelScope.launch(Dispatchers.IO) { - val exported = writeNoteText(appContext, noteText) + val exported = writeNoteText(appContext, noteText) // 执行导出 withContext(Dispatchers.Main) { - onComplete(exported) + onComplete(exported) // 回到主线程回调 } } } + /** + * 将当前便签保存为长图片 + * + * 将便签的文本内容生成一张可滚动长截图风格的图片, + * 图片会使用便签的背景样式,并保存到相册的 Notes 文件夹中。 + * 文件名格式:note_long_image_yyyyMMdd_HHmmss.png + * + * 执行流程: + * 1. 在 IO 线程中根据文本内容生成 Bitmap + * 2. 将 Bitmap 保存到设备存储 + * 3. 回收 Bitmap 释放内存 + * 4. 通过回调返回结果 + * + * @param context 上下文(用于获取资源、测量尺寸) + * @param noteText 便签文本内容 + * @param textSize 文本大小(像素),用于排版 + * @param backgroundResId 背景图片资源 ID + * @param imageWidth 图片宽度(像素),通常为屏幕宽度 + * @param onComplete 完成回调,参数 Boolean 表示是否保存成功 + */ fun saveCurrentNoteAsLongImage( context: Context, noteText: String, @@ -54,6 +108,7 @@ class NoteEditViewModel : ViewModel() { ) { val appContext = context.applicationContext viewModelScope.launch(Dispatchers.IO) { + // 生成长图片 Bitmap val bitmap = createLongImageBitmap( appContext, noteText, @@ -61,6 +116,7 @@ class NoteEditViewModel : ViewModel() { backgroundResId, imageWidth ) + // 保存 Bitmap val saved = saveLongImageBitmap(appContext, bitmap) withContext(Dispatchers.Main) { onComplete(saved) @@ -68,6 +124,25 @@ class NoteEditViewModel : ViewModel() { } } + // ==================== 长图片生成 ==================== + + /** + * 生成长图片 Bitmap + * + * 将文本内容绘制成一张可滚动长截图样式的图片。 + * + * 实现原理: + * 1. 使用 TextPaint 和 StaticLayout 进行文本测量和排版 + * 2. 根据文本实际高度计算图片所需高度 + * 3. 创建 Bitmap 并在 Canvas 上绘制背景和文本 + * + * @param context 上下文 + * @param noteText 便签文本内容 + * @param textSize 文本大小(像素) + * @param backgroundResId 背景图片资源 ID + * @param imageWidth 图片宽度(像素) + * @return 生成的 Bitmap,失败时返回 null + */ private fun createLongImageBitmap( context: Context, noteText: String, @@ -76,50 +151,85 @@ class NoteEditViewModel : ViewModel() { imageWidth: Int ): Bitmap? { return try { + // 处理空内容:使用空格占位,避免空文本导致布局高度异常 val content = noteText.ifEmpty { " " } + + // 计算内边距 val horizontalPadding = dpToPx(context, LONG_IMAGE_HORIZONTAL_PADDING_DP) val verticalPadding = dpToPx(context, LONG_IMAGE_VERTICAL_PADDING_DP) + + // 计算文本实际可用宽度 val contentWidth = max(imageWidth - horizontalPadding * 2, dpToPx(context, 160)) + // 配置文本画笔 val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(51, 51, 51) - this.textSize = if (textSize > 0) textSize else spToPx(context, 18) + color = Color.rgb(51, 51, 51) // 深灰色文字 + this.textSize = if (textSize > 0) textSize else spToPx(context, 18) // 默认 18sp } + // 创建文本布局(测量文本占用的实际高度) val layout = createTextLayout(context, content, textPaint, contentWidth) + + // 计算图片总高度(文本高度 + 上下内边距) val imageHeight = max( layout.height + verticalPadding * 2, - dpToPx(context, LONG_IMAGE_MIN_HEIGHT_DP) + dpToPx(context, LONG_IMAGE_MIN_HEIGHT_DP) // 最小高度限制 ) + // 创建 Bitmap 并绘制内容 createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888).apply { val canvas = Canvas(this) + // 绘制背景 drawLongImageBackground(context, canvas, backgroundResId, imageWidth, imageHeight) + // 平移画布后绘制文本(留出内边距) canvas.withTranslation(horizontalPadding.toFloat(), verticalPadding.toFloat()) { layout.draw(this) } } } catch (error: OutOfMemoryError) { + // OOM 保护:避免因图片过大导致崩溃 Log.e(TAG, "Create long image bitmap failed", error) null } } + /** + * 创建文本布局(StaticLayout) + * + * StaticLayout 是 Android 提供的文本排版工具,可以自动处理换行、行间距等。 + * + * @param context 上下文 + * @param content 文本内容 + * @param textPaint 文本画笔 + * @param contentWidth 文本区域宽度(像素) + * @return StaticLayout 实例,包含了文本排版信息 + */ private fun createTextLayout( context: Context, content: String, textPaint: TextPaint, contentWidth: Int ): StaticLayout { - val spacingAdd = dpToPx(context, 4).toFloat() + val spacingAdd = dpToPx(context, 4).toFloat() // 额外行间距(4dp) return StaticLayout.Builder .obtain(content, 0, content.length, textPaint, contentWidth) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setLineSpacing(spacingAdd, 1.4f) - .setIncludePad(false) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) // 左对齐 + .setLineSpacing(spacingAdd, 1.4f) // 行间距 = spacingAdd + 1.4倍行高 + .setIncludePad(false) // 不包含额外的上下内边距 .build() } + /** + * 绘制长图片的背景 + * + * 优先使用传入的背景图片资源,如果加载失败则使用白色背景。 + * + * @param context 上下文 + * @param canvas 画布 + * @param backgroundResId 背景图片资源 ID + * @param width 图片宽度 + * @param height 图片高度 + */ private fun drawLongImageBackground( context: Context, canvas: Canvas, @@ -128,6 +238,7 @@ class NoteEditViewModel : ViewModel() { height: Int ) { try { + // 尝试加载背景图片并拉伸绘制 ResourcesCompat.getDrawable(context.resources, backgroundResId, context.theme)?.let { background -> background.setBounds(0, 0, width, height) background.draw(canvas) @@ -136,17 +247,32 @@ class NoteEditViewModel : ViewModel() { } catch (error: Exception) { Log.e(TAG, "Draw long image background failed", error) } + // 降级方案:使用白色背景 canvas.drawColor(Color.WHITE) } + // ==================== 长图片保存 ==================== + + /** + * 保存长图片 Bitmap + * + * 根据 Android 版本选择不同的保存方式: + * - Android 10+:使用 MediaStore API + * - Android 9 及以下:直接保存到应用的私有外部存储目录 + * + * @param context 上下文 + * @param bitmap 要保存的 Bitmap + * @return true 保存成功,false 保存失败 + */ private fun saveLongImageBitmap(context: Context, bitmap: Bitmap?): Boolean { bitmap ?: return false return try { + // 生成文件名:note_long_image_yyyyMMdd_HHmmss.png val displayName = "note_long_image_${DateFormat.format("yyyyMMdd_HHmmss", System.currentTimeMillis())}.png" val saved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveBitmapToMediaStore(context, bitmap, displayName) + saveBitmapToMediaStore(context, bitmap, displayName) // Android 10+ } else { saveBitmapToScopedFile( context, @@ -154,19 +280,32 @@ class NoteEditViewModel : ViewModel() { Environment.DIRECTORY_PICTURES, "Notes", displayName - ) + ) // Android 9 及以下 } saved } finally { + // 确保 Bitmap 被回收,释放内存 bitmap.recycle() } } + // ==================== TXT 导出 ==================== + + /** + * 写入便签文本到文件 + * + * 根据 Android 版本选择不同的保存方式。 + * + * @param context 上下文 + * @param noteText 要导出的文本内容 + * @return true 导出成功,false 导出失败 + */ private fun writeNoteText(context: Context, noteText: String): Boolean { + // 生成文件名:note_yyyyMMdd_HHmmss.txt val displayName = "note_${DateFormat.format("yyyyMMdd_HHmmss", System.currentTimeMillis())}.txt" return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveTextToMediaStore(context, noteText, displayName) + saveTextToMediaStore(context, noteText, displayName) // Android 10+ } else { saveTextToScopedFile( context, @@ -174,10 +313,26 @@ class NoteEditViewModel : ViewModel() { Environment.DIRECTORY_DOWNLOADS, "Notes", displayName - ) + ) // Android 9 及以下 } } + // ==================== MediaStore API(Android 10+) ==================== + + /** + * 使用 MediaStore 保存文本文件(Android 10+) + * + * 使用 MediaStore API 可以在不申请存储权限的情况下将文件保存到公共目录。 + * 流程: + * 1. 插入一条 IS_PENDING = 1 的记录(标记为待写入) + * 2. 写入文件内容 + * 3. 将 IS_PENDING 更新为 0(标记为完成) + * + * @param context 上下文 + * @param noteText 文本内容 + * @param displayName 文件名 + * @return true 保存成功,false 保存失败 + */ @RequiresApi(Build.VERSION_CODES.Q) private fun saveTextToMediaStore( context: Context, @@ -185,31 +340,45 @@ class NoteEditViewModel : ViewModel() { displayName: String ): Boolean { val resolver = context.contentResolver + // 构建文件元数据 val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/Notes") - put(MediaStore.MediaColumns.IS_PENDING, 1) + put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/Notes") // 保存路径:Downloads/Notes/ + put(MediaStore.MediaColumns.IS_PENDING, 1) // 标记为待写入状态 } + // 插入记录并获取 URI val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return false return try { + // 写入文件内容 resolver.openOutputStream(uri)?.bufferedWriter(Charsets.UTF_8).use { writer -> if (writer == null) { - resolver.delete(uri, null, null) + resolver.delete(uri, null, null) // 删除无效记录 return false } writer.write(noteText) } + // 标记为完成 markMediaStoreItemReady(context, uri) true } catch (error: IOException) { Log.e(TAG, "Export note as txt failed", error) - resolver.delete(uri, null, null) + resolver.delete(uri, null, null) // 写入失败,删除记录 false } } + /** + * 使用 MediaStore 保存图片(Android 10+) + * + * 将 Bitmap 保存到相册的 Notes 文件夹中。 + * + * @param context 上下文 + * @param bitmap 图片 + * @param displayName 文件名 + * @return true 保存成功,false 保存失败 + */ @RequiresApi(Build.VERSION_CODES.Q) private fun saveBitmapToMediaStore( context: Context, @@ -217,21 +386,24 @@ class NoteEditViewModel : ViewModel() { displayName: String ): Boolean { val resolver = context.contentResolver + // 构建文件元数据 val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.MIME_TYPE, "image/png") - put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Notes") - put(MediaStore.MediaColumns.IS_PENDING, 1) + put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Notes") // 保存路径:Pictures/Notes/ + put(MediaStore.MediaColumns.IS_PENDING, 1) // 标记为待写入 } val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false return try { + // 写入图片数据 resolver.openOutputStream(uri).use { stream -> if (stream == null || !bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) { - resolver.delete(uri, null, null) + resolver.delete(uri, null, null) // 删除无效记录 return false } } + // 标记为完成 markMediaStoreItemReady(context, uri) true } catch (error: IOException) { @@ -241,6 +413,15 @@ class NoteEditViewModel : ViewModel() { } } + /** + * 标记 MediaStore 项目为就绪状态 + * + * 将 IS_PENDING 字段从 1 更新为 0,通知媒体扫描器该文件已完成写入。 + * 这样可以确保图库等应用能够立即显示这个文件。 + * + * @param context 上下文 + * @param uri 文件的 Content URI + */ private fun markMediaStoreItemReady(context: Context, uri: android.net.Uri) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { return @@ -249,13 +430,27 @@ class NoteEditViewModel : ViewModel() { context.contentResolver.update( uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) + put(MediaStore.MediaColumns.IS_PENDING, 0) // 标记为完成 }, null, null ) } + // ==================== 传统文件保存(Android 9 及以下) ==================== + + /** + * 保存文本到应用私有外部存储(Android 9 及以下) + * + * 使用 getExternalFilesDir 获取应用私有外部存储目录,不需要存储权限。 + * + * @param context 上下文 + * @param noteText 文本内容 + * @param type 文件类型目录(如 Environment.DIRECTORY_DOWNLOADS) + * @param childDir 子目录名称 + * @param displayName 文件名 + * @return true 保存成功,false 保存失败 + */ private fun saveTextToScopedFile( context: Context, noteText: String, @@ -271,11 +466,21 @@ class NoteEditViewModel : ViewModel() { true } catch (error: IOException) { Log.e(TAG, "Export note as txt failed", error) - exportFile.delete() + exportFile.delete() // 写入失败,删除不完整的文件 false } } + /** + * 保存图片到应用私有外部存储(Android 9 及以下) + * + * @param context 上下文 + * @param bitmap 图片 + * @param type 文件类型目录 + * @param childDir 子目录名称 + * @param displayName 文件名 + * @return true 保存成功,false 保存失败 + */ private fun saveBitmapToScopedFile( context: Context, bitmap: Bitmap, @@ -300,19 +505,34 @@ class NoteEditViewModel : ViewModel() { } } + /** + * 创建用于导出的文件 + * + * 在应用的私有外部存储目录下创建必要的目录和文件。 + * + * @param context 上下文 + * @param type 文件类型目录 + * @param childDir 子目录名称 + * @param displayName 文件名 + * @return File 对象,创建失败时返回 null + */ private fun createScopedExportFile( context: Context, type: String, childDir: String, displayName: String ): File? { + // 获取基础目录(应用私有外部存储) val baseDir = context.getExternalFilesDir(type) ?: File(context.filesDir, "exports/$type") val exportDir = File(baseDir, childDir) + + // 创建目录 if (!exportDir.exists() && !exportDir.mkdirs()) { Log.e(TAG, "Create export directory failed: ${exportDir.absolutePath}") return null } + // 创建文件 val exportFile = File(exportDir, displayName) return try { if (!exportFile.exists() && !exportFile.createNewFile()) { @@ -327,6 +547,15 @@ class NoteEditViewModel : ViewModel() { } } + // ==================== 单位转换 ==================== + + /** + * 将 dp 转换为像素 + * + * @param context 上下文 + * @param dp dp 值 + * @return 像素值 + */ private fun dpToPx(context: Context, dp: Int): Int { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, @@ -335,6 +564,13 @@ class NoteEditViewModel : ViewModel() { ).toInt() } + /** + * 将 sp 转换为像素 + * + * @param context 上下文 + * @param sp sp 值 + * @return 像素值 + */ private fun spToPx(context: Context, sp: Int): Float { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, @@ -343,10 +579,12 @@ class NoteEditViewModel : ViewModel() { ) } + // ==================== 伴生对象 ==================== + companion object { - private const val TAG = "NoteEditViewModel" - private const val LONG_IMAGE_HORIZONTAL_PADDING_DP = 24 - private const val LONG_IMAGE_VERTICAL_PADDING_DP = 32 - private const val LONG_IMAGE_MIN_HEIGHT_DP = 240 + private const val TAG = "NoteEditViewModel" // 日志标签 + private const val LONG_IMAGE_HORIZONTAL_PADDING_DP = 24 // 长图水平内边距(24dp) + private const val LONG_IMAGE_VERTICAL_PADDING_DP = 32 // 长图垂直内边距(32dp) + private const val LONG_IMAGE_MIN_HEIGHT_DP = 240 // 长图最小高度(240dp) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NotesListActivity.kt b/app/src/main/kotlin/net/micode/notes/ui/NotesListActivity.kt index 930ddc8..4223707 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NotesListActivity.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NotesListActivity.kt @@ -24,66 +24,142 @@ import net.micode.notes.tool.ResourceParser import net.micode.notes.tool.defaultPreferences import net.micode.notes.widget.NoteWidgetUpdater +/** + * 便签列表活动 + * + * 这是便签应用的 Main Activity,负责显示和管理便签的列表视图。 + * 用户在此界面可以浏览、搜索、创建、删除便签,以及管理文件夹。 + * + * 主要功能: + * 1. 便签列表展示:显示当前文件夹下的所有便签和子文件夹 + * 2. 文件夹管理:创建、重命名、删除文件夹 + * 3. 批量操作:支持多选删除便签 + * 4. 搜索功能:按关键词搜索便签内容 + * 5. 导航功能:在文件夹层级间导航 + * 6. 首次启动引导:创建介绍便签 + * + * 技术特点: + * - 使用 Jetpack Compose 构建 UI + * - 使用 ViewModel 管理状态和数据 + * - 支持屏幕旋转后恢复状态 + * - 记住上次打开的文件夹 + * + * @author MiCode Open Source Community + */ class NotesListActivity : ComponentActivity() { + + // ==================== 成员变量 ==================== + + /** + * 文件夹对话框状态 + * - null 表示对话框不可见 + * - 非空表示显示对话框(创建新文件夹或重命名已有文件夹) + */ private var folderDialogState by mutableStateOf(null) + /** + * 便签编辑器的启动器 + * + * 使用 Activity Result API 启动 NoteEditActivity, + * 并在编辑器关闭后刷新当前列表以显示最新数据。 + */ private val noteEditorLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { + // 编辑器关闭后刷新列表(可能是新建、编辑或删除便签后) notesListViewModel.refresh(this) } + /** + * 便签列表的 ViewModel + * + * 负责管理列表状态、数据加载、搜索、批量操作等业务逻辑 + */ private val notesListViewModel: NotesListViewModel by lazy { ViewModelProvider(this)[NotesListViewModel::class.java] } + // ==================== 生命周期方法 ==================== + + /** + * Activity 创建时的回调 + * + * 执行流程: + * 1. 设置应用介绍便签(仅首次启动) + * 2. 恢复上次打开的文件夹(如果配置了记住功能) + * 3. 设置 Compose UI 界面 + * + * @param savedInstanceState 保存的实例状态,用于 Activity 重建后恢复 + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // 首次启动时创建介绍便签 setAppInfoFromRawRes() + + // 恢复上次打开的文件夹(仅在全新启动时,不是配置变更后) if (savedInstanceState == null && notesListViewModel.uiState.value == NotesListUiState()) { restoreLastFolder() } + // 设置 Compose UI setContent { MaterialTheme { + // 收集 ViewModel 的 UI 状态流,自动感知生命周期 val uiState by notesListViewModel.uiState.collectAsStateWithLifecycle() NotesListScreen( state = uiState, folderDialogState = folderDialogState, - onBack = { handleBack() }, - onSearchTextChange = { notesListViewModel.setSearchText(this, it) }, - onItemClick = { item -> handleItemClick(item) }, - onItemLongClick = { item -> notesListViewModel.toggleSelection(item.id) }, - onCreateNote = { createNewNote(uiState.currentFolderId) }, - onCreateFolder = { + onBack = { handleBack() }, // 返回处理 + onSearchTextChange = { notesListViewModel.setSearchText(this, it) }, // 搜索 + onItemClick = { item -> handleItemClick(item) }, // 点击项目 + onItemLongClick = { item -> notesListViewModel.toggleSelection(item.id) }, // 长按选择 + onCreateNote = { createNewNote(uiState.currentFolderId) }, // 新建便签 + onCreateFolder = { // 创建文件夹 folderDialogState = FolderDialogUiState(create = true, name = "") }, - onDeleteSelection = { confirmDeleteSelected(uiState.selectedIds.size) }, - onClearSelection = { notesListViewModel.clearSelection() }, - onRenameFolder = { + onDeleteSelection = { confirmDeleteSelected(uiState.selectedIds.size) }, // 删除选中 + onClearSelection = { notesListViewModel.clearSelection() }, // 清除选择 + onRenameFolder = { // 重命名文件夹 folderDialogState = FolderDialogUiState( create = false, name = it.title, folder = it ) }, - onDeleteFolder = { confirmDeleteFolder(it) }, - onOpenSettings = { startPreferenceActivity() }, - onFolderDialogNameChange = { name -> + onDeleteFolder = { confirmDeleteFolder(it) }, // 删除文件夹 + onOpenSettings = { startPreferenceActivity() }, // 打开设置 + onFolderDialogNameChange = { name -> // 对话框输入变化 folderDialogState = folderDialogState?.copy(name = name) }, - onDismissFolderDialog = { folderDialogState = null }, - onConfirmFolderDialog = { confirmFolderDialog() } + onDismissFolderDialog = { folderDialogState = null }, // 关闭对话框 + onConfirmFolderDialog = { confirmFolderDialog() } // 确认对话框 ) } } } + /** + * Activity 恢复显示时的回调 + * + * 每次返回列表页时刷新数据,确保显示最新状态。 + * 这样可以从设置页或编辑页返回后立即看到更新。 + */ override fun onResume() { super.onResume() notesListViewModel.refresh(this) } + // ==================== 导航与交互处理 ==================== + + /** + * 处理返回按钮点击 + * + * 根据当前状态执行不同操作: + * 1. 如果处于选择模式(有选中项)→ 退出选择模式 + * 2. 如果不在根目录 → 返回上一级文件夹 + * 3. 如果在根目录 → 关闭 Activity + */ private fun handleBack() { val state = notesListViewModel.uiState.value when { @@ -96,27 +172,52 @@ class NotesListActivity : ComponentActivity() { } } + /** + * 处理列表项点击 + * + * 行为逻辑: + * - 选择模式激活时:只有非文件夹项可被选择 + * - 正常模式时:点击文件夹进入子目录,点击便签打开编辑页 + * + * @param item 被点击的列表项 + */ private fun handleItemClick(item: NotesListItemUi) { val selectionActive = notesListViewModel.uiState.value.selectedIds.isNotEmpty() when { + // 选择模式 && 非文件夹 → 切换选中状态 selectionActive && !item.isFolder -> notesListViewModel.toggleSelection(item.id) + // 选择模式 && 文件夹 → 无操作(文件夹不能多选) selectionActive -> Unit + // 正常模式 && 文件夹 → 打开文件夹 item.isFolder -> { notesListViewModel.openFolder(this, item) rememberFolder(item.id, item.title) } + // 正常模式 && 便签 → 打开便签编辑 else -> openNote(item.id) } } + // ==================== 便签操作 ==================== + + /** + * 创建新便签 + * + * @param folderId 目标文件夹 ID(便签创建在哪个文件夹下) + */ private fun createNewNote(folderId: Long) { val intent = Intent(this, NoteEditActivity::class.java).apply { action = Intent.ACTION_INSERT_OR_EDIT putExtra(Notes.INTENT_EXTRA_FOLDER_ID, folderId) } - noteEditorLauncher.launch(intent) + noteEditorLauncher.launch(intent) // 启动编辑器,等待返回 } + /** + * 打开已有便签 + * + * @param noteId 便签 ID + */ private fun openNote(noteId: Long) { val intent = Intent(this, NoteEditActivity::class.java).apply { action = Intent.ACTION_VIEW @@ -125,6 +226,15 @@ class NotesListActivity : ComponentActivity() { noteEditorLauncher.launch(intent) } + // ==================== 删除操作 ==================== + + /** + * 确认删除选中的便签 + * + * 根据用户设置决定是否显示确认对话框。 + * + * @param selectedCount 选中的项目数量 + */ private fun confirmDeleteSelected(selectedCount: Int) { if (selectedCount == 0) { Toast.makeText(this, getString(R.string.menu_select_none), Toast.LENGTH_SHORT).show() @@ -151,6 +261,11 @@ class NotesListActivity : ComponentActivity() { .show() } + /** + * 确认删除文件夹 + * + * @param folder 要删除的文件夹 + */ private fun confirmDeleteFolder(folder: NotesListItemUi) { if (!NotesPreferences.isDeleteConfirmationEnabled(this)) { deleteFolder(folder) @@ -166,29 +281,52 @@ class NotesListActivity : ComponentActivity() { .show() } + /** + * 执行删除选中的便签 + * + * 调用 ViewModel 删除便签,然后: + * 1. 更新桌面小部件 + * 2. 刷新列表 + */ private fun deleteSelectedNotes() { notesListViewModel.deleteSelectedNotes(context = this) { widgets -> - updateWidgets(widgets) - notesListViewModel.refresh(this) + updateWidgets(widgets) // 更新受影响的桌面小部件 + notesListViewModel.refresh(this) // 刷新列表 } } + /** + * 执行删除文件夹 + * + * 注意:删除文件夹会同时删除文件夹内的所有便签。 + * + * @param folder 要删除的文件夹 + */ private fun deleteFolder(folder: NotesListItemUi) { notesListViewModel.deleteFolder(context = this, folderId = folder.id) { widgets -> + // 如果删除的是记住的文件夹,清除记住的状态 if (NotesPreferences.getRememberedFolderId(this) == folder.id) { NotesPreferences.clearRememberedFolder(this) } - updateWidgets(widgets) - notesListViewModel.refresh(this) + updateWidgets(widgets) // 更新受影响的桌面小部件 + notesListViewModel.refresh(this) // 刷新列表 } } + // ==================== 文件夹管理对话框 ==================== + + /** + * 确认文件夹对话框的操作 + * + * 根据对话框状态执行创建新文件夹或重命名现有文件夹。 + */ private fun confirmFolderDialog() { val dialogState = folderDialogState ?: return notesListViewModel.saveFolder( context = this, dialogState = dialogState, onDuplicateName = { + // 文件夹名称重复时的错误提示 Toast.makeText( this, getString(R.string.folder_exist, dialogState.name.trim()), @@ -197,6 +335,7 @@ class NotesListActivity : ComponentActivity() { }, onComplete = { success -> if (success) { + // 重命名成功后,如果当前在重命名的文件夹内且记住了位置,更新记住的名称 dialogState.folder?.takeIf { !dialogState.create }?.let { folder -> if (NotesPreferences.getRememberedFolderId(this) == folder.id) { rememberFolder(folder.id, dialogState.name.trim()) @@ -209,18 +348,38 @@ class NotesListActivity : ComponentActivity() { ) } + // ==================== 小部件更新 ==================== + + /** + * 更新桌面小部件 + * + * 当便签被删除或修改时,更新所有关联的桌面小部件 + * + * @param widgets 需要更新的小部件列表 + */ private fun updateWidgets(widgets: Set) { NoteWidgetUpdater.updateWidgets(this, widgets) } + // ==================== 其他功能 ==================== + + /** + * 启动设置页面 + */ private fun startPreferenceActivity() { startActivity(Intent(this, NotesPreferenceActivity::class.java)) } + /** + * 恢复上次打开的文件夹 + * + * 如果用户启用了"记住上次打开的文件夹"功能, + * 则在应用启动时自动导航到上次浏览的文件夹。 + */ private fun restoreLastFolder() { val folderId = NotesPreferences.getRememberedFolderId(this) if (folderId == Notes.ID_ROOT_FOLDER.toLong()) { - return + return // 根文件夹不需要恢复 } notesListViewModel.restoreFolder( @@ -229,16 +388,36 @@ class NotesListActivity : ComponentActivity() { ) } + /** + * 记住当前文件夹(用于下次启动恢复) + * + * @param folderId 文件夹 ID + * @param folderTitle 文件夹标题 + */ private fun rememberFolder(folderId: Long, folderTitle: String) { NotesPreferences.rememberLastFolder(this, folderId, folderTitle) } + /** + * 从原始资源文件设置应用介绍便签 + * + * 在应用首次启动时,从 R.raw.introduction 读取介绍文本, + * 并创建一个默认便签作为初始内容。 + * + * 实现原理: + * 1. 检查 SharedPreferences 是否已添加过介绍便签 + * 2. 如果是首次启动,从 raw 资源读取介绍文本 + * 3. 创建新的便签并写入介绍内容 + * 4. 标记已添加,避免重复创建 + */ private fun setAppInfoFromRawRes() { val preferences = defaultPreferences() + // 检查是否已添加过介绍便签 if (preferences.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) { return } + // 读取原始资源文件中的介绍文本 val introduction = runCatching { resources.openRawResource(R.raw.introduction).bufferedReader().use { it.readText() } }.getOrElse { error -> @@ -246,23 +425,27 @@ class NotesListActivity : ComponentActivity() { return } + // 创建一个新的便签并设置介绍内容 val note = WorkingNote.createEmptyNote( this, - Notes.ID_ROOT_FOLDER.toLong(), - AppWidgetManager.INVALID_APPWIDGET_ID, - Notes.TYPE_WIDGET_INVALIDE, - ResourceParser.getDefaultBgId(this) + Notes.ID_ROOT_FOLDER.toLong(), // 根文件夹 + AppWidgetManager.INVALID_APPWIDGET_ID, // 无关联小部件 + Notes.TYPE_WIDGET_INVALIDE, // 无效的小部件类型 + ResourceParser.getDefaultBgId(this) // 默认背景颜色 ) - note.setWorkingText(introduction) + note.setWorkingText(introduction) // 设置介绍文本 if (note.saveNote()) { + // 标记已添加,下次启动不再重复创建 preferences.edit { putBoolean(PREFERENCE_ADD_INTRODUCTION, true) } } else { Log.e(TAG, "Save introduction note error") } } + // ==================== 伴生对象 ==================== + companion object { - private const val TAG = "NotesListActivity" - private const val PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction" + private const val TAG = "NotesListActivity" // 日志标签 + private const val PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction" // 首选项 Key } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NotesListScreen.kt b/app/src/main/kotlin/net/micode/notes/ui/NotesListScreen.kt index 6ae6c85..b6b60bd 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NotesListScreen.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NotesListScreen.kt @@ -66,12 +66,49 @@ import net.micode.notes.tool.ResourceParser import java.text.DateFormat import java.util.Date +/** + * 文件夹对话框的 UI 状态数据类 + * + * @param create true 表示创建新文件夹,false 表示重命名现有文件夹 + * @param name 文件夹名称(输入框的值) + * @param folder 正在操作的文件夹(重命名时需要,创建时为 null) + */ data class FolderDialogUiState( val create: Boolean, val name: String, val folder: NotesListItemUi? = null ) +/** + * 便签列表主屏幕 + * + * 这是便签应用的核心列表界面,负责展示所有便签和文件夹。 + * + * 主要功能: + * 1. 展示当前文件夹下的所有便签和子文件夹 + * 2. 搜索框:按关键词搜索便签 + * 3. 顶部栏:显示标题、返回按钮、设置按钮 + * 4. 底部栏:新建便签、新建文件夹、批量删除 + * 5. 支持多选模式:长按便签进入选择模式 + * 6. 文件夹菜单:重命名、删除文件夹 + * + * @param state 列表 UI 状态 + * @param folderDialogState 文件夹对话框状态(null 表示不可见) + * @param onBack 返回回调 + * @param onSearchTextChange 搜索文本变更回调 + * @param onItemClick 列表项点击回调 + * @param onItemLongClick 列表项长按回调 + * @param onCreateNote 新建便签回调 + * @param onCreateFolder 创建文件夹回调 + * @param onDeleteSelection 删除选中项回调 + * @param onClearSelection 清除选择回调 + * @param onRenameFolder 重命名文件夹回调 + * @param onDeleteFolder 删除文件夹回调 + * @param onOpenSettings 打开设置回调 + * @param onFolderDialogNameChange 文件夹对话框名称变更回调 + * @param onDismissFolderDialog 关闭文件夹对话框回调 + * @param onConfirmFolderDialog 确认文件夹对话框回调 + */ @Composable fun NotesListScreen( state: NotesListUiState, @@ -91,45 +128,56 @@ fun NotesListScreen( onDismissFolderDialog: () -> Unit, onConfirmFolderDialog: () -> Unit ) { - val selectionActive = state.selectedIds.isNotEmpty() + val selectionActive = state.selectedIds.isNotEmpty() // 是否处于选择模式 + + // 根据当前状态确定屏幕标题 val screenTitle = when { selectionActive -> pluralStringResource( R.plurals.menu_select_title, state.selectedIds.size, state.selectedIds.size - ) - state.isRoot -> stringResource(R.string.app_name) - state.isCallRecordFolder -> stringResource(R.string.call_record_folder_name) - else -> state.currentFolderTitle.ifBlank { stringResource(R.string.app_name) } + ) // 选择模式:显示"选中了 X 项" + state.isRoot -> stringResource(R.string.app_name) // 根目录:显示"便签" + state.isCallRecordFolder -> stringResource(R.string.call_record_folder_name) // 通话记录文件夹 + else -> state.currentFolderTitle.ifBlank { stringResource(R.string.app_name) } // 其他文件夹 } + // 处理系统返回键 BackHandler(enabled = selectionActive || !state.isRoot) { onBack() } + // 主容器 Box(modifier = Modifier.fillMaxSize()) { + // 背景层:使用列表背景图片 ResourceDrawableBackground( resId = R.drawable.list_background, modifier = Modifier.fillMaxSize() ) - Column( - modifier = Modifier.fillMaxSize() - ) { + + Column(modifier = Modifier.fillMaxSize()) { + // 顶部栏 NotesHeader( title = screenTitle, - showBack = selectionActive || !state.isRoot, - showSettings = state.isRoot && !selectionActive, + showBack = selectionActive || !state.isRoot, // 选择模式或非根目录时显示返回 + showSettings = state.isRoot && !selectionActive, // 根目录且非选择模式时显示设置 onBack = onBack, onOpenSettings = onOpenSettings ) + + // 搜索框 SearchBar( value = state.searchText, onValueChange = onSearchTextChange ) + + // 主要内容区域(列表或空状态) Box(modifier = Modifier.weight(1f)) { if (state.items.isEmpty() && !state.isLoading) { + // 空状态(无数据) EmptyState(isCallRecordFolder = state.isCallRecordFolder) } else { + // 列表视图 LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), @@ -137,7 +185,7 @@ fun NotesListScreen( ) { items( items = state.items, - key = { item -> item.id } + key = { item -> item.id } // 使用 ID 作为唯一标识 ) { item -> NoteListRow( item = item, @@ -159,6 +207,8 @@ fun NotesListScreen( } } } + + // 底部操作栏 BottomBar( state = state, onCreateNote = onCreateNote, @@ -168,6 +218,7 @@ fun NotesListScreen( ) } + // 文件夹对话框(创建/重命名) folderDialogState?.let { dialogState -> AlertDialog( onDismissRequest = onDismissFolderDialog, @@ -175,9 +226,9 @@ fun NotesListScreen( Text( text = stringResource( if (dialogState.create) { - R.string.menu_create_folder + R.string.menu_create_folder // "新建文件夹" } else { - R.string.menu_folder_change_name + R.string.menu_folder_change_name // "修改文件夹名称" } ) ) @@ -188,13 +239,13 @@ fun NotesListScreen( onValueChange = onFolderDialogNameChange, singleLine = true, modifier = Modifier.fillMaxWidth(), - placeholder = { Text(text = stringResource(R.string.hint_foler_name)) } + placeholder = { Text(text = stringResource(R.string.hint_foler_name)) } // "请输入名称" ) }, confirmButton = { TextButton( onClick = onConfirmFolderDialog, - enabled = dialogState.name.isNotBlank() + enabled = dialogState.name.isNotBlank() // 名称为空时禁用确认按钮 ) { Text(text = stringResource(android.R.string.ok)) } @@ -209,6 +260,15 @@ fun NotesListScreen( } } +/** + * 顶部栏组件 + * + * @param title 标题文字 + * @param showBack 是否显示返回按钮 + * @param showSettings 是否显示设置按钮 + * @param onBack 返回回调 + * @param onOpenSettings 打开设置回调 + */ @Composable private fun NotesHeader( title: String, @@ -220,18 +280,20 @@ private fun NotesHeader( Row( modifier = Modifier .fillMaxWidth() - .statusBarsPadding() + .statusBarsPadding() // 适配状态栏高度 .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { + // 左侧区域:返回按钮或占位符 if (showBack) { TextButton(onClick = onBack) { - Text(text = stringResource(R.string.action_back)) + Text(text = stringResource(R.string.action_back)) // "返回" } } else { Spacer(modifier = Modifier.width(12.dp)) } + // 中间标题(可伸缩,最多1行) Text( text = title, style = MaterialTheme.typography.titleLarge, @@ -239,12 +301,13 @@ private fun NotesHeader( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - color = Color(0xFF1F1A14) + color = Color(0xFF1F1A14) // 深棕色 ) + // 右侧区域:设置按钮或占位符 if (showSettings) { TextButton(onClick = onOpenSettings) { - Text(text = stringResource(R.string.menu_setting)) + Text(text = stringResource(R.string.menu_setting)) // "设置" } } else { Spacer(modifier = Modifier.width(12.dp)) @@ -252,6 +315,15 @@ private fun NotesHeader( } } +/** + * 搜索框组件 + * + * 使用 BasicTextField 实现,支持搜索输入 + * 键盘确认按钮为"搜索"(ImeAction.Search) + * + * @param value 当前的搜索文本 + * @param onValueChange 文本变更回调 + */ @Composable private fun SearchBar( value: String, @@ -262,25 +334,26 @@ private fun SearchBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) - .border(width = 1.dp, color = Color(0x33B58B4B), shape = shape), + .border(width = 1.dp, color = Color(0x33B58B4B), shape = shape), // 浅金色边框 shape = shape, - color = Color(0x55FFF7E8) + color = Color(0x55FFF7E8) // 半透米色背景 ) { BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), // 搜索按键 textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color(0xFF2A261F)), - cursorBrush = SolidColor(Color(0xFF8D5F23)), + cursorBrush = SolidColor(Color(0xFF8D5F23)), // 棕色光标 modifier = Modifier .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 14.dp), decorationBox = { innerTextField -> Box(modifier = Modifier.fillMaxWidth()) { + // 空内容时显示提示文字 if (value.isEmpty()) { Text( - text = stringResource(R.string.search_hint), + text = stringResource(R.string.search_hint), // "搜索便签" style = MaterialTheme.typography.bodyLarge, color = Color(0x886E6253) ) @@ -292,6 +365,23 @@ private fun SearchBar( } } +/** + * 列表项组件 + * + * 支持: + * - 单击:打开便签/文件夹 + * - 长按:进入选择模式(仅便签,文件夹不支持长按选择) + * - 选中边框高亮 + * - 文件夹菜单(更多操作) + * + * @param item 列表项数据 + * @param isSelected 是否被选中 + * @param selectionActive 是否处于选择模式 + * @param onClick 点击回调 + * @param onLongClick 长按回调 + * @param onRenameFolder 重命名文件夹回调 + * @param onDeleteFolder 删除文件夹回调 + */ @OptIn(ExperimentalFoundationApi::class) @Composable private fun NoteListRow( @@ -303,7 +393,7 @@ private fun NoteListRow( onRenameFolder: () -> Unit, onDeleteFolder: () -> Unit ) { - var menuExpanded by remember(item.id) { mutableStateOf(false) } + var menuExpanded by remember(item.id) { mutableStateOf(false) } // 文件夹菜单状态 Surface( modifier = Modifier @@ -312,16 +402,18 @@ private fun NoteListRow( .combinedClickable( onClick = onClick, onLongClick = { + // 只有便签支持长按选择,文件夹不支持 if (!item.isFolder) { onLongClick() } } ) .then( + // 选中时添加边框高亮 if (isSelected) { Modifier.border( width = 2.dp, - color = Color(0xFF8D5F23), + color = Color(0xFF8D5F23), // 棕色边框 shape = RoundedCornerShape(22.dp) ) } else { @@ -338,18 +430,21 @@ private fun NoteListRow( .padding(horizontal = 18.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { + // 左侧图标/徽章 RowBadge( resId = when { - isSelected -> R.drawable.selected - item.id == Notes.ID_CALL_RECORD_FOLDER.toLong() -> R.drawable.call_record + isSelected -> R.drawable.selected // 选中状态显示对勾 + item.id == Notes.ID_CALL_RECORD_FOLDER.toLong() -> R.drawable.call_record // 通话记录文件夹图标 else -> null }, tintColor = when { - item.isFolder -> Color(0xFF9C7A3D) - else -> Color(0xFFCDB78C) + item.isFolder -> Color(0xFF9C7A3D) // 文件夹:金色 + else -> Color(0xFFCDB78C) // 便签:浅金色 } ) Spacer(modifier = Modifier.width(14.dp)) + + // 中间文字区域 Column(modifier = Modifier.weight(1f)) { Text( text = rowTitle(item), @@ -367,10 +462,13 @@ private fun NoteListRow( overflow = TextOverflow.Ellipsis ) } + + // 右侧区域:文件夹菜单或删除图标 if (item.type == Notes.TYPE_FOLDER && !selectionActive) { + // 文件夹:更多操作菜单 Box { TextButton(onClick = { menuExpanded = true }) { - Text(text = stringResource(R.string.action_more)) + Text(text = stringResource(R.string.action_more)) // "更多" } DropdownMenu( expanded = menuExpanded, @@ -400,6 +498,7 @@ private fun NoteListRow( } } } else if (isSelected) { + // 选择模式:显示删除图标 Image( painter = painterResource(R.drawable.menu_delete), contentDescription = stringResource(R.string.menu_delete), @@ -410,6 +509,12 @@ private fun NoteListRow( } } +/** + * 列表项徽章/图标组件 + * + * @param resId 图标资源 ID(null 时显示纯色圆点) + * @param tintColor 纯色圆点的颜色 + */ @Composable private fun RowBadge( @DrawableRes resId: Int?, @@ -422,21 +527,34 @@ private fun RowBadge( modifier = Modifier.size(width = 20.dp, height = 24.dp) ) } else { + // 默认显示纯色圆点 Box( modifier = Modifier .size(14.dp) - .clip(RoundedCornerShape(7.dp)) + .clip(RoundedCornerShape(7.dp)) // 圆形 .background(tintColor) ) } } +/** + * 列表底部信息组件 + * + * 显示: + * - 项目数量统计(如"共 5 项内容") + * - 操作提示(如"轻触底部按钮即可新建便签") + * + * @param itemCount 项目数量 + * @param isCallRecordFolder 是否为通话记录文件夹 + * @param showAddHint 是否显示新建提示(否则显示搜索提示) + */ @Composable private fun Footer( itemCount: Int, isCallRecordFolder: Boolean, showAddHint: Boolean ) { + // 标题:数量统计或空状态文字 val title = if (isCallRecordFolder) { if (itemCount > 0) { pluralStringResource(R.plurals.footer_call_notes_count, itemCount, itemCount) @@ -451,10 +569,11 @@ private fun Footer( } } + // 提示文字 val hint = if (showAddHint) { - stringResource(R.string.footer_hint_add_note) + stringResource(R.string.footer_hint_add_note) // "轻触底部按钮即可新建便签" } else { - stringResource(R.string.footer_hint_search) + stringResource(R.string.footer_hint_search) // "使用上方搜索可更快找到内容" } Column( @@ -476,6 +595,13 @@ private fun Footer( } } +/** + * 空状态组件 + * + * 当列表为空时显示居中提示文字 + * + * @param isCallRecordFolder 是否为通话记录文件夹(影响提示文字) + */ @Composable private fun EmptyState(isCallRecordFolder: Boolean) { Box( @@ -496,6 +622,19 @@ private fun EmptyState(isCallRecordFolder: Boolean) { } } +/** + * 底部操作栏组件 + * + * 根据状态显示不同的操作按钮: + * - 选择模式:显示"取消"和"删除"按钮 + * - 正常模式:显示"新建便签"和"新建文件夹"按钮 + * + * @param state 列表 UI 状态 + * @param onCreateNote 新建便签回调 + * @param onCreateFolder 创建文件夹回调 + * @param onDeleteSelection 删除选中项回调 + * @param onClearSelection 清除选择回调 + */ @Composable private fun BottomBar( state: NotesListUiState, @@ -505,18 +644,20 @@ private fun BottomBar( onClearSelection: () -> Unit ) { val selectionActive = state.selectedIds.isNotEmpty() - val showPrimaryAction = !state.isCallRecordFolder - val showSecondaryActions = state.isRoot + val showPrimaryAction = !state.isCallRecordFolder // 非通话文件夹显示新建按钮 + val showSecondaryActions = state.isRoot // 根目录显示创建文件夹按钮 + // 没有任何按钮需要显示时,不渲染底部栏 if (!selectionActive && !showPrimaryAction && !showSecondaryActions) { return } + // 选择模式:显示取消和删除按钮 if (selectionActive) { Row( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() + .navigationBarsPadding() // 适配导航栏高度 .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { @@ -524,18 +665,19 @@ private fun BottomBar( modifier = Modifier.weight(1f), onClick = onClearSelection ) { - Text(text = stringResource(android.R.string.cancel)) + Text(text = stringResource(android.R.string.cancel)) // "取消" } FilledTonalButton( modifier = Modifier.weight(1f), onClick = onDeleteSelection ) { - Text(text = stringResource(R.string.menu_delete)) + Text(text = stringResource(R.string.menu_delete)) // "删除" } } return } + // 正常模式:显示主要操作按钮 Column( modifier = Modifier .fillMaxWidth() @@ -546,7 +688,7 @@ private fun BottomBar( if (showPrimaryAction) { NewNoteButton( modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.notelist_menu_new), + label = stringResource(R.string.notelist_menu_new), // "新建便签" onClick = onCreateNote ) } @@ -556,12 +698,21 @@ private fun BottomBar( modifier = Modifier.fillMaxWidth(), onClick = onCreateFolder ) { - Text(text = stringResource(R.string.menu_create_folder)) + Text(text = stringResource(R.string.menu_create_folder)) // "新建文件夹" } } } } +/** + * 新建便签按钮组件 + * + * 使用背景图片实现按下/正常状态的视觉效果 + * + * @param modifier 修饰符 + * @param label 按钮标签(无障碍文本) + * @param onClick 点击回调 + */ @Composable private fun NewNoteButton( modifier: Modifier = Modifier, @@ -570,10 +721,12 @@ private fun NewNoteButton( ) { val interactionSource = remember { MutableInteractionSource() } val pressed by interactionSource.collectIsPressedAsState() + + // 根据按压状态选择背景图片 val backgroundResId = if (pressed) { - R.drawable.new_note_pressed + R.drawable.new_note_pressed // 按下状态 } else { - R.drawable.new_note_normal + R.drawable.new_note_normal // 正常状态 } Box( @@ -586,7 +739,7 @@ private fun NewNoteButton( } .clickable( interactionSource = interactionSource, - indication = null, + indication = null, // 无涟漪效果(使用图片状态代替) onClick = onClick ), contentAlignment = Alignment.Center @@ -600,36 +753,56 @@ private fun NewNoteButton( } } +/** + * 获取列表项标题 + * + * @param item 列表项 + * @return 标题文字 + */ @Composable private fun rowTitle(item: NotesListItemUi): String { return when { - item.id == Notes.ID_CALL_RECORD_FOLDER.toLong() -> stringResource(R.string.call_record_folder_name) + item.id == Notes.ID_CALL_RECORD_FOLDER.toLong() -> stringResource(R.string.call_record_folder_name) // "通话便签" item.title.isNotBlank() -> item.title - else -> stringResource(R.string.notelist_string_info) + else -> stringResource(R.string.notelist_string_info) // "…" } } +/** + * 获取列表项副标题 + * + * @param item 列表项 + * @return 副标题文字(文件夹显示文件数量,便签显示修改时间) + */ @Composable private fun rowSubtitle(item: NotesListItemUi): String { return if (item.isFolder) { + // 文件夹:显示包含的便签数量 stringResource(R.string.format_folder_files_count, item.notesCount) } else { + // 便签:显示格式化的修改时间 DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) .format(Date(item.modifiedDate)) } } +/** + * 获取列表项的背景颜色 + * + * @param item 列表项 + * @return 对应的 Color 对象 + */ private fun noteSurfaceColor(item: NotesListItemUi): Color { return if (item.isFolder) { - Color(0xFFE8D9B5) + Color(0xFFE8D9B5) // 文件夹:米棕色 } else { when (item.bgColorId) { - ResourceParser.YELLOW -> Color(0xFFFFF3BF) - ResourceParser.BLUE -> Color(0xFFDCEBFF) - ResourceParser.WHITE -> Color(0xFFF7F4EC) - ResourceParser.GREEN -> Color(0xFFE1F2D8) - ResourceParser.RED -> Color(0xFFFFDED7) - else -> Color(0xFFFFF3BF) + ResourceParser.YELLOW -> Color(0xFFFFF3BF) // 黄色 + ResourceParser.BLUE -> Color(0xFFDCEBFF) // 蓝色 + ResourceParser.WHITE -> Color(0xFFF7F4EC) // 白色 + ResourceParser.GREEN -> Color(0xFFE1F2D8) // 绿色 + ResourceParser.RED -> Color(0xFFFFDED7) // 红色 + else -> Color(0xFFFFF3BF) // 默认黄色 } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NotesListViewModel.kt b/app/src/main/kotlin/net/micode/notes/ui/NotesListViewModel.kt index 142d5e4..de97138 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NotesListViewModel.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NotesListViewModel.kt @@ -15,6 +15,23 @@ import net.micode.notes.data.NotesRepository import net.micode.notes.data.room.NoteListRow import net.micode.notes.tool.NotesPreferences +/** + * 便签列表项的 UI 数据类 + * + * 这是从数据库 NoteListRow 转换而来的 UI 层数据模型, + * 专门用于 Compose UI 的展示。 + * + * @property id 项目ID(便签ID或文件夹ID) + * @property title 标题(便签内容摘要或文件夹名称) + * @property type 类型(笔记、文件夹、系统文件夹等) + * @property folderId 所属文件夹ID + * @property modifiedDate 最后修改时间(毫秒时间戳) + * @property notesCount 便签数量(仅文件夹有用) + * @property bgColorId 背景颜色ID + * @property hasAttachment 是否有附件 + * @property hasAlert 是否有提醒 + * @property widget 关联的桌面小部件属性 + */ data class NotesListItemUi( val id: Long, val title: String, @@ -27,10 +44,29 @@ data class NotesListItemUi( val hasAlert: Boolean, val widget: AppWidgetAttribute ) { + /** + * 是否为文件夹 + * + * TYPE_FOLDER: 普通文件夹 + * TYPE_SYSTEM: 系统文件夹(如通话记录文件夹) + */ val isFolder: Boolean get() = type == Notes.TYPE_FOLDER || type == Notes.TYPE_SYSTEM } +/** + * 便签列表的 UI 状态数据类 + * + * 使用不可变数据类存储列表界面的所有状态, + * 通过 StateFlow 传递给 Compose UI。 + * + * @property currentFolderId 当前打开的文件夹ID + * @property currentFolderTitle 当前文件夹标题 + * @property searchText 搜索关键词 + * @property items 列表项集合 + * @property selectedIds 已选中的项目ID集合 + * @property isLoading 是否正在加载数据 + */ data class NotesListUiState( val currentFolderId: Long = Notes.ID_ROOT_FOLDER.toLong(), val currentFolderTitle: String = "", @@ -39,38 +75,106 @@ data class NotesListUiState( val selectedIds: Set = emptySet(), val isLoading: Boolean = false ) { + /** + * 是否在根目录 + */ val isRoot: Boolean get() = currentFolderId == Notes.ID_ROOT_FOLDER.toLong() + /** + * 是否在通话记录文件夹 + */ val isCallRecordFolder: Boolean get() = currentFolderId == Notes.ID_CALL_RECORD_FOLDER.toLong() } +/** + * 便签列表 ViewModel + * + * 负责管理便签列表界面的所有状态和业务逻辑。 + * + * 主要职责: + * 1. 加载和刷新列表数据(支持按文件夹、搜索关键词、排序模式) + * 2. 管理文件夹导航(打开文件夹、返回根目录) + * 3. 管理多选模式(选中/取消选中、清除选择) + * 4. 批量删除便签 + * 5. 文件夹的创建、重命名、删除 + * 6. 搜索功能 + * 7. 记住上次打开的文件夹 + * + * 技术特点: + * - 使用 StateFlow 管理 UI 状态 + * - 使用 Kotlin Coroutines 处理异步操作 + * - 使用 queryGeneration 机制防止数据竞态 + * + * @author MiCode Open Source Community + */ class NotesListViewModel : ViewModel() { + + // ==================== 状态管理 ==================== + + /** + * UI 状态的可变 StateFlow(仅限内部修改) + */ private val _uiState = MutableStateFlow(NotesListUiState()) + + /** + * UI 状态的不可变 StateFlow(对外暴露) + * + * UI 层通过 collectAsStateWithLifecycle 收集此流 + */ val uiState = _uiState.asStateFlow() + /** + * 查询代数计数器 + * + * 用于防止并发查询时旧数据覆盖新数据的问题。 + * 每次发起新查询时 increment,查询返回时对比是否仍然是当前代。 + * 如果不匹配,说明已经有更新的查询发起,丢弃当前结果。 + */ private var queryGeneration = 0L + // ==================== 数据加载与刷新 ==================== + + /** + * 刷新当前列表 + * + * 根据当前状态(文件夹、搜索关键词)从数据库加载数据。 + * + * 执行流程: + * 1. 递增查询代数 + * 2. 设置 isLoading = true + * 3. 在 IO 线程中查询数据库 + * 4. 将 NoteListRow 转换为 NotesListItemUi + * 5. 回到主线程,检查查询代数是否仍然有效 + * 6. 过滤掉已删除的选中项 + * 7. 更新 UI 状态,设置 isLoading = false + * + * @param context 上下文(用于访问数据库和 SharedPreferences) + */ fun refresh(context: Context) { - val generation = ++queryGeneration - val currentState = _uiState.value - _uiState.update { it.copy(isLoading = true) } + val generation = ++queryGeneration // 递增代数,记录当前查询 + val currentState = _uiState.value // 获取当前状态快照 + _uiState.update { it.copy(isLoading = true) } // 显示加载状态 viewModelScope.launch(Dispatchers.IO) { + // 从数据库加载列表项 val items = NotesRepository(context).loadListItems( folderId = currentState.currentFolderId, searchText = currentState.searchText, - sortMode = NotesPreferences.getListSortMode(context) + sortMode = NotesPreferences.getListSortMode(context) // 获取用户设置的排序模式 ).map { item -> - item.toUi() + item.toUi() // 转换为 UI 数据模型 } + // 回到主线程更新状态 withContext(Dispatchers.Main) { + // 检查是否仍然是当前查询(防止旧数据覆盖新数据) if (generation != queryGeneration) { return@withContext } + // 过滤掉已被删除的选中项 val validSelectedIds = currentState.selectedIds.intersect(items.mapTo(hashSetOf()) { it.id }) _uiState.update { it.copy( @@ -83,27 +187,57 @@ class NotesListViewModel : ViewModel() { } } + // ==================== 搜索功能 ==================== + + /** + * 设置搜索文本 + * + * 当用户输入搜索关键词时调用。 + * 设置后会立即触发列表刷新。 + * + * @param context 上下文 + * @param searchText 搜索关键词 + */ fun setSearchText(context: Context, searchText: String) { _uiState.update { it.copy( searchText = searchText, - selectedIds = emptySet() + selectedIds = emptySet() // 搜索时清空选中状态 ) } - refresh(context) + refresh(context) // 立即刷新列表 } + // ==================== 文件夹导航 ==================== + + /** + * 打开文件夹 + * + * 进入指定的子文件夹,更新当前文件夹信息并刷新列表。 + * + * @param context 上下文 + * @param item 要打开的文件夹项 + */ fun openFolder(context: Context, item: NotesListItemUi) { _uiState.update { it.copy( currentFolderId = item.id, currentFolderTitle = item.title, - selectedIds = emptySet() + selectedIds = emptySet() // 切换文件夹时清空选中状态 ) } refresh(context) } + /** + * 恢复文件夹(用于 Activity 重建后恢复状态) + * + * 不触发刷新,只更新状态。 + * 用于从 SharedPreferences 恢复上次打开的文件夹。 + * + * @param folderId 文件夹 ID + * @param folderTitle 文件夹标题 + */ fun restoreFolder(folderId: Long, folderTitle: String) { _uiState.update { it.copy( @@ -114,6 +248,13 @@ class NotesListViewModel : ViewModel() { } } + /** + * 返回根目录 + * + * 清空当前文件夹信息,回到根目录。 + * + * @param context 上下文 + */ fun goToRoot(context: Context) { _uiState.update { it.copy( @@ -125,20 +266,49 @@ class NotesListViewModel : ViewModel() { refresh(context) } + // ==================== 多选模式 ==================== + + /** + * 清除所有选中项 + * + * 退出多选模式。 + */ fun clearSelection() { _uiState.update { it.copy(selectedIds = emptySet()) } } + /** + * 切换项目的选中状态 + * + * 如果项目已选中则取消选中,否则添加选中。 + * + * @param itemId 项目 ID + */ fun toggleSelection(itemId: Long) { _uiState.update { state -> val selectedIds = state.selectedIds.toMutableSet() if (!selectedIds.add(itemId)) { - selectedIds.remove(itemId) + selectedIds.remove(itemId) // 已存在则移除 } state.copy(selectedIds = selectedIds) } } + // ==================== 批量删除 ==================== + + /** + * 删除选中的便签 + * + * 执行流程: + * 1. 获取当前选中的 ID 集合 + * 2. 收集选中项关联的小部件信息 + * 3. 在 IO 线程中执行删除操作 + * 4. 删除完成后清空选中状态 + * 5. 通过回调返回受影响的小部件列表 + * + * @param context 上下文 + * @param onComplete 删除完成回调,返回受影响的小部件集合 + */ fun deleteSelectedNotes( context: Context, onComplete: (Set) -> Unit @@ -149,6 +319,7 @@ class NotesListViewModel : ViewModel() { return } + // 收集需要更新的小部件 val selectedWidgets = _uiState.value.items .filter { it.id in selectedIds } .mapTo(linkedSetOf()) { it.widget } @@ -161,12 +332,29 @@ class NotesListViewModel : ViewModel() { } withContext(Dispatchers.Main) { - _uiState.update { it.copy(selectedIds = emptySet()) } - onComplete(selectedWidgets) + _uiState.update { it.copy(selectedIds = emptySet()) } // 清空选中状态 + onComplete(selectedWidgets) // 回调通知更新小部件 } } } + // ==================== 文件夹管理 ==================== + + /** + * 删除文件夹 + * + * 注意:删除文件夹会同时删除文件夹内的所有便签。 + * + * 执行流程: + * 1. 验证文件夹 ID 不是根目录 + * 2. 收集文件夹内所有便签关联的小部件信息 + * 3. 在 IO 线程中执行删除操作 + * 4. 删除完成后通过回调返回受影响的小部件列表 + * + * @param context 上下文 + * @param folderId 要删除的文件夹 ID + * @param onComplete 删除完成回调,返回受影响的小部件集合 + */ fun deleteFolder( context: Context, folderId: Long, @@ -180,6 +368,7 @@ class NotesListViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { val repository = NotesRepository(context) + // 收集文件夹内所有便签的小部件 val widgets = repository.collectFolderWidgets(folderId) val success = repository.deleteFolder(folderId) @@ -193,6 +382,24 @@ class NotesListViewModel : ViewModel() { } } + /** + * 保存文件夹(创建或重命名) + * + * 根据 dialogState.create 判断是创建新文件夹还是重命名现有文件夹。 + * + * 执行流程: + * 1. 验证名称不为空 + * 2. 检查名称是否重复(仅当创建或名称发生变化时) + * 3. 如果重复,调用 onDuplicateName 回调并返回 + * 4. 否则执行创建或重命名操作 + * 5. 如果重命名成功且当前正在重命名的文件夹内,更新 currentFolderTitle + * 6. 通过 onComplete 回调返回结果 + * + * @param context 上下文 + * @param dialogState 对话框状态(包含操作类型、名称、文件夹信息) + * @param onDuplicateName 名称重复时的回调 + * @param onComplete 操作完成回调,参数 Boolean 表示是否成功 + */ fun saveFolder( context: Context, dialogState: FolderDialogUiState, @@ -207,17 +414,21 @@ class NotesListViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { val repository = NotesRepository(context) + + // 检查名称是否重复(创建时或重命名时名称发生了变化) val duplicateName = (dialogState.create || !name.contentEquals(dialogState.folder?.title)) && repository.hasVisibleFolderNamed(name) + if (duplicateName) { withContext(Dispatchers.Main) { - onDuplicateName() + onDuplicateName() // 通知 UI 显示名称重复错误 } return@launch } + // 执行创建或重命名 val success = if (dialogState.create) { - repository.createFolder(name) > 0 + repository.createFolder(name) > 0 // 返回文件夹 ID,大于0表示成功 } else { val folderId = dialogState.folder?.id if (folderId == null) { @@ -233,6 +444,7 @@ class NotesListViewModel : ViewModel() { } withContext(Dispatchers.Main) { + // 如果重命名成功,且当前正在重命名的文件夹内,更新标题 if (success && !dialogState.create && _uiState.value.currentFolderId == dialogState.folder?.id) { _uiState.update { state -> state.copy(currentFolderTitle = name) @@ -243,16 +455,29 @@ class NotesListViewModel : ViewModel() { } } + // ==================== 伴生对象 ==================== + companion object { - private const val TAG = "NotesListViewModel" + private const val TAG = "NotesListViewModel" // 日志标签 } } +/** + * 将数据库实体 NoteListRow 转换为 UI 数据模型 NotesListItemUi + * + * 转换过程中: + * 1. 移除标题中的复选框符号(√ 和 □) + * 2. 处理空内容的占位符 + * 3. 转换布尔值字段 + * + * @return NotesListItemUi 实例 + */ private fun NoteListRow.toUi(): NotesListItemUi { + // 移除标题中的清单符号,得到纯净的标题文本 val title = snippet - .replace(NoteEditActivity.TAG_CHECKED, "") - .replace(NoteEditActivity.TAG_UNCHECKED, "") - .trim() + .replace(NoteEditActivity.TAG_CHECKED, "") // 移除 "√" 符号 + .replace(NoteEditActivity.TAG_UNCHECKED, "") // 移除 "□" 符号 + .trim() // 去除首尾空白 return NotesListItemUi( id = id, @@ -262,11 +487,11 @@ private fun NoteListRow.toUi(): NotesListItemUi { modifiedDate = modifiedDate, notesCount = notesCount, bgColorId = bgColorId, - hasAttachment = hasAttachment > 0, - hasAlert = alertedDate > 0L, + hasAttachment = hasAttachment > 0, // 转为 Boolean + hasAlert = alertedDate > 0L, // 转为 Boolean(有提醒时间表示有提醒) widget = AppWidgetAttribute( widgetId = widgetId, widgetType = widgetType ) ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceActivity.kt b/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceActivity.kt index 693f518..cbf7592 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceActivity.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceActivity.kt @@ -10,36 +10,83 @@ import androidx.compose.runtime.setValue import androidx.core.view.WindowCompat import net.micode.notes.tool.NotesPreferences +/** + * 便签应用设置页面 Activity + * + * 该 Activity 负责显示和管理应用的所有用户偏好设置。 + * + * 主要功能: + * 1. 新建便签背景颜色随机 - 开关控制 + * 2. 默认新建便签颜色 - 颜色选择(黄、蓝、白、绿、红) + * 3. 列表排序 - 排序方式选择(最近修改优先、最早修改优先、按标题排序) + * 4. 删除前确认 - 开关控制 + * 5. 记住上次打开的文件夹 - 开关控制 + * + * 技术特点: + * - 使用 Jetpack Compose 构建 UI + * - 使用 mutableStateOf 管理 UI 状态 + * - 设置变更后立即更新 UI 状态 + * - 遵循 Material 3 设计规范 + * + * @author MiCode Open Source Community + */ class NotesPreferenceActivity : ComponentActivity() { + + // ==================== 成员变量 ==================== + + /** + * 设置页面的 UI 状态 + * + * 使用 mutableStateOf 确保状态变化时 UI 自动重组。 + * 存储所有设置项的当前值。 + */ private var uiState by mutableStateOf(NotesSettingsUiState()) + // ==================== 生命周期方法 ==================== + + /** + * Activity 创建时的回调 + * + * 执行流程: + * 1. 配置窗口(边到边显示,内容延伸到系统栏区域) + * 2. 从 SharedPreferences 加载所有设置项的状态 + * 3. 设置 Compose UI,传入状态和事件回调 + * + * @param savedInstanceState 保存的实例状态(本 Activity 未使用) + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // 配置窗口:让内容延伸到系统栏区域(边到边显示) + // 这样设置页面的背景可以延伸到状态栏和导航栏下方 WindowCompat.setDecorFitsSystemWindows(window, false) + + // 从 SharedPreferences 加载当前设置状态 uiState = loadUiState() + // 设置 Compose UI setContent { MaterialTheme { NotesPreferenceScreen( - state = uiState, - onBack = { finish() }, - onToggleRandomBackground = { enabled -> + state = uiState, // 当前设置状态 + onBack = { finish() }, // 返回按钮回调 + onToggleRandomBackground = { enabled -> // 随机背景开关切换 NotesPreferences.setRandomBackgroundEnabled(this, enabled) uiState = uiState.copy(randomBackgroundEnabled = enabled) }, - onSelectDefaultBackgroundColor = { colorId -> + onSelectDefaultBackgroundColor = { colorId -> // 默认背景颜色选择 NotesPreferences.setDefaultBackgroundColor(this, colorId) uiState = uiState.copy(defaultBackgroundColor = colorId) }, - onSelectListSortMode = { sortMode -> + onSelectListSortMode = { sortMode -> // 列表排序模式选择 NotesPreferences.setListSortMode(this, sortMode) uiState = uiState.copy(listSortMode = sortMode) }, - onToggleDeleteConfirmation = { enabled -> + onToggleDeleteConfirmation = { enabled -> // 删除确认开关切换 NotesPreferences.setDeleteConfirmationEnabled(this, enabled) uiState = uiState.copy(deleteConfirmationEnabled = enabled) }, - onToggleRememberLastFolder = { enabled -> + onToggleRememberLastFolder = { enabled -> // 记住上次文件夹开关切换 NotesPreferences.setRememberLastFolderEnabled(this, enabled) uiState = uiState.copy(rememberLastFolderEnabled = enabled) } @@ -48,6 +95,23 @@ class NotesPreferenceActivity : ComponentActivity() { } } + // ==================== 私有辅助方法 ==================== + + /** + * 加载设置状态 + * + * 从 SharedPreferences 中读取所有设置项的当前值, + * 并封装成 NotesSettingsUiState 数据类返回。 + * + * 读取的设置项: + * - randomBackgroundEnabled: 新建便签背景颜色是否随机 + * - defaultBackgroundColor: 默认新建便签颜色 ID + * - listSortMode: 列表排序模式 + * - deleteConfirmationEnabled: 删除前是否确认 + * - rememberLastFolderEnabled: 是否记住上次打开的文件夹 + * + * @return 包含所有设置当前值的 NotesSettingsUiState 对象 + */ private fun loadUiState(): NotesSettingsUiState { return NotesSettingsUiState( randomBackgroundEnabled = NotesPreferences.isRandomBackgroundEnabled(this), @@ -57,4 +121,4 @@ class NotesPreferenceActivity : ComponentActivity() { rememberLastFolderEnabled = NotesPreferences.isRememberLastFolderEnabled(this) ) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceScreen.kt b/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceScreen.kt index 4f88eaf..2b64066 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceScreen.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/NotesPreferenceScreen.kt @@ -35,6 +35,18 @@ import net.micode.notes.R import net.micode.notes.tool.NotesSortMode import net.micode.notes.tool.ResourceParser +/** + * 设置页面的 UI 状态数据类 + * + * 存储应用的所有用户偏好设置项的值。 + * 使用不可变数据类,确保状态一致性。 + * + * @property randomBackgroundEnabled 新建便签时是否随机选择背景颜色 + * @property defaultBackgroundColor 默认新建便签的背景颜色 ID(随机关闭时使用) + * @property listSortMode 列表排序模式(最近修改优先/最早修改优先/按标题排序) + * @property deleteConfirmationEnabled 删除操作前是否显示确认对话框 + * @property rememberLastFolderEnabled 是否记住上次打开的文件夹(应用重启后恢复) + */ data class NotesSettingsUiState( val randomBackgroundEnabled: Boolean = false, val defaultBackgroundColor: Int = ResourceParser.BG_DEFAULT_COLOR, @@ -43,6 +55,33 @@ data class NotesSettingsUiState( val rememberLastFolderEnabled: Boolean = true ) +/** + * 设置页面主屏幕 + * + * 这是应用设置界面的核心 Composable 组件,负责显示和管理所有偏好设置项。 + * + * 主要功能: + * 1. 随机背景开关 - 控制新建便签时是否随机选择背景颜色 + * 2. 默认颜色选择器 - 5 种颜色可选(黄、蓝、白、绿、红) + * 3. 列表排序选择器 - 3 种排序方式可选 + * 4. 删除确认开关 + * 5. 记住上次文件夹开关 + * + * UI 设计特点: + * - 使用圆角卡片 (Card) 组织每个设置项 + * - 开关设置项使用 Switch 组件 + * - 颜色选择使用圆形预览色块 + 文字标签 + * - 排序选择使用单选按钮式卡片 + * - 背景使用与应用列表相同的纸张纹理 + * + * @param state 当前设置状态 + * @param onBack 返回按钮回调 + * @param onToggleRandomBackground 随机背景开关切换回调 + * @param onSelectDefaultBackgroundColor 默认背景颜色选择回调 + * @param onSelectListSortMode 列表排序模式选择回调 + * @param onToggleDeleteConfirmation 删除确认开关切换回调 + * @param onToggleRememberLastFolder 记住上次文件夹开关切换回调 + */ @Composable fun NotesPreferenceScreen( state: NotesSettingsUiState, @@ -53,46 +92,56 @@ fun NotesPreferenceScreen( onToggleDeleteConfirmation: (Boolean) -> Unit, onToggleRememberLastFolder: (Boolean) -> Unit ) { + // 主容器:填充整个屏幕 Box(modifier = Modifier.fillMaxSize()) { + // 背景层:使用与应用列表相同的纸张纹理背景 ResourceDrawableBackground( resId = R.drawable.list_background, modifier = Modifier.fillMaxSize() ) + + // 主内容列 Column(modifier = Modifier.fillMaxSize()) { + // 顶部栏 Row( modifier = Modifier .fillMaxWidth() - .statusBarsPadding() + .statusBarsPadding() // 适配状态栏高度 .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { + // 返回按钮 TextButton(onClick = onBack) { - Text(text = stringResource(R.string.action_back)) + Text(text = stringResource(R.string.action_back)) // "返回" } + // 标题 Text( - text = stringResource(R.string.preferences_title), + text = stringResource(R.string.preferences_title), // "设置" style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) } + // 设置项列表(可滚动区域) Column( modifier = Modifier - .weight(1f) + .weight(1f) // 占据剩余空间 .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + verticalArrangement = Arrangement.spacedBy(14.dp) // 设置项之间间距 14dp ) { + // 1. 随机背景开关 SettingSwitchCard( title = stringResource(R.string.preferences_bg_random_appear_title), summary = if (state.randomBackgroundEnabled) { - stringResource(R.string.state_enabled) + stringResource(R.string.state_enabled) // "已开启" } else { - stringResource(R.string.state_disabled) + stringResource(R.string.state_disabled) // "已关闭" }, checked = state.randomBackgroundEnabled, onCheckedChange = onToggleRandomBackground ) + // 2. 默认背景颜色选择 Card( shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth() @@ -103,22 +152,25 @@ fun NotesPreferenceScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { + // 标题 Text( - text = stringResource(R.string.preferences_default_color_title), + text = stringResource(R.string.preferences_default_color_title), // "默认新建便签颜色" style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) + // 当前选中的颜色名称 Text( text = colorLabel(state.defaultBackgroundColor), style = MaterialTheme.typography.bodyMedium ) + // 5 种颜色选项(水平排列) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { noteColorOptions().forEach { option -> ColorOptionButton( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f), // 等宽分布 option = option, selected = option.colorId == state.defaultBackgroundColor, onClick = { onSelectDefaultBackgroundColor(option.colorId) } @@ -128,6 +180,7 @@ fun NotesPreferenceScreen( } } + // 3. 列表排序选择 Card( shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth() @@ -139,10 +192,11 @@ fun NotesPreferenceScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = stringResource(R.string.preferences_sort_title), + text = stringResource(R.string.preferences_sort_title), // "列表排序" style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) + // 3 种排序选项(垂直排列) sortOptions().forEach { option -> SelectionRow( label = stringResource(option.labelRes), @@ -153,8 +207,9 @@ fun NotesPreferenceScreen( } } + // 4. 删除确认开关 SettingSwitchCard( - title = stringResource(R.string.preferences_delete_confirmation_title), + title = stringResource(R.string.preferences_delete_confirmation_title), // "删除前确认" summary = if (state.deleteConfirmationEnabled) { stringResource(R.string.state_enabled) } else { @@ -164,8 +219,9 @@ fun NotesPreferenceScreen( onCheckedChange = onToggleDeleteConfirmation ) + // 5. 记住上次文件夹开关 SettingSwitchCard( - title = stringResource(R.string.preferences_remember_last_folder_title), + title = stringResource(R.string.preferences_remember_last_folder_title), // "记住上次打开的文件夹" summary = if (state.rememberLastFolderEnabled) { stringResource(R.string.state_enabled) } else { @@ -176,6 +232,7 @@ fun NotesPreferenceScreen( ) } + // 底部占位符(适配导航栏) Spacer( modifier = Modifier .navigationBarsPadding() @@ -185,6 +242,17 @@ fun NotesPreferenceScreen( } } +/** + * 设置开关卡片组件 + * + * 用于显示带标题、摘要和开关按钮的设置项。 + * 常用于开启/关闭功能的设置项。 + * + * @param title 标题文字 + * @param summary 摘要/状态描述文字 + * @param checked 开关当前状态 + * @param onCheckedChange 状态变更回调 + */ @Composable private fun SettingSwitchCard( title: String, @@ -202,6 +270,7 @@ private fun SettingSwitchCard( .padding(20.dp), verticalAlignment = Alignment.CenterVertically ) { + // 左侧:标题和摘要 Column(modifier = Modifier.weight(1f)) { Text( text = title, @@ -214,6 +283,7 @@ private fun SettingSwitchCard( style = MaterialTheme.typography.bodyMedium ) } + // 右侧:开关 Switch( checked = checked, onCheckedChange = onCheckedChange @@ -222,6 +292,23 @@ private fun SettingSwitchCard( } } +/** + * 颜色选项按钮组件 + * + * 用于在默认背景颜色选择器中显示单个颜色选项。 + * UI 组成: + * - 彩色圆形预览色块 + * - 颜色名称文字(如"黄色"、"蓝色"等) + * + * 视觉反馈: + * - 选中时边框变为深棕色,且背景有浅色高亮 + * - 未选中时边框为浅棕色 + * + * @param modifier 修饰符 + * @param option 颜色选项数据 + * @param selected 是否被选中 + * @param onClick 点击回调 + */ @Composable private fun ColorOptionButton( modifier: Modifier, @@ -233,13 +320,13 @@ private fun ColorOptionButton( modifier = modifier, shape = RoundedCornerShape(18.dp), color = if (selected) { - Color(0x1AB58B4B) + Color(0x1AB58B4B) // 选中时:半透明金色背景 } else { - Color(0x0FFFFFFF) + Color(0x0FFFFFFF) // 未选中时:完全透明 }, border = BorderStroke( width = if (selected) 2.dp else 1.dp, - color = if (selected) Color(0xFF8D5F23) else Color(0x33B58B4B) + color = if (selected) Color(0xFF8D5F23) else Color(0x33B58B4B) // 深棕/浅棕 ) ) { Column( @@ -250,12 +337,14 @@ private fun ColorOptionButton( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // 圆形颜色预览块 Box( modifier = Modifier .size(24.dp) .clip(CircleShape) .background(option.previewColor) ) + // 颜色名称 Text( text = stringResource(option.labelRes), style = MaterialTheme.typography.bodySmall @@ -264,6 +353,22 @@ private fun ColorOptionButton( } } +/** + * 选择行组件(单选按钮式卡片) + * + * 用于排序模式选择器中显示单个选项。 + * UI 组成: + * - 圆形选中指示器(选中时实心,未选中时空心) + * - 选项文字标签 + * + * 视觉反馈: + * - 选中时边框和背景有高亮效果 + * - 点击整个卡片区域均可触发选中 + * + * @param label 选项文字 + * @param selected 是否被选中 + * @param onClick 点击回调 + */ @Composable private fun SelectionRow( label: String, @@ -286,19 +391,21 @@ private fun SelectionRow( .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { + // 圆形选中指示器 Box( modifier = Modifier .size(18.dp) .clip(CircleShape) .background( if (selected) { - Color(0xFF8D5F23) + Color(0xFF8D5F23) // 选中:深棕色实心圆 } else { - Color(0x33B58B4B) + Color(0x33B58B4B) // 未选中:浅棕色空心(用半透明模拟) } ) ) Spacer(modifier = Modifier.size(10.dp)) + // 选项文字 Text( text = label, style = MaterialTheme.typography.bodyLarge @@ -307,36 +414,77 @@ private fun SelectionRow( } } +/** + * 获取当前默认背景颜色的显示名称 + * + * 根据颜色 ID 查找对应的资源字符串并返回。 + * + * @param colorId 颜色 ID(ResourceParser 中的常量) + * @return 颜色名称字符串(如"黄色"、"蓝色"等) + */ @Composable private fun colorLabel(colorId: Int): String { - return stringResource(noteColorOptions().firstOrNull { it.colorId == colorId }?.labelRes ?: R.string.color_yellow) + return stringResource( + noteColorOptions().firstOrNull { it.colorId == colorId }?.labelRes + ?: R.string.color_yellow // 默认返回"黄色" + ) } +/** + * 颜色选项数据类 + * + * @param colorId 颜色 ID(ResourceParser 中的常量) + * @param previewColor 预览色块的颜色值 + * @param labelRes 颜色名称的资源 ID + */ private data class NoteColorOption( val colorId: Int, val previewColor: Color, val labelRes: Int ) +/** + * 排序选项数据类 + * + * @param sortMode 排序模式枚举值 + * @param labelRes 排序方式名称的资源 ID + */ private data class SortOption( val sortMode: NotesSortMode, val labelRes: Int ) +/** + * 预定义的颜色选项列表 + * + * 包含 5 种可选颜色:黄、蓝、白、绿、红 + * + * @return 颜色选项列表 + */ private fun noteColorOptions(): List { return listOf( - NoteColorOption(ResourceParser.YELLOW, Color(0xFFFFF3BF), R.string.color_yellow), - NoteColorOption(ResourceParser.BLUE, Color(0xFFDCEBFF), R.string.color_blue), - NoteColorOption(ResourceParser.WHITE, Color(0xFFF7F4EC), R.string.color_white), - NoteColorOption(ResourceParser.GREEN, Color(0xFFE1F2D8), R.string.color_green), - NoteColorOption(ResourceParser.RED, Color(0xFFFFDED7), R.string.color_red) + NoteColorOption(ResourceParser.YELLOW, Color(0xFFFFF3BF), R.string.color_yellow), // 黄色 + NoteColorOption(ResourceParser.BLUE, Color(0xFFDCEBFF), R.string.color_blue), // 蓝色 + NoteColorOption(ResourceParser.WHITE, Color(0xFFF7F4EC), R.string.color_white), // 白色 + NoteColorOption(ResourceParser.GREEN, Color(0xFFE1F2D8), R.string.color_green), // 绿色 + NoteColorOption(ResourceParser.RED, Color(0xFFFFDED7), R.string.color_red) // 红色 ) } +/** + * 预定义的排序选项列表 + * + * 包含 3 种排序方式: + * - MODIFIED_DESC: 最近修改优先 + * - MODIFIED_ASC: 最早修改优先 + * - TITLE_ASC: 按标题排序(A-Z) + * + * @return 排序选项列表 + */ private fun sortOptions(): List { return listOf( - SortOption(NotesSortMode.MODIFIED_DESC, R.string.preferences_sort_modified_desc), - SortOption(NotesSortMode.MODIFIED_ASC, R.string.preferences_sort_modified_asc), - SortOption(NotesSortMode.TITLE_ASC, R.string.preferences_sort_title_asc) + SortOption(NotesSortMode.MODIFIED_DESC, R.string.preferences_sort_modified_desc), // 最近修改优先 + SortOption(NotesSortMode.MODIFIED_ASC, R.string.preferences_sort_modified_asc), // 最早修改优先 + SortOption(NotesSortMode.TITLE_ASC, R.string.preferences_sort_title_asc) // 按标题排序 ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/micode/notes/ui/ResourceDrawableBackground.kt b/app/src/main/kotlin/net/micode/notes/ui/ResourceDrawableBackground.kt index d21f837..ebbefe2 100644 --- a/app/src/main/kotlin/net/micode/notes/ui/ResourceDrawableBackground.kt +++ b/app/src/main/kotlin/net/micode/notes/ui/ResourceDrawableBackground.kt @@ -6,21 +6,60 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +/** + * 资源图片背景组件 + * + * 这是一个 Compose 可组合函数,用于将 Android 传统的 Drawable 资源作为背景显示。 + * + * 主要功能: + * - 将传统的图片资源(如 .png、.9.png、selector 等)显示为背景 + * - 自动拉伸图片以填满给定的空间(使用 FIT_XY 缩放类型) + * - 兼容旧版 UI 资源,无需迁移到 Compose 的 Image 组件 + * + * 使用场景: + * - 便签列表的背景(纸张纹理效果) + * - 便签编辑页面的背景(纸张纹理) + * - 设置页面的背景 + * - 任何需要显示传统 Drawable 资源作为背景的地方 + * + * 实现原理: + * 使用 AndroidView 将传统的 ImageView 嵌入到 Compose UI 树中。 + * 这样可以复用项目中已有的图片资源,无需重新设计或转换。 + * + * 为什么需要这个组件: + * - 项目长期以来积累了大量传统图片资源 + * - 这些资源可能包括 9-patch 图片、状态选择器等 + * - 完全迁移到 Compose 的 Image 组件成本较高 + * - 通过 AndroidView 桥接,可以平滑过渡 + * + * @param resId 图片资源 ID(R.drawable.xxx) + * @param modifier 修饰符,用于控制组件的大小、位置等布局属性 + * + * @author MiCode Open Source Community + */ @Composable fun ResourceDrawableBackground( @DrawableRes resId: Int, modifier: Modifier = Modifier ) { + // 使用 AndroidView 将传统 View 嵌入 Compose AndroidView( modifier = modifier, factory = { context -> + // 创建 ImageView 实例 ImageView(context).apply { + // 设置缩放类型为 FIT_XY + // FIT_XY 会将图片独立缩放以匹配目标尺寸,可能改变宽高比 + // 这确保背景图片完全填满给定区域,适合作为背景使用 scaleType = ImageView.ScaleType.FIT_XY + // 设置要显示的图片资源 setImageResource(resId) } }, update = { view -> + // 当 resId 发生变化时,更新 ImageView 显示的图片 + // 这确保在重组时如果传入不同的资源 ID,背景会相应更新 view.setImageResource(resId) } ) -} +} \ No newline at end of file