diff --git a/core/src/main/kotlin/plus/rua/project/ui/Licenses.kt b/core/src/main/kotlin/plus/rua/project/ui/Licenses.kt index da09f04..5610947 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/Licenses.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/Licenses.kt @@ -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" + ) ) diff --git a/core/src/main/kotlin/plus/rua/project/ui/LicensesScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/LicensesScreen.kt index da33bcb..f916db7 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/LicensesScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/LicensesScreen.kt @@ -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) + ) + } +} diff --git a/core/src/test/kotlin/plus/rua/project/ui/LicensesTest.kt b/core/src/test/kotlin/plus/rua/project/ui/LicensesTest.kt index 61a6314..350d331 100644 --- a/core/src/test/kotlin/plus/rua/project/ui/LicensesTest.kt +++ b/core/src/test/kotlin/plus/rua/project/ui/LicensesTest.kt @@ -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) 地址") + } } }