Compare commits

..

No commits in common. "8f24706d8c9588bd044af63ee9cce4b940023938" and "5b5d7805937ba5282f3cd46ae8a9b0e81b2b17b5" have entirely different histories.

17 changed files with 280 additions and 517 deletions

1
.gitignore vendored
View File

@ -22,4 +22,3 @@ node_modules/
.omc/
logs/
.claude/
docs/superpowers/

View File

@ -5,103 +5,6 @@ All notable changes to the YaYa project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-06-02
### Added
#### Date Checker Tool
- New "Date Checker" tool page accessible from FAB → Tools menu for tracking item expiration dates
- Swipe-to-delete with animated removal and staggered enter/exit animations
- Expired status display with visual indicators
- Auto-scroll and highlight animation for new entries
#### Tools Page
- New "Tools" entry in FAB menu linking to a dedicated tools landing page
- Date Checker as the first tool module
#### Theme & Visual
- `YaYaTheme` introduced and applied to all Activities for unified theming
- Legal holiday badges now display with colored background and continuous edge rounded corners
- Holiday badge wave-scale entrance animation
- Personal shift badges redesigned with light circle background + centered text
- Shift badge circular base to avoid overlapping with selection ring
#### Year ↔ Month View Transition
- BottomCard slide-in animation and fade effect during year/month view transitions
- Month→year view no longer forces collapse state to expand
#### Performance
- LunarCache LRU cache for lunar/solar term calculations with startup pre-computation
- Macrobenchmark module with automated Baseline Profile generation
- Baseline Profile covering date checker, shift settings, tools page, and core calendar scenarios
- `ComposeTrace` cross-platform trace markers for Perfetto/Systrace
- SolarDay static cache to eliminate repeated object creation
- MiniMonth pure Canvas rendering eliminating 96 Text measurement overhead
- `graphicsLayer(translationY)` replacing `offset(Dp)` to avoid layout passes
- Aggregated `CalendarUiState` to reduce Compose recomposition
- `remember` stabilization for lambdas and computations
- Scene-based `profile.sh` with `--all` batch mode for 15 automated trace scenarios
- Perfetto trace analysis script (`analyze-trace.sh`)
- Trace build type for release + retained trace markers
#### Build & Tooling
- Spotless 8.5.1 code formatter with ktlint integration
- `.editorconfig` for ktlint Composable function naming rules
- Dependency update checker and auto-upgrade tool integration
- `app_icon` shrunk to 512×512 and converted to WebP (446KB savings)
- 152 GIF assets batch-converted to animated WebP format
- `uiTooling` moved to `debugImplementation`; unused `@Preview` and `kotlin-test` entries removed
- `sketch` library for GIF/WebP display (`sketch-compose` + `sketch-animated-webp`)
- PowerShell performance tracing script (`profile.ps1`)
#### Documentation
- Comprehensive `AGENTS.md` at every directory level (root, app, core, scripts, etc.)
- Updated `DEVELOPMENT.md` with Perfetto trace analysis and emulator launch commands
- Updated `CLAUDE.md` to reflect pure Android project structure
### Changed
- Project migrated from Kotlin Multiplatform (KMP/CMP) to pure Android (`:app` + `:core`)
- All Compose UI and business logic consolidated into `:core` module; `:app` remains a thin shell
- Removed KMP/CMP plugins, iOS app module, and `:shared` module
- `androidApp` module renamed to `app`
- Collapse animation refactored: removed fling velocity threshold, now spring-driven
- `CalendarPager``WeekPager` switching uses `AnimatedContent` for smooth crossfade
- Year view page year calculation uses `settledPage` to prevent flicker during swipe
- ViewModel decoupled from Compose runtime, migrated to `StateFlow`
- `LunarCache` made injectable with extracted repeated computations
- MenuItem and ToolItem unified to use `Card(onClick)` pattern
- Holiday badge null checks simplified to Elvis operator
- `@Suppress` annotations cleaned up with deprecated API replacements
- Removed unnecessary P0 code (custom combine, dead StateFlow, duplicate grid algorithms, runBlocking)
- Removed debug logging from LicensesScreen and BottomCard
### Fixed
- Lunar first-day month name no longer appends redundant "月" suffix
- Year view stale year display on enter
- Year view page year flicker during swipe transitions
- Collapse animation flicker when switching between CalendarPager and WeekPager
- Folded state cross-month dates not grayed out in week view
- Date checker swipe-to-delete state misalignment and deprecation warning
- Shared element transition animation loss after year view page change
- Night mode theme transparency issues with explicit background colors
- Predictive back gesture failure and end-of-animation flash on certain devices
- Back animation residual transition eliminated with `snapTo`
- Fast swipe collapse/expand failure, now uses progress threshold detection
- `graphicsLayer` optimization reverted due to excessive GPU compositing overhead on real devices
- Reverted shared element transitions in favor of zoom + fade animation
### Removed
- iOS app module (`iosApp/`) and all related Xcode project files
- `:shared` module and `shared/build.gradle.kts`
- Shared element transition animations (replaced by zoom + fade)
- Year/month scroll wheel picker with haptic feedback (reverted)
- Aliyun Maven mirrors (switched to Maven Central / Google)
- Unused Compose runtime ProGuard keep rules
- Temporary performance monitoring logs (trace markers retained)
## [1.0.0] - 2026-05-20
### Added
@ -236,6 +139,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Aliyun Maven mirrors (switched back to Maven Central / Google)
- Unused Compose runtime ProGuard keep rules
## [1.1.0] - 2026-06-02
### Added
#### Date Checker Tool
- New "Date Checker" tool page accessible from FAB → Tools menu for tracking item expiration dates
- Swipe-to-delete with animated removal and staggered enter/exit animations
- Expired status display with visual indicators
- Auto-scroll and highlight animation for new entries
#### Tools Page
- New "Tools" entry in FAB menu linking to a dedicated tools landing page
- Date Checker as the first tool module
#### Theme & Visual
- `YaYaTheme` introduced and applied to all Activities for unified theming
- Legal holiday badges now display with colored background and continuous edge rounded corners
- Holiday badge wave-scale entrance animation
- Personal shift badges redesigned with light circle background + centered text
- Shift badge circular base to avoid overlapping with selection ring
#### Year ↔ Month View Transition
- BottomCard slide-in animation and fade effect during year/month view transitions
- Month→year view no longer forces collapse state to expand
#### Performance
- LunarCache LRU cache for lunar/solar term calculations with startup pre-computation
- Macrobenchmark module with automated Baseline Profile generation
- Baseline Profile covering date checker, shift settings, tools page, and core calendar scenarios
- `ComposeTrace` cross-platform trace markers for Perfetto/Systrace
- SolarDay static cache to eliminate repeated object creation
- MiniMonth pure Canvas rendering eliminating 96 Text measurement overhead
- `graphicsLayer(translationY)` replacing `offset(Dp)` to avoid layout passes
- Aggregated `CalendarUiState` to reduce Compose recomposition
- `remember` stabilization for lambdas and computations
- Scene-based `profile.sh` with `--all` batch mode for 15 automated trace scenarios
- Perfetto trace analysis script (`analyze-trace.sh`)
- Trace build type for release + retained trace markers
#### Build & Tooling
- Spotless 8.5.1 code formatter with ktlint integration
- `.editorconfig` for ktlint Composable function naming rules
- Dependency update checker and auto-upgrade tool integration
- `app_icon` shrunk to 512×512 and converted to WebP (446KB savings)
- 152 GIF assets batch-converted to animated WebP format
- `uiTooling` moved to `debugImplementation`; unused `@Preview` and `kotlin-test` entries removed
- `sketch` library for GIF/WebP display (`sketch-compose` + `sketch-animated-webp`)
- PowerShell performance tracing script (`profile.ps1`)
#### Documentation
- Comprehensive `AGENTS.md` at every directory level (root, app, core, scripts, etc.)
- Updated `DEVELOPMENT.md` with Perfetto trace analysis and emulator launch commands
- Updated `CLAUDE.md` to reflect pure Android project structure
### Changed
- Project migrated from Kotlin Multiplatform (KMP/CMP) to pure Android (`:app` + `:core`)
- All Compose UI and business logic consolidated into `:core` module; `:app` remains a thin shell
- Removed KMP/CMP plugins, iOS app module, and `:shared` module
- `androidApp` module renamed to `app`
- Collapse animation refactored: removed fling velocity threshold, now spring-driven
- `CalendarPager``WeekPager` switching uses `AnimatedContent` for smooth crossfade
- Year view page year calculation uses `settledPage` to prevent flicker during swipe
- ViewModel decoupled from Compose runtime, migrated to `StateFlow`
- `LunarCache` made injectable with extracted repeated computations
- MenuItem and ToolItem unified to use `Card(onClick)` pattern
- Holiday badge null checks simplified to Elvis operator
- `@Suppress` annotations cleaned up with deprecated API replacements
- Removed unnecessary P0 code (custom combine, dead StateFlow, duplicate grid algorithms, runBlocking)
- Removed debug logging from LicensesScreen and BottomCard
### Fixed
- Lunar first-day month name no longer appends redundant "月" suffix
- Year view stale year display on enter
- Year view page year flicker during swipe transitions
- Collapse animation flicker when switching between CalendarPager and WeekPager
- Folded state cross-month dates not grayed out in week view
- Date checker swipe-to-delete state misalignment and deprecation warning
- Shared element transition animation loss after year view page change
- Night mode theme transparency issues with explicit background colors
- Predictive back gesture failure and end-of-animation flash on certain devices
- Back animation residual transition eliminated with `snapTo`
- Fast swipe collapse/expand failure, now uses progress threshold detection
- `graphicsLayer` optimization reverted due to excessive GPU compositing overhead on real devices
- Reverted shared element transitions in favor of zoom + fade animation
### Removed
- iOS app module (`iosApp/`) and all related Xcode project files
- `:shared` module and `shared/build.gradle.kts`
- Shared element transition animations (replaced by zoom + fade)
- Year/month scroll wheel picker with haptic feedback (reverted)
- Aliyun Maven mirrors (switched to Maven Central / Google)
- Unused Compose runtime ProGuard keep rules
- Temporary performance monitoring logs (trace markers retained)
## [Unreleased]
- No unreleased changes at this time.

