Compare commits
10 Commits
bd4dad4794
...
e07ccd8b4e
| Author | SHA1 | Date | |
|---|---|---|---|
| e07ccd8b4e | |||
| 3fcdeab702 | |||
| ac48a56465 | |||
| 6532363cdb | |||
| 9d2f67b9fb | |||
| 6830c1e510 | |||
| 1e43638fbc | |||
| 3ab50ec900 | |||
| 1caea057c5 | |||
| 5714802c89 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,5 +22,6 @@ node_modules/
|
|||||||
.omc/
|
.omc/
|
||||||
logs/
|
logs/
|
||||||
.claude/
|
.claude/
|
||||||
docs/superpowers/
|
docs/superpowers/*
|
||||||
|
!docs/superpowers/specs/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|||||||
@ -33,6 +33,10 @@
|
|||||||
android:name=".AboutActivity"
|
android:name=".AboutActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".DogParkActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LicensesActivity"
|
android:name=".LicensesActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@ -13,11 +13,14 @@ class AboutActivity : BaseActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
YaYaTheme {
|
YaYaTheme {
|
||||||
AboutScreen(
|
AboutScreen(
|
||||||
onBack = { finishWithSlideBack() },
|
onBack = { finishWithSlideBack() },
|
||||||
onNavigateToLicenses = {
|
onNavigateToLicenses = {
|
||||||
startActivityWithSlide(Intent(this, LicensesActivity::class.java))
|
startActivityWithSlide(Intent(this, LicensesActivity::class.java))
|
||||||
}
|
},
|
||||||
)
|
onNavigateToDogPark = {
|
||||||
|
startActivityWithFade(Intent(this, DogParkActivity::class.java))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,4 +57,19 @@ abstract class BaseActivity : ComponentActivity() {
|
|||||||
overridePendingTransition(enterAnim, exitAnim)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/src/main/kotlin/plus/rua/project/DogParkActivity.kt
Normal file
36
app/src/main/kotlin/plus/rua/project/DogParkActivity.kt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/res/anim/fade_in.xml
Normal file
6
app/src/main/res/anim/fade_in.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?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" />
|
||||||
6
app/src/main/res/anim/fade_out.xml
Normal file
6
app/src/main/res/anim/fade_out.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?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" />
|
||||||
@ -86,6 +86,8 @@ dependencies {
|
|||||||
implementation(libs.sketch.compose)
|
implementation(libs.sketch.compose)
|
||||||
implementation(libs.sketch.animated.webp)
|
implementation(libs.sketch.animated.webp)
|
||||||
implementation(libs.androidx.profileinstaller)
|
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("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|||||||
BIN
core/src/main/assets/video/enter_screen_bg1.mp4
Normal file
BIN
core/src/main/assets/video/enter_screen_bg1.mp4
Normal file
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
<!-- Parent: ../../../../../AGENTS.md -->
|
<!-- Parent: ../../../../../AGENTS.md -->
|
||||||
<!-- Generated: 2026-05-20 | Updated: 2026-05-20 -->
|
<!-- Generated: 2026-05-20 | Updated: 2026-06-16 -->
|
||||||
|
|
||||||
# ui
|
# ui
|
||||||
|
|
||||||
@ -20,9 +20,10 @@
|
|||||||
| `DayCell.kt` | 单个日期单元格:支持选中/今天/班次状态样式 |
|
| `DayCell.kt` | 单个日期单元格:支持选中/今天/班次状态样式 |
|
||||||
| `BottomCard.kt` | 底部卡片:拖拽手柄,驱动折叠/展开手势,显示农历、节气、节假日和班次状态 |
|
| `BottomCard.kt` | 底部卡片:拖拽手柄,驱动折叠/展开手势,显示农历、节气、节假日和班次状态 |
|
||||||
| `AboutScreen.kt` | 关于页面:应用信息、开源许可入口 |
|
| `AboutScreen.kt` | 关于页面:应用信息、开源许可入口 |
|
||||||
|
| `DogParkScreen.kt` | 小狗乐园彩蛋页面:全屏循环播放视频 |
|
||||||
| `LicensesScreen.kt` | 许可证列表页面 |
|
| `LicensesScreen.kt` | 许可证列表页面 |
|
||||||
| `Licenses.kt` | 许可证数据(依赖项名称 + 许可证文本) |
|
| `Licenses.kt` | 许可证数据(依赖项名称 + 许可证文本) |
|
||||||
| `AnimatedGif.kt` | GIF 动画显示组件(基于 sketch) |
|
| `AnimatedWebp.kt` | GIF 动画显示组件(基于 sketch) |
|
||||||
| `CalendarUtils.kt` | 日历工具:pager 常量、页码↔日期转换算法 |
|
| `CalendarUtils.kt` | 日历工具:pager 常量、页码↔日期转换算法 |
|
||||||
|
|
||||||
## Subdirectories
|
## Subdirectories
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package plus.rua.project.ui
|
package plus.rua.project.ui
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -21,24 +22,47 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.github.panpf.sketch.AsyncImage
|
import com.github.panpf.sketch.AsyncImage
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import plus.rua.project.AppInfo
|
import plus.rua.project.AppInfo
|
||||||
import plus.rua.project.getAppIconUri
|
import plus.rua.project.getAppIconUri
|
||||||
import plus.rua.project.getAppVersion
|
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 onBack 返回回调
|
||||||
* @param onNavigateToLicenses 跳转到开源许可页面回调
|
* @param onNavigateToLicenses 跳转到开源许可页面回调
|
||||||
|
* @param onNavigateToDogPark 跳转到小狗乐园彩蛋页面回调
|
||||||
* @param modifier 布局修饰符
|
* @param modifier 布局修饰符
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -46,8 +70,18 @@ import plus.rua.project.getAppVersion
|
|||||||
fun AboutScreen(
|
fun AboutScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToLicenses: () -> Unit,
|
onNavigateToLicenses: () -> Unit,
|
||||||
|
onNavigateToDogPark: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
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(
|
Box(
|
||||||
modifier = modifier.fillMaxSize()
|
modifier = modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
@ -105,7 +139,19 @@ fun AboutScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
TextButton(onClick = { /* TODO */ }) {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "版本:${getAppVersion()}",
|
text = "版本:${getAppVersion()}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
92
core/src/main/kotlin/plus/rua/project/ui/DogParkScreen.kt
Normal file
92
core/src/main/kotlin/plus/rua/project/ui/DogParkScreen.kt
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
175
docs/superpowers/specs/2026-06-16-dog-park-easter-egg-design.md
Normal file
175
docs/superpowers/specs/2026-06-16-dog-park-easter-egg-design.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# 关于页面「小狗乐园」彩蛋设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
在「关于鸭鸭日历」页面中,版本号目前是一个无实际功能的 `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+ 的淡入动画都生效。
|
||||||
|
|
||||||
|
## 未包含在本期
|
||||||
|
|
||||||
|
- 多次进入彩蛋后的不同内容。
|
||||||
|
- 视频下载/动态更新。
|
||||||
|
- 屏幕常亮保持。
|
||||||
|
- 分享彩蛋入口。
|
||||||
@ -6,6 +6,7 @@ android-targetSdk = "37"
|
|||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
|
androidx-media3 = "1.6.1"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
androidx-uiautomator = "2.3.0"
|
androidx-uiautomator = "2.3.0"
|
||||||
benchmarkMacro = "1.4.1"
|
benchmarkMacro = "1.4.1"
|
||||||
@ -24,6 +25,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
|
|||||||
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
|
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-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-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-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" }
|
||||||
androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
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" }
|
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user