优化阅读记录界面,添加按日期查看记录的功能
This commit is contained in:
@@ -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()) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user