View File

@ -1,26 +1,23 @@
# YaYa
纯 Android + Jetpack Compose 日历应用,支持农历/节气/节日、个人班次排期,提供月/周/年三种视图。
<div>
<img src="app/src/main/assets/app_icon.png" />
</div>
基于 Kotlin Multiplatform 与 Compose Multiplatform 的跨平台日历应用,Android 与 iOS 共享同一套 UI 与业务逻辑。
## 特性
- **流畅的视图切换** —— 月视图、周视图、年视图三种模式,拖拽手势驱动月↔周折叠,弹簧动画自动吸附
- **无限滑动分页** —— 基于 `Int.MAX_VALUE` 的虚拟分页前后无边界翻页
- **完整中式日历** —— 公历 + 农历 + 二十四节气 + 传统节日ISO 8601 周起始(周一)
- **个人排班周期** —— 自定义工作/休息循环与公共节假日独立
- **Material 3 设计** —— 动态配色深色模式
- **流畅的视图切换** —— 月视图、周视图、年视图三种模式,拖拽手势驱动月↔周折叠,弹簧动画自动吸附
- **无限滑动分页** —— 基于 `Int.MAX_VALUE` 的虚拟分页,前后无边界翻页
- **完整中式日历** —— 公历 + 农历 + 二十四节气 + 传统节日,ISO 8601 周起始(周一)
- **个人排班周期** —— 自定义工作/休息循环,与公共节假日独立
- **Material 3 设计** —— 动态配色,深色模式
## 技术栈
- Kotlin 2.3 · Jetpack Compose · Material 3
- Kotlin 2.3 · Compose Multiplatform 1.11 · Material 3
- `kotlinx-datetime` 处理所有日期逻辑
- `tyme4kt` 提供农历、节气与传统节日
- `sketch` 渲染 GIF 动画
- 双模块:`:core`UI + 逻辑) · `:app`(薄壳)
- 双模块:`:shared`(UI + 逻辑) · `:androidApp`(薄壳)
- iOS 入口为 `MainViewController.kt`,Xcode 工程位于 `iosApp/`
## 构建

