feat(ui): 重新设计开放源代码许可页面,集成 M3 Expressive 设计与弹性动画

This commit is contained in:
xfy 2026-07-24 13:30:55 +08:00
parent 4f6dc2f36a
commit 7bf16dfe5a
3 changed files with 525 additions and 30 deletions

View File

@ -8,22 +8,91 @@ package plus.rua.project.ui
*/
data class LicenseItem(
val library: String,
val license: String
val license: String,
val description: String,
val category: String,
val url: String? = null
)
/**
* 项目使用的第三方库及其许可证列表
*/
val licenses = listOf(
LicenseItem("AndroidX Activity Compose", "Apache-2.0"),
LicenseItem("AndroidX CameraX", "Apache-2.0"),
LicenseItem("AndroidX Lifecycle", "Apache-2.0"),
LicenseItem("AndroidX Media3 (ExoPlayer)", "Apache-2.0"),
LicenseItem("AndroidX Room", "Apache-2.0"),
LicenseItem("Compose Material 3", "Apache-2.0"),
LicenseItem("Kotlin", "Apache-2.0"),
LicenseItem("kotlinx-datetime", "Apache-2.0"),
LicenseItem("Sketch", "Apache-2.0"),
LicenseItem("tyme4kt", "MIT"),
LicenseItem("ZoomImage", "Apache-2.0"),
LicenseItem(
library = "tyme4kt",
license = "MIT",
description = "中国传统历法推演引擎,支持公历、农历、干支、节气及法定节假日推算。",
category = "农历与节气",
url = "https://github.com/6tail/tyme4kt"
),
LicenseItem(
library = "kotlinx-datetime",
license = "Apache-2.0",
description = "Kotlin 官方 DateTime 日期时间计算库,取代传统 Java Calendar。",
category = "日期时间",
url = "https://github.com/Kotlin/kotlinx-datetime"
),
LicenseItem(
library = "Sketch",
license = "Apache-2.0",
description = "高效强大的图片加载与解码引擎,支持 WebP 动图扫描与帧动画播放。",
category = "图片处理",
url = "https://github.com/panpf/sketch"
),
LicenseItem(
library = "ZoomImage",
license = "Apache-2.0",
description = "日期记录器照片手势缩放、平移与双击查看手势缩放组件。",
category = "图片处理",
url = "https://github.com/panpf/zoomimage"
),
LicenseItem(
library = "AndroidX Room",
license = "Apache-2.0",
description = "日期记录器 (DateRecorder) 本地 SQLite 结构化数据持久化数据库。",
category = "数据存储",
url = "https://developer.android.com/training/data-storage/room"
),
LicenseItem(
library = "AndroidX CameraX",
license = "Apache-2.0",
description = "日期记录器内置相机拍照、镜头切换与图片捕捉与预览能力。",
category = "媒体与相机",
url = "https://developer.android.com/training/camerax"
),
LicenseItem(
library = "AndroidX Media3 (ExoPlayer)",
license = "Apache-2.0",
description = "小狗乐园彩蛋页面全屏视频循环播放的高性能媒体引擎。",
category = "媒体与相机",
url = "https://developer.android.com/media/media3"
),
LicenseItem(
library = "Compose Material 3",
license = "Apache-2.0",
description = "Material Design 3 现代化 UI 交互组件、动态配色与主题系统。",
category = "UI 框架",
url = "https://developer.android.com/jetpack/compose/design/material3"
),
LicenseItem(
library = "AndroidX Activity Compose",
license = "Apache-2.0",
description = "Jetpack Compose 与 Android Activity 生命周期及 Intent 桥接支持。",
category = "UI 框架",
url = "https://developer.android.com/jetpack/androidx/releases/activity"
),
LicenseItem(
library = "AndroidX Lifecycle",
license = "Apache-2.0",
description = "ViewModel 与 UI State 状态绑定及生命周期感知组合能力。",
category = "架构组件",
url = "https://developer.android.com/jetpack/androidx/releases/lifecycle"
),
LicenseItem(
library = "Kotlin",
license = "Apache-2.0",
description = "Kotlin 编程语言标准库与 Flow / Coroutine 异步协程框架。",
category = "核心语言",
url = "https://kotlinlang.org"
)
)

View File

