优化阅读记录界面,添加按日期查看记录的功能
This commit is contained in:
@@ -26,8 +26,8 @@ interface ReadRecordDao {
|
||||
)
|
||||
val allShow: List<ReadRecordShow>
|
||||
|
||||
@get:Query("select sum(readTime) from readRecord")
|
||||
val allTime: Long
|
||||
@Query("select sum(readTime) from readRecord")
|
||||
suspend fun getTotalReadTime(): Long
|
||||
|
||||
@Query(
|
||||
"""
|
||||
@@ -138,10 +138,13 @@ interface ReadRecordDao {
|
||||
SELECT * FROM readRecordSession
|
||||
WHERE deviceId = :deviceId
|
||||
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>
|
||||
|
||||
@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")
|
||||
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> {
|
||||
return if (query.isBlank()) {
|
||||
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> {
|
||||
return if (query.isBlank()) {
|
||||
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) {
|
||||
dao.deleteDetail(detail)
|
||||
}
|
||||
@@ -129,6 +130,8 @@ class ReadRecordRepository(
|
||||
}
|
||||
|
||||
// 暴露总时长
|
||||
val allTime: Long
|
||||
get() = dao.allTime
|
||||
suspend fun getTotalReadTime(): Long {
|
||||
return dao.getTotalReadTime()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
|
||||
private fun initAllTime() {
|
||||
lifecycleScope.launch {
|
||||
val allTime = withContext(IO) {
|
||||
appDb.readRecordDao.allTime
|
||||
appDb.readRecordDao.getTotalReadTime()
|
||||
}
|
||||
binding.tvReadingTime.text = formatDuring(allTime)
|
||||
}
|
||||
|
||||
@@ -17,13 +17,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
||||
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.List
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.zIndex
|
||||
import cn.hutool.core.date.DateUtil
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
import io.legado.app.data.entities.readRecord.ReadRecord
|
||||
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
||||
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.EmptyMessageView
|
||||
import io.legado.app.ui.widget.components.SearchBarSection
|
||||
import io.legado.app.utils.StringUtils.formatFriendlyDate
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
|
||||
// 包含绘制时间线所需的上下文信息
|
||||
data class TimelineItem(
|
||||
@@ -93,6 +95,7 @@ fun ReadRecordScreen(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var showCalendar by remember { mutableStateOf(false) }
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
@@ -105,7 +108,7 @@ fun ReadRecordScreen(
|
||||
|
||||
LaunchedEffect(searchText) {
|
||||
if (showSearch) {
|
||||
kotlinx.coroutines.delay(100L)
|
||||
delay(100L)
|
||||
viewModel.loadData(searchText)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +158,9 @@ fun ReadRecordScreen(
|
||||
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
|
||||
Icon(icon, description)
|
||||
}
|
||||
IconButton(onClick = { showCalendar = !showCalendar }) {
|
||||
Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
|
||||
}
|
||||
IconButton(onClick = { showSearch = !showSearch }) {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
}
|
||||
@@ -172,8 +178,6 @@ fun ReadRecordScreen(
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
|
||||
TotalTimeHeader(state.totalReadTime)
|
||||
val isEmpty = when (displayMode) {
|
||||
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
|
||||
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
|
||||
@@ -184,18 +188,76 @@ fun ReadRecordScreen(
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
label = "ContentCrossfade"
|
||||
) { isListEmpty ->
|
||||
if (isListEmpty){
|
||||
Box(
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
EmptyMessageView(
|
||||
message = "没有记录"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
|
||||
item {
|
||||
val selectedDate = state.selectedDate
|
||||
|
||||
if (selectedDate != null) {
|
||||
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){
|
||||
DisplayMode.AGGREGATE -> {
|
||||
state.groupedRecords.forEach { (date, details) ->
|
||||
@@ -226,9 +288,7 @@ fun ReadRecordScreen(
|
||||
stickyHeader { DateHeader(date, dailyTotalTime) }
|
||||
|
||||
val timelineItems = sessions.mapIndexed { index, session ->
|
||||
val previousSession = sessions.getOrNull(index - 1)
|
||||
|
||||
val showHeader = index == 0 || session.bookName != previousSession?.bookName
|
||||
val showHeader = true
|
||||
TimelineItem(session, showHeader)
|
||||
}
|
||||
|
||||
@@ -253,7 +313,7 @@ fun ReadRecordScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,7 +356,7 @@ fun LatestReadItem(
|
||||
color = Color.Gray
|
||||
)
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -317,8 +377,8 @@ fun TimelineSessionItem(
|
||||
coverPath = viewModel.getBookCover(session.bookName)
|
||||
}
|
||||
|
||||
val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm")
|
||||
val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm")
|
||||
val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
|
||||
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
|
||||
val duration = session.endTime - session.startTime
|
||||
|
||||
val nodeRadius = 4.dp
|
||||
@@ -378,22 +438,21 @@ fun TimelineSessionItem(
|
||||
Text(
|
||||
text = session.bookName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
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
|
||||
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(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "阅读时长",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatDuring(time),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ReadRecordUiState(
|
||||
val isLoading: Boolean = true,
|
||||
@@ -22,7 +24,8 @@ data class ReadRecordUiState(
|
||||
//每日所有阅读会话
|
||||
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
|
||||
//最后阅读列表
|
||||
val latestRecords: List<ReadRecord> = emptyList()
|
||||
val latestRecords: List<ReadRecord> = emptyList(),
|
||||
val selectedDate: LocalDate? = null
|
||||
)
|
||||
|
||||
enum class DisplayMode {
|
||||
@@ -48,33 +51,104 @@ class ReadRecordViewModel(
|
||||
_displayMode.value = mode
|
||||
}
|
||||
|
||||
fun setSelectedDate(date: LocalDate?) {
|
||||
_uiState.update { it.copy(selectedDate = date) }
|
||||
loadData()
|
||||
}
|
||||
|
||||
fun loadData(query: String = "") {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
val selectedDate = _uiState.value.selectedDate
|
||||
|
||||
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 sessions = repository.getAllSessionsForDate(date)
|
||||
timelineMap[date] = sessions
|
||||
}
|
||||
val latest = repository.getLatestReadRecords(query)
|
||||
val totalTime = withContext(Dispatchers.IO) { repository.allTime }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
totalReadTime = totalTime,
|
||||
groupedRecords = grouped,
|
||||
timelineRecords = timelineMap,
|
||||
latestRecords = latest
|
||||
)
|
||||
if (selectedDate != null) {
|
||||
//筛选特定日期
|
||||
val dateString = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
//某一天所有书籍的明细
|
||||
val dailyDetails = repository.getAllRecordDetailsByDate(dateString, query)
|
||||
val grouped = dailyDetails.groupBy { it.date }
|
||||
//某一天所有会话
|
||||
val allSessions = repository.getAllSessionsByDate(dateString)
|
||||
val mergedSessions = mergeContinuousSessions(allSessions).reversed()
|
||||
val timelineMap = mapOf(dateString to mergedSessions)
|
||||
//LATEST
|
||||
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
|
||||
)
|
||||
}
|
||||
} 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) {
|
||||
viewModelScope.launch {
|
||||
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()) }
|
||||
}
|
||||
Reference in New Issue
Block a user