View File

@ -1,60 +0,0 @@
# 发布流程
## 1. 更新 CHANGELOG.md
按倒序(新版在前)在 `[Unreleased]` 下方添加新版本条目,格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。
底部添加版本链接:
```
[x.y.z]: https://github.com/DefectingCat/yayacal/releases/tag/vx.y.z
```
## 2. 更新版本号
编辑 `gradle.properties`
- `app.version.base` 改为新版本号(如 `1.2.0`
编辑 `app/build.gradle.kts`
- `versionCode` 递增 `+1`
> `app.version.base` 优先于 `build.gradle.kts` 中的默认值,因此以 `gradle.properties` 为准。
## 3. 构建 Release APK
```bash
./gradlew :app:assembleRelease
```
产物路径:`app/build/outputs/apk/release/app-release.apk`
## 4. 提交、打 Tag、推送
```bash
git add CHANGELOG.md gradle.properties app/build.gradle.kts
git commit -m "release: vx.y.z"
git tag vx.y.z
git push origin main --tags
```
## 5. 创建 GitHub Release
```bash
gh release create vx.y.z \
app/build/outputs/apk/release/app-release.apk \
--title "YaYa vx.y.z" \
--notes-file CHANGELOG.md
```
`--notes-file` 会读取 CHANGELOG.md 全文作为 Release body。
## 检查清单
- [ ] CHANGELOG.md 新版本条目已添加(倒序,新版在前)
- [ ] CHANGELOG.md 底部链接已添加
- [ ] `gradle.properties``app.version.base``app/build.gradle.kts``versionCode` 已更新
- [ ] Release APK 构建成功
- [ ] Git tag 已推送
- [ ] GitHub Release 已创建且包含 APK

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

View File

@ -63,7 +63,6 @@ dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.animation)
implementation(libs.compose.material.icons)
implementation(libs.compose.material3)
implementation(libs.compose.ui)

