[优化] 优化Compose界面动画与显示
This commit is contained in:
@@ -384,5 +384,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.reorderable)
|
implementation(libs.reorderable)
|
||||||
implementation(libs.material.kolor)
|
implementation(libs.material.kolor)
|
||||||
|
implementation(libs.haze.core)
|
||||||
|
implementation(libs.haze.materials)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import androidx.room.Insert
|
|||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecord
|
|
||||||
import io.legado.app.data.entities.ReadRecordShow
|
import io.legado.app.data.entities.ReadRecordShow
|
||||||
|
import io.legado.app.data.entities.readRecord.ReadRecord
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ReadRecordDao {
|
interface ReadRecordDao {
|
||||||
@@ -26,8 +27,8 @@ interface ReadRecordDao {
|
|||||||
)
|
)
|
||||||
val allShow: List<ReadRecordShow>
|
val allShow: List<ReadRecordShow>
|
||||||
|
|
||||||
@Query("select sum(readTime) from readRecord")
|
@Query("SELECT sum(readTime) FROM readRecord")
|
||||||
suspend fun getTotalReadTime(): Long
|
fun getTotalReadTime(): Flow<Long?>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@@ -113,11 +114,11 @@ interface ReadRecordDao {
|
|||||||
|
|
||||||
/** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
|
/** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
|
||||||
@Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
|
@Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
|
||||||
suspend fun getAllReadRecordsSortedByLastRead(): List<ReadRecord>
|
fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
|
||||||
|
|
||||||
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
|
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
|
||||||
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
|
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
|
||||||
suspend fun searchReadRecordsByLastRead(query: String): List<ReadRecord>
|
fun searchReadRecordsByLastRead(query: String): Flow<List<ReadRecord>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某一天某一本书的所有会话记录
|
* 获取某一天某一本书的所有会话记录
|
||||||
@@ -150,10 +151,13 @@ interface ReadRecordDao {
|
|||||||
fun deleteSessionByName(bookName: String)
|
fun deleteSessionByName(bookName: String)
|
||||||
|
|
||||||
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
|
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
|
||||||
suspend fun getAllDetails(): List<ReadRecordDetail>
|
fun getAllDetails(): Flow<List<ReadRecordDetail>>
|
||||||
|
|
||||||
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC")
|
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
|
||||||
suspend fun searchDetails(query: String): List<ReadRecordDetail>
|
fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
|
||||||
|
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteDetail(detail: ReadRecordDetail)
|
suspend fun deleteDetail(detail: ReadRecordDetail)
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
package io.legado.app.data.repository
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
|
import io.legado.app.data.dao.BookChapterDao
|
||||||
|
import io.legado.app.data.dao.BookDao
|
||||||
import io.legado.app.data.entities.Book
|
import io.legado.app.data.entities.Book
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class BookRepository {
|
class BookRepository(
|
||||||
|
private val bookDao: BookDao,
|
||||||
|
private val bookChapterDao: BookChapterDao
|
||||||
|
) {
|
||||||
|
suspend fun getBookCoverByName(bookName: String): String? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChapterTitle(bookName: String, chapterIndex: Int): String? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val book = bookDao.findByName(bookName).firstOrNull()
|
||||||
|
val bookUrl = book?.bookUrl
|
||||||
|
if (bookUrl.isNullOrEmpty()) return@withContext null
|
||||||
|
|
||||||
|
bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getBook(bookUrl: String): Book? {
|
suspend fun getBook(bookUrl: String): Book? {
|
||||||
return appDb.bookDao.getBook(bookUrl)
|
return appDb.bookDao.getBook(bookUrl)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package io.legado.app.data.repository
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import cn.hutool.core.date.DatePattern
|
import cn.hutool.core.date.DatePattern
|
||||||
import cn.hutool.core.date.DateUtil
|
import cn.hutool.core.date.DateUtil
|
||||||
import io.legado.app.data.dao.ReadRecordDao
|
import io.legado.app.data.dao.ReadRecordDao
|
||||||
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
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -16,11 +17,42 @@ class ReadRecordRepository(
|
|||||||
) {
|
) {
|
||||||
private fun getCurrentDeviceId(): String = ""
|
private fun getCurrentDeviceId(): String = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取总阅读时长流
|
||||||
|
*/
|
||||||
|
fun getTotalReadTime(): Flow<Long> {
|
||||||
|
return dao.getTotalReadTime().map { it ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存一个完整的阅读会话
|
* 根据搜索关键字获取最新的阅读书籍列表流
|
||||||
|
*/
|
||||||
|
fun getLatestReadRecords(query: String = ""): Flow<List<ReadRecord>> {
|
||||||
|
return if (query.isBlank()) {
|
||||||
|
dao.getAllReadRecordsSortedByLastRead()
|
||||||
|
} else {
|
||||||
|
dao.searchReadRecordsByLastRead(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有的每日统计详情流
|
||||||
|
*/
|
||||||
|
fun getAllRecordDetails(query: String = ""): Flow<List<ReadRecordDetail>> {
|
||||||
|
return if (query.isBlank()) {
|
||||||
|
dao.getAllDetails()
|
||||||
|
} else {
|
||||||
|
dao.searchDetails(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllSessions(): Flow<List<ReadRecordSession>> {
|
||||||
|
return dao.getAllSessions(getCurrentDeviceId())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存一个完整的阅读会话.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
|
||||||
suspend fun saveReadSession(newSession: ReadRecordSession) {
|
suspend fun saveReadSession(newSession: ReadRecordSession) {
|
||||||
val segmentDuration = newSession.endTime - newSession.startTime
|
val segmentDuration = newSession.endTime - newSession.startTime
|
||||||
dao.insertSession(newSession)
|
dao.insertSession(newSession)
|
||||||
@@ -29,37 +61,28 @@ class ReadRecordRepository(
|
|||||||
updateReadRecord(newSession, segmentDuration)
|
updateReadRecord(newSession, segmentDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新总记录表 (ReadRecord)
|
|
||||||
* @param durationDelta 增加的时长
|
|
||||||
*/
|
|
||||||
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
|
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
|
||||||
if (durationDelta <= 0) return
|
if (durationDelta <= 0) return
|
||||||
|
|
||||||
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
|
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
|
||||||
|
|
||||||
if (existingRecord != null) {
|
if (existingRecord != null) {
|
||||||
val updatedRecord = existingRecord.copy(
|
dao.update(
|
||||||
|
existingRecord.copy(
|
||||||
readTime = existingRecord.readTime + durationDelta,
|
readTime = existingRecord.readTime + durationDelta,
|
||||||
lastRead = session.endTime
|
lastRead = session.endTime
|
||||||
|
)
|
||||||
)
|
)
|
||||||
dao.update(updatedRecord)
|
|
||||||
} else {
|
} else {
|
||||||
val newRecord = ReadRecord(
|
dao.insert(
|
||||||
|
ReadRecord(
|
||||||
deviceId = session.deviceId,
|
deviceId = session.deviceId,
|
||||||
bookName = session.bookName,
|
bookName = session.bookName,
|
||||||
readTime = durationDelta,
|
readTime = durationDelta,
|
||||||
lastRead = session.endTime
|
lastRead = session.endTime
|
||||||
|
)
|
||||||
)
|
)
|
||||||
dao.insert(newRecord)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新每日详情表 (ReadRecordDetail)
|
|
||||||
* @param durationDelta 增加的时长
|
|
||||||
* @param wordsDelta 增加的字数
|
|
||||||
*/
|
|
||||||
private suspend fun updateReadRecordDetail(
|
private suspend fun updateReadRecordDetail(
|
||||||
session: ReadRecordSession,
|
session: ReadRecordSession,
|
||||||
durationDelta: Long,
|
durationDelta: Long,
|
||||||
@@ -67,9 +90,7 @@ class ReadRecordRepository(
|
|||||||
dateString: String
|
dateString: String
|
||||||
) {
|
) {
|
||||||
if (durationDelta <= 0 && wordsDelta <= 0) return
|
if (durationDelta <= 0 && wordsDelta <= 0) return
|
||||||
|
|
||||||
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
|
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
|
||||||
|
|
||||||
if (existingDetail != null) {
|
if (existingDetail != null) {
|
||||||
existingDetail.readTime += durationDelta
|
existingDetail.readTime += durationDelta
|
||||||
existingDetail.readWords += wordsDelta
|
existingDetail.readWords += wordsDelta
|
||||||
@@ -77,7 +98,8 @@ class ReadRecordRepository(
|
|||||||
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
|
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
|
||||||
dao.insertDetail(existingDetail)
|
dao.insertDetail(existingDetail)
|
||||||
} else {
|
} else {
|
||||||
val newDetail = ReadRecordDetail(
|
dao.insertDetail(
|
||||||
|
ReadRecordDetail(
|
||||||
deviceId = session.deviceId,
|
deviceId = session.deviceId,
|
||||||
bookName = session.bookName,
|
bookName = session.bookName,
|
||||||
date = dateString,
|
date = dateString,
|
||||||
@@ -85,53 +107,16 @@ class ReadRecordRepository(
|
|||||||
readWords = wordsDelta,
|
readWords = wordsDelta,
|
||||||
firstReadTime = session.startTime,
|
firstReadTime = session.startTime,
|
||||||
lastReadTime = session.endTime
|
lastReadTime = session.endTime
|
||||||
|
)
|
||||||
)
|
)
|
||||||
dao.insertDetail(newDetail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
|
|
||||||
return if (query.isBlank()) {
|
|
||||||
dao.getAllReadRecordsSortedByLastRead()
|
|
||||||
} else {
|
|
||||||
dao.searchReadRecordsByLastRead(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
|
|
||||||
return if (query.isBlank()) {
|
|
||||||
dao.getAllDetails()
|
|
||||||
} else {
|
|
||||||
dao.searchDetails(query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearAll() {
|
suspend fun clearAll() {
|
||||||
dao.clear() // 清除总表
|
dao.clear()
|
||||||
// dao.clearDetails()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露总时长
|
|
||||||
suspend fun getTotalReadTime(): Long {
|
|
||||||
return dao.getTotalReadTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -21,35 +21,24 @@ import org.koin.dsl.module
|
|||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
|
|
||||||
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
|
||||||
|
|
||||||
viewModel { ReplaceRuleViewModel(androidApplication()) }
|
|
||||||
|
|
||||||
// ReadRecord
|
|
||||||
single { get<AppDatabase>().readRecordDao }
|
single { get<AppDatabase>().readRecordDao }
|
||||||
single { get<AppDatabase>().bookDao }
|
single { get<AppDatabase>().bookDao }
|
||||||
single { get<AppDatabase>().bookChapterDao }
|
single { get<AppDatabase>().bookChapterDao }
|
||||||
|
|
||||||
single { ReadRecordRepository(get()) }
|
single { ReadRecordRepository(get()) }
|
||||||
viewModel { ReadRecordViewModel(get(), get(), get()) }
|
|
||||||
|
single { BookRepository(get(), get()) }
|
||||||
|
|
||||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||||
|
|
||||||
// Explore
|
|
||||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||||
viewModel { ExploreShowViewModel(get()) }
|
|
||||||
|
|
||||||
// Bookmark
|
|
||||||
viewModel {
|
|
||||||
AllBookmarkViewModel(
|
|
||||||
androidApplication(),
|
|
||||||
get()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
|
||||||
single { SearchContentRepository() }
|
single { SearchContentRepository() }
|
||||||
single { BookRepository() }
|
|
||||||
viewModel { SearchContentViewModel(get(), get()) }
|
|
||||||
|
|
||||||
|
viewModel { ReadRecordViewModel(get(), get()) }
|
||||||
|
viewModel { ReplaceEditViewModel(get(), get(), get()) }
|
||||||
|
viewModel { ReplaceRuleViewModel(androidApplication()) }
|
||||||
|
viewModel { ExploreShowViewModel(get()) }
|
||||||
|
viewModel { SearchContentViewModel(get(), get()) }
|
||||||
viewModel { MyViewModel(get()) }
|
viewModel { MyViewModel(get()) }
|
||||||
|
|
||||||
|
viewModel { AllBookmarkViewModel(androidApplication(), get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -23,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
@@ -55,6 +56,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -81,10 +83,21 @@ fun AllBookmarkScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val searchText by viewModel.searchQuery.collectAsState()
|
|
||||||
val uiState by viewModel.bookmarksState.collectAsState()
|
|
||||||
val collapsedGroups by viewModel.collapsedGroups.collectAsState()
|
|
||||||
|
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val contentState = when {
|
||||||
|
uiState.isLoading -> "LOADING"
|
||||||
|
uiState.bookmarks.isEmpty() -> "EMPTY"
|
||||||
|
else -> "CONTENT"
|
||||||
|
}
|
||||||
|
val searchText = uiState.searchQuery
|
||||||
|
val collapsedGroups = uiState.collapsedGroups
|
||||||
|
val bookmarksGrouped = uiState.bookmarks
|
||||||
|
val allKeys = bookmarksGrouped.keys
|
||||||
|
val isAllCollapsed =
|
||||||
|
allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var showSearch by remember { mutableStateOf(false) }
|
var showSearch by remember { mutableStateOf(false) }
|
||||||
var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
|
var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
|
||||||
@@ -92,10 +105,6 @@ fun AllBookmarkScreen(
|
|||||||
var pendingExportIsMd by remember { mutableStateOf(false) }
|
var pendingExportIsMd by remember { mutableStateOf(false) }
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
|
||||||
val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap()
|
|
||||||
val allKeys = bookmarksGrouped.keys
|
|
||||||
val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
|
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
@@ -173,7 +182,9 @@ fun AllBookmarkScreen(
|
|||||||
SearchBarSection(
|
SearchBarSection(
|
||||||
query = searchText,
|
query = searchText,
|
||||||
onQueryChange = { viewModel.onSearchQueryChanged(it) },
|
onQueryChange = { viewModel.onSearchQueryChanged(it) },
|
||||||
placeholder = "搜索..."
|
placeholder = "搜索...",
|
||||||
|
scrollState = listState,
|
||||||
|
scope = scope
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,11 +196,11 @@ fun AllBookmarkScreen(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = uiState,
|
targetState = contentState,
|
||||||
label = "bookmarkTransition"
|
label = "bookmarkTransition"
|
||||||
) { state ->
|
) { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
BookmarkUiState.Loading -> {
|
"LOADING" -> {
|
||||||
EmptyMessageView(
|
EmptyMessageView(
|
||||||
message = "加载中...",
|
message = "加载中...",
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
@@ -198,64 +209,55 @@ fun AllBookmarkScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is BookmarkUiState.Success -> {
|
"EMPTY" -> {
|
||||||
if (state.bookmarks.isEmpty()) {
|
|
||||||
EmptyMessageView(
|
|
||||||
message = "没有书签!",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
FastScrollLazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
state.bookmarks.forEach { (headerKey, bookmarks) ->
|
|
||||||
|
|
||||||
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
EmptyMessageView(
|
||||||
|
message = "没有书签!",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
|
"CONTENT" -> {
|
||||||
BookAuthorHeader(
|
FastScrollLazyColumn(
|
||||||
bookTitle = headerKey.bookName,
|
state = listState,
|
||||||
bookAuthor = headerKey.bookAuthor,
|
modifier = Modifier.fillMaxSize()
|
||||||
isCollapsed = isCollapsed,
|
) {
|
||||||
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
|
uiState.bookmarks.forEach { (headerKey, bookmarks) ->
|
||||||
)
|
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "content_${headerKey}") {
|
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
|
||||||
AnimatedVisibility(
|
BookAuthorHeader(
|
||||||
visible = !isCollapsed,
|
modifier = Modifier
|
||||||
enter = expandVertically() + fadeIn(),
|
.animateItem()
|
||||||
exit = shrinkVertically() + fadeOut()
|
.fillMaxWidth(),
|
||||||
) {
|
bookTitle = headerKey.bookName,
|
||||||
Column(
|
bookAuthor = headerKey.bookAuthor,
|
||||||
modifier = Modifier.animateContentSize()
|
isCollapsed = isCollapsed,
|
||||||
) {
|
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
|
||||||
bookmarks.forEach { bookmark ->
|
)
|
||||||
BookmarkItem(
|
}
|
||||||
bookmark = bookmark,
|
|
||||||
modifier = Modifier
|
if (!isCollapsed) {
|
||||||
.animateItem()
|
items(
|
||||||
.fillMaxWidth(),
|
items = bookmarks,
|
||||||
onClick = {
|
key = { it.id }
|
||||||
editingBookmark = bookmark
|
) { bookmarkUi ->
|
||||||
showBottomSheet = true
|
BookmarkItem(
|
||||||
}
|
bookmark = bookmarkUi,
|
||||||
)
|
modifier = Modifier
|
||||||
}
|
.animateItem()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
editingBookmark = bookmarkUi.rawBookmark
|
||||||
|
showBottomSheet = true
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is BookmarkUiState.Error -> {
|
|
||||||
EmptyMessageView(
|
|
||||||
message = state.throwable.localizedMessage ?: "发生错误",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +284,7 @@ fun AllBookmarkScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BookAuthorHeader(
|
fun BookAuthorHeader(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
bookTitle: String,
|
bookTitle: String,
|
||||||
bookAuthor: String,
|
bookAuthor: String,
|
||||||
isCollapsed: Boolean,
|
isCollapsed: Boolean,
|
||||||
@@ -289,7 +292,7 @@ fun BookAuthorHeader(
|
|||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onToggle)
|
.clickable(onClick = onToggle)
|
||||||
) {
|
) {
|
||||||
@@ -340,8 +343,8 @@ fun BookAuthorHeader(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BookmarkItem(
|
fun BookmarkItem(
|
||||||
bookmark: Bookmark,
|
bookmark: BookmarkItemUi,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier,
|
||||||
onClick: () -> Unit) {
|
onClick: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.bookmark
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.data.dao.BookmarkDao
|
import io.legado.app.data.dao.BookmarkDao
|
||||||
@@ -14,18 +15,13 @@ import io.legado.app.utils.toastOnUi
|
|||||||
import io.legado.app.utils.writeToOutputStream
|
import io.legado.app.utils.writeToOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -39,86 +35,130 @@ data class BookmarkGroupHeader(
|
|||||||
override fun toString(): String = "$bookName|$bookAuthor"
|
override fun toString(): String = "$bookName|$bookAuthor"
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class BookmarkUiState {
|
@Immutable
|
||||||
object Loading : BookmarkUiState()
|
data class BookmarkItemUi(
|
||||||
data class Success(val bookmarks: Map<BookmarkGroupHeader, List<Bookmark>>) : BookmarkUiState()
|
val id: Long,
|
||||||
data class Error(val throwable: Throwable) : BookmarkUiState()
|
val content: String,
|
||||||
|
val chapterName: String,
|
||||||
|
val bookText: String,
|
||||||
|
val bookName: String,
|
||||||
|
val bookAuthor: String,
|
||||||
|
val rawBookmark: Bookmark
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class BookmarkUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val bookmarks: Map<BookmarkGroupHeader, List<BookmarkItemUi>> = emptyMap(),
|
||||||
|
val error: Throwable? = null,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val collapsedGroups: Set<String> = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface BookmarkListItem {
|
||||||
|
val key: String
|
||||||
|
|
||||||
|
data class Header(
|
||||||
|
val header: BookmarkGroupHeader,
|
||||||
|
val collapsed: Boolean
|
||||||
|
) : BookmarkListItem {
|
||||||
|
override val key = "header:${header.bookName}|${header.bookAuthor}"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Item(
|
||||||
|
val data: BookmarkItemUi
|
||||||
|
) : BookmarkListItem {
|
||||||
|
override val key = "item:${data.id}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AllBookmarkViewModel(
|
class AllBookmarkViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val bookmarkDao: BookmarkDao
|
private val bookmarkDao: BookmarkDao
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery = _searchQuery.asStateFlow()
|
|
||||||
|
|
||||||
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val collapsedGroups = _collapsedGroups.asStateFlow()
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val uiState: StateFlow<BookmarkUiState> = combine(
|
||||||
|
_searchQuery,
|
||||||
|
_collapsedGroups,
|
||||||
|
bookmarkDao.flowAll()
|
||||||
|
) { query, collapsed, allBookmarks ->
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
val filteredList = if (query.isBlank()) {
|
||||||
val bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
|
allBookmarks
|
||||||
.debounce(300L)
|
} else {
|
||||||
.flatMapLatest { query ->
|
allBookmarks.filter {
|
||||||
val flow = if (query.isBlank()) {
|
it.bookName.contains(query, ignoreCase = true) ||
|
||||||
bookmarkDao.flowAll()
|
it.content.contains(query, ignoreCase = true) ||
|
||||||
} else {
|
it.bookAuthor.contains(query, ignoreCase = true)
|
||||||
bookmarkDao.flowSearchAll(query)
|
|
||||||
}
|
}
|
||||||
flow.map<List<Bookmark>, BookmarkUiState> { list ->
|
|
||||||
BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) })
|
|
||||||
}
|
|
||||||
.onStart { emit(BookmarkUiState.Loading) }
|
|
||||||
.catch { e ->
|
|
||||||
e.printStackTrace()
|
|
||||||
emit(BookmarkUiState.Error(e))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
|
||||||
.stateIn(
|
val grouped = filteredList.asSequence()
|
||||||
viewModelScope,
|
.map { bookmark ->
|
||||||
SharingStarted.WhileSubscribed(5000),
|
BookmarkItemUi(
|
||||||
BookmarkUiState.Loading
|
id = bookmark.time,
|
||||||
|
content = bookmark.content,
|
||||||
|
chapterName = bookmark.chapterName,
|
||||||
|
bookText = bookmark.bookText,
|
||||||
|
bookName = bookmark.bookName,
|
||||||
|
bookAuthor = bookmark.bookAuthor,
|
||||||
|
rawBookmark = bookmark
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.groupBy { item ->
|
||||||
|
BookmarkGroupHeader(item.bookName, item.bookAuthor)
|
||||||
|
}
|
||||||
|
|
||||||
|
BookmarkUiState(
|
||||||
|
isLoading = false,
|
||||||
|
bookmarks = grouped,
|
||||||
|
searchQuery = query,
|
||||||
|
collapsedGroups = collapsed
|
||||||
)
|
)
|
||||||
|
}.catch { e ->
|
||||||
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
|
emit(BookmarkUiState(isLoading = false, error = e))
|
||||||
val stringKey = groupKey.toString()
|
}.stateIn(
|
||||||
val current = _collapsedGroups.value
|
scope = viewModelScope,
|
||||||
if (current.contains(stringKey)) {
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
_collapsedGroups.value = current - stringKey
|
initialValue = BookmarkUiState(isLoading = true)
|
||||||
} else {
|
)
|
||||||
_collapsedGroups.value = current + stringKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
|
|
||||||
val stringKeys = currentKeys.map { it.toString() }.toSet()
|
|
||||||
val currentCollapsed = _collapsedGroups.value
|
|
||||||
if (currentCollapsed.containsAll(stringKeys) && currentKeys.isNotEmpty()) {
|
|
||||||
_collapsedGroups.value = emptySet()
|
|
||||||
} else {
|
|
||||||
_collapsedGroups.value = stringKeys
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSearchQueryChanged(query: String) {
|
fun onSearchQueryChanged(query: String) {
|
||||||
_searchQuery.value = query
|
_searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateBookmark(bookmark: Bookmark) {
|
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
|
||||||
viewModelScope.launch {
|
val stringKey = groupKey.toString()
|
||||||
withContext(Dispatchers.IO) {
|
_collapsedGroups.update { current ->
|
||||||
bookmarkDao.insert(bookmark)
|
if (current.contains(stringKey)) current - stringKey else current + stringKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
|
||||||
|
val stringKeys = currentKeys.map { it.toString() }.toSet()
|
||||||
|
_collapsedGroups.update { current ->
|
||||||
|
if (current.containsAll(stringKeys) && stringKeys.isNotEmpty()) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
stringKeys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBookmark(bookmark: Bookmark) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
bookmarkDao.insert(bookmark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteBookmark(bookmark: Bookmark) {
|
fun deleteBookmark(bookmark: Bookmark) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
bookmarkDao.delete(bookmark)
|
||||||
bookmarkDao.delete(bookmark)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,39 +169,46 @@ class AllBookmarkViewModel(
|
|||||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
||||||
val suffix = if (isMarkdown) "md" else "json"
|
val suffix = if (isMarkdown) "md" else "json"
|
||||||
val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
|
val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
|
||||||
|
|
||||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
val dirDoc = FileDoc.fromUri(treeUri, true)
|
||||||
val fileDoc = dirDoc.createFileIfNotExist(fileName)
|
val fileDoc = dirDoc.createFileIfNotExist(fileName)
|
||||||
|
|
||||||
fileDoc.openOutputStream().getOrThrow().use { outputStream ->
|
fileDoc.openOutputStream().getOrThrow().use { outputStream ->
|
||||||
|
val allData = bookmarkDao.all
|
||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
writeMarkdown(outputStream, bookmarkDao.all)
|
writeMarkdown(outputStream, allData)
|
||||||
} else {
|
} else {
|
||||||
GSON.writeToOutputStream(outputStream, bookmarkDao.all)
|
GSON.writeToOutputStream(outputStream, allData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
context.toastOnUi("导出成功")
|
context.toastOnUi("导出成功: $fileName")
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
getApplication<Application>().toastOnUi("导出失败: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
|
private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
|
||||||
var name = ""
|
val sb = StringBuilder()
|
||||||
var author = ""
|
var lastHeader = ""
|
||||||
|
|
||||||
bookmarks.forEach {
|
bookmarks.forEach {
|
||||||
if (it.bookName != name && it.bookAuthor != author) {
|
val currentHeader = "${it.bookName}|${it.bookAuthor}"
|
||||||
name = it.bookName
|
if (currentHeader != lastHeader) {
|
||||||
author = it.bookAuthor
|
lastHeader = currentHeader
|
||||||
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
|
sb.append("\n## ${it.bookName} - ${it.bookAuthor}\n\n")
|
||||||
}
|
}
|
||||||
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
|
sb.append("#### ${it.chapterName}\n")
|
||||||
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
|
sb.append("> **原文:** ${it.bookText}\n\n")
|
||||||
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
|
sb.append("${it.content}\n\n")
|
||||||
|
sb.append("---\n")
|
||||||
}
|
}
|
||||||
|
outputStream.write(sb.toString().toByteArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -66,7 +67,6 @@ 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.ui.widget.components.SectionHeader
|
import io.legado.app.ui.widget.components.SectionHeader
|
||||||
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.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -83,20 +83,12 @@ fun ReadRecordScreen(
|
|||||||
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 showCalendar by remember { mutableStateOf(false) }
|
||||||
var searchText by remember { mutableStateOf("") }
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
|
||||||
LaunchedEffect(showSearch) {
|
LaunchedEffect(state.searchKey) {
|
||||||
if (!showSearch) {
|
if (state.searchKey.isNullOrBlank()) {
|
||||||
viewModel.loadData("")
|
listState.animateScrollToItem(0)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(searchText) {
|
|
||||||
if (showSearch && searchText.isNotBlank()) {
|
|
||||||
delay(100L)
|
|
||||||
viewModel.loadData(searchText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,8 +146,8 @@ fun ReadRecordScreen(
|
|||||||
|
|
||||||
AnimatedVisibility(visible = showSearch) {
|
AnimatedVisibility(visible = showSearch) {
|
||||||
SearchBarSection(
|
SearchBarSection(
|
||||||
query = searchText,
|
query = state.searchKey ?: "",
|
||||||
onQueryChange = { searchText = it }
|
onQueryChange = { viewModel.setSearchKey(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(visible = showCalendar) {
|
AnimatedVisibility(visible = showCalendar) {
|
||||||
@@ -192,7 +184,8 @@ fun ReadRecordScreen(
|
|||||||
"LOADING" -> {
|
"LOADING" -> {
|
||||||
EmptyMessageView(
|
EmptyMessageView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
message = "加载中"
|
message = "加载中",
|
||||||
|
isLoading = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +198,7 @@ fun ReadRecordScreen(
|
|||||||
|
|
||||||
"CONTENT" -> {
|
"CONTENT" -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,20 +2,24 @@ 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 cn.hutool.core.date.DateUtil
|
||||||
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
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
||||||
|
import io.legado.app.data.repository.BookRepository
|
||||||
import io.legado.app.data.repository.ReadRecordRepository
|
import io.legado.app.data.repository.ReadRecordRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
data class ReadRecordUiState(
|
data class ReadRecordUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
@@ -26,7 +30,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
|
val selectedDate: LocalDate? = null,
|
||||||
|
val searchKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DisplayMode {
|
enum class DisplayMode {
|
||||||
@@ -35,18 +40,75 @@ enum class DisplayMode {
|
|||||||
LATEST
|
LATEST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ReadRecordViewModel(
|
class ReadRecordViewModel(
|
||||||
private val repository: ReadRecordRepository,
|
private val repository: ReadRecordRepository,
|
||||||
private val bookDao: BookDao,
|
private val bookRepository: BookRepository
|
||||||
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()
|
||||||
private val _uiState = MutableStateFlow(ReadRecordUiState())
|
|
||||||
val uiState = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
private val _searchKey = MutableStateFlow("")
|
||||||
loadData()
|
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||||
|
|
||||||
|
// 数据源:直接从 Repository 获取 Flow
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val loadedDataFlow = _searchKey
|
||||||
|
.flatMapLatest { query ->
|
||||||
|
combine(
|
||||||
|
repository.getAllRecordDetails(query),
|
||||||
|
repository.getLatestReadRecords(query),
|
||||||
|
repository.getAllSessions(),
|
||||||
|
repository.getTotalReadTime()
|
||||||
|
) { details, latest, sessions, totalTime ->
|
||||||
|
LoadedData(totalTime, details, latest, sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uiState: StateFlow<ReadRecordUiState> = combine(
|
||||||
|
loadedDataFlow,
|
||||||
|
_selectedDate,
|
||||||
|
_searchKey
|
||||||
|
) { data, selectedDate, searchKey ->
|
||||||
|
|
||||||
|
val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||||
|
val filteredDetails = if (dateStr != null) {
|
||||||
|
data.details.filter { it.date == dateStr }
|
||||||
|
} else data.details
|
||||||
|
|
||||||
|
val timelineMap = data.sessions
|
||||||
|
.asSequence()
|
||||||
|
.filter { session ->
|
||||||
|
val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
|
||||||
|
(dateStr == null || sDate == dateStr) &&
|
||||||
|
(searchKey.isEmpty() || session.bookName.contains(
|
||||||
|
searchKey,
|
||||||
|
ignoreCase = true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
|
||||||
|
.mapValues { (_, sessions) ->
|
||||||
|
mergeContinuousSessions(sessions).reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadRecordUiState(
|
||||||
|
isLoading = false,
|
||||||
|
totalReadTime = data.totalReadTime,
|
||||||
|
groupedRecords = filteredDetails.groupBy { it.date },
|
||||||
|
timelineRecords = timelineMap,
|
||||||
|
latestRecords = data.latestRecords,
|
||||||
|
selectedDate = selectedDate,
|
||||||
|
searchKey = searchKey
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = ReadRecordUiState(isLoading = true)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setSearchKey(query: String) {
|
||||||
|
_searchKey.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplayMode(mode: DisplayMode) {
|
fun setDisplayMode(mode: DisplayMode) {
|
||||||
@@ -54,132 +116,44 @@ class ReadRecordViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedDate(date: LocalDate?) {
|
fun setSelectedDate(date: LocalDate?) {
|
||||||
_uiState.update { it.copy(selectedDate = date) }
|
_selectedDate.value = date
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadData(query: String = "") {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
|
||||||
|
|
||||||
val selectedDate = _uiState.value.selectedDate
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
}
|
||||||
loadData()
|
|
||||||
|
private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
|
||||||
|
if (sessions.isEmpty()) return emptyList()
|
||||||
|
val mergedList = mutableListOf<ReadRecordSession>()
|
||||||
|
mergedList.add(sessions.first().copy())
|
||||||
|
|
||||||
|
val gapLimit = 20 * 60 * 1000L
|
||||||
|
|
||||||
|
for (i in 1 until sessions.size) {
|
||||||
|
val current = sessions[i]
|
||||||
|
val last = mergedList.last()
|
||||||
|
if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
|
||||||
|
mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
|
||||||
|
} else {
|
||||||
|
mergedList.add(current.copy())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return mergedList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
|
||||||
|
return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getBookCover(bookName: String): String? {
|
suspend fun getBookCover(bookName: String): String? {
|
||||||
return withContext(Dispatchers.IO) {
|
return bookRepository.getBookCoverByName(bookName)
|
||||||
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoadedData(
|
||||||
|
val totalReadTime: Long,
|
||||||
|
val details: List<ReadRecordDetail>,
|
||||||
|
val latestRecords: List<ReadRecord>,
|
||||||
|
val sessions: List<ReadRecordSession>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,6 @@ internal object TransparentColorScheme : BaseColorScheme() {
|
|||||||
inverseSurface = Color(0xFF333027),
|
inverseSurface = Color(0xFF333027),
|
||||||
inverseOnSurface = Color(0xFFF6F0E2),
|
inverseOnSurface = Color(0xFFF6F0E2),
|
||||||
inversePrimary = Color(0xA0FFFFFF),
|
inversePrimary = Color(0xA0FFFFFF),
|
||||||
// Material 3.1+ Fixed Colors
|
|
||||||
primaryFixed = Color(0xA0FFFFFF),
|
primaryFixed = Color(0xA0FFFFFF),
|
||||||
onPrimaryFixed = Color(0xFF211B00),
|
onPrimaryFixed = Color(0xFF211B00),
|
||||||
primaryFixedDim = Color(0xB0FFFFFF),
|
primaryFixedDim = Color(0xB0FFFFFF),
|
||||||
@@ -52,7 +51,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
|
|||||||
surfaceDim = Color(0x1AFFFFFF),
|
surfaceDim = Color(0x1AFFFFFF),
|
||||||
surfaceBright = Color(0x33FFFFFF),
|
surfaceBright = Color(0x33FFFFFF),
|
||||||
surfaceContainerLowest = Color(0x0DFFFFFF),
|
surfaceContainerLowest = Color(0x0DFFFFFF),
|
||||||
surfaceContainerLow = Color(0x1AFFFFFF),
|
surfaceContainerLow = Color(0x8FFFFFFF),
|
||||||
surfaceContainer = Color(0x33FFFFFF),
|
surfaceContainer = Color(0x33FFFFFF),
|
||||||
surfaceContainerHigh = Color(0x4DFFFFFF),
|
surfaceContainerHigh = Color(0x4DFFFFFF),
|
||||||
surfaceContainerHighest = Color(0x66FFFFFF),
|
surfaceContainerHighest = Color(0x66FFFFFF),
|
||||||
@@ -102,7 +101,7 @@ internal object TransparentColorScheme : BaseColorScheme() {
|
|||||||
surfaceDim = Color(0x1AFFFFFF),
|
surfaceDim = Color(0x1AFFFFFF),
|
||||||
surfaceBright = Color(0x33FFFFFF),
|
surfaceBright = Color(0x33FFFFFF),
|
||||||
surfaceContainerLowest = Color(0x0DFFFFFF),
|
surfaceContainerLowest = Color(0x0DFFFFFF),
|
||||||
surfaceContainerLow = Color(0x1AFFFFFF),
|
surfaceContainerLow = Color(0x8F000000),
|
||||||
surfaceContainer = Color(0x33FFFFFF),
|
surfaceContainer = Color(0x33FFFFFF),
|
||||||
surfaceContainerHigh = Color(0x4DFFFFFF),
|
surfaceContainerHigh = Color(0x4DFFFFFF),
|
||||||
surfaceContainerHighest = Color(0x66FFFFFF),
|
surfaceContainerHighest = Color(0x66FFFFFF),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material3.ContainedLoadingIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -50,7 +50,7 @@ fun EmptyMessageView(
|
|||||||
label = "LoadingStateAnimation"
|
label = "LoadingStateAnimation"
|
||||||
) { loading ->
|
) { loading ->
|
||||||
if (loading) {
|
if (loading) {
|
||||||
LoadingIndicator()
|
ContainedLoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
AnimatedTextLine(
|
AnimatedTextLine(
|
||||||
text = currentFace,
|
text = currentFace,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
@@ -9,10 +10,12 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.VerticalAlignTop
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -23,14 +26,18 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -39,13 +46,19 @@ fun SearchBarSection(
|
|||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
placeholder: String = "搜索书名",
|
placeholder: String = "搜索书名",
|
||||||
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
//外部定义末尾的图标按钮
|
scrollState: LazyListState? = null,
|
||||||
|
scope: CoroutineScope = rememberCoroutineScope(),
|
||||||
trailingIcon: @Composable (() -> Unit)? = null,
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
//外部定义下拉菜单的内容
|
|
||||||
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null
|
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val showScrollToTop by remember(scrollState) {
|
||||||
|
derivedStateOf {
|
||||||
|
(scrollState?.firstVisibleItemIndex ?: 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -64,28 +77,38 @@ fun SearchBarSection(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(end = 4.dp)
|
modifier = Modifier
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.animateContentSize()
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = query.isNotEmpty(),
|
visible = query.isNotEmpty(),
|
||||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
enter = fadeIn() + scaleIn(),
|
||||||
exit = fadeOut() + scaleOut(targetScale = 0.8f)
|
exit = fadeOut() + scaleOut()
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
onQueryChange("")
|
|
||||||
}) {
|
|
||||||
Icon(Icons.Default.Clear, "清空输入")
|
Icon(Icons.Default.Clear, "清空输入")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showScrollToTop,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut()
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch { scrollState?.animateScrollToItem(0) }
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.VerticalAlignTop, "回到顶部")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部自定义按钮
|
||||||
if (trailingIcon != null) {
|
if (trailingIcon != null) {
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = { if (dropdownMenu != null) showMenu = true }) {
|
||||||
if (dropdownMenu != null) showMenu = true
|
|
||||||
}) {
|
|
||||||
trailingIcon()
|
trailingIcon()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropdownMenu != null) {
|
if (dropdownMenu != null) {
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ koin-bom = "4.1.1"
|
|||||||
biometricKtx = "1.1.0"
|
biometricKtx = "1.1.0"
|
||||||
|
|
||||||
reorderable = "3.0.0"
|
reorderable = "3.0.0"
|
||||||
|
haze = "1.7.1"
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|
||||||
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
|
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
|
||||||
@@ -109,6 +110,8 @@ androidx-startup-runtime = { module = "androidx.startup:startup-runtime", versio
|
|||||||
appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||||
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
|
||||||
|
haze-core = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
|
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
||||||
koin-core = { module = "io.insert-koin:koin-core" }
|
koin-core = { module = "io.insert-koin:koin-core" }
|
||||||
koin-android = { group = "io.insert-koin", name = "koin-android" }
|
koin-android = { group = "io.insert-koin", name = "koin-android" }
|
||||||
|
|||||||
Reference in New Issue
Block a user