Compare commits

..

No commits in common. "e07ccd8b4ebe05e1a6dbfbadd8e7f9e6bdc5b3df" and "bd4dad47945b1adc79919231fb833bcf6e4b2abb" have entirely different histories.

15 changed files with 9 additions and 434 deletions

3
.gitignore vendored
View File

@ -22,6 +22,5 @@ node_modules/
.omc/
logs/
.claude/
docs/superpowers/*
!docs/superpowers/specs/
docs/superpowers/
.worktrees/

View File

@ -33,10 +33,6 @@
android:name=".AboutActivity"
android:exported="false" />
<activity
android:name=".DogParkActivity"
android:exported="false" />
<activity
android:name=".LicensesActivity"
android:exported="false" />

View File

@ -13,14 +13,11 @@ class AboutActivity : BaseActivity() {
setContent {
YaYaTheme {
AboutScreen(
onBack = { finishWithSlideBack() },
onNavigateToLicenses = {
startActivityWithSlide(Intent(this, LicensesActivity::class.java))
},
onNavigateToDogPark = {
startActivityWithFade(Intent(this, DogParkActivity::class.java))
}
)
onBack = { finishWithSlideBack() },
onNavigateToLicenses = {
startActivityWithSlide(Intent(this, LicensesActivity::class.java))
}
)
}
}
}

View File

@ -57,19 +57,4 @@ abstract class BaseActivity : ComponentActivity() {
overridePendingTransition(enterAnim, exitAnim)
}
}
/**
* fade 进入动画的 startActivity
*/
protected fun startActivityWithFade(
intent: android.content.Intent,
@AnimRes enterAnim: Int = R.anim.fade_in,
@AnimRes exitAnim: Int = R.anim.fade_out
) {
startActivity(intent)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@Suppress("DEPRECATION") // API 34+ 使用 overrideActivityTransition低版本仍用 overridePendingTransition 实现 fade 转场
overridePendingTransition(enterAnim, exitAnim)
}
}
}

View File

@ -1,36 +0,0 @@
package plus.rua.project
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import plus.rua.project.ui.DogParkScreen
import plus.rua.project.ui.theme.YaYaTheme
private const val TAG = "DogParkActivity"
class DogParkActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
overrideActivityTransition(
OVERRIDE_TRANSITION_OPEN,
R.anim.fade_in,
R.anim.fade_out
)
}
setContent {
YaYaTheme {
DogParkScreen(
onPlaybackError = {
Log.e(TAG, "Playback failed, finishing easter egg")
finishWithSlideBack()
}
)
}
}
}
}

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="400"
android:fromAlpha="0.0"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:toAlpha="1.0" />

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:fromAlpha="1.0"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:toAlpha="0.0" />

View File