View File

@ -1,43 +0,0 @@
package plus.rua.project
import android.content.Context
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
class DateCheckerStorage(private val prefs: SharedPreferences) {
companion object {
private const val KEY_PRODUCTION_DATE = "production_date"
private const val KEY_ROWS = "rows"
private const val ROWS_SEPARATOR = ","
fun fromContext(context: Context): DateCheckerStorage {
val prefs = context.getSharedPreferences("date_checker", Context.MODE_PRIVATE)
return DateCheckerStorage(prefs)
}
}
fun save(productionDate: LocalDate, rows: List<Int?>) {
val nonNullRows = rows.filterNotNull()
prefs.edit()
.putString(KEY_PRODUCTION_DATE, productionDate.toString())
.putString(KEY_ROWS, nonNullRows.joinToString(ROWS_SEPARATOR))
.apply()
}
fun load(): Pair<LocalDate, List<Int>>? {
val dateStr = prefs.getString(KEY_PRODUCTION_DATE, null) ?: return null
val rowsStr = prefs.getString(KEY_ROWS, null) ?: return null
val date = LocalDate.parse(dateStr)
val rows = if (rowsStr.isBlank()) {
emptyList()
} else {
rowsStr.split(ROWS_SEPARATOR).map { it.toInt() }
}
return date to rows
}
fun clear() {
prefs.edit().clear().apply()
}
}

View File