@ -1,27 +1,74 @@
package plus.rua.project.ui
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* 开放源代码许可页面展示项目使用的第三方库及其许可证
*
* 包含 header 汇总面板分类与许可类型筛选 Chip搜索栏以及支持弹性展开与弹性动画的列表
*
* @param onBack 返回回调
* @param modifier 布局修饰符
*/
@ -31,6 +78,27 @@ fun LicensesScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var searchQuery by remember { mutableStateOf("") }
var selectedFilter by remember { mutableStateOf("全部") }
val filterOptions = listOf("全部", "Apache-2.0", "MIT")
val filteredLicenses = remember(searchQuery, selectedFilter) {
licenses.filter { item ->
val matchesFilter = when (selectedFilter) {
"全部" -> true
else -> item.license.equals(selectedFilter, ignoreCase = true)
}
val matchesSearch = searchQuery.isBlank() ||
item.library.contains(searchQuery, ignoreCase = true) ||
item.description.contains(searchQuery, ignoreCase = true) ||
item.category.contains(searchQuery, ignoreCase = true)
matchesFilter && matchesSearch
}
}
Scaffold(
topBar = {
TopAppBar(
@ -50,33 +118,386 @@ fun LicensesScreen(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(licenses) { item ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
item(key = "header_banner") {
LicenseHeroBanner(
totalCount = licenses.size,
apacheCount = licenses.count { it.license == "Apache-2.0" },
mitCount = licenses.count { it.license == "MIT" },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
item(key = "search_and_filters") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ListItem(
headlineContent = {
Text(
text = item.library,
style = MaterialTheme.typography.bodyLarge
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("搜索开源库名称或用途...") },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "搜索"
)
},
trailingContent = {
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "清除搜索"
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filterOptions) { filter ->
val isSelected = selectedFilter == filter
FilterChip(
selected = isSelected,
onClick = { selectedFilter = filter },
label = { Text(filter) },
leadingIcon = if (isSelected) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
}
} else null
)
}
}
}
}
if (filteredLicenses.isEmpty()) {
item(key = "empty_state") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = item.license,
style = MaterialTheme.typography.bodyMedium,
text = "未找到匹配的开源许可条目",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
items(
items = filteredLicenses,
key = { it.library }
) { item ->
LicenseItemCard(
item = item,
onOpenUrl = { url ->
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
} catch (_: Exception) {
Toast.makeText(context, "无法打开网页", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier
.animateItem(
placementSpec = spring(
stiffness = Spring.StiffnessLow,
dampingRatio = Spring.DampingRatioMediumBouncy
)
)
.padding(horizontal = 16.dp)
)
}
}
item(key = "bottom_space") {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun LicenseHeroBanner(
totalCount: Int,
apacheCount: Int,
mitCount: Int,
modifier: Modifier = Modifier
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
modifier = modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Outlined.Code,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "开源致谢",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Text(
text = "鸭鸭日历的诞生离不开以下优秀的开源组件与社区贡献者的无私奉献。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatBadge(text = "$totalCount 个开源库")
StatBadge(text = "Apache-2.0 ($apacheCount)")
StatBadge(text = "MIT ($mitCount)")
}
}
}
}
@Composable
private fun StatBadge(text: String) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
@Composable
private fun LicenseItemCard(
item: LicenseItem,
onOpenUrl: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "expand_arrow_rotation"
)
val containerColor by animateColorAsState(
targetValue = if (expanded) {
MaterialTheme.colorScheme.surfaceContainerHigh
} else {
MaterialTheme.colorScheme.surfaceContainerLow
},
animationSpec = tween(durationMillis = 200),
label = "card_container_color"
)
Card(
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(16.dp),
modifier = modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
) {
Column(
modifier = Modifier
.padding(16.dp)
.animateContentSize(
animationSpec = spring(
stiffness = Spring.StiffnessLow,
dampingRatio = Spring.DampingRatioMediumBouncy
)
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
CategoryBadge(category = item.category)
Text(
text = item.library,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
text = item.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (expanded) Int.MAX_VALUE else 1
)
}
Spacer(modifier = Modifier.width(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
LicenseBadge(license = item.license)
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = if (expanded) "折叠" else "展开",
modifier = Modifier.rotate(rotationAngle),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
) {
Column(
modifier = Modifier.padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "许可证声明 (${item.license})",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = when (item.license) {
"MIT" -> "授权人免费授予获得本软件和相关文档文件副本的任何人不受限制地处置本软件的权利,包括使用、复制、修改、合并、出版、发行、再许可和/或出售副本。"
else -> "根据 Apache License 2.0 许可授权。在遵循许可规定的前提下,允许自由使用、修改和分发代码,并保留版权与许可声明。"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (item.url != null) {
OutlinedButton(
onClick = { onOpenUrl(item.url) },
modifier = Modifier.align(Alignment.End),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Outlined.OpenInNew,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "访问开源项目")
}
}
}
}
}
}
}
@Composable
private fun CategoryBadge(category: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp)
) {
Text(
text = category,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
@Composable
private fun LicenseBadge(license: String) {
val containerColor = when (license) {
"MIT" -> MaterialTheme.colorScheme.tertiaryContainer
"Apache-2.0" -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when (license) {
"MIT" -> MaterialTheme.colorScheme.onTertiaryContainer
"Apache-2.0" -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = containerColor,
contentColor = contentColor,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = license,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}

View File

@ -9,11 +9,16 @@ import kotlin.test.assertTrue
class LicensesTest {
@Test
fun `licenses list is non empty and contains valid library names and licenses`() {
fun `licenses list is non empty and contains valid fields`() {
assertTrue(licenses.isNotEmpty(), "许可列表不应为空")
licenses.forEach { item ->
assertTrue(item.library.isNotBlank(), "库名称不应为空")
assertTrue(item.license.isNotBlank(), "许可证名称不应为空")
assertTrue(item.description.isNotBlank(), "库描述不应为空")
assertTrue(item.category.isNotBlank(), "库分类不应为空")
if (item.url != null) {
assertTrue(item.url.startsWith("http://") || item.url.startsWith("https://"), "URL 应为有效 HTTP(S) 地址")
}
}
}