优化阅读记录界面,添加按日期查看记录的功能

This commit is contained in:
HapeLee
2025-11-28 00:14:13 +08:00
parent dfbdd8a3e6
commit 101a9fa878
6 changed files with 597 additions and 94 deletions
@@ -26,8 +26,8 @@ interface ReadRecordDao {
) )
val allShow: List<ReadRecordShow> val allShow: List<ReadRecordShow>
@get:Query("select sum(readTime) from readRecord") @Query("select sum(readTime) from readRecord")
val allTime: Long suspend fun getTotalReadTime(): Long
@Query( @Query(
""" """
@@ -138,10 +138,13 @@ interface ReadRecordDao {
SELECT * FROM readRecordSession SELECT * FROM readRecordSession
WHERE deviceId = :deviceId WHERE deviceId = :deviceId
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime DESC ORDER BY startTime ASC
""") """)
suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession> suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
// 清除会话记录 // 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName") @Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
fun deleteSessionByName(bookName: String) fun deleteSessionByName(bookName: String)
@@ -90,14 +90,6 @@ class ReadRecordRepository(
} }
} }
suspend fun getDailyDetails(deviceId: String, date: String): List<ReadRecordDetail> {
return dao.getDetailsByDate(deviceId, date)
}
suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List<ReadRecordSession> {
return dao.getSessionsByBookAndDate(deviceId, bookName, date)
}
suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> { suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
return if (query.isBlank()) { return if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead() dao.getAllReadRecordsSortedByLastRead()
@@ -106,11 +98,6 @@ class ReadRecordRepository(
} }
} }
suspend fun getAllSessionsForDate(date: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, date)
}
suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> { suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
return if (query.isBlank()) { return if (query.isBlank()) {
dao.getAllDetails() dao.getAllDetails()
@@ -119,6 +106,20 @@ class ReadRecordRepository(
} }
} }
suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List<ReadRecordDetail> {
val deviceId = getCurrentDeviceId()
return if (query.isBlank()) {
dao.getDetailsByDate(deviceId, dateString)
} else {
dao.searchDetailsByDate(deviceId, dateString, query)
}
}
suspend fun getAllSessionsByDate(dateString: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, dateString)
}
suspend fun deleteDetail(detail: ReadRecordDetail) { suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail) dao.deleteDetail(detail)
} }
@@ -129,6 +130,8 @@ class ReadRecordRepository(
} }
// 暴露总时长 // 暴露总时长
val allTime: Long suspend fun getTotalReadTime(): Long {
get() = dao.allTime return dao.getTotalReadTime()
}
} }
@@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
private fun initAllTime() { private fun initAllTime() {
lifecycleScope.launch { lifecycleScope.launch {
val allTime = withContext(IO) { val allTime = withContext(IO) {
appDb.readRecordDao.allTime appDb.readRecordDao.getTotalReadTime()
} }
binding.tvReadingTime.text = formatDuring(allTime) binding.tvReadingTime.text = formatDuring(allTime)
} }
@@ -17,13 +17,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.utils.startActivityForBook import io.legado.app.utils.startActivityForBook
@@ -34,20 +30,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
import io.legado.app.base.BaseComposeActivity import io.legado.app.base.BaseComposeActivity
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.ui.widget.components.AnimatedTextLine import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Calendar
import io.legado.app.ui.widget.components.Cover import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.utils.StringUtils.formatFriendlyDate import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
// 包含绘制时间线所需的上下文信息 // 包含绘制时间线所需的上下文信息
data class TimelineItem( data class TimelineItem(
@@ -93,6 +95,7 @@ fun ReadRecordScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState() val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -105,7 +108,7 @@ fun ReadRecordScreen(
LaunchedEffect(searchText) { LaunchedEffect(searchText) {
if (showSearch) { if (showSearch) {
kotlinx.coroutines.delay(100L) delay(100L)
viewModel.loadData(searchText) viewModel.loadData(searchText)
} }
} }
@@ -155,6 +158,9 @@ fun ReadRecordScreen(
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate" val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
Icon(icon, description) Icon(icon, description)
} }
IconButton(onClick = { showCalendar = !showCalendar }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
}
IconButton(onClick = { showSearch = !showSearch }) { IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = null) Icon(Icons.Default.Search, contentDescription = null)
} }
@@ -172,8 +178,6 @@ fun ReadRecordScreen(
} }
) { padding -> ) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) { Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TotalTimeHeader(state.totalReadTime)
val isEmpty = when (displayMode) { val isEmpty = when (displayMode) {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty() DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty() DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
@@ -184,18 +188,76 @@ fun ReadRecordScreen(
animationSpec = tween(durationMillis = 500), animationSpec = tween(durationMillis = 500),
label = "ContentCrossfade" label = "ContentCrossfade"
) { isListEmpty -> ) { isListEmpty ->
if (isListEmpty){
Box( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .nestedScroll(scrollBehavior.nestedScrollConnection)
contentAlignment = Alignment.Center
) { ) {
EmptyMessageView(
message = "没有记录" item {
) val selectedDate = state.selectedDate
}
} else { if (selectedDate != null) {
LazyColumn { val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
}
item {
AnimatedVisibility(visible = showCalendar) {
CalendarSection(
selectedDate = state.selectedDate,
onDateSelected = { date ->
viewModel.setSelectedDate(date)
showCalendar = false // 选择后自动收起日历
},
onClearDate = {
viewModel.setSelectedDate(null)
showCalendar = false // 清除后自动收起
}
)
}
if (isListEmpty){
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
}
when(displayMode){ when(displayMode){
DisplayMode.AGGREGATE -> { DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) -> state.groupedRecords.forEach { (date, details) ->
@@ -226,9 +288,7 @@ fun ReadRecordScreen(
stickyHeader { DateHeader(date, dailyTotalTime) } stickyHeader { DateHeader(date, dailyTotalTime) }
val timelineItems = sessions.mapIndexed { index, session -> val timelineItems = sessions.mapIndexed { index, session ->
val previousSession = sessions.getOrNull(index - 1) val showHeader = true
val showHeader = index == 0 || session.bookName != previousSession?.bookName
TimelineItem(session, showHeader) TimelineItem(session, showHeader)
} }
@@ -253,7 +313,7 @@ fun ReadRecordScreen(
} }
} }
} }
}
} }
} }
} }
@@ -296,7 +356,7 @@ fun LatestReadItem(
color = Color.Gray color = Color.Gray
) )
Text( Text(
text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}", text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -317,8 +377,8 @@ fun TimelineSessionItem(
coverPath = viewModel.getBookCover(session.bookName) coverPath = viewModel.getBookCover(session.bookName)
} }
val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm") val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm") val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
val duration = session.endTime - session.startTime val duration = session.endTime - session.startTime
val nodeRadius = 4.dp val nodeRadius = 4.dp
@@ -378,22 +438,21 @@ fun TimelineSessionItem(
Text( Text(
text = session.bookName, text = session.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1 maxLines = 2
) )
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
} }
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
} }
} }
} }
@@ -472,32 +531,129 @@ fun DateHeader(
} }
@Composable @Composable
fun TotalTimeHeader(time: Long) { fun CalendarSection(
selectedDate: LocalDate?,
onDateSelected: (LocalDate) -> Unit,
onClearDate: () -> Unit
) {
val effectiveInitialDate = selectedDate ?: LocalDate.now()
Calendar(
modifier = Modifier.padding(horizontal = 16.dp),
initialDate = effectiveInitialDate,
selectedDate = selectedDate,
onDateSelected = onDateSelected,
onClearDate = onClearDate
)
}
@Composable
fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
}
}
val totalDurationMinutes = totalTimeMillis / 60000
Card( Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer containerColor = MaterialTheme.colorScheme.surfaceContainer
), )
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier
.padding(12.dp), .fillMaxWidth()
horizontalAlignment = Alignment.Start .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text(
text = "阅读时长",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text( Column(modifier = Modifier.weight(1f)) {
text = formatDuring(time),
style = MaterialTheme.typography.labelLarge, Text(
fontWeight = FontWeight.Bold, text = title,
color = MaterialTheme.colorScheme.onSurface style = MaterialTheme.typography.labelLarge,
) color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = "已读 ",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "$bookCount",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
Text(
text = " 本书",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
val hours = totalDurationMinutes / 60
val minutes = totalDurationMinutes % 60
val timeString = if (hours > 0) "${hours}小时${minutes}分钟" else "${minutes}分钟"
Text(
text = "共阅读 $timeString",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (bookNamesForCover.isNotEmpty()) {
BookStackView(coverPaths = coverPaths)
}
}
}
}
@Composable
fun BookStackView(coverPaths: List<String?>) {
val xOffsetStep = 12.dp
val stackWidth = 48.dp + (xOffsetStep * (coverPaths.size - 1).coerceAtLeast(0))
Box(
modifier = Modifier
.width(stackWidth)
.height(72.dp),
contentAlignment = Alignment.CenterStart
) {
coverPaths.forEachIndexed { index, path ->
Box(
modifier = Modifier
.padding(start = xOffsetStep * index)
.zIndex(index.toFloat())
.rotate(if (index % 2 == 0) 3f else -3f)
) {
Surface(
shadowElevation = 4.dp,
shape = RoundedCornerShape(4.dp),
color = Color.Transparent
) {
Cover(path = path)
}
}
} }
} }
} }
@@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
data class ReadRecordUiState( data class ReadRecordUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
@@ -22,7 +24,8 @@ data class ReadRecordUiState(
//每日所有阅读会话 //每日所有阅读会话
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(), val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
//最后阅读列表 //最后阅读列表
val latestRecords: List<ReadRecord> = emptyList() val latestRecords: List<ReadRecord> = emptyList(),
val selectedDate: LocalDate? = null
) )
enum class DisplayMode { enum class DisplayMode {
@@ -48,33 +51,104 @@ class ReadRecordViewModel(
_displayMode.value = mode _displayMode.value = mode
} }
fun setSelectedDate(date: LocalDate?) {
_uiState.update { it.copy(selectedDate = date) }
loadData()
}
fun loadData(query: String = "") { fun loadData(query: String = "") {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
val selectedDate = _uiState.value.selectedDate
val details = repository.getAllRecordDetails(query) if (selectedDate != null) {
val grouped = details.groupBy { it.date } //筛选特定日期
val uniqueDates = grouped.keys.toList() val dateString = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>() //某一天所有书籍的明细
for (date in uniqueDates) { val dailyDetails = repository.getAllRecordDetailsByDate(dateString, query)
val sessions = repository.getAllSessionsForDate(date) val grouped = dailyDetails.groupBy { it.date }
timelineMap[date] = sessions //某一天所有会话
} val allSessions = repository.getAllSessionsByDate(dateString)
val latest = repository.getLatestReadRecords(query) val mergedSessions = mergeContinuousSessions(allSessions).reversed()
val totalTime = withContext(Dispatchers.IO) { repository.allTime } val timelineMap = mapOf(dateString to mergedSessions)
_uiState.update { //LATEST
it.copy( val latest = repository.getLatestReadRecords(query)
isLoading = false, val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
totalReadTime = totalTime, _uiState.update {
groupedRecords = grouped, it.copy(
timelineRecords = timelineMap, isLoading = false,
latestRecords = latest totalReadTime = totalTime,
) groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
} else {
val details = repository.getAllRecordDetails(query)
val grouped = details.groupBy { it.date }
val uniqueDates = grouped.keys.toList()
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
for (date in uniqueDates) {
val rawSessions = repository.getAllSessionsByDate(date)
val mergedSessions = mergeContinuousSessions(rawSessions).reversed()
timelineMap[date] = mergedSessions
}
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
} }
} }
} }
/**
* 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
* 只保留最晚结束的那条会话(更新其结束时间)。
* @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
* @return 合并后的 ReadRecordSession 列表。
*/
fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
if (sessions.isEmpty()) return emptyList()
val mergedList = mutableListOf<ReadRecordSession>()
mergedList.add(sessions.first().copy())
val twentyMinutesInMillis = 2 * 60 * 1000L
for (i in 1 until sessions.size) {
val currentSession = sessions[i]
val lastMergedSession = mergedList.last()
val isSameBook = currentSession.bookName == lastMergedSession.bookName
val timeGap = currentSession.startTime - lastMergedSession.endTime
val isContinuous = timeGap <= twentyMinutesInMillis && timeGap >= 0
if (isSameBook && isContinuous) {
mergedList.removeAt(mergedList.lastIndex)
val updatedSession = lastMergedSession.copy(
endTime = currentSession.endTime
)
mergedList.add(updatedSession)
} else {
mergedList.add(currentSession.copy())
}
}
return mergedList
}
fun deleteDetail(detail: ReadRecordDetail) { fun deleteDetail(detail: ReadRecordDetail) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteDetail(detail) repository.deleteDetail(detail)
@@ -0,0 +1,267 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.*
const val START_PAGE_OFFSET = 100
/**
* @param modifier 外部修饰符
* @param initialDate 初始显示的月份日期
* @param onDateSelected 日期被选中时的回调
*/
@Composable
fun Calendar(
modifier: Modifier = Modifier,
initialDate: LocalDate = LocalDate.now(),
onDateSelected: (LocalDate) -> Unit,
selectedDate: LocalDate? = null,
onClearDate: (() -> Unit)? = null
) {
val baseMonth = remember(selectedDate) {
selectedDate?.withDayOfMonth(1) ?: initialDate.withDayOfMonth(1)
}
val pagerState = rememberPagerState(initialPage = START_PAGE_OFFSET) {
START_PAGE_OFFSET * 2
}
val coroutineScope = rememberCoroutineScope()
val currentMonth by remember {
derivedStateOf {
val pageOffset = pagerState.currentPage - START_PAGE_OFFSET
baseMonth.plusMonths(pageOffset.toLong())
}
}
Card(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(modifier = Modifier.padding(vertical = 12.dp).animateContentSize()) {
MonthNavigation(
currentMonth = currentMonth,
selectedDate = selectedDate,
onPreviousClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
onNextClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
onClearClick = onClearDate
)
Spacer(modifier = Modifier.height(8.dp))
DayOfWeekHeader()
Spacer(modifier = Modifier.height(8.dp))
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
verticalAlignment = Alignment.Top
) { page ->
val pageOffset = page - START_PAGE_OFFSET
val monthForPage = baseMonth.plusMonths(pageOffset.toLong())
MonthPageContent(
month = monthForPage,
selectedDate = selectedDate,
onDateClick = onDateSelected
)
}
}
}
}
@Composable
fun MonthPageContent(
month: LocalDate,
selectedDate: LocalDate?,
onDateClick: (LocalDate) -> Unit
) {
val datesInMonth = getDatesInMonth(month)
val firstDayOfWeek = month.dayOfWeek.value % 7
val offset = firstDayOfWeek
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier
.fillMaxWidth(),
userScrollEnabled = false,
horizontalArrangement = Arrangement.SpaceAround
) {
items(offset) {
Spacer(modifier = Modifier.size(36.dp))
}
items(datesInMonth) { date ->
DateItem(
date = date,
isSelected = date == selectedDate,
isToday = date == LocalDate.now(),
isCurrentMonth = date.month == month.month,
onClick = { onDateClick(date) }
)
}
}
}
@Composable
fun MonthNavigation(
currentMonth: LocalDate,
selectedDate: LocalDate?,
onPreviousClick: () -> Unit,
onNextClick: () -> Unit,
onClearClick: (() -> Unit)?
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = currentMonth.year.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
AnimatedTextLine(
text = currentMonth.format(DateTimeFormatter.ofPattern("MMMM", Locale.getDefault())),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (selectedDate != null && onClearClick != null) {
FilledTonalIconButton(onClick = onClearClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear Date Selection",
)
}
}
IconButton(onClick = onPreviousClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Previous Month",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = onNextClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = "Next Month",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
fun DayOfWeekHeader() {
val dayNames = listOf(
DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY
).map {
it.getDisplayName(TextStyle.SHORT, Locale.getDefault())
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
dayNames.forEach { dayName ->
Text(
text = dayName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(36.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
@Composable
fun DateItem(
date: LocalDate,
isSelected: Boolean,
isToday: Boolean,
isCurrentMonth: Boolean,
onClick: () -> Unit
) {
val buttonColors = when {
isSelected -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
isCurrentMonth -> ButtonDefaults.textButtonColors(
contentColor = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
else -> ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
val borderStroke = if (isToday && !isSelected) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
} else {
null
}
Button(
onClick = onClick,
modifier = Modifier.size(36.dp).padding(4.dp),
shape = CircleShape,
colors = buttonColors,
border = borderStroke,
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)
) {
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
fun getDatesInMonth(month: LocalDate): List<LocalDate> {
val firstDayOfMonth = month.withDayOfMonth(1)
val lengthOfMonth = month.lengthOfMonth()
return (0 until lengthOfMonth).map { firstDayOfMonth.plusDays(it.toLong()) }
}