@ -1,5 +1,6 @@
package plus.rua.project.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -7,10 +8,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -22,6 +20,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import com.github.panpf.sketch.AsyncImage
import plus.rua.project.AppInfo
@ -48,10 +48,24 @@ fun AboutScreen(
title = { Text("关于鸭鸭日历") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回"
val arrowColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = Modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = arrowColor,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = arrowColor,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
}
)
@ -85,13 +99,11 @@ fun AboutScreen(
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { /* TODO */ }) {
Text(
text = "版本:${getAppVersion()}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(48.dp))

View File

@ -18,6 +18,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@ -37,14 +38,10 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -66,6 +63,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
@ -369,18 +368,8 @@ fun CalendarMonthView(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
AnimatedContent(
targetState = isMenuExpanded,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "fab_icon"
) { expanded ->
Icon(
imageVector = if (expanded) Icons.Filled.Close else Icons.Filled.Menu,
contentDescription = if (expanded) "关闭菜单" else "打开菜单"
)
}
val iconColor = MaterialTheme.colorScheme.onPrimaryContainer
MenuIcon(color = iconColor)
}
// Scrim全透明仅拦截点击关闭菜单无动画
@ -477,6 +466,24 @@ fun CalendarMonthView(
}
}
@Composable
private fun MenuIcon(color: Color, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
val lineSpacing = 4.dp.toPx()
val totalHeight = strokeWidth * 3 + lineSpacing * 2
val startY = (size.height - totalHeight) / 2
repeat(3) { i ->
drawLine(
color = color,
start = Offset(0f, startY + i * (strokeWidth + lineSpacing)),
end = Offset(size.width, startY + i * (strokeWidth + lineSpacing)),
strokeWidth = strokeWidth
)
}
}
}
@Composable
private fun CalendarPagerArea(
selectedDate: LocalDate,

View File

@ -8,7 +8,6 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -28,20 +27,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
@ -55,7 +47,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -67,10 +58,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight
@ -90,7 +78,6 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.todayIn
import plus.rua.project.DateCheckerStorage
private data class ExpiryRow(val id: Int, val days: Int? = null)
@ -133,30 +120,22 @@ private fun ExpiryStatus.containerColor(): Color = when (this) {
@Composable
fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) }
val context = LocalContext.current.applicationContext
val storage = remember { DateCheckerStorage.fromContext(context) }
val saved = remember { storage.load() }
val defaultRows = listOf(30, 60, 180)
var productionDate by remember { mutableStateOf(saved?.first ?: today) }
var productionDate by remember { mutableStateOf(today) }
var rows by remember {
mutableStateOf(
(saved?.second ?: defaultRows).mapIndexed { index, days ->
ExpiryRow(index, days)
}
listOf(
ExpiryRow(0, 30),
ExpiryRow(1, 60),
ExpiryRow(2, 180)
)
)
}
var nextId by remember { mutableIntStateOf(rows.size) }
LaunchedEffect(productionDate, rows) {
storage.save(productionDate, rows.map { it.days })
}
var nextId by remember { mutableIntStateOf(3) }
var pendingDeleteIds by remember { mutableStateOf(setOf<Int>()) }
var showDatePicker by remember { mutableStateOf(false) }
var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) }
var showResetDialog by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
@ -175,10 +154,7 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回"
)
BackArrowIcon()
}
},
colors = TopAppBarDefaults.topAppBarColors(
@ -202,21 +178,16 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "添加",
tint = MaterialTheme.colorScheme.onPrimary
)
PlusIcon(color = MaterialTheme.colorScheme.onPrimary)
}
},
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Column(modifier = Modifier.fillMaxSize()) {
ProductionDateCard(
date = productionDate,
isToday = productionDate == today,
@ -251,35 +222,6 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(12.dp))
if (rows.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Image(
painter = painterResource(id = plus.rua.project.shared.R.drawable.ic_empty_state),
contentDescription = "空状态",
modifier = Modifier.size(120.dp)
)
Text(
text = "暂无保质期记录",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "点击右下角 + 添加",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
@ -296,11 +238,7 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val isBeingDeleted = row.id in pendingDeleteIds
key(row.id) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { newValue ->
newValue == SwipeToDismissBoxValue.EndToStart
}
)
val dismissState = rememberSwipeToDismissBoxState()
androidx.compose.runtime.LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart && !isBeingDeleted) {
@ -391,55 +329,10 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(10.dp))
}
}
Spacer(modifier = Modifier.height(80.dp))
}
}
}
FloatingActionButton(
onClick = { showResetDialog = true },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = 16.dp),
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "重置",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("恢复默认") },
text = { Text("将重置生产日期和保质期列表为默认值") },
confirmButton = {
TextButton(onClick = {
productionDate = today
rows = defaultRows.mapIndexed { index, days ->
ExpiryRow(index, days)
}
nextId = defaultRows.size
showResetDialog = false
}) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("取消")
}
}
)
}
if (showDatePicker) {
val initialMillis = when (val target = datePickerTarget) {
is DatePickerTarget.Production -> productionDate.toEpochMillis()
@ -690,6 +583,52 @@ private fun ExpiryCard(
// region Icons
@Composable
private fun BackArrowIcon(modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurface
androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = color,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
private fun PlusIcon(color: Color, modifier: Modifier = Modifier) {
androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
val cx = size.width / 2
val cy = size.height / 2
val half = size.minDimension * 0.35f
drawLine(
color = color,
start = Offset(cx, cy - half),
end = Offset(cx, cy + half),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(cx - half, cy),
end = Offset(cx + half, cy),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
private fun CalendarIcon(color: Color, modifier: Modifier = Modifier) {
androidx.compose.foundation.Canvas(modifier = modifier.size(24.dp)) {

View File

@ -1,17 +1,16 @@
package plus.rua.project.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
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
@ -20,6 +19,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
/**
@ -40,10 +41,24 @@ fun LicensesScreen(
title = { Text("开放源代码许可") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回"
val arrowColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = Modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = arrowColor,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = arrowColor,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
}
)

View File

@ -1,17 +1,18 @@
package plus.rua.project.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -19,6 +20,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
@ -45,10 +48,24 @@ fun ToolsScreen(
title = { Text("工具") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ChevronLeft,
contentDescription = "返回"
val arrowColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = Modifier.size(24.dp)) {
val strokeWidth = 2.dp.toPx()
drawLine(
color = arrowColor,
start = Offset(size.width * 0.75f, size.height * 0.15f),
end = Offset(size.width * 0.25f, size.height * 0.5f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = arrowColor,
start = Offset(size.width * 0.25f, size.height * 0.5f),
end = Offset(size.width * 0.75f, size.height * 0.85f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,118 +0,0 @@
package plus.rua.project
import android.content.SharedPreferences
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
class DateCheckerStorageTest {
private val prefs = InMemorySharedPreferences()
private val storage = DateCheckerStorage(prefs)
@Test
fun load_noSavedData_returnsDefault() {
storage.clear()
val result = storage.load()
assertEquals(null, result)
}
@Test
fun saveAndLoad_roundTrips_correctly() {
storage.clear()
val date = LocalDate(2026, 5, 15)
val rows = listOf(30, 60, 180, 365)
storage.save(date, rows)
val result = storage.load()
assertEquals(date to rows, result)
}
@Test
fun saveAndLoad_nullDaysNotPersisted() {
storage.clear()
val date = LocalDate(2026, 6, 1)
val rows = listOf(30, null, 180)
storage.save(date, rows)
val result = storage.load()
assertEquals(date to listOf(30, 180), result)
}
@Test
fun saveAndLoad_emptyRows_savesSuccessfully() {
storage.clear()
val date = LocalDate(2026, 1, 1)
storage.save(date, emptyList())
val result = storage.load()
assertEquals(date to emptyList(), result)
}
}
private class InMemorySharedPreferences : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
data[key] as? Set<String> ?: defValues
override fun getInt(key: String, defValue: Int): Int =
data[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
data[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float =
data[key] as? Float ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean =
data[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = InMemoryEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
}
private class InMemoryEditor(private val data: MutableMap<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearPending = false
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
pending[key] = value
}
override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor = apply {
pending[key] = values
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value }
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value }
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value }
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value }
override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = null }
override fun clear(): SharedPreferences.Editor = apply { clearPending = true }
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearPending) {
data.clear()
clearPending = false
}
data.putAll(pending)
pending.clear()
}
}

View File

@ -10,7 +10,7 @@ org.gradle.parallel=true
org.gradle.daemon=true
#App
app.version.base=1.1.0
app.version.base=1.0.0
#Android
android.nonTransitiveRClass=true

View File

@ -31,7 +31,6 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator",
compose-animation = { module = "androidx.compose.animation:animation" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-ui = { module = "androidx.compose.ui:ui" }