[新增] 阅读记录时间线视图现在可以显示一次记录的最后阅读章节
This commit is contained in:
@@ -43,4 +43,13 @@ interface BookChapterDao {
|
|||||||
@Query("update chapters set wordCount = :wordCount where bookUrl = :bookUrl and url = :url")
|
@Query("update chapters set wordCount = :wordCount where bookUrl = :bookUrl and url = :url")
|
||||||
fun upWordCount(bookUrl: String, url: String, wordCount: String)
|
fun upWordCount(bookUrl: String, url: String, wordCount: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据书籍的唯一标识 bookUrl 和章节索引 index 查找章节标题。
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT title FROM chapters
|
||||||
|
WHERE bookUrl = :bookUrl
|
||||||
|
AND `index` = :chapterIndex
|
||||||
|
""")
|
||||||
|
suspend fun getChapterTitleByUrlAndIndex(bookUrl: String, chapterIndex: Int): String?
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,8 @@ import org.koin.dsl.module
|
|||||||
val readRecordModule = module {
|
val readRecordModule = module {
|
||||||
single { get<AppDatabase>().readRecordDao }
|
single { get<AppDatabase>().readRecordDao }
|
||||||
single { get<AppDatabase>().bookDao }
|
single { get<AppDatabase>().bookDao }
|
||||||
|
single { get<AppDatabase>().bookChapterDao }
|
||||||
single { ReadRecordRepository(get()) }
|
single { ReadRecordRepository(get()) }
|
||||||
|
|
||||||
viewModel { ReadRecordViewModel(get(), get()) }
|
viewModel { ReadRecordViewModel(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
bookName = currentBookName,
|
bookName = currentBookName,
|
||||||
startTime = readStartTime,
|
startTime = readStartTime,
|
||||||
endTime = readStartTime,
|
endTime = readStartTime,
|
||||||
words = 0
|
words = durChapterIndex.toLong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,12 +327,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var wordChange = currentLength - lastReadLength
|
|
||||||
if (wordChange < 0) wordChange = 0
|
|
||||||
|
|
||||||
currentActiveSession = currentActiveSession!!.copy(
|
currentActiveSession = currentActiveSession!!.copy(
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
words = currentActiveSession!!.words + wordChange
|
words = durChapterIndex.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
readStartTime = endTime
|
readStartTime = endTime
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
|||||||
bookName = currentBookName,
|
bookName = currentBookName,
|
||||||
startTime = readStartTime,
|
startTime = readStartTime,
|
||||||
endTime = readStartTime,
|
endTime = readStartTime,
|
||||||
words = 0
|
words = durChapterIndex.toLong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,12 +173,9 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var wordChange = currentLength - lastReadLength
|
|
||||||
if (wordChange < 0) wordChange = 0
|
|
||||||
|
|
||||||
currentActiveSession = currentActiveSession!!.copy(
|
currentActiveSession = currentActiveSession!!.copy(
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
words = currentActiveSession!!.words + wordChange
|
words = durChapterIndex.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
readStartTime = endTime
|
readStartTime = endTime
|
||||||
|
|||||||
@@ -337,7 +337,6 @@ fun ExploreBookItem(
|
|||||||
Text(
|
Text(
|
||||||
text = book.author,
|
text = book.author,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.Gray,
|
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -365,7 +364,7 @@ fun ExploreBookItem(
|
|||||||
Text(
|
Text(
|
||||||
text = intro,
|
text = intro,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.DarkGray,
|
color = Color.Gray,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import cn.hutool.core.date.DateUtil
|
import cn.hutool.core.date.DateUtil
|
||||||
@@ -272,8 +273,7 @@ fun ReadRecordScreen(
|
|||||||
}
|
}
|
||||||
DisplayMode.TIMELINE -> {
|
DisplayMode.TIMELINE -> {
|
||||||
state.timelineRecords.forEach { (date, sessions) ->
|
state.timelineRecords.forEach { (date, sessions) ->
|
||||||
val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime }
|
stickyHeader { DateHeader(date) }
|
||||||
stickyHeader { DateHeader(date, dailyTotalTime) }
|
|
||||||
|
|
||||||
val timelineItems = sessions.mapIndexed { index, session ->
|
val timelineItems = sessions.mapIndexed { index, session ->
|
||||||
val showHeader = true
|
val showHeader = true
|
||||||
@@ -360,14 +360,15 @@ fun TimelineSessionItem(
|
|||||||
) {
|
) {
|
||||||
val session = item.session
|
val session = item.session
|
||||||
var coverPath by remember { mutableStateOf<String?>(null) }
|
var coverPath by remember { mutableStateOf<String?>(null) }
|
||||||
|
var chapterTitle by remember { mutableStateOf<String?>("加载中...") }
|
||||||
|
|
||||||
LaunchedEffect(session.bookName) {
|
LaunchedEffect(session.bookName) {
|
||||||
coverPath = viewModel.getBookCover(session.bookName)
|
coverPath = viewModel.getBookCover(session.bookName)
|
||||||
|
val title = viewModel.getChapterTitle(session.bookName, session.words)
|
||||||
|
chapterTitle = title ?: "第 ${session.words} 章"
|
||||||
}
|
}
|
||||||
|
|
||||||
val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
|
|
||||||
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
|
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
|
||||||
val duration = session.endTime - session.startTime
|
|
||||||
|
|
||||||
val nodeRadius = 4.dp
|
val nodeRadius = 4.dp
|
||||||
val lineWidth = 2.dp
|
val lineWidth = 2.dp
|
||||||
@@ -380,7 +381,6 @@ fun TimelineSessionItem(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 0.dp)
|
|
||||||
.clickable { onBookClick(session.bookName) }
|
.clickable { onBookClick(session.bookName) }
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
val x = timelineX.toPx()
|
val x = timelineX.toPx()
|
||||||
@@ -399,7 +399,6 @@ fun TimelineSessionItem(
|
|||||||
radius = nodeRadius.toPx(),
|
radius = nodeRadius.toPx(),
|
||||||
center = Offset(x, cy)
|
center = Offset(x, cy)
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -418,28 +417,26 @@ fun TimelineSessionItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Row(
|
||||||
if (item.showHeader) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
) {
|
||||||
Cover(coverPath)
|
Cover(coverPath)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = session.bookName,
|
text = session.bookName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 2
|
maxLines = 2,
|
||||||
|
minLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
Text(
|
||||||
"时长: ${formatDuring(duration)}",
|
text = chapterTitle.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.Gray
|
color = Color.Gray,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
// Text(
|
|
||||||
// "字数: ${session.words}",
|
|
||||||
// style = MaterialTheme.typography.bodySmall,
|
|
||||||
// color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
// )
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,10 +491,9 @@ fun ReadRecordItem(
|
|||||||
@Composable
|
@Composable
|
||||||
fun DateHeader(
|
fun DateHeader(
|
||||||
date: String,
|
date: String,
|
||||||
dailyTotalTime: Long
|
dailyTotalTime: Long? = null
|
||||||
) {
|
) {
|
||||||
val dateText = formatFriendlyDate(date)
|
val dateText = formatFriendlyDate(date)
|
||||||
val totalTimeText = "已读 ${formatDuring(dailyTotalTime)}"
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
@@ -509,14 +505,16 @@ fun DateHeader(
|
|||||||
color = MaterialTheme.colorScheme.secondary
|
color = MaterialTheme.colorScheme.secondary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dailyTotalTime?.let { total ->
|
||||||
Text(
|
Text(
|
||||||
text = totalTimeText,
|
text = "已读 ${formatDuring(total)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarSection(
|
fun CalendarSection(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.readRecord
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.data.dao.BookChapterDao
|
||||||
import io.legado.app.data.dao.BookDao
|
import io.legado.app.data.dao.BookDao
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecord
|
import io.legado.app.data.entities.readRecord.ReadRecord
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
||||||
@@ -36,7 +37,8 @@ enum class DisplayMode {
|
|||||||
|
|
||||||
class ReadRecordViewModel(
|
class ReadRecordViewModel(
|
||||||
private val repository: ReadRecordRepository,
|
private val repository: ReadRecordRepository,
|
||||||
private val bookDao: BookDao
|
private val bookDao: BookDao,
|
||||||
|
private val bookChapterDao: BookChapterDao
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
|
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
|
||||||
val displayMode = _displayMode.asStateFlow()
|
val displayMode = _displayMode.asStateFlow()
|
||||||
@@ -149,6 +151,25 @@ class ReadRecordViewModel(
|
|||||||
return mergedList
|
return mergedList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getChapterTitle(
|
||||||
|
bookName: String,
|
||||||
|
chapterIndexLong: Long
|
||||||
|
): String? {
|
||||||
|
val chapterIndex = chapterIndexLong.toInt()
|
||||||
|
val book = withContext(Dispatchers.IO) {
|
||||||
|
bookDao.findByName(bookName).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val bookUrl = book?.bookUrl
|
||||||
|
if (bookUrl.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteDetail(detail: ReadRecordDetail) {
|
fun deleteDetail(detail: ReadRecordDetail) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.deleteDetail(detail)
|
repository.deleteDetail(detail)
|
||||||
|
|||||||
Reference in New Issue
Block a user