@ -86,8 +86,6 @@ dependencies {
implementation(libs.sketch.compose)
implementation(libs.sketch.animated.webp)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
testImplementation(libs.kotlinx.coroutines.test)

View File

@ -1,5 +1,5 @@
<!-- Parent: ../../../../../AGENTS.md -->
<!-- Generated: 2026-05-20 | Updated: 2026-06-16 -->
<!-- Generated: 2026-05-20 | Updated: 2026-05-20 -->
# ui
@ -20,10 +20,9 @@
| `DayCell.kt` | 单个日期单元格:支持选中/今天/班次状态样式 |
| `BottomCard.kt` | 底部卡片:拖拽手柄,驱动折叠/展开手势,显示农历、节气、节假日和班次状态 |
| `AboutScreen.kt` | 关于页面:应用信息、开源许可入口 |
| `DogParkScreen.kt` | 小狗乐园彩蛋页面:全屏循环播放视频 |
| `LicensesScreen.kt` | 许可证列表页面 |
| `Licenses.kt` | 许可证数据(依赖项名称 + 许可证文本) |
| `AnimatedWebp.kt` | GIF 动画显示组件(基于 sketch |
| `AnimatedGif.kt` | GIF 动画显示组件(基于 sketch |
| `CalendarUtils.kt` | 日历工具pager 常量、页码↔日期转换算法 |
## Subdirectories

View File

@ -1,6 +1,5 @@
package plus.rua.project.ui
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -22,47 +21,24 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.github.panpf.sketch.AsyncImage
import kotlinx.coroutines.delay
import plus.rua.project.AppInfo
import plus.rua.project.getAppIconUri
import plus.rua.project.getAppVersion
private const val DOG_PARK_CLICK_THRESHOLD = 7
private const val EASTER_EGG_TIMEOUT_MS = 1500L
/**
* 根据当前连续点击次数返回应显示的 Toast 文案
*
* @param clickCount 当前连续点击次数 1 开始
* @return 需要显示的文案若无需提示则返回 null
*/
internal fun getToastMessage(clickCount: Int): String? = when (clickCount) {
4 -> "再点击 3 下进入小狗乐园"
5 -> "再点击 2 下进入小狗乐园"
6 -> "再点击 1 下进入小狗乐园"
else -> null
}
/**
* 关于页面展示应用图标名称版本号及开源许可入口
*
* @param onBack 返回回调
* @param onNavigateToLicenses 跳转到开源许可页面回调
* @param onNavigateToDogPark 跳转到小狗乐园彩蛋页面回调
* @param modifier 布局修饰符
*/
@OptIn(ExperimentalMaterial3Api::class)
@ -70,18 +46,8 @@ internal fun getToastMessage(clickCount: Int): String? = when (clickCount) {
fun AboutScreen(
onBack: () -> Unit,
onNavigateToLicenses: () -> Unit,
onNavigateToDogPark: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var clickCount by remember { mutableIntStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount == 0) return@LaunchedEffect
delay(EASTER_EGG_TIMEOUT_MS)
clickCount = 0
}
Box(
modifier = modifier.fillMaxSize()
) {
@ -139,19 +105,7 @@ fun AboutScreen(
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
clickCount++
if (clickCount >= DOG_PARK_CLICK_THRESHOLD) {
clickCount = 0
onNavigateToDogPark()
} else {
getToastMessage(clickCount)?.let { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
}
) {
TextButton(onClick = { /* TODO */ }) {
Text(
text = "版本:${getAppVersion()}",
style = MaterialTheme.typography.bodyMedium,

View File

@ -1,92 +0,0 @@
package plus.rua.project.ui
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
/**
* 小狗乐园彩蛋页面全屏循环播放彩蛋视频
*
* 特性
* - 视频源`asset:///video/enter_screen_bg1.mp4`
* - 等比裁剪铺满全屏`RESIZE_MODE_ZOOM`
* - 静音无播放控件循环播放
* - 跟随生命周期自动 play / pause离开页面时自动释放
*
* @param onPlaybackError 播放出错时的回调通常由调用方 finish Activity
* @param modifier 布局修饰符
*/
@Composable
fun DogParkScreen(
onPlaybackError: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val player = remember(context) {
ExoPlayer.Builder(context).build().apply {
volume = 0f
repeatMode = Player.REPEAT_MODE_ONE
setMediaItem(MediaItem.fromUri("asset:///video/enter_screen_bg1.mp4"))
addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
onPlaybackError()
}
})
prepare()
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
play()
}
}
}
DisposableEffect(player, lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> player.play()
Lifecycle.Event.ON_PAUSE -> player.pause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
player.release()
}
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
},
update = { playerView ->
if (playerView.player != player) {
playerView.player = player
}
},
modifier = modifier.fillMaxSize()
)
}

View File

@ -1,35 +0,0 @@
package plus.rua.project.ui
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AboutEasterEggTest {
@Test
fun `first three clicks show no toast`() {
assertNull(getToastMessage(1))
assertNull(getToastMessage(2))
assertNull(getToastMessage(3))
}
@Test
fun `fourth click shows remaining three`() {
assertEquals("再点击 3 下进入小狗乐园", getToastMessage(4))
}
@Test
fun `fifth click shows remaining two`() {
assertEquals("再点击 2 下进入小狗乐园", getToastMessage(5))
}
@Test
fun `sixth click shows remaining one`() {
assertEquals("再点击 1 下进入小狗乐园", getToastMessage(6))
}
@Test
fun `seventh click triggers navigation and shows no toast`() {
assertNull(getToastMessage(7))
}
}

View File

@ -1,175 +0,0 @@
# 关于页面「小狗乐园」彩蛋设计
## 背景
在「关于鸭鸭日历」页面中,版本号目前是一个无实际功能的 `TextButton`。本设计为其增加一个隐藏彩蛋:连续点击版本号 7 次后进入「小狗乐园」页面,全屏循环播放一段彩蛋视频。
## 目标
- 提升应用趣味性,给用户一个可发现的隐藏彩蛋。
- 保持现有架构:所有 UI 与业务逻辑留在 `:core``:app` 仅作为 Activity 壳。
- 不引入过度复杂的依赖或状态管理。
## 设计决策摘要
| 决策项 | 选择 | 说明 |
|--------|------|------|
| 总点击次数 | 7 次 | 足够隐藏,又不会太难触发 |
| 提示开始时机 | 第 4 次点击 | 前 3 次静默,避免普通用户误触时被打扰 |
| 提示文案 | 「再点击 N 下进入小狗乐园」 | N 为剩余次数,简洁明确 |
| 超时重置 | 1.5 秒 | 与 Android 开发者选项等经典彩蛋保持一致节奏 |
| 进度持久化 | 不持久化 | 离开页面或超时即重置 |
| 提示组件 | 系统 Toast | 最符合「小气泡」语义,轻量 |
| 视频播放 | Media3 ExoPlayer | 功能强、与 Compose 集成成熟 |
| 视频位置 | `core/src/main/assets/video/enter_screen_bg1.mp4` | 与现有 `app_icon.webp` 等资源保持一致 |
| 视频显示 | 等比裁剪铺满(`RESIZE_MODE_ZOOM` | 填满屏幕,视觉沉浸 |
| 声音 | 静音 | 不打扰用户 |
| 屏幕方向 | 跟随系统 | 不强制横竖屏 |
| 退出方式 | 系统返回键 | 支持预测性返回手势Activity 自然 finish |
| 进入过渡动画 | 淡入 400ms | 营造进入彩蛋的仪式感 |
| 退出过渡动画 | 默认 slide | 保持现有返回风格 |
## 触发机制(关于页面)
### 状态
- 在 `AboutScreen` 内使用 `remember { mutableIntStateOf(0) }` 保存当前连续点击次数。
- 计数为局部状态,不提升到 ViewModel也不持久化。
- `AboutScreen` 离开 Composition 时计数自然消失。
### 点击行为
| 当前点击次数 | 行为 |
|--------------|------|
| 1 ~ 3 | 计数 +1无 Toast |
| 4 | Toast「再点击 3 下进入小狗乐园」 |
| 5 | Toast「再点击 2 下进入小狗乐园」 |
| 6 | Toast「再点击 1 下进入小狗乐园」 |
| 7 | 调用 `onNavigateToDogPark()`,进入彩蛋页面 |
### 超时重置
每次点击启动/重启一个 `LaunchedEffect`
- 在 1.5 秒内收到下一次点击:取消旧 Job计数 +1。
- 1.5 秒内无新点击Job 执行,计数重置为 0。
## 导航链路
```
MainActivity
└── startActivityWithSlide → AboutActivity
└── onNavigateToDogPark → startActivityWithSlide → DogParkActivity
```
- `AboutActivity``AboutScreen` 新增回调 `onNavigateToDogPark: () -> Unit`
- `DogParkActivity` 继承 `BaseActivity`,自动获得 edge-to-edge 和 slide 转场支持。
## 小狗乐园页面
### 组件
- `DogParkScreen`:位于 `:core`,无业务参数,只负责全屏视频播放。
- `DogParkActivity`:位于 `:app`,继承 `BaseActivity`,壳逻辑。
### 视频播放
- 使用 Media3 ExoPlayer + `PlayerView`
- 通过 Compose `AndroidView` 嵌入 `PlayerView`
- 配置:
- `resizeMode = RESIZE_MODE_ZOOM`:等比裁剪铺满全屏。
- `useController = false`:不显示播放控件。
- `player.volume = 0f`:静音。
- `repeatMode = Player.REPEAT_MODE_ONE`:循环播放。
- 媒体源 URI`asset:///video/enter_screen_bg1.mp4`
### 生命周期
- `onStart``player.play()`
- `onStop``player.pause()`
- `onDestroy``player.release()`
- 使用 `DisposableEffect` 绑定释放逻辑。
### 退出
- 用户按系统返回键或执行返回手势时 Activity finish。
- 预测性返回手势由 Manifest 中的 `android:enableOnBackInvokedCallback="true"` 支持。
- 退出动画保留 `BaseActivity` 默认 slide。
## 过渡动画
### 进入动画(淡入)
- 新增 `app/src/main/res/anim/fade_in.xml`
- `DogParkActivity.onCreate` 中:
- Android 14+`overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.fade_in, R.anim.fade_out)`
- 低版本:`overridePendingTransition(R.anim.fade_in, R.anim.fade_out)`(在 `super.onCreate` 之后、`setContent` 之前调用)
- 淡入时长约 400ms。
### 退出动画
- 保留 `BaseActivity` 默认的 slide 返回动画,不覆盖。
## 资源
- 视频文件:
- 来源:`~/Pictures/enter_screen_bg1.mp4`
- 目标:`core/src/main/assets/video/enter_screen_bg1.mp4`
- 动画资源:
- 新增 `app/src/main/res/anim/fade_in.xml`
## 依赖变更
### `gradle/libs.versions.toml`
新增:
```toml
[versions]
androidx-media3 = "1.6.1"
[libraries]
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
```
### `core/build.gradle.kts`
新增:
```kotlin
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
```
## 错误处理
- 视频加载/准备失败时,直接 `finishWithSlideBack()` 静默返回关于页面。
- 不显示弹窗或 Snackbar避免破坏彩蛋体验。
## 测试计划
### 单元测试
- 抽离纯函数 `getToastMessage(clickCount: Int): String?` 并测试:
- 1 ~ 3 返回 `null`
- 4 返回「再点击 3 下进入小狗乐园」
- 5 返回「再点击 2 下进入小狗乐园」
- 6 返回「再点击 1 下进入小狗乐园」
- 7 返回 `null`(此时已跳转)
### 手动测试
- 连续点击版本号 7 次,确认进入 `DogParkActivity`
- 点击过程中停顿 1.5 秒,确认计数重置。
- 确认进入动画为淡入。
- 确认视频全屏、静音、无控件、循环播放。
- 确认系统返回键正常退出,并回到关于页面。
- 确认低版本(< Android 14 Android 14+ 的淡入动画都生效
## 未包含在本期
- 多次进入彩蛋后的不同内容。
- 视频下载/动态更新。
- 屏幕常亮保持。
- 分享彩蛋入口。

View File

@ -6,7 +6,6 @@ android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-media3 = "1.6.1"
androidx-testExt = "1.3.0"
androidx-uiautomator = "2.3.0"
benchmarkMacro = "1.4.1"
@ -25,8 +24,6 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" }
androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }