[新增] 重写了主页面和书架界面、封面设置界面,现在支持底栏模糊,更多的封面样式、封面阴影、随机默认封面等功能(在高级 - 测试 - 封面设置中应用)。

This commit is contained in:
HapeLee
2026-03-16 01:24:02 +08:00
parent 83ca030be4
commit 6922fdeed9
68 changed files with 4060 additions and 4654 deletions
+2
View File
@@ -228,6 +228,7 @@ dependencies {
implementation(libs.nanohttpd.websocket)
implementation(libs.zxing.lite)
implementation(libs.colorpicker)
implementation(libs.colorpicker.compose)
implementation(libs.libarchive)
implementation(libs.commons.text)
implementation(libs.markwon.core)
@@ -236,6 +237,7 @@ dependencies {
implementation(libs.markwon.html)
implementation(libs.quick.chinese.transfer.core)
implementation(libs.hutool.crypto)
//noinspection GradleDependency
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.perf)
@@ -37,4 +37,7 @@ object EventBus {
const val REFRESH_BOOK_INFO = "refreshBookInfo"
const val REFRESH_BOOK_CONTENT = "refreshBookContent"
const val UP_TOC = "upToc"
}
const val UP_BOOKSHELF_COUNT = "upBookshelfCount"
const val UP_ALL_BOOK_TOC = "upAllBookToc"
}
@@ -23,6 +23,7 @@ object PreferKey {
const val coverDefaultColor = "coverDefaultColor"
const val coverShowNameN = "coverShowNameN"
const val coverShowAuthorN = "coverShowAuthorN"
const val coverInfoOrientation = "coverInfoOrientation"
const val remoteServerId = "remoteServerId"
const val hideStatusBar = "hideStatusBar"
const val clickActionTL = "clickActionTopLeft"
@@ -50,6 +51,7 @@ object PreferKey {
const val bookshelfLayout = "bookshelfLayout"
const val bookshelfLayoutModePortrait = "bookshelfLayoutPortrait"
const val bookshelfLayoutModeLandscape = "bookshelf_layout_landscape"
const val bookshelfLayoutCompact = "bookshelfLayoutCompact"
const val bookshelfSort = "bookshelfSort"
const val bookExportFileName = "bookExportFileName"
const val bookImportFileName = "bookImportFileName"
@@ -222,6 +224,10 @@ object PreferKey {
const val sharedElementEnterTransitionEnable = "sharedElementEnterTransitionEnable"
const val bookshelfLayoutGridLandscape = "bookshelfLayoutGridLandscape"
const val bookshelfLayoutGridPortrait = "bookshelfLayoutGridPortrait"
const val bookshelfTitleSmallFont = "bookshelfTitleSmallFont"
const val bookshelfTitleCenter = "bookshelfTitleCenter"
const val bookshelfTitleMaxLines = "bookshelfTitleMaxLines"
const val bookshelfCoverShadow = "bookshelfCoverShadow"
const val exploreLayoutGridLandscape = "exploreLayoutGridLandscape"
@@ -348,6 +348,9 @@ data class Book(
newBook.customIntro = customIntro
newBook.customTag = customTag
newBook.canUpdate = canUpdate
if (config.fixedType) {
newBook.type = type
}
newBook.readConfig = readConfig
return newBook
}
@@ -402,7 +405,9 @@ data class Book(
val mangaColorFilter: String? = null,
var mangaScrollMode: Int? = null,
var webtoonSidePaddingDp: Int? = null,
var mangaBackground: String? = null
var mangaBackground: String? = null,
var fixedType: Boolean = false // 固定书籍类型,不随书源更新
) : Parcelable
@@ -414,4 +419,4 @@ data class Book(
@TypeConverter
fun stringToReadConfig(json: String?) = GSON.fromJsonObject<ReadConfig>(json).getOrNull()
}
}
}
@@ -20,15 +20,19 @@ import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.book.import.remote.RemoteBookViewModel
import io.legado.app.ui.book.info.BookInfoViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreViewModel
import io.legado.app.ui.main.my.MyViewModel
import io.legado.app.ui.main.rss.RssViewModel
@@ -76,11 +80,15 @@ val appModule = module {
viewModelOf(::ReadRecordViewModel)
viewModelOf(::ExploreShowViewModel)
viewModelOf(::MyViewModel)
viewModelOf(::BookshelfViewModel)
viewModelOf(::MainViewModel)
viewModelOf(::GroupViewModel)
viewModelOf(::ReplaceRuleViewModel)
viewModelOf(::AllBookmarkViewModel)
viewModelOf(::TxtTocRuleViewModel)
viewModelOf(::OtherConfigViewModel)
viewModelOf(::ReadConfigViewModel)
viewModelOf(::CoverConfigViewModel)
viewModelOf(::TocViewModel)
viewModelOf(::RemoteBookViewModel)
viewModelOf(::BookInfoViewModel)
@@ -339,6 +339,9 @@ fun Book.updateTo(newBook: Book): Book {
newBook.customIntro = customIntro
newBook.customTag = customTag
newBook.canUpdate = canUpdate
if (config.fixedType) {
newBook.type = type
}
newBook.readConfig = readConfig
val variableMap = variableMap.toMutableMap()
variableMap.keys.removeIf {
@@ -41,6 +41,7 @@ import io.legado.app.utils.getPrefString
import kotlinx.coroutines.currentCoroutineContext
import splitties.init.appCtx
import java.io.File
import kotlin.random.Random
@Keep
object BookCover {
@@ -101,16 +102,32 @@ object BookCover {
appCtx.getPrefInt(PreferKey.coverShadowColorN, Color.BLACK)
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
val path = appCtx.getPrefString(key)
if (path.isNullOrBlank()) {
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
if (paths.isNullOrEmpty()) {
defaultDrawable = appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
return
}
val randomPath = paths[Random.nextInt(paths.size)]
defaultDrawable = kotlin.runCatching {
BitmapUtils.decodeBitmap(path, 600, 900)!!.toDrawable(appCtx.resources)
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
fun getRandomDefaultDrawable(seed: Any? = null): Drawable {
val isNightTheme = AppConfig.isNightTheme
val key = if (isNightTheme) PreferKey.defaultCoverDark else PreferKey.defaultCover
val paths = appCtx.getPrefString(key)?.split(",")?.filter { it.isNotBlank() }
if (paths.isNullOrEmpty()) {
return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
}
val random = if (seed != null) Random(seed.hashCode()) else Random
val randomPath = paths[random.nextInt(paths.size)]
return kotlin.runCatching {
BitmapUtils.decodeBitmap(randomPath, 600, 900)!!.toDrawable(appCtx.resources)
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
/**
* 加载封面
@@ -122,8 +139,9 @@ object BookCover {
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null,
): RequestBuilder<Drawable> {
val currentDefault = getRandomDefaultDrawable()
if (AppConfig.useDefaultCover) {
return ImageLoader.load(context, defaultDrawable)
return ImageLoader.load(context, currentDefault)
.centerCrop()
}
var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
@@ -156,8 +174,8 @@ object BookCover {
}
})
}
return builder.placeholder(defaultDrawable)
.error(defaultDrawable)
return builder.placeholder(currentDefault)
.error(currentDefault)
.centerCrop()
}
@@ -222,7 +240,8 @@ object BookCover {
loadOnlyWifi: Boolean = false,
sourceOrigin: String? = null,
): RequestBuilder<Drawable> {
val loadBlur = ImageLoader.load(context, defaultDrawable)
val currentDefault = getRandomDefaultDrawable()
val loadBlur = ImageLoader.load(context, currentDefault)
.transform(BlurTransformation(25), CenterCrop())
if (AppConfig.useDefaultCover) {
return loadBlur
@@ -302,4 +321,4 @@ object BookCover {
}
}
}
}
@@ -162,8 +162,10 @@ object WebBook {
book: Book,
canReName: Boolean = true,
): Book {
book.removeAllBookType()
book.addType(bookSource.getBookType())
if (!book.config.fixedType) {
book.removeAllBookType()
book.addType(bookSource.getBookType())
}
if (!book.infoHtml.isNullOrEmpty()) {
BookInfo.analyzeBookInfo(
bookSource = bookSource,
@@ -235,8 +237,10 @@ object WebBook {
book: Book,
runPerJs: Boolean = false
): Result<List<BookChapter>> {
book.removeAllBookType()
book.addType(bookSource.getBookType())
if (!book.config.fixedType) {
book.removeAllBookType()
book.addType(bookSource.getBookType())
}
return kotlin.runCatching {
if (runPerJs) {
runPreUpdateJs(bookSource, book).getOrThrow()
@@ -420,4 +424,4 @@ object WebBook {
}
}
}
}
@@ -86,7 +86,6 @@ import io.legado.app.model.BookShelfState
import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.SearchBarSection
@@ -94,6 +93,7 @@ import io.legado.app.ui.widget.components.button.AnimatedTextButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.cover.Cover
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
@@ -599,21 +599,22 @@ fun ExploreBookItem(
if (!latestChapter.isNullOrEmpty()) {
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1
)
Text(
text = "最新: $latestChapter",
style = MaterialTheme.typography.labelSmall,
color = Color.Gray,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(4.dp))
val intro = book.intro?.replace("\\s+".toRegex(), "") ?: ""
if (intro.isNotEmpty()) {
Text(
@@ -0,0 +1,249 @@
package io.legado.app.ui.book.group
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookGroup
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.widget.components.cover.Cover
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.SelectImageContract
import io.legado.app.utils.externalFiles
import io.legado.app.utils.launch
import io.legado.app.utils.toastOnUi
import org.koin.androidx.compose.koinViewModel
import splitties.init.appCtx
import java.io.FileOutputStream
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupEditSheet(
group: BookGroup? = null,
onDismissRequest: () -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
GlassModalBottomSheet(onDismissRequest = onDismissRequest) {
GroupEditContent(
group = group,
onDismissRequest = onDismissRequest,
viewModel = viewModel
)
}
}
@Composable
fun GroupEditContent(
group: BookGroup? = null,
onDismissRequest: () -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
val context = LocalContext.current
var groupName by remember(group) { mutableStateOf(group?.groupName ?: "") }
var coverPath by remember(group) { mutableStateOf(group?.cover) }
var enableRefresh by remember(group) { mutableStateOf(group?.enableRefresh ?: true) }
var selectedSortIndex by remember(group) { mutableIntStateOf(group?.bookSort ?: -1) }
val sortOptions = stringArrayResource(R.array.book_sort)
val sortEntryValues = remember(sortOptions) {
Array(sortOptions.size) { (it - 1).toString() }
}
val selectImage = rememberLauncherForActivityResult(SelectImageContract()) { result ->
result.uri?.let { uri ->
try {
val inputStream = context.contentResolver.openInputStream(uri)
?: return@rememberLauncherForActivityResult
inputStream.use { input ->
val fileName = MD5Utils.md5Encode(input) + ".png"
val file =
FileUtils.createFileIfNotExist(context.externalFiles, "covers", fileName)
FileOutputStream(file).use { output ->
context.contentResolver.openInputStream(uri)?.use { it.copyTo(output) }
}
coverPath = file.absolutePath
}
} catch (e: Exception) {
appCtx.toastOnUi(e.localizedMessage)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.group_edit),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 16.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Cover(
path = coverPath,
modifier = Modifier
.size(width = 90.dp, height = 130.dp)
.clickable { selectImage.launch() }
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = groupName,
onValueChange = { groupName = it },
label = { Text(stringResource(R.string.group_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
DropdownListSettingItem(
title = stringResource(R.string.sort),
selectedValue = selectedSortIndex.toString(),
displayEntries = sortOptions,
entryValues = sortEntryValues,
onValueChange = {
selectedSortIndex = it.toInt()
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
SettingItem(
title = stringResource(R.string.allow_drop_down_refresh),
trailingContent = {
Switch(
checked = enableRefresh,
onCheckedChange = { enableRefresh = it }
)
},
onClick = { enableRefresh = !enableRefresh }
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (group != null && (group.groupId > 0 || group.groupId == Long.MIN_VALUE)) {
OutlinedIconButton(onClick = {
context.alert(R.string.delete, R.string.sure_del) {
yesButton {
viewModel.delGroup(group) {
onDismissRequest()
}
}
noButton()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
OutlinedIconButton(onClick = {
if (group != null) {
viewModel.clearCover(group) {
coverPath = null
appCtx.toastOnUi("封面已重置")
}
} else {
coverPath = null
}
}) {
Icon(Icons.Default.Restore, contentDescription = stringResource(R.string.reset))
}
}
Spacer(modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Button(onClick = {
if (groupName.isEmpty()) {
appCtx.toastOnUi("分组名称不能为空")
} else {
if (group != null) {
viewModel.upGroup(
group.copy(
groupName = groupName,
cover = coverPath,
bookSort = selectedSortIndex,
enableRefresh = enableRefresh
)
) {
onDismissRequest()
}
} else {
viewModel.addGroup(
groupName,
selectedSortIndex,
enableRefresh,
coverPath
) {
onDismissRequest()
}
}
}
}) {
Text(stringResource(R.string.ok))
}
}
}
}
}
@@ -19,6 +19,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import io.legado.app.R
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.config.coverConfig.CoverConfigScreen
import io.legado.app.ui.config.otherConfig.OtherConfigScreen
import io.legado.app.ui.config.readConfig.ReadConfigScreen
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
@@ -38,6 +39,9 @@ class TestConfigActivity : BaseComposeActivity() {
@Serializable
object ReadConfigRoute
@Serializable
object CoverConfigRoute
@Composable
override fun Content() {
val navController = rememberNavController()
@@ -50,7 +54,8 @@ class TestConfigActivity : BaseComposeActivity() {
ConfigNavScreen(
onBackClick = { finish() },
onNavigateToOther = { navController.navigate(OtherConfigRoute) },
onNavigateToRead = { navController.navigate(ReadConfigRoute) }
onNavigateToRead = { navController.navigate(ReadConfigRoute) },
onNavigateToCover = { navController.navigate(CoverConfigRoute) }
)
}
@@ -61,6 +66,10 @@ class TestConfigActivity : BaseComposeActivity() {
composable<ReadConfigRoute> {
ReadConfigScreen(onBackClick = { navController.popBackStack() })
}
composable<CoverConfigRoute> {
CoverConfigScreen(onBackClick = { navController.popBackStack() })
}
}
}
@@ -69,7 +78,8 @@ class TestConfigActivity : BaseComposeActivity() {
fun ConfigNavScreen(
onBackClick: () -> Unit,
onNavigateToOther: () -> Unit,
onNavigateToRead: () -> Unit
onNavigateToRead: () -> Unit,
onNavigateToCover: () -> Unit
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -101,6 +111,10 @@ class TestConfigActivity : BaseComposeActivity() {
title = stringResource(R.string.read_config),
onClick = onNavigateToRead
)
ClickableSettingItem(
title = stringResource(R.string.cover_config),
onClick = onNavigateToCover
)
}
}
}
@@ -0,0 +1,120 @@
package io.legado.app.ui.config.bookshelfConfig
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
import io.legado.app.utils.postEvent
/**
* 书架配置
*/
object BookshelfConfig {
/**
* 书架分组样式: 0: Tab, 1:隐藏Tab, 2:文件夹
*/
var bookGroupStyle by prefDelegate(PreferKey.bookGroupStyle, 0) {
postEvent(EventBus.NOTIFY_MAIN, false)
}
/**
* 书架排序方式
*/
var bookshelfSort by prefDelegate(PreferKey.bookshelfSort, 0)
/**
* 书架排序顺序
*/
var bookshelfSortOrder by prefDelegate(PreferKey.bookshelfSortOrder, 1)
/**
* 是否显示未读标志
*/
var showUnread by prefDelegate(PreferKey.showUnread, true)
/**
* 是否显示新未读标志(小圆点)
*/
var showUnreadNew by prefDelegate(PreferKey.showUnreadNew, true)
/**
* 是否显示更新提示
*/
var showTip by prefDelegate(PreferKey.showTip, false)
/**
* 是否在列表中显示最后更新时间
*/
var showLastUpdateTime by prefDelegate(PreferKey.showLastUpdateTime, false)
/**
* 是否显示等待更新的书籍数量
*/
var showWaitUpCount by prefDelegate(PreferKey.showWaitUpCount, false)
/**
* 是否显示书架快速滚动条
*/
var showBookshelfFastScroller by prefDelegate(PreferKey.showBookshelfFastScroller, false)
/**
* 是否在书架Tab处显示分类展开按钮
*/
var shouldShowExpandButton by prefDelegate(PreferKey.shouldShowExpandButton, false)
/**
* 书架同时更新书籍数量限制 (0为无限制)
*/
var bookshelfRefreshingLimit by prefDelegate(PreferKey.bookshelfRefreshingLimit, 0)
/**
* 竖屏书架布局模式: 0:列表, 1:网格
*/
var bookshelfLayoutModePortrait by prefDelegate(PreferKey.bookshelfLayoutModePortrait, 1)
/**
* 竖屏书架网格列数
*/
var bookshelfLayoutGridPortrait by prefDelegate(PreferKey.bookshelfLayoutGridPortrait, 3)
/**
* 横屏书架布局模式: 0:列表, 1:网格
*/
var bookshelfLayoutModeLandscape by prefDelegate(PreferKey.bookshelfLayoutModeLandscape, 1)
/**
* 横屏书架网格列数
*/
var bookshelfLayoutGridLandscape by prefDelegate(PreferKey.bookshelfLayoutGridLandscape, 7)
/**
* 紧凑模式
*/
var bookshelfLayoutCompact by prefDelegate(PreferKey.bookshelfLayoutCompact, false)
/**
* 书架标题小字体
*/
var bookshelfTitleSmallFont by prefDelegate(PreferKey.bookshelfTitleSmallFont, false)
/**
* 书架标题居中
*/
var bookshelfTitleCenter by prefDelegate(PreferKey.bookshelfTitleCenter, true)
/**
* 书架标题最大行数
*/
var bookshelfTitleMaxLines by prefDelegate(PreferKey.bookshelfTitleMaxLines, 2)
/**
* 书架封面阴影
*/
var bookshelfCoverShadow by prefDelegate(PreferKey.bookshelfCoverShadow, false)
/**
* 启动时自动刷新书架
*/
var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
}
@@ -0,0 +1,87 @@
package io.legado.app.ui.config.coverConfig
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
object CoverConfig {
var loadCoverOnlyWifi by prefDelegate(
PreferKey.loadCoverOnlyWifi,
false
)
var useDefaultCover by prefDelegate(
PreferKey.useDefaultCover,
false
)
var coverShowShadow by prefDelegate(
PreferKey.coverShowShadow,
false
)
var coverShowStroke by prefDelegate(
PreferKey.coverShowStroke,
true
)
var coverDefaultColor by prefDelegate(
PreferKey.coverDefaultColor,
true
)
var defaultCover by prefDelegate(
PreferKey.defaultCover,
""
)
var coverTextColor by prefDelegate(
PreferKey.coverTextColor,
-16777216 // This might need a better default or a color resource lookup
)
var coverShadowColor by prefDelegate(
PreferKey.coverShadowColor,
-16777216
)
var coverShowName by prefDelegate(
PreferKey.coverShowName,
true
)
var coverShowAuthor by prefDelegate(
PreferKey.coverShowAuthor,
true
)
var defaultCoverDark by prefDelegate(
PreferKey.defaultCoverDark,
""
)
var coverTextColorN by prefDelegate(
PreferKey.coverTextColorN,
-1
)
var coverShadowColorN by prefDelegate(
PreferKey.coverShadowColorN,
-1
)
var coverShowNameN by prefDelegate(
PreferKey.coverShowNameN,
true
)
var coverShowAuthorN by prefDelegate(
PreferKey.coverShowAuthorN,
true
)
var coverInfoOrientation by prefDelegate(
PreferKey.coverInfoOrientation,
"0" // 0: vertical, 1: horizontal
)
}
@@ -0,0 +1,285 @@
package io.legado.app.ui.config.coverConfig
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoverConfigScreen(
onBackClick: () -> Unit,
viewModel: CoverConfigViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var showCoverRuleSheet by remember { mutableStateOf(false) }
var manageKey by remember { mutableStateOf<String?>(null) }
var showColorPickerByField by remember { mutableStateOf<String?>(null) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
GlassMediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.cover_config)) },
scrollBehavior = scrollBehavior,
navigationIcon = {
TopbarNavigationButton(onClick = onBackClick)
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
SplicedColumnGroup {
SwitchSettingItem(
title = stringResource(R.string.only_wifi),
description = stringResource(R.string.only_wifi_summary),
checked = CoverConfig.loadCoverOnlyWifi,
onCheckedChange = { CoverConfig.loadCoverOnlyWifi = it }
)
ClickableSettingItem(
title = stringResource(R.string.cover_rule),
description = stringResource(R.string.cover_rule_summary),
onClick = { showCoverRuleSheet = true }
)
SwitchSettingItem(
title = stringResource(R.string.use_default_cover),
description = stringResource(R.string.use_default_cover_s),
checked = CoverConfig.useDefaultCover,
onCheckedChange = { CoverConfig.useDefaultCover = it }
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_shadow),
checked = CoverConfig.coverShowShadow,
onCheckedChange = {
CoverConfig.coverShowShadow = it
viewModel.updateCoverStyle()
}
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_stroke),
checked = CoverConfig.coverShowStroke,
onCheckedChange = {
CoverConfig.coverShowStroke = it
viewModel.updateCoverStyle()
}
)
SwitchSettingItem(
title = stringResource(R.string.default_color),
checked = CoverConfig.coverDefaultColor,
onCheckedChange = {
CoverConfig.coverDefaultColor = it
viewModel.updateCoverStyle()
}
)
}
SplicedColumnGroup {
DropdownListSettingItem(
title = "封面信息方向",
selectedValue = CoverConfig.coverInfoOrientation,
displayEntries = arrayOf(
stringResource(R.string.screen_portrait),
stringResource(R.string.screen_landscape)
),
entryValues = arrayOf("0", "1"),
onValueChange = {
CoverConfig.coverInfoOrientation = it
viewModel.updateCoverStyle()
}
)
}
SplicedColumnGroup(title = stringResource(R.string.day)) {
val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size
ClickableSettingItem(
title = stringResource(R.string.default_cover),
description = if (coverCount > 0) "已选择 $coverCount 张图片" else stringResource(
R.string.select_image
),
onClick = { manageKey = PreferKey.defaultCover }
)
ClickableSettingItem(
title = stringResource(R.string.text_color),
option = "#${Integer.toHexString(CoverConfig.coverTextColor).uppercase()}",
onClick = { showColorPickerByField = "coverTextColor" },
trailingContent = {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(CoverConfig.coverTextColor))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.text_shadow_color),
option = "#${Integer.toHexString(CoverConfig.coverShadowColor).uppercase()}",
onClick = { showColorPickerByField = "coverShadowColor" },
trailingContent = {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(CoverConfig.coverShadowColor))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
)
}
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_name),
description = stringResource(R.string.cover_show_name_summary),
checked = CoverConfig.coverShowName,
onCheckedChange = { viewModel.updateShowName(it) }
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_author),
description = stringResource(R.string.cover_show_author_summary),
checked = CoverConfig.coverShowAuthor,
enabled = CoverConfig.coverShowName,
onCheckedChange = { viewModel.updateShowAuthor(it) }
)
}
SplicedColumnGroup(title = stringResource(R.string.night)) {
val coverCount =
CoverConfig.defaultCoverDark.split(",").filter { it.isNotBlank() }.size
ClickableSettingItem(
title = stringResource(R.string.default_cover),
description = if (coverCount > 0) "已选择 $coverCount 张图片" else stringResource(
R.string.select_image
),
onClick = { manageKey = PreferKey.defaultCoverDark }
)
ClickableSettingItem(
title = stringResource(R.string.text_color),
option = "#${Integer.toHexString(CoverConfig.coverTextColorN).uppercase()}",
onClick = { showColorPickerByField = "coverTextColorN" },
trailingContent = {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(CoverConfig.coverTextColorN))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
)
}
)
ClickableSettingItem(
title = stringResource(R.string.text_shadow_color),
option = "#${Integer.toHexString(CoverConfig.coverShadowColorN).uppercase()}",
onClick = { showColorPickerByField = "coverShadowColorN" },
trailingContent = {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(CoverConfig.coverShadowColorN))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
)
}
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_name),
description = stringResource(R.string.cover_show_name_summary),
checked = CoverConfig.coverShowNameN,
onCheckedChange = { viewModel.updateShowName(it, true) }
)
SwitchSettingItem(
title = stringResource(R.string.cover_show_author),
description = stringResource(R.string.cover_show_author_summary),
checked = CoverConfig.coverShowAuthorN,
enabled = CoverConfig.coverShowNameN,
onCheckedChange = { viewModel.updateShowAuthor(it, true) }
)
}
}
}
if (showCoverRuleSheet) {
CoverRuleConfigSheet(onDismissRequest = { showCoverRuleSheet = false })
}
manageKey?.let { key ->
CoverManageSheet(
preferenceKey = key,
onDismissRequest = { manageKey = null },
viewModel = viewModel
)
}
showColorPickerByField?.let { field ->
val initialColor = when (field) {
"coverTextColor" -> CoverConfig.coverTextColor
"coverShadowColor" -> CoverConfig.coverShadowColor
"coverTextColorN" -> CoverConfig.coverTextColorN
"coverShadowColorN" -> CoverConfig.coverShadowColorN
else -> 0
}
ColorPickerSheet(
initialColor = initialColor,
onDismissRequest = { showColorPickerByField = null },
onColorSelected = { color ->
when (field) {
"coverTextColor" -> CoverConfig.coverTextColor = color
"coverShadowColor" -> CoverConfig.coverShadowColor = color
"coverTextColorN" -> CoverConfig.coverTextColorN = color
"coverShadowColorN" -> CoverConfig.coverShadowColorN = color
}
viewModel.updateCoverStyle()
}
)
}
}
@@ -0,0 +1,139 @@
package io.legado.app.ui.config.coverConfig
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey
import io.legado.app.exception.NoStackTraceException
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.model.BookCover
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.RealPathUtil
import io.legado.app.utils.externalFiles
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.toastOnUi
import splitties.init.appCtx
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
class CoverConfigViewModel : ViewModel() {
fun addCoverFromUri(preferenceKey: String, uris: List<Uri>) {
uris.forEach { uri ->
readUri(appCtx, uri) { fileDoc, _ ->
kotlin.runCatching {
var file = appCtx.externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val inputStreamForMd5 = appCtx.contentResolver.openInputStream(uri)
?: throw NoStackTraceException("无法打开输入流")
val fileName = MD5Utils.md5Encode(inputStreamForMd5) + ".$suffix"
file = FileUtils.createFileIfNotExist(file, "covers", fileName)
FileOutputStream(file).use {
appCtx.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(it)
}
}
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
CoverConfig.defaultCover
} else {
CoverConfig.defaultCoverDark
}
val newList =
currentCovers.split(",").filter { it.isNotBlank() }.toMutableList()
if (!newList.contains(file.absolutePath)) {
newList.add(file.absolutePath)
}
val newCovers = newList.joinToString(",")
if (preferenceKey == PreferKey.defaultCover) {
CoverConfig.defaultCover = newCovers
} else {
CoverConfig.defaultCoverDark = newCovers
}
BookCover.upDefaultCover()
}.onFailure {
appCtx.toastOnUi(it.localizedMessage)
}
}
}
}
private fun readUri(
context: Context,
uri: Uri?,
success: (fileDoc: FileDoc, inputStream: InputStream) -> Unit
) {
uri ?: return
try {
if (uri.isContentScheme()) {
val doc = DocumentFile.fromSingleUri(context, uri)
doc ?: throw NoStackTraceException("未获取到文件")
val fileDoc = FileDoc.fromDocumentFile(doc)
context.contentResolver.openInputStream(uri)!!.use { inputStream ->
success.invoke(fileDoc, inputStream)
}
} else {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.onGranted {
RealPathUtil.getPath(context, uri)?.let { path ->
val file = File(path)
val fileDoc = FileDoc.fromFile(file)
FileInputStream(file).use { inputStream ->
success.invoke(fileDoc, inputStream)
}
}
}
.request()
}
} catch (e: Exception) {
e.printOnDebug()
AppLog.put("读取Uri出错\n$e", e, true)
}
}
fun removeCover(preferenceKey: String, path: String) {
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
CoverConfig.defaultCover
} else {
CoverConfig.defaultCoverDark
}
val newList = currentCovers.split(",").filter { it.isNotBlank() && it != path }
val newCovers = newList.joinToString(",")
if (preferenceKey == PreferKey.defaultCover) {
CoverConfig.defaultCover = newCovers
} else {
CoverConfig.defaultCoverDark = newCovers
}
BookCover.upDefaultCover()
}
fun updateShowName(show: Boolean, isNight: Boolean = false) {
if (isNight) {
CoverConfig.coverShowNameN = show
} else {
CoverConfig.coverShowName = show
}
BookCover.upDefaultCover()
}
fun updateShowAuthor(show: Boolean, isNight: Boolean = false) {
if (isNight) {
CoverConfig.coverShowAuthorN = show
} else {
CoverConfig.coverShowAuthor = show
}
BookCover.upDefaultCover()
}
fun updateCoverStyle() {
BookCover.upDefaultCover()
}
}
@@ -0,0 +1,154 @@
package io.legado.app.ui.config.coverConfig
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoverManageSheet(
preferenceKey: String,
onDismissRequest: () -> Unit,
viewModel: CoverConfigViewModel
) {
var showFilePicker by remember { mutableStateOf(false) }
val selectImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNotEmpty()) {
viewModel.addCoverFromUri(preferenceKey, uris)
}
}
val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
CoverConfig.defaultCover
} else {
CoverConfig.defaultCoverDark
}
val coverList = currentCovers.split(",").filter { it.isNotBlank() }
GlassModalBottomSheet(
onDismissRequest = onDismissRequest
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.default_cover),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
items(coverList) { path ->
Box {
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp
) {
AsyncImage(
model = path,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.7f),
contentScale = ContentScale.Crop
)
}
IconButton(
onClick = { viewModel.removeCover(preferenceKey, path) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(24.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp)
)
}
}
}
}
item {
Surface(
onClick = { showFilePicker = true },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.7f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
if (showFilePicker) {
FilePickerSheet(
onDismissRequest = { showFilePicker = false },
onSelectSysFiles = {
selectImages.launch("image/*")
showFilePicker = false
},
allowExtensions = arrayOf("jpg", "jpeg", "png", "webp")
)
}
}
@@ -0,0 +1,126 @@
package io.legado.app.ui.config.coverConfig
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.model.BookCover
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import splitties.init.appCtx
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoverRuleConfigSheet(
onDismissRequest: () -> Unit
) {
var enable by remember { mutableStateOf(false) }
var searchUrl by remember { mutableStateOf("") }
var coverRule by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
val rule = withContext(Dispatchers.IO) {
BookCover.getCoverRule()
}
enable = rule.enable
searchUrl = rule.searchUrl
coverRule = rule.coverRule
}
GlassModalBottomSheet(
onDismissRequest = onDismissRequest
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = stringResource(R.string.cover_rule),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = enable,
onCheckedChange = { enable = it }
)
Text(text = stringResource(R.string.enable))
}
InputSettingItem(
title = "搜索URL",
value = searchUrl,
onConfirm = { searchUrl = it }
)
InputSettingItem(
title = "封面规则",
value = coverRule,
onConfirm = { coverRule = it }
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = {
BookCover.delCoverRule()
onDismissRequest()
}) {
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Button(onClick = {
if (searchUrl.isBlank() || coverRule.isBlank()) {
appCtx.toastOnUi("搜索url和cover规则不能为空")
} else {
BookCover.CoverRule(enable, searchUrl, coverRule).let { config ->
BookCover.saveCoverRule(config)
}
onDismissRequest()
}
}) {
Text(stringResource(R.string.ok))
}
}
}
}
}
@@ -0,0 +1,19 @@
package io.legado.app.ui.config.mainConfig
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
object MainConfig {
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRSS by prefDelegate(PreferKey.showRss, true)
var showBottomView by prefDelegate(PreferKey.showBottomView, true)
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)
var navExtended by prefDelegate("navExtended", false)
var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
}
@@ -1,39 +1,16 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.main
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.view.Gravity
import android.view.MenuItem
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.get
import androidx.core.view.postDelayed
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomnavigation.LabelVisibilityMode
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.base.BaseComposeActivity
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ActivityMainBinding
import io.legado.app.help.AppWebDav
import io.legado.app.help.book.BookHelp
import io.legado.app.help.config.AppConfig
@@ -46,114 +23,36 @@ import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog
import io.legado.app.ui.about.UpdateDialog
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
import io.legado.app.ui.main.bookshelf.books.BookshelfFragment1
import io.legado.app.ui.main.bookshelf.books.BookshelfFragment2
import io.legado.app.ui.main.bookshelf.books.BookshelfFragment3
import io.legado.app.ui.main.bookshelf.books.BookshelfFragment4
import io.legado.app.ui.main.explore.ExploreFragment
import io.legado.app.ui.main.my.MyFragment
import io.legado.app.ui.main.rss.RssFragment
import io.legado.app.ui.welcome.WelcomeActivity
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.gone
import io.legado.app.utils.observeEvent
import io.legado.app.utils.setNavigationBarColorAuto
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.themeColor
import io.legado.app.utils.toggleSystemBar
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* 主界面
*/
open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
BottomNavigationView.OnNavigationItemSelectedListener,
BottomNavigationView.OnNavigationItemReselectedListener {
open class MainActivity : BaseComposeActivity() {
override val binding by viewBinding(ActivityMainBinding::inflate)
override val viewModel by viewModels<MainViewModel>()
private val idBookshelf = 0
private val idBookshelf1 = 11
private val idBookshelf2 = 12
private val idBookshelf3 = 13
private val idBookshelf4 = 14
private val idExplore = 1
private val idRss = 2
private val idMy = 3
private var bookshelfReselected: Long = 0
private var exploreReselected: Long = 0
private var pagePosition = 0
private val fragmentMap = hashMapOf<Int, Fragment>()
private var bottomMenuCount = 4
private val realPositions = arrayOf(idBookshelf, idExplore, idRss, idMy)
private val swipeAnimation = AppConfig.swipeAnimation
private val adapter by lazy {
TabFragmentPageAdapter(this)
}
private lateinit var backCallback: OnBackPressedCallback
private val badge by lazy {
getNavigationBarView().getOrCreateBadge(R.id.menu_bookshelf)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
recreate()
}
private fun setupBackCallback() {
backCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (pagePosition != 0) {
binding.viewPagerMain.currentItem = 0
return
}
}
}
onBackPressedDispatcher.addCallback(this, backCallback)
}
private val viewModel by viewModel<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
toggleSystemBar(AppConfig.showStatusBar)
if (checkStartupRoute()) return
setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
if (savedInstanceState != null) {
pagePosition = savedInstanceState.getInt("currentPagePosition", 0)
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S)
binding.viewPagerMain.fitsSystemWindows = true
// 其他初始化逻辑
setupBackCallback()
if (checkStartupRoute()) return
// 智能自启:如果上次是手动开启状态(web_service_auto 为 true),则自启
if (AppConfig.webServiceAutoStart) {
WebService.startForeground(this)
}
upBottomMenu()
initView()
upHomePage()
}
override fun onResume() {
super.onResume()
toggleSystemBar(AppConfig.showStatusBar)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
lifecycleScope.launch {
//版本更新
upVersion()
@@ -164,51 +63,27 @@ open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
//自动更新书籍
val isAutoRefreshedBook = savedInstanceState?.getBoolean("isAutoRefreshedBook") ?: false
if (AppConfig.autoRefreshBook && !isAutoRefreshedBook) {
binding.viewPagerMain.postDelayed(1000) {
viewModel.upAllBookToc()
}
}
binding.viewPagerMain.postDelayed(3000) {
viewModel.postLoad()
viewModel.upAllBookToc()
}
viewModel.postLoad()
}
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
val index = when (item.itemId) {
R.id.menu_bookshelf -> 0
R.id.menu_discovery -> realPositions.indexOf(idExplore)
R.id.menu_rss -> realPositions.indexOf(idRss)
R.id.menu_my_config -> realPositions.indexOf(idMy)
else -> 0
}
binding.viewPagerMain.setCurrentItem(index, swipeAnimation)
return true
}
@Composable
override fun Content() {
val orientation = resources.configuration.orientation
val smallestWidthDp = resources.configuration.smallestScreenWidthDp
val tabletInterface = AppConfig.tabletInterface
override fun onNavigationItemReselected(item: MenuItem) {
when (item.itemId) {
R.id.menu_bookshelf -> {
val fragment = fragmentMap[getFragmentId(0)] as? BookshelfFragment2
if (fragment != null && fragment.groupId != BookGroup.IdRoot) {
fragment.back()
} else {
if (System.currentTimeMillis() - bookshelfReselected > 300) {
bookshelfReselected = System.currentTimeMillis()
} else {
fragment?.gotoTop()
}
}
}
R.id.menu_discovery -> {
if (System.currentTimeMillis() - exploreReselected > 300) {
exploreReselected = System.currentTimeMillis()
} else {
(fragmentMap[1] as? ExploreFragment)?.compressExplore()
}
}
val useRail = when (tabletInterface) {
"always" -> true
"landscape" -> orientation == Configuration.ORIENTATION_LANDSCAPE
"off" -> false
"auto" -> smallestWidthDp >= 600
else -> false
}
MainScreen(useRail = useRail)
}
private fun checkStartupRoute(): Boolean {
@@ -226,12 +101,6 @@ open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
}
}
private fun initView() = binding.run {
viewPagerMain.offscreenPageLimit = 3
viewPagerMain.adapter = adapter
viewPagerMain.registerOnPageChangeCallback(PageChangeCallback())
}
/**
* 版本更新日志
*/
@@ -275,10 +144,6 @@ open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
}
}
/**
* 设置本地密码
*/
private fun notifyAppCrash() {
if (!LocalConfig.appCrash || BuildConfig.DEBUG) {
return
@@ -316,7 +181,6 @@ open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("currentPagePosition", pagePosition)
if (AppConfig.autoRefreshBook) {
outState.putBoolean("isAutoRefreshedBook", true)
}
@@ -332,268 +196,6 @@ open class MainActivity : VMBaseActivity<ActivityMainBinding, MainViewModel>(),
}
}
/**
* 如果重启太快fragment不会重建,这里更新一下书架的排序
*/
override fun recreate() {
(fragmentMap[getFragmentId(0)] as? BaseBookshelfFragment)?.run {
upSort()
}
super.recreate()
}
override fun observeLiveBus() {
super.observeLiveBus()
viewModel.onUpBooksLiveData.observe(this) {
badge.isVisible = it > 0
badge.number = it
}
observeEvent<Boolean>(EventBus.NOTIFY_MAIN) {
binding.apply {
upBottomMenu()
updateBookshelfIcon(true)
if (it) {
viewPagerMain.setCurrentItem(bottomMenuCount - 1, false)
}
}
}
observeEvent<String>(PreferKey.threadCount) {
viewModel.upPool()
}
}
private fun getNavigationBarView(): NavigationBarView {
val tabletInterface = AppConfig.tabletInterface
val orientation = resources.configuration.orientation
val smallestWidthDp = resources.configuration.smallestScreenWidthDp
val useRail = when (tabletInterface) {
"always" -> true
"landscape" -> orientation == Configuration.ORIENTATION_LANDSCAPE
"off" -> false
"auto" -> smallestWidthDp >= 600
else -> false
}
return if (useRail) binding.navigationRailView else binding.bottomNavigationView
}
@SuppressLint("NotifyDataSetChanged")
private fun upBottomMenu() {
val navView = getNavigationBarView()
val menu = navView.menu
menu.findItem(R.id.menu_discovery).isVisible = AppConfig.showDiscovery
menu.findItem(R.id.menu_rss).isVisible = AppConfig.showRSS
var index = 0
if (AppConfig.showDiscovery) {
index++
realPositions[index] = idExplore
}
if (AppConfig.showRSS) {
index++
realPositions[index] = idRss
}
index++
realPositions[index] = idMy
bottomMenuCount = index + 1
binding.viewPagerMain.adapter?.notifyDataSetChanged()
if (AppConfig.showBottomView) {
window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
val navView = getNavigationBarView()
navView.visible()
if (navView === binding.bottomNavigationView) {
binding.navigationRailView.gone()
} else {
binding.bottomNavigationView.gone()
}
navView.labelVisibilityMode = when (AppConfig.labelVisibilityMode) {
"auto" -> LabelVisibilityMode.LABEL_VISIBILITY_AUTO
"selected" -> LabelVisibilityMode.LABEL_VISIBILITY_SELECTED
"labeled" -> LabelVisibilityMode.LABEL_VISIBILITY_LABELED
"unlabeled" -> LabelVisibilityMode.LABEL_VISIBILITY_UNLABELED
else -> LabelVisibilityMode.LABEL_VISIBILITY_AUTO
}
} else {
window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
binding.bottomNavigationView.gone()
binding.navigationRailView.gone()
}
val lp =
binding.navigationRailView.headerView!!.layoutParams as FrameLayout.LayoutParams
lp.gravity = Gravity.START
binding.navigationRailView.headerView!!
.setPadding(
binding.navigationRailView.itemActiveIndicatorExpandedMarginHorizontal,
0,
binding.navigationRailView.itemActiveIndicatorExpandedMarginHorizontal,
0)
val efab =
binding.navigationRailView.headerView!!.findViewById<ExtendedFloatingActionButton>(R.id.nav_fab)
val button =
binding.navigationRailView.headerView!!.findViewById<ImageView>(R.id.nav_botton)
efab.let {
if (LocalConfig.navExtended) {
it.isExtended = true
binding.navigationRailView.expand()
button.setImageResource(R.drawable.ic_menu_open)
} else {
it.isExtended = false
binding.navigationRailView.collapse()
button.setImageResource(R.drawable.ic_menu)
}
}
efab.setOnClickListener {
startActivity<SearchActivity>()
}
button.setOnClickListener {
efab.let { it1 ->
if (it1.isExtended) {
efab.isExtended = false
binding.navigationRailView.collapse()
button.setImageResource(R.drawable.ic_menu)
} else {
efab.isExtended = true
binding.navigationRailView.expand()
button.setImageResource(R.drawable.ic_menu_open)
}
LocalConfig.navExtended = it1.isExtended
}
}
navView.setOnItemSelectedListener { onNavigationItemSelected(it) }
navView.setOnItemReselectedListener { onNavigationItemReselected(it) }
binding.viewPagerMain.post {
val currentPosition = if (pagePosition < bottomMenuCount) pagePosition else 0
binding.viewPagerMain.setCurrentItem(currentPosition, false)
getNavigationBarView().menu[currentPosition].isChecked = true
updateBackCallbackState()
}
}
private fun updateBookshelfIcon(isRoot: Boolean) {
val navView = getNavigationBarView()
val bookshelfMenuItem = navView.menu.findItem(R.id.menu_bookshelf) ?: return
if (isRoot) {
bookshelfMenuItem.setIcon(R.drawable.ic_bottom_books)
} else {
val currentFragment = fragmentMap[getFragmentId(0)]
if (currentFragment is BookshelfFragment2) {
bookshelfMenuItem.setIcon(R.drawable.ic_arrow_back)
} else {
bookshelfMenuItem.setIcon(R.drawable.ic_bottom_books)
}
}
}
private fun upHomePage() {
when (AppConfig.defaultHomePage) {
"bookshelf" -> {}
"explore" -> if (AppConfig.showDiscovery && AppConfig.showBottomView) {
binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idExplore), false)
}
"rss" -> if (AppConfig.showRSS && AppConfig.showBottomView) {
binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idRss), false)
}
"my" -> if (AppConfig.showBottomView) {
binding.viewPagerMain.setCurrentItem(realPositions.indexOf(idMy), false)
}
}
}
private fun getFragmentId(position: Int): Int {
val id = realPositions[position]
if (id == idBookshelf) {
return when (AppConfig.bookGroupStyle) {
0 -> idBookshelf1
1 -> idBookshelf2
2 -> idBookshelf3
3 -> idBookshelf4
else -> idBookshelf1
}
}
return id
}
private inner class PageChangeCallback : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
pagePosition = position
getNavigationBarView().selectedItemId = when (realPositions[position]) {
idBookshelf -> R.id.menu_bookshelf
idExplore -> R.id.menu_discovery
idRss -> R.id.menu_rss
idMy -> R.id.menu_my_config
else -> R.id.menu_bookshelf
}
if (position == 0) {
val fragment = fragmentMap[getFragmentId(0)] as? BookshelfFragment2
updateBookshelfIcon(fragment?.groupId == BookGroup.IdRoot)
} else {
updateBookshelfIcon(true)
}
updateBackCallbackState()
}
}
private fun updateBackCallbackState() {
backCallback.isEnabled = (pagePosition != 0)
}
private inner class TabFragmentPageAdapter(
activity: FragmentActivity
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = bottomMenuCount
override fun createFragment(position: Int): Fragment {
val fragment = when (getFragmentId(position)) {
idBookshelf1 -> BookshelfFragment1(position)
idBookshelf2 -> BookshelfFragment2(position).apply {
this.onGroupIdChangedListener = { isRoot ->
if (pagePosition == 0) {
updateBookshelfIcon(isRoot)
}
}
}
idBookshelf3 -> BookshelfFragment3(position)
idBookshelf4 -> BookshelfFragment4(position)
idExplore -> ExploreFragment(position)
idRss -> RssFragment(position)
else -> MyFragment.newInstance(position)
}
fragmentMap[getFragmentId(position)] = fragment
return fragment
}
override fun getItemId(position: Int): Long {
return getFragmentId(position).toLong()
}
override fun containsItem(itemId: Long): Boolean {
return (0 until bottomMenuCount).any { getItemId(it) == itemId }
}
}
}
class LauncherW : MainActivity()
@@ -0,0 +1,53 @@
package io.legado.app.ui.main
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.R
sealed class MainDestination(
val route: String,
@StringRes val labelId: Int,
val icon: ImageVector,
val selectedIcon: ImageVector
) {
object Bookshelf : MainDestination(
"bookshelf",
R.string.bookshelf,
Icons.AutoMirrored.Outlined.LibraryBooks,
Icons.AutoMirrored.Filled.LibraryBooks
)
object Explore : MainDestination(
"explore",
R.string.screen_find,
Icons.Outlined.Explore,
Icons.Default.Explore
)
object Rss : MainDestination(
"rss",
R.string.rss,
Icons.Outlined.RssFeed,
Icons.Default.RssFeed
)
object My : MainDestination(
"my",
R.string.my,
Icons.Outlined.Person,
Icons.Default.Person
)
companion object {
val mainDestinations = listOf(Bookshelf, Explore, Rss, My)
}
}
@@ -0,0 +1,207 @@
package io.legado.app.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuOpen
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FlexibleBottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.explore.ExploreScreen
import io.legado.app.ui.main.my.MyScreen
import io.legado.app.ui.main.rss.RssScreen
import io.legado.app.ui.theme.regularHazeEffect
import io.legado.app.ui.widget.components.GlassDefaults
import io.legado.app.utils.startActivity
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MainScreen(
viewModel: MainViewModel = koinViewModel(),
useRail: Boolean
) {
val context = LocalContext.current
val uiState by viewModel.state.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
val hazeState = remember { HazeState() }
val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) {
MainDestination.mainDestinations.filter {
when (it) {
MainDestination.Explore -> MainConfig.showDiscovery
MainDestination.Rss -> MainConfig.showRSS
else -> true
}
}
}
val initialPage = remember(destinations) {
val index = destinations.indexOfFirst { it.route == MainConfig.defaultHomePage }
if (index != -1) index else 0
}
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
Row(modifier = Modifier.fillMaxSize()) {
if (useRail && MainConfig.showBottomView) {
NavigationRail(
header = {
IconButton(onClick = { MainConfig.navExtended = !MainConfig.navExtended }) {
Icon(
if (MainConfig.navExtended) Icons.AutoMirrored.Filled.MenuOpen else Icons.Default.Menu,
contentDescription = null
)
}
ExtendedFloatingActionButton(
onClick = { context.startActivity<SearchActivity>() },
expanded = MainConfig.navExtended,
icon = { Icon(Icons.Default.Search, contentDescription = null) },
text = { Text(text = stringResource(R.string.search)) }
)
}
) {
destinations.forEachIndexed { index, destination ->
val selected = pagerState.currentPage == index
NavigationRailItem(
selected = selected,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
icon = {
NavigationIcon(destination, selected, uiState.upBooksCount)
},
label = { Text(stringResource(destination.labelId)) }
)
}
}
}
Scaffold(
modifier = Modifier.weight(1f),
bottomBar = {
if (!useRail && MainConfig.showBottomView) {
FlexibleBottomAppBar(
modifier = Modifier.regularHazeEffect(state = hazeState),
containerColor = GlassDefaults.glassColor(
noBlurColor = BottomAppBarDefaults.containerColor,
blurAlpha = GlassDefaults.DefaultBlurAlpha
)
) {
destinations.forEachIndexed { index, destination ->
val selected = pagerState.currentPage == index
NavigationBarItem(
selected = selected,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
icon = {
NavigationIcon(destination, selected, uiState.upBooksCount)
},
colors = NavigationBarItemDefaults.colors(
indicatorColor = GlassDefaults.glassColor(
noBlurColor = MaterialTheme.colorScheme.primaryContainer,
blurAlpha = GlassDefaults.ThickBlurAlpha
),
),
label = { Text(stringResource(destination.labelId)) },
alwaysShowLabel = false
)
}
}
}
},
contentWindowInsets = WindowInsets(0)
) { _ ->
Box(modifier = Modifier.hazeSource(hazeState)) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = true,
beyondViewportPageCount = 1
) { page ->
when (destinations[page]) {
MainDestination.Bookshelf -> BookshelfScreen(
onBookClick = { book ->
context.startActivity<ReadBookActivity> {
putExtra("bookUrl", book.bookUrl)
}
},
onBookLongClick = { book ->
context.startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
)
MainDestination.Explore -> ExploreScreen()
MainDestination.Rss -> RssScreen()
MainDestination.My -> MyScreen(
viewModel = koinViewModel(),
onNavigate = { event ->
viewModel.onPrefClickEvent(context, event)
}
)
}
}
}
}
}
}
@Composable
private fun NavigationIcon(
destination: MainDestination,
selected: Boolean,
upBooksCount: Int
) {
val icon = if (selected) destination.selectedIcon else destination.icon
if (destination == MainDestination.Bookshelf && upBooksCount > 0) {
BadgedBox(badge = { Badge { Text(upBooksCount.toString()) } }) {
Icon(icon, contentDescription = null)
}
} else {
Icon(icon, contentDescription = null)
}
}
@@ -1,251 +1,47 @@
package io.legado.app.ui.main
import android.app.Application
import androidx.lifecycle.MutableLiveData
import android.content.Context
import android.content.Intent
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.help.AppWebDav
import io.legado.app.help.DefaultData
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isUpError
import io.legado.app.help.book.removeType
import io.legado.app.help.book.sync
import io.legado.app.help.config.AppConfig
import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook
import io.legado.app.model.SourceCallBack
import io.legado.app.model.webBook.WebBook
import io.legado.app.service.CacheBookService
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import io.legado.app.ui.main.my.PrefClickEvent
import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.sendToClip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.min
data class MainUiState(
val upBooksCount: Int = 0
)
class MainViewModel(application: Application) : BaseViewModel(application) {
private var threadCount = AppConfig.threadCount
private var poolSize = min(threadCount, AppConst.MAX_THREAD)
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
private val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
val onUpBooksLiveData = MutableLiveData<Int>()
private var upTocJob: Job? = null
private var cacheBookJob: Job? = null
private val eventListenerSource = ConcurrentHashMap<BookSource, Boolean>()
val booksListRecycledViewPool = RecycledViewPool().apply {
setMaxRecycledViews(0, 30)
}
val booksGridRecycledViewPool = RecycledViewPool().apply {
setMaxRecycledViews(0, 100)
}
private val upBooksCountFlow = MutableStateFlow(0)
val state: StateFlow<MainUiState> = upBooksCountFlow
.map { count -> MainUiState(count) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainUiState())
init {
viewModelScope.launch {
FlowEventBus.with<Int>(EventBus.UP_BOOKSHELF_COUNT).collect {
upBooksCountFlow.value = it
}
}
deleteNotShelfBook()
}
override fun onCleared() {
super.onCleared()
upTocPool.close()
}
fun upPool() {
threadCount = AppConfig.threadCount
if (upTocJob?.isActive == true || cacheBookJob?.isActive == true) {
return
}
val newPoolSize = min(threadCount, AppConst.MAX_THREAD)
if (poolSize == newPoolSize) {
return
}
poolSize = newPoolSize
upTocPool.close()
upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
}
fun isUpdate(bookUrl: String): Boolean {
return onUpTocBooks.contains(bookUrl)
}
fun upAllBookToc() {
execute {
addToWaitUp(appDb.bookDao.hasUpdateBooks)
}
}
fun upToc(books: List<Book>) {
execute(context = upTocPool) {
books.filter {
!it.isLocal && it.canUpdate
}.let {
addToWaitUp(it)
}
}
}
@Synchronized
private fun addToWaitUp(books: List<Book>) {
books.forEach { book ->
if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) {
waitUpTocBooks.add(book.bookUrl)
}
}
if (upTocJob == null) {
startUpTocJob()
}
}
private fun startUpTocJob() {
upPool()
postUpBooksLiveData()
upTocJob = viewModelScope.launch(upTocPool) {
flow {
while (true) {
emit(waitUpTocBooks.poll() ?: break)
}
}.onEachParallel(threadCount) {
onUpTocBooks.add(it)
postEvent(EventBus.UP_BOOKSHELF, it)
updateToc(it)
}.onEach {
onUpTocBooks.remove(it)
postEvent(EventBus.UP_BOOKSHELF, it)
postUpBooksLiveData()
}.onCompletion {
upTocJob = null
if (waitUpTocBooks.isNotEmpty()) {
startUpTocJob()
}
if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
//所有目录更新完再开始缓存章节
cacheBook()
}
}.catch {
AppLog.put("更新目录出错\n${it.localizedMessage}", it)
}.collect()
}
}
private suspend fun updateToc(bookUrl: String) {
val book = appDb.bookDao.getBook(bookUrl) ?: return
val source = appDb.bookSourceDao.getBookSource(book.origin)
if (source == null) {
if (!book.isUpError) {
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
return
}
if (source.eventListener) {
// 使用 putIfAbsent 确保只添加一次
if (eventListenerSource.putIfAbsent(source, true) == null) {
// 通知监听事件的书源,书架刷新开始
SourceCallBack.callBackSource(
viewModelScope,
SourceCallBack.START_SHELF_REFRESH,
source
)
}
}
kotlin.runCatching {
val oldBook = book.copy()
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(source, book)
} else {
WebBook.runPreUpdateJs(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
book.sync(oldBook)
book.removeType(BookType.updateError)
if (book.bookUrl == bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.replace(oldBook, book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(bookUrl)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadBook.onChapterListUpdated(book)
addDownload(source, book)
}.onFailure {
currentCoroutineContext().ensureActive()
AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it)
//这里可能因为时间太长书籍信息已经更改,所以重新获取
appDb.bookDao.getBook(book.bookUrl)?.let { book ->
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
}
}
fun postUpBooksLiveData(reset: Boolean = false) {
if (AppConfig.showWaitUpCount) {
onUpBooksLiveData.postValue(waitUpTocBooks.size + onUpTocBooks.size)
} else if (reset) {
onUpBooksLiveData.postValue(0)
}
}
@Synchronized
private fun addDownload(source: BookSource, book: Book) {
if (AppConfig.preDownloadNum == 0) return
val endIndex = min(
book.totalChapterNum - 1,
book.durChapterIndex.plus(AppConfig.preDownloadNum)
)
val cacheBook = CacheBook.getOrCreate(source, book)
cacheBook.addDownload(book.durChapterIndex, endIndex)
}
/**
* 缓存书籍
*/
private fun cacheBook() {
//开始缓存前,通知监听事件的书源,书架刷新已完成
eventListenerSource.toList().forEach {
SourceCallBack.callBackSource(
viewModelScope,
SourceCallBack.END_SHELF_REFRESH,
it.first
)
}
eventListenerSource.clear()
if (AppConfig.preDownloadNum == 0) return
cacheBookJob?.cancel()
cacheBookJob = viewModelScope.launch(upTocPool) {
launch {
while (isActive && CacheBook.isRun) {
val isOnUpTocBooksEmpty =
onUpTocBooks.isEmpty()
//有目录更新是不缓存,优先更新目录,现在更多网站限制并发
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && isOnUpTocBooksEmpty)
delay(1000)
}
}
CacheBook.startProcessJob(upTocPool)
}
FlowEventBus.post(EventBus.UP_ALL_BOOK_TOC, Unit)
}
fun postLoad() {
@@ -270,4 +66,34 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
}
}
}
fun onPrefClickEvent(context: Context, event: PrefClickEvent) {
when (event) {
is PrefClickEvent.OpenUrl -> context.startActivity(
Intent(
Intent.ACTION_VIEW,
android.net.Uri.parse(event.url)
)
)
is PrefClickEvent.CopyUrl -> context.sendToClip(event.url)
is PrefClickEvent.ShowMd -> {
// Handle showing MD dialog
}
is PrefClickEvent.StartActivity -> {
context.startActivity(Intent(context, event.destination).apply {
event.configTag?.let { putExtra("configTag", it) }
})
}
PrefClickEvent.ExitApp -> {
if (context is androidx.activity.ComponentActivity) {
context.finish()
}
}
else -> Unit
}
}
}
@@ -1,179 +0,0 @@
package io.legado.app.ui.main.bookshelf
import android.annotation.SuppressLint
import android.view.Menu
import android.view.MenuItem
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import io.legado.app.R
import io.legado.app.base.VMBaseFragment
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.help.DirectLinkUpload
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.book.group.GroupManageDialog
import io.legado.app.ui.book.import.local.ImportBookActivity
import io.legado.app.ui.book.import.remote.RemoteBookActivity
import io.legado.app.ui.book.manage.BookshelfManageActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.main.MainFragmentInterface
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.readText
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
abstract class BaseBookshelfFragment(layoutId: Int) : VMBaseFragment<BookshelfViewModel>(layoutId),
MainFragmentInterface {
override val position: Int? get() = arguments?.getInt("position")
val activityViewModel by activityViewModels<MainViewModel>()
override val viewModel by viewModels<BookshelfViewModel>()
private val importBookshelf = registerForActivityResult(HandleFileContract()) {
kotlin.runCatching {
it.uri?.readText(requireContext())?.let { text ->
viewModel.importBookshelf(text, groupId)
}
}.onFailure {
toastOnUi(it.localizedMessage ?: "ERROR")
}
}
private val exportResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
requireContext().sendToClip(uri.toString())
}
}
}
}
abstract val groupId: Long
abstract val books: List<Book>
private var groupsLiveData: LiveData<List<BookGroup>>? = null
private val waitDialog by lazy {
WaitDialog(requireContext()).apply {
setOnCancelListener {
viewModel.addBookJob?.cancel()
}
}
}
abstract fun gotoTop()
override fun onCompatCreateOptionsMenu(menu: Menu) {
menuInflater.inflate(R.menu.main_bookshelf, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem) {
super.onCompatOptionsItemSelected(item)
when (item.itemId) {
R.id.menu_remote -> startActivity<RemoteBookActivity>()
R.id.menu_search -> startActivity<SearchActivity>()
R.id.menu_update_toc -> activityViewModel.upToc(books)
R.id.menu_bookshelf_layout -> showDialogFragment<BookshelfConfigBottomSheet>()
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_add_local -> startActivity<ImportBookActivity>()
R.id.menu_add_url -> showAddBookByUrlAlert()
R.id.menu_bookshelf_manage -> startActivity<BookshelfManageActivity> {
putExtra("groupId", groupId)
}
R.id.menu_download -> startActivity<CacheActivity> {
putExtra("groupId", groupId)
}
R.id.menu_export_bookshelf -> viewModel.exportBookshelf(books) { file ->
exportResult.launch {
mode = HandleFileContract.EXPORT
fileData =
HandleFileContract.FileData("bookshelf.json", file, "application/json")
}
}
R.id.menu_import_bookshelf -> importBookshelfAlert(groupId)
R.id.menu_log -> showDialogFragment<AppLogDialog>()
}
}
protected fun initBookGroupData() {
groupsLiveData?.removeObservers(viewLifecycleOwner)
groupsLiveData = appDb.bookGroupDao.show.apply {
observe(viewLifecycleOwner) {
upGroup(it)
}
}
}
abstract fun upGroup(data: List<BookGroup>)
abstract fun upSort()
override fun observeLiveBus() {
viewModel.addBookProgressLiveData.observe(this) { count ->
if (count < 0) {
waitDialog.dismiss()
} else {
waitDialog.setText("添加中... ($count)")
}
}
}
@SuppressLint("InflateParams")
fun showAddBookByUrlAlert() {
alert(titleResource = R.string.add_book_url) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url"
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
waitDialog.setText("添加中...")
waitDialog.show()
viewModel.addBookByUrl(it)
}
}
cancelButton()
}
}
private fun importBookshelfAlert(groupId: Long) {
alert(titleResource = R.string.import_bookshelf) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url/json"
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
viewModel.importBookshelf(it, groupId)
}
}
cancelButton()
neutralButton(R.string.select_file) {
importBookshelf.launch {
mode = HandleFileContract.FILE
allowExtensions = arrayOf("txt", "json")
}
}
}
}
}
@@ -0,0 +1,325 @@
package io.legado.app.ui.main.bookshelf
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.cover.BookCover
/**
* 通用的书架条目布局组件
* 支持 列表/网格 模式及 紧凑/常规 样式
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookshelfItem(
isGrid: Boolean,
isCompact: Boolean,
cover: @Composable (Modifier) -> Unit,
title: String,
modifier: Modifier = Modifier,
subTitle: String? = null,
desc: String? = null,
extra: @Composable (RowScope.() -> Unit)? = null,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
titleMaxLines: Int = 2,
coverShadow: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
if (isGrid) {
Box(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.padding(4.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val coverModifier = if (coverShadow) {
Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(4.dp))
} else {
Modifier.fillMaxWidth()
}
Box {
cover(coverModifier)
if (isCompact) {
Text(
text = title,
style = (if (titleSmallFont) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium).copy(
color = Color.White,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.5f),
blurRadius = 4f
)
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
.padding(horizontal = 6.dp, vertical = 6.dp)
)
}
}
if (!isCompact) {
Text(
text = title,
style = if (titleSmallFont) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
)
}
}
}
} else {
// 列表布局
Column {
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
cover(
Modifier
.width(if (!isCompact) 70.dp else 55.dp)
.padding(end = 12.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
maxLines = if (!isCompact) 2 else 1,
overflow = TextOverflow.Ellipsis
)
subTitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
if (!isCompact) {
desc?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
}
extra?.let {
Row(
modifier = Modifier.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
content = it
)
}
}
}
if (!isCompact) HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
}
}
}
@Composable
fun BookGroupCover(
books: List<Book>,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.aspectRatio(5f / 7f)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(0)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(1)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
}
Row(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(2)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
) {
books.getOrNull(3)?.let {
BookCover(
name = it.name,
author = it.author,
path = it.getDisplayCover(),
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
@Composable
fun BookGroupItemGrid(
group: BookGroup,
previewBooks: List<Book>,
isCompact: Boolean = false,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
titleMaxLines: Int = 2,
coverShadow: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
BookshelfItem(
isGrid = true,
isCompact = isCompact,
cover = { BookGroupCover(books = previewBooks, modifier = it) },
title = group.groupName,
modifier = modifier,
titleSmallFont = titleSmallFont,
titleCenter = titleCenter,
titleMaxLines = titleMaxLines,
coverShadow = coverShadow,
onClick = onClick,
onLongClick = onLongClick
)
}
@Composable
fun BookGroupItemList(
group: BookGroup,
previewBooks: List<Book>,
isCompact: Boolean = false,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
titleMaxLines: Int = 2,
coverShadow: Boolean = false,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
BookshelfItem(
isGrid = false,
isCompact = isCompact,
cover = { BookGroupCover(books = previewBooks, modifier = it) },
title = group.groupName,
titleSmallFont = titleSmallFont,
titleCenter = titleCenter,
titleMaxLines = titleMaxLines,
coverShadow = coverShadow,
modifier = modifier,
subTitle = "${previewBooks.size} 本书籍",
desc = "点击打开文件夹",
onClick = onClick,
onLongClick = onLongClick
)
}
@@ -1,228 +0,0 @@
package io.legado.app.ui.main.bookshelf
import android.content.res.Configuration
import android.os.Bundle
import android.view.View
import androidx.fragment.app.activityViewModels
import com.google.android.material.chip.Chip
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogBookshelfConfigBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.widget.number.NumberPickerDialog
import io.legado.app.utils.bookshelfLayoutGrid
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
class BookshelfConfigBottomSheet : BaseBottomSheetDialogFragment(R.layout.dialog_bookshelf_config) {
private val binding by viewBinding(DialogBookshelfConfigBinding::bind)
private val activityViewModel by activityViewModels<MainViewModel>()
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val orientation = requireContext().resources.configuration.orientation
val bookshelfLayout = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
AppConfig.bookshelfLayoutModeLandscape
} else {
AppConfig.bookshelfLayoutModePortrait
}
val columnCount = requireContext().bookshelfLayoutGrid.takeIf { it > 0 } ?: 1
val bookshelfSort = AppConfig.bookshelfSort
binding.apply {
resources.getStringArray(R.array.group_style).forEachIndexed { index, label ->
val chip = Chip(context).apply {
text = label
isCheckable = true
isClickable = true
id = View.generateViewId()
}
chipGroupStyle.addView(chip)
if (index == AppConfig.bookGroupStyle) {
chipGroupStyle.check(chip.id)
}
}
resources.getStringArray(R.array.bookshelf_px_array).forEachIndexed { index, label ->
val chip = Chip(context).apply {
text = label
isCheckable = true
isClickable = true
id = index
}
chipGroupSort.addView(chip)
if (index == AppConfig.bookshelfSort) {
chipGroupSort.check(chip.id)
}
}
swShowUnread.isChecked = AppConfig.showUnread
swShowUnreadNew.isChecked = AppConfig.showUnreadNew
swShowTip.isChecked = AppConfig.showTip
swShowLastUpdateTime.isChecked = AppConfig.showLastUpdateTime
swShowWaitUpBooks.isChecked = AppConfig.showWaitUpCount
swShowBookshelfFastScroller.isChecked = AppConfig.showBookshelfFastScroller
swShowBookshelfTabMenu.isChecked = AppConfig.shouldShowExpandButton
when (bookshelfLayout) {
0 -> chipGroupLayout.check(R.id.chip_list)
1 -> chipGroupLayout.check(R.id.chip_grid)
2 -> chipGroupLayout.check(R.id.chip_grid_compact)
3 -> chipGroupLayout.check(R.id.chip_grid_cover)
4 -> chipGroupLayout.check(R.id.chip_list_compact)
}
sliderGridCount.progress = columnCount
updateControlsEnableState()
chipGroupLayout.setOnCheckedStateChangeListener { group, checkedIds ->
updateControlsEnableState()
}
chipGroupStyle.setOnCheckedStateChangeListener { group, checkedIds ->
updateControlsEnableState()
}
binding.tvValue.text = if (AppConfig.bookshelfRefreshingLimit <= 0) "无限制"
else "${AppConfig.bookshelfRefreshingLimit}"
binding.tvValue.setOnClickListener {
showNumberPicker()
}
binding.layoutRefreshLimit.setOnClickListener {
showNumberPicker()
}
when (AppConfig.bookshelfSortOrder) {
0 -> binding.chipGroupOrder.check(binding.chipAsc.id)
1 -> binding.chipGroupOrder.check(binding.chipDesc.id)
}
btnOk.setOnClickListener {
var notifyMain = false
var recreate = false
if (AppConfig.bookGroupStyle != chipGroupStyle.checkedChipId) {
AppConfig.bookGroupStyle = chipGroupStyle.indexOfChild(
chipGroupStyle.findViewById(chipGroupStyle.checkedChipId)
)
notifyMain = true
}
if (AppConfig.showUnread != swShowUnread.isChecked) {
AppConfig.showUnread = swShowUnread.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (AppConfig.showUnreadNew != swShowUnreadNew.isChecked) {
AppConfig.showUnreadNew = swShowUnreadNew.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (AppConfig.showTip != swShowTip.isChecked) {
AppConfig.showTip = swShowTip.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (AppConfig.showLastUpdateTime != swShowLastUpdateTime.isChecked) {
AppConfig.showLastUpdateTime = swShowLastUpdateTime.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (AppConfig.showWaitUpCount != swShowWaitUpBooks.isChecked) {
AppConfig.showWaitUpCount = swShowWaitUpBooks.isChecked
activityViewModel.postUpBooksLiveData(true)
}
if (AppConfig.showBookshelfFastScroller != swShowBookshelfFastScroller.isChecked) {
AppConfig.showBookshelfFastScroller = swShowBookshelfFastScroller.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (AppConfig.shouldShowExpandButton != swShowBookshelfTabMenu.isChecked) {
AppConfig.shouldShowExpandButton = swShowBookshelfTabMenu.isChecked
postEvent(EventBus.BOOKSHELF_REFRESH, "")
}
if (bookshelfSort != chipGroupSort.checkedChipId) {
AppConfig.bookshelfSort = chipGroupSort.checkedChipId
(requireParentFragment() as? BaseBookshelfFragment)?.upSort()
}
val selectedColumn = sliderGridCount.progress
val newLayout = when (chipGroupLayout.checkedChipId) {
R.id.chip_list -> 0
R.id.chip_grid -> 1
R.id.chip_grid_compact -> 2
R.id.chip_grid_cover -> 3
R.id.chip_list_compact -> 4
else -> 0
}
val oldColumn = requireContext().bookshelfLayoutGrid
if (bookshelfLayout != newLayout || oldColumn != selectedColumn) {
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
AppConfig.bookshelfLayoutModeLandscape = newLayout
AppConfig.bookshelfLayoutGridLandscape = selectedColumn
} else {
AppConfig.bookshelfLayoutModePortrait = newLayout
AppConfig.bookshelfLayoutGridPortrait = selectedColumn
}
if (newLayout == 0 || newLayout == 4) {
activityViewModel.booksGridRecycledViewPool.clear()
} else {
activityViewModel.booksListRecycledViewPool.clear()
}
recreate = true
}
val newOrder = when (binding.chipGroupOrder.checkedChipId) {
binding.chipAsc.id -> 0
binding.chipDesc.id -> 1
else -> 1
}
if (AppConfig.bookshelfSortOrder != newOrder) {
AppConfig.bookshelfSortOrder = newOrder
(requireParentFragment() as? BaseBookshelfFragment)?.upSort()
}
if (recreate) {
dismiss()
postEvent(EventBus.RECREATE, "")
} else if (notifyMain) {
dismiss()
postEvent(EventBus.NOTIFY_MAIN, false)
}
dismiss()
}
btnCancel.setOnClickListener {
dismiss()
}
}
}
private fun showNumberPicker() {
NumberPickerDialog(requireContext())
.setTitle("书架更新数量限制")
.setMinValue(0) // 0 表示无限制
.setMaxValue(500) // 可根据需求设定最大值
.setValue(AppConfig.bookshelfRefreshingLimit)
.show { selected ->
AppConfig.bookshelfRefreshingLimit = selected
binding.tvValue.text = if (selected <= 0) "无限制" else "$selected"
}
}
private fun updateControlsEnableState() {
val checkedLayoutId = binding.chipGroupLayout.checkedChipId
val isListMode = checkedLayoutId == R.id.chip_list || checkedLayoutId == R.id.chip_list_compact
binding.swShowLastUpdateTime.isEnabled = isListMode
val checkedStyleChip = binding.chipGroupStyle.findViewById<Chip>(binding.chipGroupStyle.checkedChipId)
val isGroupStyle0 = binding.chipGroupStyle.indexOfChild(checkedStyleChip) == 0
binding.swShowBookshelfTabMenu.isEnabled = isGroupStyle0
}
}
@@ -0,0 +1,228 @@
package io.legado.app.ui.main.bookshelf
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.BlurOn
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.SortByAlpha
import androidx.compose.material.icons.filled.Style
import androidx.compose.material.icons.filled.TextFormat
import androidx.compose.material.icons.filled.ViewCompact
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfConfigSheet(
onDismissRequest: () -> Unit
) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
GlassModalBottomSheet(onDismissRequest = onDismissRequest) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.bookshelf_layout),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 16.dp)
)
// Group Style
CompactDropdownSettingItem(
title = stringResource(R.string.group_style),
selectedValue = BookshelfConfig.bookGroupStyle.toString(),
displayEntries = stringArrayResource(R.array.group_style),
entryValues = Array(stringArrayResource(R.array.group_style).size) { it.toString() },
imageVector = Icons.Default.Style,
onValueChange = { BookshelfConfig.bookGroupStyle = it.toInt() }
)
// Sort
CompactDropdownSettingItem(
title = stringResource(R.string.sort),
selectedValue = BookshelfConfig.bookshelfSort.toString(),
displayEntries = stringArrayResource(R.array.bookshelf_px_array),
entryValues = Array(stringArrayResource(R.array.bookshelf_px_array).size) { it.toString() },
imageVector = Icons.AutoMirrored.Filled.Sort,
onValueChange = { BookshelfConfig.bookshelfSort = it.toInt() }
)
// Sort Order
CompactDropdownSettingItem(
title = "排序方向",
selectedValue = BookshelfConfig.bookshelfSortOrder.toString(),
displayEntries = arrayOf(
stringResource(R.string.ascending_order),
stringResource(R.string.descending_order)
),
entryValues = arrayOf("0", "1"),
imageVector = Icons.Default.SortByAlpha,
onValueChange = { BookshelfConfig.bookshelfSortOrder = it.toInt() }
)
// Layout Mode
val layoutMode =
if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscape else BookshelfConfig.bookshelfLayoutModePortrait
CompactDropdownSettingItem(
title = "布局模式",
description = stringResource(if (isLandscape) R.string.screen_landscape else R.string.screen_portrait),
selectedValue = layoutMode.toString(),
displayEntries = arrayOf("列表", "网格"),
entryValues = arrayOf("0", "1"),
imageVector = Icons.AutoMirrored.Filled.ViewList,
onValueChange = {
if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscape = it.toInt()
else BookshelfConfig.bookshelfLayoutModePortrait = it.toInt()
}
)
SwitchSettingItem(
title = "紧凑模式",
checked = BookshelfConfig.bookshelfLayoutCompact,
imageVector = Icons.Default.ViewCompact,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.bookshelfLayoutCompact = it }
)
// Grid Count
val gridCount =
if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscape else BookshelfConfig.bookshelfLayoutGridPortrait
CompactSliderSettingItem(
title = stringResource(R.string.number_rows_columns),
value = gridCount.toFloat(),
valueRange = 1f..15f,
steps = 14,
onValueChange = {
if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscape = it.toInt()
else BookshelfConfig.bookshelfLayoutGridPortrait = it.toInt()
}
)
SwitchSettingItem(
title = "标题小字体",
checked = BookshelfConfig.bookshelfTitleSmallFont,
imageVector = Icons.Default.TextFormat,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.bookshelfTitleSmallFont = it }
)
SwitchSettingItem(
title = "标题居中",
checked = BookshelfConfig.bookshelfTitleCenter,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.bookshelfTitleCenter = it }
)
CompactSliderSettingItem(
title = "标题最大行数",
value = BookshelfConfig.bookshelfTitleMaxLines.toFloat(),
valueRange = 1f..5f,
steps = 4,
onValueChange = { BookshelfConfig.bookshelfTitleMaxLines = it.toInt() }
)
SwitchSettingItem(
title = "封面阴影",
checked = BookshelfConfig.bookshelfCoverShadow,
imageVector = Icons.Default.BlurOn,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.bookshelfCoverShadow = it }
)
// Switches
SwitchSettingItem(
title = stringResource(R.string.show_unread),
checked = BookshelfConfig.showUnread,
imageVector = Icons.Default.Notifications,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showUnread = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_unread_new),
checked = BookshelfConfig.showUnreadNew,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showUnreadNew = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_tip),
checked = BookshelfConfig.showTip,
imageVector = Icons.Default.Info,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showTip = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_last_update_time),
checked = BookshelfConfig.showLastUpdateTime,
imageVector = Icons.Default.History,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showLastUpdateTime = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_wait_up_count),
checked = BookshelfConfig.showWaitUpCount,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showWaitUpCount = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_bookshelf_fast_scroller),
checked = BookshelfConfig.showBookshelfFastScroller,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.showBookshelfFastScroller = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_bookshelf_tab_menu),
checked = BookshelfConfig.shouldShowExpandButton,
imageVector = Icons.Default.Menu,
color = MaterialTheme.colorScheme.surface,
onCheckedChange = { BookshelfConfig.shouldShowExpandButton = it }
)
// Refresh Limit
CompactSliderSettingItem(
title = stringResource(R.string.bookshelf_update_limit),
description = if (BookshelfConfig.bookshelfRefreshingLimit <= 0) "无限制" else "${BookshelfConfig.bookshelfRefreshingLimit}",
value = BookshelfConfig.bookshelfRefreshingLimit.toFloat(),
valueRange = 0f..100f,
steps = 100,
onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() }
)
}
}
}
@@ -0,0 +1,534 @@
package io.legado.app.ui.main.bookshelf
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.help.book.isLocal
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.book.import.local.ImportBookActivity
import io.legado.app.ui.book.import.remote.RemoteBookActivity
import io.legado.app.ui.book.manage.BookshelfManageActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.widget.components.cover.BookCoverWithProgress
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.utils.readText
import io.legado.app.utils.startActivity
import io.legado.app.utils.toTimeAgo
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun BookshelfScreen(
viewModel: BookshelfViewModel = koinViewModel(),
onBookClick: (Book) -> Unit,
onBookLongClick: (Book) -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val scope = rememberCoroutineScope()
var showAddUrlDialog by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
var showConfigSheet by remember { mutableStateOf(false) }
var showGroupManageSheet by remember { mutableStateOf(false) }
val importLauncher = rememberLauncherForActivityResult(HandleFileContract()) {
runCatching {
it.uri?.readText(context)?.let { text ->
val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(text, groupId)
}
}.onFailure {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
val pagerState = rememberPagerState(
initialPage = uiState.selectedGroupIndex,
pageCount = { uiState.groups.size }
)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (uiState.groups.isNotEmpty() && page < uiState.groups.size) {
viewModel.changeGroup(uiState.groups[page].groupId)
}
}
}
LaunchedEffect(uiState.selectedGroupIndex) {
if (uiState.selectedGroupIndex != pagerState.currentPage && uiState.selectedGroupIndex < pagerState.pageCount) {
pagerState.scrollToPage(uiState.selectedGroupIndex)
}
}
val bookGroupStyle = BookshelfConfig.bookGroupStyle
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
val title = if (bookGroupStyle == 2 && uiState.groups.isNotEmpty()) {
if (isInFolderRoot) "书架"
else uiState.groups.getOrNull(pagerState.currentPage)?.groupName ?: "书架"
} else {
"书架"
}
if (bookGroupStyle == 2 && !isInFolderRoot) {
BackHandler {
isInFolderRoot = true
}
}
ListScaffold(
title = title,
state = uiState,
onSearchToggle = { context.startActivity<SearchActivity>() },
onSearchQueryChange = { viewModel.setSearchKey(it) },
topBarActions = { },
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.add_remote_book)) },
onClick = { context.startActivity<RemoteBookActivity>(); dismiss() },
leadingIcon = { Icon(Icons.Default.Wifi, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.book_local)) },
onClick = { context.startActivity<ImportBookActivity>(); dismiss() },
leadingIcon = { Icon(Icons.Default.Save, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.update_toc)) },
onClick = { viewModel.upToc(uiState.items); dismiss() },
leadingIcon = { Icon(Icons.Default.Refresh, null) }
)
RoundDropdownMenuItem(
text = { Text("布局设置") },
onClick = { showConfigSheet = true; dismiss() },
leadingIcon = { Icon(Icons.Default.GridView, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.group_manage)) },
onClick = { showGroupManageSheet = true; dismiss() },
leadingIcon = { Icon(Icons.Default.Edit, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.add_url)) },
onClick = { showAddUrlDialog = true; dismiss() },
leadingIcon = { Icon(Icons.Default.Link, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.bookshelf_management)) },
onClick = {
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
context.startActivity<BookshelfManageActivity> {
putExtra("groupId", groupId)
}
dismiss()
},
leadingIcon = { Icon(Icons.Default.Settings, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.cache_export)) },
onClick = {
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
context.startActivity<CacheActivity> {
putExtra("groupId", groupId)
}
dismiss()
},
leadingIcon = { Icon(Icons.Default.Download, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.export_bookshelf)) },
onClick = {
dismiss()
},
leadingIcon = { Icon(Icons.Default.ImportExport, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.import_bookshelf)) },
onClick = { showImportSheet = true; dismiss() },
leadingIcon = { Icon(Icons.Default.FileOpen, null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.log)) },
onClick = {
dismiss()
},
leadingIcon = { Icon(Icons.Default.History, null) }
)
},
bottomContent = if (bookGroupStyle == 0) {
{
if (uiState.groups.isNotEmpty()) {
SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp,
containerColor = Color.Transparent,
divider = {}
) {
uiState.groups.forEachIndexed { index, group ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
},
text = { Text(group.groupName) }
)
}
}
}
}
} else null
) { paddingValues ->
var isRefreshing by remember { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.upAllBookToc()
delay(1000)
isRefreshing = false
}
},
state = pullToRefreshState,
modifier = Modifier.fillMaxSize(),
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding())
)
}
) {
if (bookGroupStyle == 2 && isInFolderRoot) {
if (uiState.bookshelfLayoutMode == 0) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = 56.dp
)
) {
items(uiState.groups) { group ->
BookGroupItemList(
group = group,
previewBooks = uiState.groupPreviews[group.groupId] ?: emptyList(),
isCompact = uiState.bookshelfLayoutCompact,
titleSmallFont = uiState.titleSmallFont,
titleCenter = uiState.titleCenter,
titleMaxLines = uiState.titleMaxLines,
onClick = {
val index = uiState.groups.indexOf(group)
if (index != -1) {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
}
},
onLongClick = { showGroupManageSheet = true }
)
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(uiState.bookshelfLayoutGrid),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 12.dp,
end = 12.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.groups) { group ->
BookGroupItemGrid(
group = group,
previewBooks = uiState.groupPreviews[group.groupId] ?: emptyList(),
isCompact = uiState.bookshelfLayoutCompact,
titleSmallFont = uiState.titleSmallFont,
titleCenter = uiState.titleCenter,
titleMaxLines = uiState.titleMaxLines,
coverShadow = uiState.coverShadow,
onClick = {
val index = uiState.groups.indexOf(group)
if (index != -1) {
scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false
}
},
onLongClick = { showGroupManageSheet = true }
)
}
}
}
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize(),
beyondViewportPageCount = 1
) {
BookshelfPage(
paddingValues = paddingValues,
uiState = uiState,
onBookClick = onBookClick,
onBookLongClick = onBookLongClick
)
}
}
}
}
if (showConfigSheet) {
BookshelfConfigSheet(onDismissRequest = { showConfigSheet = false })
}
if (showGroupManageSheet) {
GroupManageSheet(onDismissRequest = { showGroupManageSheet = false })
}
if (showAddUrlDialog) {
SourceInputDialog(
title = stringResource(R.string.add_book_url),
onDismissRequest = { showAddUrlDialog = false },
onConfirm = { url ->
viewModel.addBookByUrl(url)
showAddUrlDialog = false
}
)
}
if (showImportSheet) {
FilePickerSheet(
onDismissRequest = { showImportSheet = false },
title = stringResource(R.string.import_bookshelf),
onSelectSysFile = { types ->
importLauncher.launch {
mode = HandleFileContract.FILE
allowExtensions = types
}
showImportSheet = false
},
onManualInput = {
showAddUrlDialog = true
showImportSheet = false
}
)
}
if (uiState.isLoading) {
Dialog(onDismissRequest = {}) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
uiState.loadingText?.let {
Text(
text = it,
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
@Composable
fun BookshelfPage(
paddingValues: PaddingValues,
uiState: BookshelfUiState,
onBookClick: (Book) -> Unit,
onBookLongClick: (Book) -> Unit
) {
if (uiState.bookshelfLayoutMode == 0) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = 56.dp
)
) {
items(uiState.items, key = { it.bookUrl }) { book ->
BookItem(
book = book,
layoutMode = uiState.bookshelfLayoutMode,
isCompact = uiState.bookshelfLayoutCompact,
isUpdating = uiState.updatingBooks.contains(book.bookUrl),
onClick = { onBookClick(book) },
onLongClick = { onBookLongClick(book) }
)
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(uiState.bookshelfLayoutGrid),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 12.dp,
end = 12.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(uiState.items, key = { it.bookUrl }) { book ->
BookItem(
book = book,
layoutMode = uiState.bookshelfLayoutMode,
isCompact = uiState.bookshelfLayoutCompact,
isUpdating = uiState.updatingBooks.contains(book.bookUrl),
titleSmallFont = uiState.titleSmallFont,
titleCenter = uiState.titleCenter,
titleMaxLines = uiState.titleMaxLines,
coverShadow = uiState.coverShadow,
onClick = { onBookClick(book) },
onLongClick = { onBookLongClick(book) }
)
}
}
}
}
@Composable
fun BookItem(
book: Book,
layoutMode: Int,
isCompact: Boolean = false,
isUpdating: Boolean = false,
titleSmallFont: Boolean = false,
titleCenter: Boolean = true,
titleMaxLines: Int = 2,
coverShadow: Boolean = false,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val unreadCount = (book.totalChapterNum - book.durChapterIndex).coerceAtLeast(0)
BookshelfItem(
isGrid = layoutMode != 0,
isCompact = isCompact,
cover = { modifier ->
BookCoverWithProgress(
name = book.name,
author = book.author,
path = book.getDisplayCover(),
isUpdating = isUpdating,
modifier = modifier
)
},
title = book.name,
subTitle = if (isCompact && layoutMode == 0) {
stringResource(R.string.author_read, book.author, unreadCount)
} else {
book.author
},
desc = stringResource(R.string.read_dur_progress, book.durChapterTitle ?: ""),
extra = {
if (BookshelfConfig.showLastUpdateTime && !book.isLocal) {
Text(
text = book.latestChapterTime.toTimeAgo(),
style = MaterialTheme.typography.bodySmall,
color = if (!isCompact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(end = 4.dp)
)
}
Text(
text = book.latestChapterTitle ?: "",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
titleSmallFont = titleSmallFont,
titleCenter = titleCenter,
titleMaxLines = titleMaxLines,
coverShadow = coverShadow,
onClick = onClick,
onLongClick = onLongClick
)
}
@@ -0,0 +1,26 @@
package io.legado.app.ui.main.bookshelf
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.widget.components.list.ListUiState
data class BookshelfUiState(
override val items: List<Book> = emptyList(),
override val selectedIds: Set<Any> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<BookGroup> = emptyList(),
val groupPreviews: Map<Long, List<Book>> = emptyMap(),
val selectedGroupIndex: Int = 0,
val loadingText: String? = null,
val bookshelfLayoutMode: Int = 1,
val bookshelfLayoutGrid: Int = 3,
val bookshelfLayoutCompact: Boolean = BookshelfConfig.bookshelfLayoutCompact,
val titleSmallFont: Boolean = BookshelfConfig.bookshelfTitleSmallFont,
val titleCenter: Boolean = BookshelfConfig.bookshelfTitleCenter,
val titleMaxLines: Int = BookshelfConfig.bookshelfTitleMaxLines,
val coverShadow: Boolean = BookshelfConfig.bookshelfCoverShadow,
val updatingBooks: Set<String> = emptySet()
) : ListUiState<Book>
@@ -1,42 +1,416 @@
package io.legado.app.ui.main.bookshelf
import android.app.Application
import androidx.lifecycle.MutableLiveData
import android.content.res.Configuration
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.google.gson.stream.JsonWriter
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isUpError
import io.legado.app.help.book.removeType
import io.legado.app.help.book.sync
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook
import io.legado.app.model.SourceCallBack
import io.legado.app.model.webBook.WebBook
import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.cnCompare
import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.max
import kotlin.math.min
class BookshelfViewModel(application: Application) : BaseViewModel(application) {
val addBookProgressLiveData = MutableLiveData(-1)
var addBookJob: Coroutine<*>? = null
private val groupIdFlow = MutableStateFlow(BookGroup.IdAll)
private val searchKeyFlow = MutableStateFlow("")
private val refreshTrigger = MutableStateFlow(0)
private val loadingTextFlow = MutableStateFlow<String?>(null)
// 更新相关
private var threadCount = AppConfig.threadCount
private var poolSize = min(threadCount, AppConst.MAX_THREAD)
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
private val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet())
private var upTocJob: Job? = null
private var cacheBookJob: Job? = null
private val eventListenerSource = ConcurrentHashMap<BookSource, Boolean>()
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val groupsFlow = appDb.bookGroupDao.show.asFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
.flatMapLatest { groupId ->
appDb.bookDao.flowByGroup(groupId).map { list ->
sortBooks(list, groupId)
}
}.flowOn(Dispatchers.Default)
private val internalStateFlow = combine(
groupIdFlow,
searchKeyFlow,
loadingTextFlow,
updatingBooksFlow
) { groupId, searchKey, loadingText, updatingBooks ->
InternalState(groupId, searchKey, loadingText, updatingBooks)
}
data class InternalState(
val groupId: Long,
val searchKey: String,
val loadingText: String?,
val updatingBooks: Set<String>
)
val uiState: StateFlow<BookshelfUiState> = combine(
booksFlow,
groupsFlow,
internalStateFlow
) { books, groups, internal ->
val filteredBooks = if (internal.searchKey.isEmpty()) {
books
} else {
books.filter {
it.name.contains(
internal.searchKey,
true
) || it.author.contains(internal.searchKey, true)
}
}
val currentGroups = groups ?: emptyList()
val isLandscape =
context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// 计算分组预览图
val previews = mutableMapOf<Long, List<Book>>()
if (BookshelfConfig.bookGroupStyle in 2..3) {
currentGroups.forEach { group ->
val groupBooks = if (group.groupId == BookGroup.IdAll) {
books.take(4)
} else {
books.filter { (it.group and group.groupId) != 0L }.take(4)
}
previews[group.groupId] = groupBooks
}
}
BookshelfUiState(
items = filteredBooks,
groups = currentGroups,
groupPreviews = previews,
selectedGroupIndex = currentGroups.indexOfFirst { it.groupId == internal.groupId }
.coerceAtLeast(0),
searchKey = internal.searchKey,
isSearch = internal.searchKey.isNotEmpty(),
isLoading = internal.loadingText != null,
loadingText = internal.loadingText,
bookshelfLayoutMode = if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscape else BookshelfConfig.bookshelfLayoutModePortrait,
bookshelfLayoutGrid = if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscape else BookshelfConfig.bookshelfLayoutGridPortrait,
bookshelfLayoutCompact = BookshelfConfig.bookshelfLayoutCompact,
titleSmallFont = BookshelfConfig.bookshelfTitleSmallFont,
titleCenter = BookshelfConfig.bookshelfTitleCenter,
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
coverShadow = BookshelfConfig.bookshelfCoverShadow,
updatingBooks = internal.updatingBooks
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
init {
viewModelScope.launch {
FlowEventBus.with<Unit>(EventBus.UP_ALL_BOOK_TOC).collect {
upAllBookToc()
}
}
viewModelScope.launch {
FlowEventBus.with<Unit>(EventBus.BOOKSHELF_REFRESH).collect {
refresh()
}
}
if (BookshelfConfig.autoRefreshBook) {
upAllBookToc()
}
}
override fun onCleared() {
super.onCleared()
upTocPool.close()
}
private fun sortBooks(list: List<Book>, groupId: Long): List<Book> {
val group = appDb.bookGroupDao.getByID(groupId)
val bookSort = group?.getRealBookSort() ?: AppConfig.bookshelfSort
val isDescending = AppConfig.bookshelfSortOrder == 1
return when (bookSort) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
else list.sortedBy { it.latestChapterTime }
2 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
else
list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> if (isDescending) list.sortedByDescending { it.order }
else list.sortedBy { it.order }
4 -> if (isDescending) list.sortedByDescending {
max(
it.latestChapterTime,
it.durChapterTime
)
}
else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
5 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
else
list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
else list.sortedBy { it.durChapterTime }
}
}
fun changeGroup(groupId: Long) {
groupIdFlow.value = groupId
}
fun setSearchKey(key: String) {
searchKeyFlow.value = key
}
fun refresh() {
refreshTrigger.value++
}
fun gotoTop() {
scrollTrigger.tryEmit(Unit)
}
// 更新逻辑移入
fun upAllBookToc() {
execute {
addToWaitUp(appDb.bookDao.hasUpdateBooks)
}
}
fun upToc(books: List<Book>) {
execute(context = upTocPool) {
books.filter {
!it.isLocal && it.canUpdate
}.let {
addToWaitUp(it)
}
}
}
@Synchronized
private fun addToWaitUp(books: List<Book>) {
books.forEach { book ->
if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) {
waitUpTocBooks.add(book.bookUrl)
}
}
if (upTocJob == null) {
startUpTocJob()
}
}
private fun startUpTocJob() {
upPool()
postUpBooksCount()
upTocJob = viewModelScope.launch(upTocPool) {
flow {
while (true) {
emit(waitUpTocBooks.poll() ?: break)
}
}.onEachParallel(threadCount) {
onUpTocBooks.add(it)
updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it)
updateToc(it)
}.onEach {
onUpTocBooks.remove(it)
updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it)
postUpBooksCount()
}.onCompletion {
upTocJob = null
if (waitUpTocBooks.isNotEmpty()) {
startUpTocJob()
}
if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
cacheBook()
}
}.catch {
AppLog.put("更新目录出错\n${it.localizedMessage}", it)
}.collect()
}
}
private fun upPool() {
threadCount = AppConfig.threadCount
val newPoolSize = min(threadCount, AppConst.MAX_THREAD)
if (poolSize == newPoolSize) return
poolSize = newPoolSize
upTocPool.close()
upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
}
private suspend fun updateToc(bookUrl: String) {
val book = appDb.bookDao.getBook(bookUrl) ?: return
val source = appDb.bookSourceDao.getBookSource(book.origin)
if (source == null) {
if (!book.isUpError) {
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
return
}
if (source.eventListener) {
if (eventListenerSource.putIfAbsent(source, true) == null) {
SourceCallBack.callBackSource(
viewModelScope,
SourceCallBack.START_SHELF_REFRESH,
source
)
}
}
kotlin.runCatching {
val oldBook = book.copy()
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(source, book)
} else {
WebBook.runPreUpdateJs(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
book.sync(oldBook)
book.removeType(BookType.updateError)
if (book.bookUrl == bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.replace(oldBook, book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(bookUrl)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadBook.onChapterListUpdated(book)
addDownload(source, book)
}.onFailure {
currentCoroutineContext().ensureActive()
AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it)
appDb.bookDao.getBook(book.bookUrl)?.let { book ->
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
}
}
private fun postUpBooksCount() {
val count = if (AppConfig.showWaitUpCount) waitUpTocBooks.size + onUpTocBooks.size else 0
FlowEventBus.post(EventBus.UP_BOOKSHELF_COUNT, count)
}
private fun addDownload(source: BookSource, book: Book) {
if (AppConfig.preDownloadNum == 0) return
val endIndex =
min(book.totalChapterNum - 1, book.durChapterIndex + AppConfig.preDownloadNum)
val cacheBook = CacheBook.getOrCreate(source, book)
cacheBook.addDownload(book.durChapterIndex, endIndex)
}
private fun cacheBook() {
eventListenerSource.toList().forEach {
SourceCallBack.callBackSource(
viewModelScope,
SourceCallBack.END_SHELF_REFRESH,
it.first
)
}
eventListenerSource.clear()
if (AppConfig.preDownloadNum == 0) return
cacheBookJob?.cancel()
cacheBookJob = viewModelScope.launch(upTocPool) {
launch {
while (isActive && CacheBook.isRun) {
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty())
delay(1000)
}
}
CacheBook.startProcessJob(upTocPool)
}
}
fun addBookByUrl(bookUrls: String) {
var successCount = 0
loadingTextFlow.value = "添加中..."
addBookJob = execute {
val hasBookUrlPattern: List<BookSourcePart> by lazy {
appDb.bookSourceDao.hasBookUrlPattern
@@ -83,7 +457,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
it.save()
}
successCount++
addBookProgressLiveData.postValue(successCount)
loadingTextFlow.value = "添加中... ($successCount)"
}
}
}.onSuccess {
@@ -95,7 +469,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
}.onError {
AppLog.put("添加网址出错\n${it.localizedMessage}", it, true)
}.onFinally {
addBookProgressLiveData.postValue(-1)
loadingTextFlow.value = null
}
}
@@ -154,6 +528,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
}
private fun importBookshelfByJson(json: String, groupId: Long) {
loadingTextFlow.value = "导入中..."
execute {
val bookSourceParts = appDb.bookSourceDao.allEnabledPart
val semaphore = Semaphore(AppConfig.threadCount)
@@ -181,6 +556,7 @@ class BookshelfViewModel(application: Application) : BaseViewModel(application)
}.onError {
it.printOnDebug()
}.onFinally {
loadingTextFlow.value = null
context.toastOnUi(R.string.success)
}
}
@@ -0,0 +1,139 @@
package io.legado.app.ui.main.bookshelf
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.book.group.GroupEditContent
import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.SettingItem
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupManageSheet(
onDismissRequest: () -> Unit,
viewModel: GroupViewModel = koinViewModel(),
bookshelfViewModel: BookshelfViewModel = koinViewModel()
) {
val groups by bookshelfViewModel.groupsFlow.collectAsState(initial = emptyList())
var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
var isEditing by remember { mutableStateOf(false) }
GlassModalBottomSheet(onDismissRequest = onDismissRequest) {
AnimatedContent(
targetState = isEditing,
transitionSpec = {
fadeIn().togetherWith(fadeOut())
},
label = "GroupManageTransition"
) { editing ->
if (editing) {
GroupEditContent(
group = editingGroup,
onDismissRequest = { isEditing = false },
viewModel = viewModel
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.group_manage),
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = {
editingGroup = null
isEditing = true
}) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(R.string.add)
)
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(groups, key = { it.groupId }) { group ->
GroupItem(
group = group,
onEdit = {
editingGroup = group
isEditing = true
},
onToggle = { isChecked ->
viewModel.upGroup(group.copy(show = isChecked))
}
)
}
}
}
}
}
}
}
@Composable
private fun GroupItem(
group: BookGroup,
onEdit: () -> Unit,
onToggle: (Boolean) -> Unit
) {
SettingItem(
title = group.groupName,
onClick = onEdit,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
}
Switch(
checked = group.show,
onCheckedChange = onToggle
)
}
}
)
}
@@ -1,375 +0,0 @@
package io.legado.app.ui.main.bookshelf.books
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.primaryColor
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewConfiguration
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy
import io.legado.app.R
import io.legado.app.base.BaseFragment
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.FragmentBooksBinding
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BaseBooksAdapter
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BooksAdapterGrid
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BooksAdapterGridCompact
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BooksAdapterGridCover
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BooksAdapterList
import io.legado.app.ui.main.bookshelf.books.styleDefalut.BooksAdapterListCompact
import io.legado.app.utils.bookshelfLayoutGrid
import io.legado.app.utils.bookshelfLayoutMode
import io.legado.app.utils.cnCompare
import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
import io.legado.app.utils.observeEvent
import io.legado.app.utils.startActivity
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.max
/**
* 书架界面
*/
class BooksFragment() : BaseFragment(R.layout.fragment_books),
BaseBooksAdapter.CallBack {
constructor(position: Int, group: BookGroup) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
bundle.putLong("groupId", group.groupId)
bundle.putInt("bookSort", group.getRealBookSort())
bundle.putBoolean("enableRefresh", group.enableRefresh)
arguments = bundle
}
private val binding by viewBinding(FragmentBooksBinding::bind)
private val activityViewModel by activityViewModels<MainViewModel>()
private val bookshelfLayoutMode by lazy { requireContext().bookshelfLayoutMode }
private val bookshelfLayoutGrid by lazy { requireContext().bookshelfLayoutGrid }
private val booksAdapter: BaseBooksAdapter<*> by lazy {
when (bookshelfLayoutMode) {
0 -> {
BooksAdapterList(requireContext(), this, this, viewLifecycleOwner.lifecycle)
}
1 -> {
BooksAdapterGrid(requireContext(), this)
}
2 -> {
BooksAdapterGridCompact(requireContext(), this)
}
3 -> {
BooksAdapterGridCover(requireContext(), this)
}
else -> {
BooksAdapterListCompact(requireContext(), this, this, viewLifecycleOwner.lifecycle)
}
}
}
private var booksFlowJob: Job? = null
var position = 0
private set
var groupId = -1L
private set
var bookSort = 0
private set
private var upLastUpdateTimeJob: Job? = null
private var enableRefresh = true
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
arguments?.let {
position = it.getInt("position", 0)
groupId = it.getLong("groupId", -1)
bookSort = it.getInt("bookSort", 0)
enableRefresh = it.getBoolean("enableRefresh", true)
binding.refreshLayout.isEnabled = enableRefresh
}
initRecyclerView()
upRecyclerData()
}
private fun initRecyclerView() {
//binding.rvBookshelf.setEdgeEffectColor(primaryColor)
upFastScrollerBar()
//binding.refreshLayout.setColorSchemeColors(accentColor)
binding.refreshLayout.setOnRefreshListener {
val books = booksAdapter.getItems()
val refreshList = if (AppConfig.bookshelfRefreshingLimit > 0) {
books.take(AppConfig.bookshelfRefreshingLimit)
} else {
books
}
binding.refreshLayout.isRefreshing = false
activityViewModel.upToc(refreshList)
}
binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayoutGrid)
binding.rvBookshelf.setRecycledViewPool(activityViewModel.booksGridRecycledViewPool)
booksAdapter.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
binding.rvBookshelf.adapter = booksAdapter
booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
val layoutManager = binding.rvBookshelf.layoutManager
if (positionStart == 0 && itemCount == 1 && layoutManager is LinearLayoutManager) {
val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount
binding.rvBookshelf.scrollToPosition(max(0, scrollTo))
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val layoutManager = binding.rvBookshelf.layoutManager
if (toPosition == 0 && itemCount == 1 && layoutManager is LinearLayoutManager) {
val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount
binding.rvBookshelf.scrollToPosition(max(0, scrollTo))
}
}
})
ViewCompat.setOnApplyWindowInsetsListener(binding.rvBookshelf) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
view.paddingLeft,
view.paddingTop,
view.paddingRight,
systemBars.bottom
)
WindowInsetsCompat.CONSUMED
}
startLastUpdateTimeJob()
}
private fun upFastScrollerBar() {
val showBookshelfFastScroller = AppConfig.showBookshelfFastScroller
binding.rvBookshelf.setFastScrollEnabled(showBookshelfFastScroller)
if (showBookshelfFastScroller) {
binding.rvBookshelf.scrollBarSize = 0
} else {
binding.rvBookshelf.scrollBarSize =
ViewConfiguration.get(requireContext()).scaledScrollBarSize
}
}
fun upBookSort(sort: Int) {
binding.root.post {
if (!isAdded || view == null) return@post
arguments?.putInt("bookSort", sort)
bookSort = sort
upRecyclerData()
}
}
fun setEnableRefresh(enable: Boolean) {
enableRefresh = enable
binding.refreshLayout.isEnabled = enable
}
/**
* 更新书籍列表信息
*/
private fun upRecyclerData() {
booksFlowJob?.cancel()
booksFlowJob = viewLifecycleOwner.lifecycleScope.launch {
appDb.bookDao.flowByGroup(groupId).map { list ->
//排序
val isDescending = AppConfig.bookshelfSortOrder == 1
when (bookSort) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
else list.sortedBy { it.latestChapterTime }
2 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
else
list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> if (isDescending) list.sortedByDescending { it.order }
else list.sortedBy { it.order }
4 -> if (isDescending) list.sortedByDescending {
max(
it.latestChapterTime,
it.durChapterTime
)
}
else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
5 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
else
list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
else list.sortedBy { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChangeFirst(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.BOOK_TABLE_NAME
).catch {
AppLog.put("书架更新出错", it)
}.conflate().flowOn(Dispatchers.Default).collect { list ->
if (view == null) return@collect
binding.emptyView.isGone = list.isNotEmpty()
binding.refreshLayout.isEnabled = enableRefresh && list.isNotEmpty()
booksAdapter.setItems(list)
delay(500)
}
}
}
private fun startLastUpdateTimeJob() {
upLastUpdateTimeJob?.cancel()
if (!AppConfig.showLastUpdateTime || (bookshelfLayoutMode != 0 && bookshelfLayoutMode != 4)) {
return
}
upLastUpdateTimeJob = viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (isActive) {
booksAdapter.upLastUpdateTime()
delay(30 * 1000)
}
}
}
}
fun getBooks(): List<Book> {
return booksAdapter.getItems()
}
fun gotoTop() {
if (AppConfig.isEInkMode) {
binding.rvBookshelf.scrollToPosition(0)
} else {
binding.rvBookshelf.smoothScrollToPosition(0)
}
}
fun getBooksCount(): Int {
return booksAdapter.itemCount
}
override fun onDestroyView() {
super.onDestroyView()
/**
* 将 RecyclerView 中的视图全部回收到 RecycledViewPool 中
*/
upLastUpdateTimeJob?.cancel()
booksFlowJob?.cancel()
binding.rvBookshelf.setItemViewCacheSize(0)
binding.rvBookshelf.adapter = null
}
override fun open(book: Book, sharedView: View) {
if (AppConfig.sharedElementEnterTransitionEnable){
val transitionName = "book_${book.bookUrl}"
sharedView.transitionName = transitionName
val cls = when {
book.isAudio -> AudioPlayActivity::class.java
book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
else -> ReadBookActivity::class.java
}
val intent = Intent(requireContext(), cls).apply {
putExtra("bookUrl", book.bookUrl)
putExtra("transitionName", transitionName)
}
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
sharedView,
transitionName
)
startActivity(intent, options.toBundle())
} else {
startActivityForBook(book)
}
}
override fun openBookInfo(book: Book, sharedView: View) {
if (AppConfig.sharedElementEnterTransitionEnable){
val intent = Intent(requireContext(), BookInfoActivity::class.java).apply {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
putExtra("transitionName", "book_${book.bookUrl}")
}
sharedView.transitionName = "book_${book.bookUrl}"
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
sharedView,
sharedView.transitionName
)
startActivity(intent, options.toBundle())
} else {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
}
override fun isUpdate(bookUrl: String): Boolean {
return activityViewModel.isUpdate(bookUrl)
}
@SuppressLint("NotifyDataSetChanged")
override fun observeLiveBus() {
super.observeLiveBus()
observeEvent<String>(EventBus.UP_BOOKSHELF) {
booksAdapter.notification(it)
}
observeEvent<String>(EventBus.BOOKSHELF_REFRESH) {
booksAdapter.notifyDataSetChanged()
startLastUpdateTimeJob()
upFastScrollerBar()
}
}
}
@@ -1,229 +0,0 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.main.bookshelf.books
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.FragmentBookshelf1Binding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.group.GroupEditDialog
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.themeColor
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlin.collections.set
/**
* 书架界面
*/
class BookshelfFragment1() : BaseBookshelfFragment(R.layout.fragment_bookshelf1),
TabLayout.OnTabSelectedListener,
SearchView.OnQueryTextListener {
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
}
private val binding by viewBinding(FragmentBookshelf1Binding::bind)
private val bookGroups = mutableListOf<BookGroup>()
private val fragmentMap = hashMapOf<Long, BooksFragment>()
override val groupId: Long get() = selectedGroup?.groupId ?: 0
private lateinit var adapter: TabFragmentPageAdapter
private var tabFadeListener: AppBarLayout.OnOffsetChangedListener? = null
override val books: List<Book>
get() {
val fragment = fragmentMap[groupId]
return fragment?.getBooks() ?: emptyList()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.titleBar.fitsSystemWindows = true
initView()
initBookGroupData()
setupExpandButton()
}
private val selectedGroup: BookGroup?
get() = bookGroups.getOrNull(binding.viewPagerBookshelf.currentItem)
private fun initView() {
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
binding.viewPagerBookshelf.offscreenPageLimit = 3
TabLayoutMediator(binding.tabLayout, binding.viewPagerBookshelf) { tab, position ->
tab.text = bookGroups[position].groupName
}.attach()
updateTabFadeEffect()
}
private fun setupExpandButton() {
binding.btnExpandTabs.setOnClickListener {
showTabSelectionMenu()
}
}
private fun updateTabFadeEffect() {
tabFadeListener?.let { binding.titleBar.removeOnOffsetChangedListener(it) }
val listener = AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset ->
val drawable = binding.tabRightFade.background as? GradientDrawable
val opacity = if (ThemeConfig.enableBlur) ThemeConfig.containerOpacity / 100f else 1f
val baseColor = if (-verticalOffset >= appBarLayout.totalScrollRange) {
requireContext().themeColor(com.google.android.material.R.attr.colorSurfaceContainer)
} else {
requireContext().themeColor(com.google.android.material.R.attr.colorSurface)
}
val newAlpha = (Color.alpha(baseColor) * opacity).toInt()
drawable?.colors = intArrayOf(Color.TRANSPARENT, ColorUtils.setAlphaComponent(baseColor, newAlpha))
}
tabFadeListener = listener
binding.titleBar.addOnOffsetChangedListener(listener)
}
private fun showTabSelectionMenu() {
val popup = PopupMenu(requireContext(), binding.btnExpandTabs)
bookGroups.forEachIndexed { index, bookGroup ->
popup.menu.add(0, index, index, bookGroup.groupName)
}
popup.setOnMenuItemClickListener { item ->
val position = item.itemId
if (position in bookGroups.indices) {
binding.viewPagerBookshelf.setCurrentItem(position, true)
}
true
}
popup.setOnDismissListener {
binding.btnExpandTabs.isChecked = false
}
popup.show()
}
private fun updateExpandButtonVisibility() {
binding.btnExpandTabs.visibility = if (AppConfig.shouldShowExpandButton) {
View.VISIBLE
} else {
View.GONE
}
}
override fun onQueryTextSubmit(query: String?): Boolean {
SearchActivity.start(requireContext(), query)
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
override fun upGroup(data: List<BookGroup>) {
if (data.isEmpty()) {
appDb.bookGroupDao.enableGroup(BookGroup.IdAll)
} else {
if (data != bookGroups) {
bookGroups.clear()
bookGroups.addAll(data)
adapter.notifyDataSetChanged()
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
TabLayoutMediator(binding.tabLayout, binding.viewPagerBookshelf) { tab, position ->
tab.text = bookGroups[position].groupName
}.attach()
updateExpandButtonVisibility()
updateTabFadeEffect()
selectLastTab()
binding.tabLayout.post {
for (i in 0 until bookGroups.size) {
binding.tabLayout.getTabAt(i)?.view?.setOnLongClickListener {
showDialogFragment(GroupEditDialog(bookGroups[i]))
true
}
}
}
}
}
}
override fun upSort() {
childFragmentManager.fragments.forEach {
if (it is BooksFragment) {
val position = it.position
val group = bookGroups.getOrNull(position) ?: return@forEach
it.setEnableRefresh(group.enableRefresh)
it.upBookSort(group.getRealBookSort())
}
}
}
private fun selectLastTab() {
binding.tabLayout.post {
binding.tabLayout.removeOnTabSelectedListener(this)
binding.tabLayout.getTabAt(AppConfig.saveTabPosition)?.select()
binding.tabLayout.addOnTabSelectedListener(this)
}
}
override fun onTabReselected(tab: TabLayout.Tab) {
selectedGroup?.let { group ->
fragmentMap[group.groupId]?.let {
toastOnUi("${group.groupName}(${it.getBooksCount()})")
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
override fun onTabSelected(tab: TabLayout.Tab) {
AppConfig.saveTabPosition = tab.position
}
override fun gotoTop() {
fragmentMap[groupId]?.gotoTop()
}
private inner class TabFragmentPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = bookGroups.size
override fun createFragment(position: Int): Fragment {
val group = bookGroups[position]
val fragment = BooksFragment(position, group)
fragmentMap[group.groupId] = fragment
return fragment
}
}
}
@@ -1,467 +0,0 @@
package io.legado.app.ui.main.bookshelf.books
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewConfiguration
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.FragmentBookshelf2Binding
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.group.GroupEditDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
import io.legado.app.ui.main.bookshelf.books.styleFold.BaseBooksAdapter
import io.legado.app.ui.main.bookshelf.books.styleFold.BooksAdapterGrid
import io.legado.app.ui.main.bookshelf.books.styleFold.BooksAdapterGridCompact
import io.legado.app.ui.main.bookshelf.books.styleFold.BooksAdapterGridCover
import io.legado.app.ui.main.bookshelf.books.styleFold.BooksAdapterList
import io.legado.app.ui.main.bookshelf.books.styleFold.BooksAdapterListCompact
import io.legado.app.utils.bookshelfLayoutGrid
import io.legado.app.utils.bookshelfLayoutMode
import io.legado.app.utils.cnCompare
import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
import io.legado.app.utils.observeEvent
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.max
/**
* 书架界面
*/
class BookshelfFragment2() : BaseBookshelfFragment(R.layout.fragment_bookshelf2),
SearchView.OnQueryTextListener,
BaseBooksAdapter.CallBack {
fun interface OnGroupIdChangeListener {
fun onGroupIdChanged()
}
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
}
private val binding by viewBinding(FragmentBookshelf2Binding::bind)
private val bookshelfLayoutMode by lazy { requireContext().bookshelfLayoutMode }
private val bookshelfLayoutGrid by lazy { requireContext().bookshelfLayoutGrid }
private var groupIdChangeListener: OnGroupIdChangeListener? = null
private val booksAdapter: BaseBooksAdapter<*> by lazy {
when (bookshelfLayoutMode) {
0 -> {
BooksAdapterList(requireContext(), this)
}
1 -> {
BooksAdapterGrid(requireContext(), this)
}
2 -> {
BooksAdapterGridCompact(requireContext(), this)
}
3 -> {
BooksAdapterGridCover(requireContext(), this)
}
else -> {
BooksAdapterListCompact(requireContext(), this)
}
}
}
var onGroupIdChangedListener: ((isRoot: Boolean) -> Unit)? = null
private var bookGroups: List<BookGroup> = emptyList()
private var booksFlowJob: Job? = null
override var groupId = BookGroup.Companion.IdRoot
private var allBooks: List<Book> = emptyList()
override var books: List<Book> = emptyList()
private var enableRefresh = true
private lateinit var backCallback: OnBackPressedCallback
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
initRecyclerView()
initBookGroupData()
initAllBooksData()
initBooksData()
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.titleBar.fitsSystemWindows = true
backCallback = object : OnBackPressedCallback(false) { // 初始禁用
override fun handleOnBackPressed() {
if (back()) {
updateBackCallbackState()
}
}
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback)
}
override fun onDestroyView() {
onGroupIdChangedListener?.invoke(true)
super.onDestroyView()
}
private fun updateBackCallbackState() {
val isRoot = groupId == BookGroup.IdRoot
backCallback.isEnabled = !isRoot
onGroupIdChangedListener?.invoke(isRoot)
}
@Suppress("UNCHECKED_CAST")
fun BaseBooksAdapter<*>.getBookItems(): List<Book> {
return getItems() as List<Book>
}
private fun initRecyclerView() {
upFastScrollerBar()
binding.refreshLayout.setOnRefreshListener {
val books = booksAdapter.getBookItems()
val refreshList = if (AppConfig.bookshelfRefreshingLimit > 0) {
books.take(AppConfig.bookshelfRefreshingLimit)
} else {
books
}
binding.refreshLayout.isRefreshing = false
activityViewModel.upToc(refreshList)
}
binding.rvBookshelf.layoutManager = GridLayoutManager(context, bookshelfLayoutGrid)
binding.rvBookshelf.itemAnimator = null
binding.rvBookshelf.adapter = booksAdapter
booksAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
val layoutManager = binding.rvBookshelf.layoutManager
if (positionStart == 0 && layoutManager is LinearLayoutManager) {
val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount
binding.rvBookshelf.scrollToPosition(max(0, scrollTo))
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
val layoutManager = binding.rvBookshelf.layoutManager
if (toPosition == 0 && layoutManager is LinearLayoutManager) {
val scrollTo = layoutManager.findFirstVisibleItemPosition() - itemCount
binding.rvBookshelf.scrollToPosition(max(0, scrollTo))
}
}
})
binding.rvBookshelf.itemAnimator = DefaultItemAnimator()
ViewCompat.setOnApplyWindowInsetsListener(binding.rvBookshelf) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
view.paddingLeft,
view.paddingTop,
view.paddingRight,
systemBars.bottom
)
WindowInsetsCompat.CONSUMED
}
}
private fun upFastScrollerBar() {
val showBookshelfFastScroller = AppConfig.showBookshelfFastScroller
binding.rvBookshelf.setFastScrollEnabled(showBookshelfFastScroller)
if (showBookshelfFastScroller) {
binding.rvBookshelf.scrollBarSize = 0
} else {
binding.rvBookshelf.scrollBarSize =
ViewConfiguration.get(requireContext()).scaledScrollBarSize
}
}
override fun upGroup(data: List<BookGroup>) {
if (data != bookGroups) {
bookGroups = data
booksAdapter.updateItems()
binding.emptyView.isGone = getItemCount() > 0
binding.refreshLayout.isEnabled = enableRefresh && getItemCount() > 0
}
}
override fun upSort() {
initAllBooksData()
initBooksData()
}
private var allBooksFlowJob: Job? = null
private fun initAllBooksData() {
allBooksFlowJob?.cancel()
allBooksFlowJob = viewLifecycleOwner.lifecycleScope.launch {
appDb.bookDao.flowAll().map { list ->
val sortType = BookGroup(BookGroup.Companion.IdRoot, "").getRealBookSort()
when (sortType) {
1 -> list.sortedByDescending { it.latestChapterTime }
2 -> list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> list.sortedBy { it.order }
4 -> list.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) }
5 -> list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else -> list.sortedByDescending { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChangeFirst(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.Companion.BOOK_TABLE_NAME
).catch {
AppLog.put("所有书籍更新出错", it)
}.conflate().flowOn(Dispatchers.Default).collect { list ->
allBooks = list
booksAdapter.setAllBooks(allBooks)
booksAdapter.updateItems()
}
}
}
private fun initBooksData() {
if (groupId == BookGroup.IdRoot) {
if (isAdded) {
binding.collTopBar.title = getString(R.string.bookshelf)
binding.refreshLayout.isEnabled = true
enableRefresh = true
}
} else {
bookGroups.firstOrNull { groupId == it.groupId }?.let {
binding.collTopBar.title = it.groupName
binding.refreshLayout.isEnabled = it.enableRefresh
enableRefresh = it.enableRefresh
}
}
booksFlowJob?.cancel()
booksFlowJob = viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
appDb.bookDao.flowByGroup(groupId)
.map { list ->
val isDescending = AppConfig.bookshelfSortOrder == 1
val sortType = AppConfig.getBookSortByGroupId(groupId)
when (sortType) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
else list.sortedBy { it.latestChapterTime }
2 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
else
list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> if (isDescending) list.sortedByDescending { it.order }
else list.sortedBy { it.order }
4 -> if (isDescending)
list.sortedByDescending { max(it.latestChapterTime, it.durChapterTime) }
else
list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
5 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
else
list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else ->
if (isDescending) list.sortedByDescending { it.durChapterTime }
else list.sortedBy { it.durChapterTime }
}
}
.flowOn(Dispatchers.Default)
.catch { AppLog.put("书架更新出错", it) }
.collect { list ->
books = list
booksAdapter.updateItems()
val empty = getItemCount() > 0
binding.emptyView.isGone = empty
binding.refreshLayout.isEnabled = enableRefresh && empty
}
}
}
}
fun back(): Boolean {
if (groupId != BookGroup.Companion.IdRoot) {
groupId = BookGroup.Companion.IdRoot
initBooksData()
groupIdChangeListener?.onGroupIdChanged()
updateBackCallbackState()
return false
}
return true
//需要提前设置,因为在返回时触发
}
override fun onQueryTextSubmit(query: String?): Boolean {
SearchActivity.Companion.start(requireContext(), query)
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
override fun gotoTop() {
if (AppConfig.isEInkMode) {
binding.rvBookshelf.scrollToPosition(0)
} else {
binding.rvBookshelf.smoothScrollToPosition(0)
}
}
override fun onItemClick(item: Any, sharedView: View) {
if (AppConfig.sharedElementEnterTransitionEnable){
when (item) {
is Book -> {
val transitionName = "book_${item.bookUrl}"
sharedView.transitionName = transitionName
val cls = when {
item.isAudio -> AudioPlayActivity::class.java
item.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
else -> ReadBookActivity::class.java
}
val intent = Intent(requireContext(), cls).apply {
putExtra("bookUrl", item.bookUrl)
putExtra("transitionName", transitionName)
}
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
sharedView,
transitionName
)
startActivity(intent, options.toBundle())
}
is BookGroup -> {
groupId = item.groupId
initBooksData()
groupIdChangeListener?.onGroupIdChanged()
updateBackCallbackState()
}
}
} else {
when (item) {
is Book -> startActivityForBook(item)
is BookGroup -> {
groupId = item.groupId
initBooksData()
groupIdChangeListener?.onGroupIdChanged()
updateBackCallbackState()
}
}
}
}
override fun onItemLongClick(item: Any, sharedView: View) {
if (AppConfig.sharedElementEnterTransitionEnable){
when (item) {
is Book -> {
val intent = Intent(requireContext(), BookInfoActivity::class.java).apply {
putExtra("name", item.name)
putExtra("author", item.author)
putExtra("bookUrl", item.bookUrl)
putExtra("transitionName", "book_${item.bookUrl}") // 给共享元素唯一标识
}
sharedView.transitionName = "book_${item.bookUrl}"
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(),
sharedView,
sharedView.transitionName
)
startActivity(intent, options.toBundle())
}
is BookGroup -> showDialogFragment(GroupEditDialog(item))
}
} else {
when (item) {
is Book -> startActivity<BookInfoActivity> {
putExtra("name", item.name)
putExtra("author", item.author)
putExtra("bookUrl", item.bookUrl)
}
is BookGroup -> showDialogFragment(GroupEditDialog(item))
}
}
}
override fun isUpdate(bookUrl: String): Boolean {
return activityViewModel.isUpdate(bookUrl)
}
fun getItemCount(): Int {
return if (groupId == BookGroup.Companion.IdRoot) {
bookGroups.size + books.size
} else {
books.size
}
}
override fun getItems(): List<Any> {
return if (groupId == BookGroup.Companion.IdRoot) {
bookGroups + books
} else {
books
}
}
@SuppressLint("NotifyDataSetChanged")
override fun observeLiveBus() {
super.observeLiveBus()
observeEvent<String>(EventBus.UP_BOOKSHELF) {
booksAdapter.notification(it)
}
observeEvent<String>(EventBus.BOOKSHELF_REFRESH) {
booksAdapter.notifyDataSetChanged()
}
}
}
@@ -1,147 +0,0 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.main.bookshelf.books
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.FragmentBookshelf3Binding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlin.collections.set
/**
* 书架界面
*/
class BookshelfFragment3() : BaseBookshelfFragment(R.layout.fragment_bookshelf3),
SearchView.OnQueryTextListener {
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
}
private val binding by viewBinding(FragmentBookshelf3Binding::bind)
private val bookGroups = mutableListOf<BookGroup>()
private val fragmentMap = hashMapOf<Long, BooksFragment>()
override val groupId: Long get() = selectedGroup?.groupId ?: 0
private lateinit var adapter: TabFragmentPageAdapter
override val books: List<Book>
get() {
val fragment = fragmentMap[groupId]
return fragment?.getBooks() ?: emptyList()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.titleBar.fitsSystemWindows = true
initView()
initBookGroupData()
}
private val selectedGroup: BookGroup?
get() = bookGroups.getOrNull(binding.viewPagerBookshelf.currentItem)
private fun initView() {
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
binding.viewPagerBookshelf.offscreenPageLimit = 3
binding.viewPagerBookshelf.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
updateToolbarTitle(position)
AppConfig.saveTabPosition = position
}
})
}
private fun updateToolbarTitle(position: Int) {
if (bookGroups.isNotEmpty() && position in 0 until bookGroups.size) {
binding.collTopBar.title = bookGroups[position].groupName
}
}
override fun onQueryTextSubmit(query: String?): Boolean {
SearchActivity.start(requireContext(), query)
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
override fun upGroup(data: List<BookGroup>) {
if (data.isEmpty()) {
appDb.bookGroupDao.enableGroup(BookGroup.IdAll)
} else {
if (data != bookGroups) {
bookGroups.clear()
bookGroups.addAll(data)
adapter.notifyDataSetChanged()
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
updateToolbarTitle(binding.viewPagerBookshelf.currentItem)
selectLastTab()
}
}
}
override fun upSort() {
childFragmentManager.fragments.forEach {
if (it is BooksFragment) {
val position = it.position
val group = bookGroups.getOrNull(position) ?: return@forEach
it.setEnableRefresh(group.enableRefresh)
it.upBookSort(group.getRealBookSort())
}
}
}
override fun gotoTop() {
fragmentMap[groupId]?.gotoTop()
}
private fun selectLastTab() {
val lastPosition = AppConfig.saveTabPosition
if (lastPosition in 0 until bookGroups.size) {
binding.viewPagerBookshelf.post {
binding.viewPagerBookshelf.setCurrentItem(lastPosition, false)
updateToolbarTitle(lastPosition)
}
} else {
AppConfig.saveTabPosition = 0
updateToolbarTitle(0)
}
}
private inner class TabFragmentPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = bookGroups.size
override fun createFragment(position: Int): Fragment {
val group = bookGroups[position]
val fragment = BooksFragment(position, group)
fragmentMap[group.groupId] = fragment
return fragment
}
}
}
@@ -1,164 +0,0 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.main.bookshelf.books
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import io.legado.app.R
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.FragmentBookshelf1Binding
import io.legado.app.databinding.FragmentBookshelf4Binding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.group.GroupEditDialog
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlin.collections.set
/**
* 书架界面
*/
class BookshelfFragment4() : BaseBookshelfFragment(R.layout.fragment_bookshelf4),
TabLayout.OnTabSelectedListener,
SearchView.OnQueryTextListener {
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
}
private val binding by viewBinding(FragmentBookshelf4Binding::bind)
private val bookGroups = mutableListOf<BookGroup>()
private val fragmentMap = hashMapOf<Long, BooksFragment>()
override val groupId: Long get() = selectedGroup?.groupId ?: 0
private lateinit var adapter: TabFragmentPageAdapter
override val books: List<Book>
get() {
val fragment = fragmentMap[groupId]
return fragment?.getBooks() ?: emptyList()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.titleBar.fitsSystemWindows = true
initView()
initBookGroupData()
}
private val selectedGroup: BookGroup?
get() = bookGroups.getOrNull(binding.viewPagerBookshelf.currentItem)
private fun initView() {
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
binding.viewPagerBookshelf.offscreenPageLimit = 3
TabLayoutMediator(binding.tabLayout, binding.viewPagerBookshelf) { tab, position ->
tab.text = bookGroups[position].groupName
}.attach()
}
override fun onQueryTextSubmit(query: String?): Boolean {
SearchActivity.start(requireContext(), query)
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
@SuppressLint("NotifyDataSetChanged")
@Synchronized
override fun upGroup(data: List<BookGroup>) {
if (data.isEmpty()) {
appDb.bookGroupDao.enableGroup(BookGroup.IdAll)
} else {
if (data != bookGroups) {
bookGroups.clear()
bookGroups.addAll(data)
adapter.notifyDataSetChanged()
adapter = TabFragmentPageAdapter(this)
binding.viewPagerBookshelf.adapter = adapter
TabLayoutMediator(binding.tabLayout, binding.viewPagerBookshelf) { tab, position ->
tab.text = bookGroups[position].groupName
}.attach()
selectLastTab()
binding.tabLayout.post {
for (i in 0 until bookGroups.size) {
binding.tabLayout.getTabAt(i)?.view?.setOnLongClickListener {
showDialogFragment(GroupEditDialog(bookGroups[i]))
true
}
}
}
}
}
}
override fun upSort() {
childFragmentManager.fragments.forEach {
if (it is BooksFragment) {
val position = it.position
val group = bookGroups.getOrNull(position) ?: return@forEach
it.setEnableRefresh(group.enableRefresh)
it.upBookSort(group.getRealBookSort())
}
}
}
private fun selectLastTab() {
binding.tabLayout.post {
binding.tabLayout.removeOnTabSelectedListener(this)
binding.tabLayout.getTabAt(AppConfig.saveTabPosition)?.select()
binding.tabLayout.addOnTabSelectedListener(this)
}
}
override fun onTabReselected(tab: TabLayout.Tab) {
selectedGroup?.let { group ->
fragmentMap[group.groupId]?.let {
toastOnUi("${group.groupName}(${it.getBooksCount()})")
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
override fun onTabSelected(tab: TabLayout.Tab) {
AppConfig.saveTabPosition = tab.position
}
override fun gotoTop() {
fragmentMap[groupId]?.gotoTop()
}
private inner class TabFragmentPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = bookGroups.size
override fun createFragment(position: Int): Fragment {
val group = bookGroups[position]
val fragment = BooksFragment(position, group)
fragmentMap[group.groupId] = fragment
return fragment
}
}
}
@@ -1,102 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.view.View
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.DiffUtil
import androidx.viewbinding.ViewBinding
import io.legado.app.base.adapter.DiffRecyclerAdapter
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
abstract class BaseBooksAdapter<VB : ViewBinding>(context: Context) :
DiffRecyclerAdapter<Book, VB>(context) {
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return getItems()[position].bookUrl.hashCode().toLong()
}
override val keepScrollPosition = true
override val diffItemCallback: DiffUtil.ItemCallback<Book> =
object : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return when {
oldItem.durChapterTime != newItem.durChapterTime -> false
oldItem.name != newItem.name -> false
oldItem.author != newItem.author -> false
oldItem.durChapterTitle != newItem.durChapterTitle -> false
oldItem.latestChapterTitle != newItem.latestChapterTitle -> false
oldItem.lastCheckCount != newItem.lastCheckCount -> false
oldItem.getDisplayCover() != newItem.getDisplayCover() -> false
oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum() -> false
else -> true
}
}
override fun getChangePayload(oldItem: Book, newItem: Book): Any? {
val bundle = bundleOf()
if (oldItem.name != newItem.name) {
bundle.putString("name", newItem.name)
}
if (oldItem.author != newItem.author) {
bundle.putString("author", newItem.author)
}
if (oldItem.durChapterTitle != newItem.durChapterTitle) {
bundle.putString("dur", newItem.durChapterTitle)
}
if (oldItem.latestChapterTitle != newItem.latestChapterTitle) {
bundle.putString("last", newItem.latestChapterTitle)
}
if (oldItem.getDisplayCover() != newItem.getDisplayCover()) {
bundle.putString("cover", newItem.getDisplayCover())
}
if (oldItem.lastCheckCount != newItem.lastCheckCount
|| oldItem.durChapterTime != newItem.durChapterTime
|| oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum()
|| oldItem.lastCheckCount != newItem.lastCheckCount
) {
bundle.putBoolean("refresh", true)
}
if (oldItem.latestChapterTime != newItem.latestChapterTime) {
bundle.putBoolean("lastUpdateTime", true)
}
if (bundle.isEmpty) return null
return bundle
}
}
override fun onViewRecycled(holder: ItemViewHolder) {
super.onViewRecycled(holder)
holder.itemView.setOnClickListener(null)
holder.itemView.setOnLongClickListener(null)
}
fun notification(bookUrl: String) {
getItems().forEachIndexed { i, it ->
if (it.bookUrl == bookUrl) {
notifyItemChanged(i, bundleOf("refresh" to true, "lastUpdateTime" to true))
return
}
}
}
fun upLastUpdateTime() {
notifyItemRangeChanged(0, itemCount, bundleOf(Pair("lastUpdateTime", null)))
}
interface CallBack {
fun open(book: Book, sharedView: View)
fun openBookInfo(book: Book, sharedView: View)
fun isUpdate(bookUrl: String): Boolean
}
}
@@ -1,93 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.isVisible
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemBookshelfGridBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
class BooksAdapterGrid(context: Context, private val callBack: CallBack) :
BaseBooksAdapter<ItemBookshelfGridBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookshelfGridBinding {
return ItemBookshelfGridBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookshelfGridBinding,
item: Book,
payloads: MutableList<Any>
) = binding.run {
if (payloads.isEmpty()) {
cdCover.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"cover" -> ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
"refresh" -> upRefresh(binding, item)
}
}
}
}
}
private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.gone()
binding.cdTip.visible()
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visible()
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.gone()
binding.newChapter.gone()
}
} else {
binding.cdUnread.gone()
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfGridBinding) {
binding.cvContent.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.open(it, binding.cdCover)
}
}
binding.cvContent.setOnLongClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBookInfo(it, binding.cdCover)
}
true
}
}
}
@@ -1,103 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemBookshelfGridCompactBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
class BooksAdapterGridCompact(context: Context, private val callBack: CallBack) :
BaseBooksAdapter<ItemBookshelfGridCompactBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookshelfGridCompactBinding {
return ItemBookshelfGridCompactBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookshelfGridCompactBinding,
item: Book,
payloads: MutableList<Any>
) = binding.run {
if (payloads.isEmpty()) {
cvContent.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(binding, item)
}
}
}
}
}
private fun upRefresh(binding: ItemBookshelfGridCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.visibility = View.GONE
binding.rlLoading.visibility = View.VISIBLE
} else {
binding.rlLoading.visibility = View.GONE
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visibility = View.VISIBLE
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.visibility = View.GONE
binding.newChapter.gone()
}
} else {
binding.cdUnread.visibility = View.GONE
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
override fun registerListener(
holder: ItemViewHolder,
binding: ItemBookshelfGridCompactBinding
) {
binding.cvContent.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.open(it, binding.cvContent)
}
}
binding.cvContent.setOnLongClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBookInfo(it, binding.cvContent)
}
true
}
}
}
@@ -1,103 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemBookshelfGridCompactBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
class BooksAdapterGridCover(context: Context, private val callBack: CallBack) :
BaseBooksAdapter<ItemBookshelfGridCompactBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookshelfGridCompactBinding {
return ItemBookshelfGridCompactBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookshelfGridCompactBinding,
item: Book,
payloads: MutableList<Any>
) = binding.run {
if (payloads.isEmpty()) {
cvContent.transitionName = "book_${item.bookUrl}"
tvName.gone()
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.gone()
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(binding, item)
}
}
}
}
}
private fun upRefresh(binding: ItemBookshelfGridCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.visibility = View.GONE
binding.rlLoading.visibility = View.VISIBLE
} else {
binding.rlLoading.visibility = View.GONE
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visibility = View.VISIBLE
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.visibility = View.GONE
binding.newChapter.gone()
}
} else {
binding.cdUnread.visibility = View.GONE
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
override fun registerListener(
holder: ItemViewHolder,
binding: ItemBookshelfGridCompactBinding
) {
binding.cvContent.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.open(it, binding.cvContent)
}
}
binding.cvContent.setOnLongClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBookInfo(it, binding.cvContent)
}
true
}
}
}
@@ -1,129 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemBookshelfListBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.toTimeAgo
import io.legado.app.utils.visible
import splitties.views.onLongClick
class BooksAdapterList(
context: Context,
private val fragment: Fragment,
private val callBack: CallBack,
private val lifecycle: Lifecycle
) : BaseBooksAdapter<ItemBookshelfListBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookshelfListBinding {
return ItemBookshelfListBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookshelfListBinding,
item: Book,
payloads: MutableList<Any>
) = binding.run {
if (payloads.isEmpty()) {
cdCover.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
tvAuthor.text = item.author
tvRead.text = item.durChapterTitle
tvLast.text = item.latestChapterTitle
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(binding, item)
upLastUpdateTime(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"author" -> tvAuthor.text = item.author
"dur" -> tvRead.text = item.durChapterTitle
"last" -> tvLast.text = item.latestChapterTitle
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin,
fragment,
lifecycle
)
"refresh" -> upRefresh(binding, item)
"lastUpdateTime" -> upLastUpdateTime(binding, item)
}
}
}
}
}
private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.gone()
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visible()
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.gone()
binding.newChapter.gone()
}
} else {
binding.cdUnread.gone()
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
private fun upLastUpdateTime(binding: ItemBookshelfListBinding, item: Book) {
if (AppConfig.showLastUpdateTime && !item.isLocal) {
val time = item.latestChapterTime.toTimeAgo()
if (binding.tvLastUpdateTime.text != time) {
binding.tvLastUpdateTime.text = time
binding.tvLastUpdateTime.isVisible = true
}
} else {
binding.tvLastUpdateTime.isVisible = false
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfListBinding) {
holder.itemView.apply {
binding.cvContent.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.open(it, binding.cdCover)
}
}
binding.cvContent.onLongClick {
getItem(holder.layoutPosition)?.let {
callBack.openBookInfo(it, binding.cdCover)
}
}
}
}
}
@@ -1,133 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleDefalut
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemBookshelfListCompactBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.toTimeAgo
import io.legado.app.utils.visible
import splitties.views.onLongClick
class BooksAdapterListCompact(
context: Context,
private val fragment: Fragment,
private val callBack: CallBack,
private val lifecycle: Lifecycle
) : BaseBooksAdapter<ItemBookshelfListCompactBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookshelfListCompactBinding {
return ItemBookshelfListCompactBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookshelfListCompactBinding,
item: Book,
payloads: MutableList<Any>
) = binding.run {
if (payloads.isEmpty()) {
cdCover.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
tvAuthor.text =
context.getString(
R.string.author_read,
item.author,
(item.totalChapterNum - item.durChapterIndex).coerceAtLeast(0)
)
tvLast.text = item.latestChapterTitle
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(binding, item)
upLastUpdateTime(binding, item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"author" -> tvAuthor.text = item.author
"last" -> tvLast.text = item.latestChapterTitle
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin,
fragment,
lifecycle
)
"refresh" -> upRefresh(binding, item)
"lastUpdateTime" -> upLastUpdateTime(binding, item)
}
}
}
}
}
private fun upRefresh(binding: ItemBookshelfListCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.gone()
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visible()
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.gone()
binding.newChapter.gone()
}
} else {
binding.cdUnread.gone()
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
private fun upLastUpdateTime(binding: ItemBookshelfListCompactBinding, item: Book) {
if (AppConfig.showLastUpdateTime && !item.isLocal) {
val time = item.latestChapterTime.toTimeAgo()
if (binding.tvLastUpdateTime.text != time) {
binding.tvLastUpdateTime.text = time
binding.tvLastUpdateTime.isVisible = true
}
} else {
binding.tvLastUpdateTime.isVisible = false
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBookshelfListCompactBinding) {
holder.itemView.apply {
binding.cvContent.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.open(it, binding.cdCover)
}
}
binding.cvContent.onLongClick {
getItem(holder.layoutPosition)?.let {
callBack.openBookInfo(it, binding.cdCover)
}
}
}
}
}
@@ -1,146 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
abstract class BaseBooksAdapter<VH : RecyclerView.ViewHolder>(
val context: Context,
val callBack: CallBack
) : RecyclerView.Adapter<VH>() {
protected val inflater: LayoutInflater = LayoutInflater.from(context)
private val diffItemCallback = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return when {
oldItem is Book && newItem is Book -> {
oldItem.bookUrl == newItem.bookUrl
}
oldItem is BookGroup && newItem is BookGroup -> {
oldItem.groupId == newItem.groupId
}
else -> false
}
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return when {
oldItem is Book && newItem is Book -> {
oldItem.durChapterTime == newItem.durChapterTime &&
oldItem.name == newItem.name &&
oldItem.author == newItem.author &&
oldItem.durChapterTitle == newItem.durChapterTitle &&
oldItem.latestChapterTitle == newItem.latestChapterTitle &&
oldItem.lastCheckCount == newItem.lastCheckCount &&
oldItem.getDisplayCover() == newItem.getDisplayCover() &&
oldItem.getUnreadChapterNum() == newItem.getUnreadChapterNum()
}
oldItem is BookGroup && newItem is BookGroup -> false
else -> false
}
}
override fun getChangePayload(oldItem: Any, newItem: Any): Any? {
val bundle = bundleOf()
when {
oldItem is Book && newItem is Book -> {
if (oldItem.name != newItem.name) {
bundle.putString("name", newItem.name)
}
if (oldItem.author != newItem.author) {
bundle.putString("author", newItem.author)
}
if (oldItem.durChapterTitle != newItem.durChapterTitle) {
bundle.putString("dur", newItem.durChapterTitle)
}
if (oldItem.latestChapterTitle != newItem.latestChapterTitle) {
bundle.putString("last", newItem.latestChapterTitle)
}
if (oldItem.getDisplayCover() != newItem.getDisplayCover()) {
bundle.putString("cover", newItem.getDisplayCover())
}
if (oldItem.lastCheckCount != newItem.lastCheckCount
|| oldItem.durChapterTime != newItem.durChapterTime
|| oldItem.getUnreadChapterNum() != newItem.getUnreadChapterNum()
|| oldItem.lastCheckCount != newItem.lastCheckCount
) {
bundle.putBoolean("refresh", true)
}
}
oldItem is BookGroup && newItem is BookGroup -> {
if (oldItem.groupName != newItem.groupName) {
bundle.putString("groupName", newItem.groupName)
}
if (oldItem.cover != newItem.cover) {
bundle.putString("cover", newItem.cover)
}
}
}
if (bundle.isEmpty) return null
return bundle
}
}
private val asyncListDiffer by lazy {
AsyncListDiffer(this, diffItemCallback)
}
fun updateItems() {
asyncListDiffer.submitList(callBack.getItems())
}
fun notification(bookUrl: String) {
for (i in 0 until itemCount) {
getItem(i).let {
if (it is Book && it.bookUrl == bookUrl) {
notifyItemChanged(i, bundleOf(Pair("refresh", null)))
return
}
}
}
}
fun getItems() = asyncListDiffer.currentList
fun getItem(position: Int) = getItems().getOrNull(position)
internal var allBooks: List<Book> = emptyList()
fun setAllBooks(all: List<Book>) {
this.allBooks = all
}
override fun getItemCount(): Int {
return getItems().size
}
override fun getItemViewType(position: Int): Int {
if (getItem(position) is BookGroup) {
return 1
}
return 0
}
final override fun onBindViewHolder(holder: VH, position: Int) {}
interface CallBack {
fun onItemClick(item: Any, sharedView: View)
fun onItemLongClick(item: Any, sharedView: View)
fun isUpdate(bookUrl: String): Boolean
fun getItems(): List<Any>
}
}
@@ -1,195 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemBookshelfGridBinding
import io.legado.app.databinding.ItemBookshelfGridGroupBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
import splitties.views.onLongClick
@Suppress("UNUSED_PARAMETER")
class BooksAdapterGrid(context: Context, callBack: CallBack) :
BaseBooksAdapter<RecyclerView.ViewHolder>(context, callBack) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
1 -> GroupViewHolder(ItemBookshelfGridGroupBinding.inflate(inflater, parent, false))
else -> BookViewHolder(ItemBookshelfGridBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
when (holder) {
is BookViewHolder -> (getItem(position) as? Book)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
is GroupViewHolder -> (getItem(position) as? BookGroup)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
}
}
private fun getBooksForGroup(group: BookGroup): List<Book> {
return if (group.groupId == -1L) {
allBooks.take(4)
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
}
}
inner class BookViewHolder(val binding: ItemBookshelfGridBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Book) = binding.run {
cvContent.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(this, item)
}
fun onBind(item: Book, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(this, item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cvContent)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cvContent)
}
}
private fun upRefresh(binding: ItemBookshelfGridBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.visibility = View.GONE
binding.rlLoading.visibility = View.VISIBLE
} else {
binding.rlLoading.visibility = View.GONE
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visibility = View.VISIBLE
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.visibility = View.GONE
}
} else {
binding.cdUnread.visibility = View.GONE
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
}
inner class GroupViewHolder(val binding: ItemBookshelfGridGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: BookGroup) = binding.run {
tvName.text = item.groupName
loadGroupCover(item)
cdUnread.gone()
}
fun onBind(item: BookGroup, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"groupName" -> tvName.text = item.groupName
"cover" -> loadGroupCover(item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cdCover)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cdCover)
}
}
private fun loadGroupCover(item: BookGroup) = binding.run {
binding.ivCover.setImageDrawable(
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_surface_variant)
)
binding.ivCover1.setImageDrawable(null)
binding.ivCover2.setImageDrawable(null)
binding.ivCover3.setImageDrawable(null)
binding.ivCover4.setImageDrawable(null)
if (!item.cover.isNullOrEmpty()) {
ivCover.load(item.cover)
} else {
val books = getBooksForGroup(item)
if (books.size > 0) binding.ivCover1.load(books[0].getDisplayCover())
if (books.size > 1) binding.ivCover2.load(books[1].getDisplayCover())
if (books.size > 2) binding.ivCover3.load(books[2].getDisplayCover())
if (books.size > 3) binding.ivCover4.load(books[3].getDisplayCover())
}
}
}
}
@@ -1,202 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemBookshelfGridCompactBinding
import io.legado.app.databinding.ItemBookshelfGridCompactGroupBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
import splitties.views.onLongClick
@Suppress("UNUSED_PARAMETER")
class BooksAdapterGridCompact(context: Context, callBack: CallBack) :
BaseBooksAdapter<RecyclerView.ViewHolder>(context, callBack) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
1 -> GroupViewHolder(
ItemBookshelfGridCompactGroupBinding.inflate(
inflater,
parent,
false
)
)
else -> BookViewHolder(ItemBookshelfGridCompactBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
when (holder) {
is BookViewHolder -> (getItem(position) as? Book)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
is GroupViewHolder -> (getItem(position) as? BookGroup)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
}
}
private fun getBooksForGroup(group: BookGroup): List<Book> {
return if (group.groupId == -1L) {
allBooks.take(4)
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
}
}
inner class BookViewHolder(val binding: ItemBookshelfGridCompactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Book) = binding.run {
cvContent.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(this, item)
}
fun onBind(item: Book, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(this, item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cvContent)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cvContent)
}
}
private fun upRefresh(binding: ItemBookshelfGridCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.visibility = View.GONE
binding.rlLoading.visibility = View.VISIBLE
} else {
binding.rlLoading.visibility = View.GONE
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visibility = View.VISIBLE
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.visibility = View.GONE
}
} else {
binding.cdUnread.visibility = View.GONE
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
}
inner class GroupViewHolder(val binding: ItemBookshelfGridCompactGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: BookGroup) = binding.run {
tvName.text = item.groupName
loadGroupCover(item)
cdUnread.gone()
}
fun onBind(item: BookGroup, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"groupName" -> tvName.text = item.groupName
"cover" -> loadGroupCover(item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cvContent)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cvContent)
}
}
private fun loadGroupCover(item: BookGroup) = binding.run {
binding.ivCover.setImageDrawable(
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_surface_variant)
)
binding.ivCover1.setImageDrawable(null)
binding.ivCover2.setImageDrawable(null)
binding.ivCover3.setImageDrawable(null)
binding.ivCover4.setImageDrawable(null)
if (!item.cover.isNullOrEmpty()) {
ivCover.load(item.cover)
} else {
val books = getBooksForGroup(item)
if (books.size > 0) binding.ivCover1.load(books[0].getDisplayCover())
if (books.size > 1) binding.ivCover2.load(books[1].getDisplayCover())
if (books.size > 2) binding.ivCover3.load(books[2].getDisplayCover())
if (books.size > 3) binding.ivCover4.load(books[3].getDisplayCover())
}
}
}
}
@@ -1,202 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemBookshelfGridCompactBinding
import io.legado.app.databinding.ItemBookshelfGridCompactGroupBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.visible
import splitties.views.onLongClick
@Suppress("UNUSED_PARAMETER")
class BooksAdapterGridCover(context: Context, callBack: CallBack) :
BaseBooksAdapter<RecyclerView.ViewHolder>(context, callBack) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
1 -> GroupViewHolder(
ItemBookshelfGridCompactGroupBinding.inflate(
inflater,
parent,
false
)
)
else -> BookViewHolder(ItemBookshelfGridCompactBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
when (holder) {
is BookViewHolder -> (getItem(position) as? Book)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
is GroupViewHolder -> (getItem(position) as? BookGroup)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
}
}
private fun getBooksForGroup(group: BookGroup): List<Book> {
return if (group.groupId == -1L) {
allBooks.take(4)
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
}
}
inner class BookViewHolder(val binding: ItemBookshelfGridCompactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Book) = binding.run {
cvContent.transitionName = "book_${item.bookUrl}"
tvName.gone()
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
upRefresh(this, item)
}
fun onBind(item: Book, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.gone()
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(this, item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cvContent)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cvContent)
}
}
private fun upRefresh(binding: ItemBookshelfGridCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.visibility = View.GONE
binding.rlLoading.visibility = View.VISIBLE
} else {
binding.rlLoading.visibility = View.GONE
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visibility = View.VISIBLE
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.visibility = View.GONE
}
} else {
binding.cdUnread.visibility = View.GONE
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
}
inner class GroupViewHolder(val binding: ItemBookshelfGridCompactGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: BookGroup) = binding.run {
tvName.gone()
loadGroupCover(item)
cdUnread.gone()
}
fun onBind(item: BookGroup, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"groupName" -> tvName.gone()
"cover" -> loadGroupCover(item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cvContent)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cvContent)
}
}
private fun loadGroupCover(item: BookGroup) = binding.run {
binding.ivCover.setImageDrawable(
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_surface_variant)
)
binding.ivCover1.setImageDrawable(null)
binding.ivCover2.setImageDrawable(null)
binding.ivCover3.setImageDrawable(null)
binding.ivCover4.setImageDrawable(null)
if (!item.cover.isNullOrEmpty()) {
ivCover.load(item.cover)
} else {
val books = getBooksForGroup(item)
if (books.size > 0) binding.ivCover1.load(books[0].getDisplayCover())
if (books.size > 1) binding.ivCover2.load(books[1].getDisplayCover())
if (books.size > 2) binding.ivCover3.load(books[2].getDisplayCover())
if (books.size > 3) binding.ivCover4.load(books[3].getDisplayCover())
}
}
}
}
@@ -1,215 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemBookshelfListBinding
import io.legado.app.databinding.ItemBookshelfListGroupBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.toTimeAgo
import io.legado.app.utils.visible
import splitties.views.onLongClick
@Suppress("UNUSED_PARAMETER")
class BooksAdapterList(context: Context, callBack: CallBack) :
BaseBooksAdapter<RecyclerView.ViewHolder>(context, callBack) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
1 -> GroupViewHolder(ItemBookshelfListGroupBinding.inflate(inflater, parent, false))
else -> BookViewHolder(ItemBookshelfListBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
when (holder) {
is BookViewHolder -> (getItem(position) as? Book)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
is GroupViewHolder -> (getItem(position) as? BookGroup)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
}
}
private fun getBooksForGroup(group: BookGroup): List<Book> {
return if (group.groupId == -1L) {
allBooks.take(4)
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
}
}
inner class BookViewHolder(val binding: ItemBookshelfListBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Book) = binding.run {
cdCover.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
tvAuthor.text = item.author
tvRead.text = item.durChapterTitle
tvLast.text = item.latestChapterTitle
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
cdUnread.visible()
// ivAuthor.visible()
// ivLast.visible()
// ivRead.visible()
upRefresh(this, item)
upLastUpdateTime(binding, item)
}
fun onBind(item: Book, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"author" -> tvAuthor.text = item.author
"dur" -> tvRead.text = item.durChapterTitle
"last" -> tvLast.text = item.latestChapterTitle
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(this, item)
"lastUpdateTime" -> upLastUpdateTime(binding, item)
}
}
}
}
}
private fun upLastUpdateTime(binding: ItemBookshelfListBinding, item: Book) {
if (AppConfig.showLastUpdateTime && !item.isLocal) {
val time = item.latestChapterTime.toTimeAgo()
if (binding.tvLastUpdateTime.text != time) {
binding.tvLastUpdateTime.text = time
binding.tvLastUpdateTime.visible()
}
} else {
binding.tvLastUpdateTime.gone()
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cdCover)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cdCover)
}
}
private fun upRefresh(binding: ItemBookshelfListBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.gone()
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visible()
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.gone()
}
} else {
binding.cdUnread.gone()
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
}
inner class GroupViewHolder(val binding: ItemBookshelfListGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: BookGroup) = binding.run {
tvName.text = item.groupName
loadGroupCover(item)
cdUnread.gone()
}
fun onBind(item: BookGroup, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"groupName" -> tvName.text = item.groupName
"cover" -> loadGroupCover(item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.ivCover)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.ivCover)
}
}
private fun loadGroupCover(item: BookGroup) = binding.run {
binding.ivCover.setImageDrawable(
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_surface_variant)
)
binding.ivCover1.setImageDrawable(null)
binding.ivCover2.setImageDrawable(null)
binding.ivCover3.setImageDrawable(null)
binding.ivCover4.setImageDrawable(null)
if (!item.cover.isNullOrEmpty()) {
ivCover.load(item.cover)
} else {
val books = getBooksForGroup(item)
if (books.size > 0) binding.ivCover1.load(books[0].getDisplayCover())
if (books.size > 1) binding.ivCover2.load(books[1].getDisplayCover())
if (books.size > 2) binding.ivCover3.load(books[2].getDisplayCover())
if (books.size > 3) binding.ivCover4.load(books[3].getDisplayCover())
}
}
}
}
@@ -1,209 +0,0 @@
package io.legado.app.ui.main.bookshelf.books.styleFold
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemBookshelfListCompactBinding
import io.legado.app.databinding.ItemBookshelfListCompactGroupBinding
import io.legado.app.help.book.getBookTypeName
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.toTimeAgo
import io.legado.app.utils.visible
import splitties.views.onLongClick
@Suppress("UNUSED_PARAMETER")
class BooksAdapterListCompact(context: Context, callBack: CallBack) :
BaseBooksAdapter<RecyclerView.ViewHolder>(context, callBack) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
1 -> GroupViewHolder(ItemBookshelfListCompactGroupBinding.inflate(inflater, parent, false))
else -> BookViewHolder(ItemBookshelfListCompactBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
when (holder) {
is BookViewHolder -> (getItem(position) as? Book)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
is GroupViewHolder -> (getItem(position) as? BookGroup)?.let {
holder.registerListener(it)
holder.onBind(it, payloads)
}
}
}
private fun getBooksForGroup(group: BookGroup): List<Book> {
return if (group.groupId == -1L) {
allBooks.take(4)
} else {
allBooks.filter { (it.group and group.groupId) != 0L }.take(4)
}
}
inner class BookViewHolder(val binding: ItemBookshelfListCompactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Book) = binding.run {
cdCover.transitionName = "book_${item.bookUrl}"
tvName.text = item.name
tvAuthor.text = item.author
tvLast.text = item.latestChapterTitle
ivCover.load(item.getDisplayCover(), item.name, item.author, false, item.origin)
cdUnread.visible()
// ivAuthor.visible()
// ivLast.visible()
// ivRead.visible()
upRefresh(this, item)
upLastUpdateTime(binding, item)
}
fun onBind(item: Book, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvName.text = item.name
"author" -> tvAuthor.text = item.author
"last" -> tvLast.text = item.latestChapterTitle
"cover" -> ivCover.load(
item.getDisplayCover(),
item.name,
item.author,
false,
item.origin
)
"refresh" -> upRefresh(this, item)
"lastUpdateTime" -> upLastUpdateTime(binding, item)
}
}
}
}
}
private fun upLastUpdateTime(binding: ItemBookshelfListCompactBinding, item: Book) {
if (AppConfig.showLastUpdateTime && !item.isLocal) {
val time = item.latestChapterTime.toTimeAgo()
if (binding.tvLastUpdateTime.text != time) {
binding.tvLastUpdateTime.text = time
binding.tvLastUpdateTime.visible()
}
} else {
binding.tvLastUpdateTime.gone()
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.cdCover)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.cdCover)
}
}
private fun upRefresh(binding: ItemBookshelfListCompactBinding, item: Book) {
if (!item.isLocal && callBack.isUpdate(item.bookUrl)) {
binding.cdUnread.gone()
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
if (AppConfig.showUnread) {
val unreadCount = item.getUnreadChapterNum()
if (unreadCount > 0) {
binding.cdUnread.visible()
binding.tvUnread.text = unreadCount.toString()
if (AppConfig.showUnreadNew)
binding.newChapter.isVisible = item.lastCheckCount > 0
} else {
binding.cdUnread.gone()
}
} else {
binding.cdUnread.gone()
}
if (AppConfig.showTip){
binding.cdTip.visible()
binding.tvTip.text = item.getBookTypeName()
} else {
binding.cdTip.gone()
}
}
}
}
inner class GroupViewHolder(val binding: ItemBookshelfListCompactGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
fun onBind(item: BookGroup) = binding.run {
tvName.text = item.groupName
loadGroupCover(item)
cdUnread.gone()
}
fun onBind(item: BookGroup, payloads: MutableList<Any>) = binding.run {
if (payloads.isEmpty()) {
onBind(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"groupName" -> tvName.text = item.groupName
"cover" -> loadGroupCover(item)
}
}
}
}
}
fun registerListener(item: Any) {
binding.cvContent.setOnClickListener {
callBack.onItemClick(item, binding.ivCover)
}
binding.cvContent.onLongClick {
callBack.onItemLongClick(item, binding.ivCover)
}
}
private fun loadGroupCover(item: BookGroup) = binding.run {
binding.ivCover.setImageDrawable(
ContextCompat.getDrawable(binding.root.context, R.drawable.bg_surface_variant)
)
binding.ivCover1.setImageDrawable(null)
binding.ivCover2.setImageDrawable(null)
if (!item.cover.isNullOrEmpty()) {
ivCover.load(item.cover)
} else {
val books = getBooksForGroup(item)
if (books.size > 0) binding.ivCover1.load(books[0].getDisplayCover())
if (books.size > 1) binding.ivCover2.load(books[1].getDisplayCover())
}
}
}
}
@@ -1,225 +0,0 @@
package io.legado.app.ui.main.explore
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import android.widget.TextView
import androidx.core.view.children
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.card.MaterialCardView
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.databinding.ItemFilletTextBinding
import io.legado.app.databinding.ItemFindBookBinding
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.clearExploreKindsCache
import io.legado.app.help.source.exploreKinds
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.activity
import io.legado.app.utils.gone
import io.legado.app.utils.removeLastElement
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.visible
import kotlinx.coroutines.CoroutineScope
import splitties.views.onLongClick
class ExploreAdapter(
context: Context,
private val callBack: CallBack
) : RecyclerAdapter<BookSourcePart, ItemFindBookBinding>(context) {
private val recycler = arrayListOf<View>()
private var expandedId: String? = null
private var scrollToId: String? = null
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return getItem(position)?.bookSourceUrl.hashCode().toLong()
}
override fun getViewBinding(parent: ViewGroup): ItemFindBookBinding {
return ItemFindBookBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemFindBookBinding,
item: BookSourcePart,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
tvName.text = item.bookSourceName
}
val isExpanded = (item.bookSourceUrl == expandedId)
if (isExpanded) {
ivStatus.isChecked = true
rotateLoading.visible()
if (scrollToId == item.bookSourceUrl) {
val pos = holder.bindingAdapterPosition
if (pos >= 0) callBack.scrollTo(pos)
scrollToId = null
}
Coroutine.async(callBack.scope) {
item.exploreKinds()
}.onSuccess { kindList ->
upKindList(flexbox, item.bookSourceUrl, kindList)
}.onFinally {
rotateLoading.gone()
}
} else {
ivStatus.isChecked = false
recyclerFlexbox(flexbox)
flexbox.gone()
}
}
}
private fun upKindList(flexbox: FlexboxLayout, sourceUrl: String, kinds: List<ExploreKind>) {
if (kinds.isNotEmpty()) kotlin.runCatching {
recyclerFlexbox(flexbox)
flexbox.visible()
kinds.forEach { kind ->
val cardView = getFlexboxChild(flexbox)
val textView = cardView.findViewById<TextView>(R.id.text_view)
flexbox.addView(cardView)
textView.text = kind.title
kind.style().apply(cardView)
if (kind.url.isNullOrBlank()) {
cardView.setOnClickListener(null)
} else {
cardView.setOnClickListener {
if (kind.title.startsWith("ERROR:")) {
it.activity?.showDialogFragment(TextDialog("ERROR", kind.url))
} else {
callBack.openExplore(sourceUrl, kind.title, kind.url)
}
}
}
}
}
}
@Synchronized
private fun getFlexboxChild(flexbox: FlexboxLayout): MaterialCardView {
val cardView = if (recycler.isEmpty()) {
ItemFilletTextBinding.inflate(inflater, flexbox, false).root
} else {
recycler.removeLastElement() as MaterialCardView
}
val lp = FlexboxLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
cardView.layoutParams = lp
cardView.forceLayout()
val tv = cardView.findViewById<TextView>(R.id.text_view)
tv.text = ""
tv.requestLayout()
return cardView
}
@Synchronized
private fun recyclerFlexbox(flexbox: FlexboxLayout) {
recycler.addAll(flexbox.children)
flexbox.removeAllViews()
}
override fun registerListener(holder: ItemViewHolder, binding: ItemFindBookBinding) {
binding.apply {
llTitle.setOnClickListener {
toggleExpandState(holder)
}
ivStatus.setOnClickListener {
toggleExpandState(holder)
}
llTitle.onLongClick {
showMenu(ivStatus, holder.bindingAdapterPosition)
}
}
}
private fun toggleExpandState(holder: ItemViewHolder) {
val item = getItem(holder.bindingAdapterPosition) ?: return
val oldId = expandedId
expandedId = if (expandedId == item.bookSourceUrl) null else item.bookSourceUrl
notifyItemChangedById(oldId)
notifyItemChangedById(expandedId)
if (expandedId != null) {
scrollToId = expandedId
callBack.scrollTo(holder.bindingAdapterPosition)
}
}
fun compressExplore(): Boolean {
return if (expandedId == null) {
false
} else {
val oldId = expandedId
expandedId = null
notifyItemChangedById(oldId)
true
}
}
private fun notifyItemChangedById(id: String?) {
id ?: return
for (pos in 0 until itemCount) {
val item = getItem(pos) ?: continue
if (item.bookSourceUrl == id) {
notifyItemChanged(pos)
break
}
}
}
private fun showMenu(view: View, position: Int) {
val source = getItem(position) ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.explore_item)
popupMenu.menu.findItem(R.id.menu_login).isVisible = source.hasLoginUrl
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_edit -> callBack.editSource(source.bookSourceUrl)
R.id.menu_top -> callBack.toTop(source)
R.id.menu_search -> callBack.searchBook(source)
R.id.menu_login -> context.startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", source.bookSourceUrl)
}
R.id.menu_refresh -> Coroutine.async(callBack.scope) {
source.clearExploreKindsCache()
}.onSuccess {
notifyItemChanged(position)
}
R.id.menu_del -> callBack.deleteSource(source)
}
true
}
popupMenu.show()
}
interface CallBack {
val scope: CoroutineScope
fun scrollTo(pos: Int)
fun openExplore(sourceUrl: String, title: String, exploreUrl: String?)
fun editSource(sourceUrl: String)
fun toTop(source: BookSourcePart)
fun deleteSource(source: BookSourcePart)
fun searchBook(bookSource: BookSourcePart)
}
}
@@ -1,17 +0,0 @@
package io.legado.app.ui.main.explore
import androidx.recyclerview.widget.DiffUtil
import io.legado.app.data.entities.BookSourcePart
class ExploreDiffItemCallBack : DiffUtil.ItemCallback<BookSourcePart>() {
override fun areItemsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: BookSourcePart, newItem: BookSourcePart): Boolean {
return oldItem.bookSourceName == newItem.bookSourceName
}
}
@@ -1,48 +0,0 @@
package io.legado.app.ui.main.explore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import io.legado.app.ui.main.MainFragmentInterface
import io.legado.app.ui.theme.AppTheme
/**
* 发现界面
*/
class ExploreFragment() : Fragment(), MainFragmentInterface {
constructor(position: Int) : this() {
arguments = Bundle().apply {
putInt("position", position)
}
}
override val position: Int
get() = arguments?.getInt("position") ?: 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme {
ExploreScreen()
}
}
}
}
fun compressExplore() {
// Compose version handled by ViewModel state
}
}
@@ -397,56 +397,54 @@ fun ExploreSourceHeader(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
PillHeaderDivider(title = item.bookSourceName)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.VerticalAlignTop) },
text = { Text(stringResource(R.string.to_top)) },
onClick = { onTop(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
text = { Text(stringResource(R.string.edit)) },
onClick = { onEdit(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Search) },
text = { Text(stringResource(R.string.search)) },
onClick = { onSearch(); showMenu = false }
)
if (item.hasLoginUrl) {
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
text = { Text(stringResource(R.string.login)) },
onClick = { onLogin(); showMenu = false }
)
}
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
text = { Text(stringResource(R.string.refresh)) },
onClick = { onRefresh(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = {
MenuItemIcon(
Icons.Default.Delete,
tint = MaterialTheme.colorScheme.error
)
},
text = {
Text(
stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error
)
},
onClick = { onDelete(); showMenu = false }
)
}
}
)
}
RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
PillHeaderDivider(title = item.bookSourceName)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.VerticalAlignTop) },
text = { Text(stringResource(R.string.to_top)) },
onClick = { onTop(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
text = { Text(stringResource(R.string.edit)) },
onClick = { onEdit(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Search) },
text = { Text(stringResource(R.string.search)) },
onClick = { onSearch(); showMenu = false }
)
if (item.hasLoginUrl) {
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
text = { Text(stringResource(R.string.login)) },
onClick = { onLogin(); showMenu = false }
)
}
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
text = { Text(stringResource(R.string.refresh)) },
onClick = { onRefresh(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = {
MenuItemIcon(
Icons.Default.Delete,
tint = MaterialTheme.colorScheme.error
)
},
text = {
Text(
stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error
)
},
onClick = { onDelete(); showMenu = false }
)
}
}
@Composable
@@ -493,8 +491,7 @@ fun ExploreKindItem(
OutlinedCard(
shape = shape,
colors = CardDefaults.outlinedCardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
contentColor = MaterialTheme.colorScheme.primary
),
modifier = modifier
) {
@@ -9,7 +9,7 @@ import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.SourceHelp
import io.legado.app.help.source.clearExploreKindsCache
import io.legado.app.help.source.exploreKinds
import io.legado.app.ui.widget.components.rules.ListUiState
import io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,4 +47,18 @@ fun Modifier.responsiveHazeEffect(
)
}
}
}
}
/**
* 仅判断 enableBlur 的简单 HazeEffect
*/
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun Modifier.regularHazeEffect(state: HazeState): Modifier {
if (!ThemeConfig.enableBlur) return this
return this.hazeEffect(
state = state,
style = HazeMaterials.ultraThin()
)
}
@@ -0,0 +1,27 @@
package io.legado.app.ui.widget.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.legado.app.ui.config.themeConfig.ThemeConfig
object GlassDefaults {
/**
* 统一的玻璃颜色处理方法
* @param noBlurColor 未开启模糊时使用的颜色
* @param blurAlpha 开启模糊时应用的透明度
*/
@Composable
fun glassColor(noBlurColor: Color, blurAlpha: Float): Color {
return if (ThemeConfig.enableBlur) {
noBlurColor.copy(alpha = blurAlpha)
} else {
noBlurColor
}
}
// 默认的玻璃效果透明度值,方便全局修改
val DefaultBlurAlpha = 0.36f
val ThickBlurAlpha = 0.72f
val TransparentAlpha = 0f
}
@@ -0,0 +1,292 @@
package io.legado.app.ui.widget.components.cover
import android.graphics.Paint
import android.graphics.Typeface
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withSave
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookCover
import io.legado.app.ui.config.coverConfig.CoverConfig
import org.koin.compose.koinInject
@Composable
fun BookCover(
name: String?,
author: String?,
path: String?,
modifier: Modifier = Modifier.width(64.dp),
badgeContent: (@Composable RowScope.() -> Unit)? = null,
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null
) {
val context = LocalContext.current
val isNight = isSystemInDarkTheme()
val useDefault = AppConfig.useDefaultCover
val finalPath = if (useDefault) null else path
// 为每个 BookCover 组件实例基于其书名或路径生成一个随机封面
// 这样书架上的每个默认封面都会不同,但对于同一本书又是固定的
val randomDefault = remember(name, author, path) {
BookCover.getRandomDefaultDrawable(seed = name ?: author ?: path ?: "")
}
Box(
modifier = modifier
.aspectRatio(5f / 7f)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
} else Modifier
)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.then(
if (CoverConfig.coverShowStroke) {
Modifier.border(
0.5.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(4.dp)
)
} else Modifier
)
) {
// 1. 封面底图
AsyncImage(
model = ImageRequest.Builder(context)
.data(finalPath ?: randomDefault)
.placeholder(randomDefault)
.error(randomDefault)
.crossfade(true)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", CoverConfig.loadCoverOnlyWifi)
.build(),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onSuccess = { onLoadFinish?.invoke() },
onError = { onLoadFinish?.invoke() }
)
// 2. 文字叠加层(始终尝试渲染,由 CoverConfig 内部控制具体显示逻辑)
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
// 3. 角标 (Badge)
if (badgeContent != null) {
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(2.dp),
shape = RoundedCornerShape(2.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f),
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = badgeContent
)
}
}
}
}
@Composable
private fun CoverTextOverlay(
name: String?,
author: String?,
isNight: Boolean
) {
val showName = if (isNight) CoverConfig.coverShowNameN else CoverConfig.coverShowName
val showAuthor =
(if (isNight) CoverConfig.coverShowAuthorN else CoverConfig.coverShowAuthor) && showName
// 如果都不显示,直接返回
if (!showName && !showAuthor) return
val secondaryColor = MaterialTheme.colorScheme.secondary.toArgb()
val textColor = if (CoverConfig.coverDefaultColor) {
secondaryColor
} else {
if (isNight) CoverConfig.coverTextColorN else CoverConfig.coverTextColor
}
val shadowColor = if (isNight) CoverConfig.coverShadowColorN else CoverConfig.coverShadowColor
val isHorizontal = CoverConfig.coverInfoOrientation == "1"
Canvas(modifier = Modifier.fillMaxSize()) {
val viewWidth = size.width
val viewHeight = size.height
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// 绘制书名
if (showName && !name.isNullOrBlank()) {
val paint = Paint().apply {
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
textSize = viewWidth / 8f
color = textColor
if (CoverConfig.coverShowShadow) {
setShadowLayer(4f, 2f, 2f, shadowColor)
}
}
if (isHorizontal) {
val maxWidth = (viewWidth * 0.8f).toInt()
val textPaint = TextPaint(paint).apply {
textAlign = Paint.Align.LEFT
}
val layout = StaticLayout.Builder
.obtain(name, 0, name.length, textPaint, maxWidth)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.setMaxLines(3)
.setEllipsize(TextUtils.TruncateAt.END)
.build()
nativeCanvas.withSave {
val textX = (viewWidth - maxWidth) / 2f
val textY = viewHeight * 0.08f
translate(textX, textY)
if (CoverConfig.coverShowStroke) {
textPaint.style = Paint.Style.STROKE
textPaint.strokeWidth = textPaint.textSize / 12
val originalColor = textPaint.color
textPaint.color = Color.White.toArgb()
textPaint.clearShadowLayer()
layout.draw(this)
// 还原
textPaint.style = Paint.Style.FILL
textPaint.color = originalColor
if (CoverConfig.coverShowShadow) {
textPaint.setShadowLayer(4f, 2f, 2f, shadowColor)
}
}
layout.draw(this)
}
} else {
var startX = viewWidth * 0.16f
var startY = viewHeight * 0.16f
val fm = paint.fontMetrics
val charHeight = fm.bottom - fm.top
name.forEach { char ->
if (CoverConfig.coverShowStroke) {
val strokePaint = Paint(paint).apply {
color = Color.White.toArgb()
style = Paint.Style.STROKE
strokeWidth = paint.textSize / 10
clearShadowLayer()
}
nativeCanvas.drawText(char.toString(), startX, startY, strokePaint)
}
nativeCanvas.drawText(char.toString(), startX, startY, paint)
startY += charHeight
if (startY > viewHeight * 0.8f) {
startX += paint.textSize * 1.2f
startY = viewHeight * 0.2f
}
}
}
}
// 绘制作者
if (showAuthor && !author.isNullOrBlank()) {
val paint = Paint().apply {
isAntiAlias = true
textAlign = Paint.Align.CENTER
textSize = viewWidth / 12f
color = textColor
if (CoverConfig.coverShowShadow) {
setShadowLayer(4f, 1f, 1f, shadowColor)
}
}
if (isHorizontal) {
val authorText = TextUtils.ellipsize(
author,
TextPaint(paint),
viewWidth * 0.9f,
TextUtils.TruncateAt.END
)
if (CoverConfig.coverShowStroke) {
val strokePaint = Paint(paint).apply {
color = Color.White.toArgb()
style = Paint.Style.STROKE
strokeWidth = paint.textSize / 10
clearShadowLayer()
}
nativeCanvas.drawText(
authorText.toString(),
viewWidth / 2,
viewHeight * 0.75f,
strokePaint
)
}
nativeCanvas.drawText(
authorText.toString(),
viewWidth / 2,
viewHeight * 0.75f,
paint
)
} else {
val startX = viewWidth * 0.84f
val fm = paint.fontMetrics
val charHeight = fm.bottom - fm.top
var startY = viewHeight * 0.16f - (author.length * charHeight)
startY = startY.coerceAtLeast(viewHeight * 0.2f)
author.forEach { char ->
nativeCanvas.drawText(char.toString(), startX, startY, paint)
startY += charHeight
}
}
}
}
}
}
@@ -0,0 +1,51 @@
package io.legado.app.ui.widget.components.cover
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
@Composable
fun BookCoverWithProgress(
name: String?,
author: String?,
path: String?,
modifier: Modifier = Modifier,
isUpdating: Boolean = false,
badgeContent: (@Composable RowScope.() -> Unit)? = null,
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null
) {
Box(modifier = modifier) {
BookCover(
name = name,
author = author,
path = path,
modifier = Modifier.fillMaxWidth(),
badgeContent = badgeContent,
sourceOrigin = sourceOrigin,
onLoadFinish = onLoadFinish
)
if (isUpdating) {
LinearProgressIndicator(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp)
.height(3.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
strokeCap = StrokeCap.Round
)
}
}
}
@@ -1,4 +1,4 @@
package io.legado.app.ui.widget.components
package io.legado.app.ui.widget.components.cover
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -0,0 +1,198 @@
package io.legado.app.ui.widget.components.dialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.skydoves.colorpicker.compose.AlphaSlider
import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController
import io.legado.app.R
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorPickerSheet(
initialColor: Int,
onDismissRequest: () -> Unit,
onColorSelected: (Int) -> Unit
) {
val controller = rememberColorPickerController()
var currentColor by remember { mutableStateOf(Color(initialColor)) }
val presets = remember {
listOf(
Color(0xFFF44336),
Color(0xFFE91E63),
Color(0xFF9C27B0),
Color(0xFF673AB7),
Color(0xFF3F51B5),
Color(0xFF2196F3),
Color(0xFF03A9F4),
Color(0xFF00BCD4),
Color(0xFF009688),
Color(0xFF4CAF50),
Color(0xFF8BC34A),
Color(0xFFCDDC39),
Color(0xFFFFEB3B),
Color(0xFFFFC107),
Color(0xFFFF9800),
Color(0xFFFF5722),
Color(0xFF795548),
Color(0xFF9E9E9E),
Color(0xFF607D8B),
Color(0xFF000000),
Color(0xFFFFFFFF)
)
}
GlassModalBottomSheet(onDismissRequest = onDismissRequest) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "颜色选择",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.padding(10.dp),
controller = controller,
initialColor = Color(initialColor),
onColorChanged = { envelope ->
currentColor = envelope.color
}
)
Spacer(modifier = Modifier.height(16.dp))
AlphaSlider(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.height(35.dp),
controller = controller,
)
BrightnessSlider(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.height(35.dp),
controller = controller,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(currentColor)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(8.dp)
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "#${Integer.toHexString(currentColor.toArgb()).uppercase()}",
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.height(16.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(36.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.heightIn(max = 80.dp)
.padding(horizontal = 10.dp)
) {
items(presets) { color ->
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color)
.border(
if (color.toArgb() == currentColor.toArgb()) 2.dp else 0.dp,
MaterialTheme.colorScheme.primary,
CircleShape
)
.clickable {
controller.selectByColor(color, true)
}
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onColorSelected(currentColor.toArgb())
onDismissRequest()
}) {
Text(stringResource(R.string.ok))
}
}
}
}
}
@@ -1,4 +1,4 @@
package io.legado.app.ui.widget.components.exportComponents
package io.legado.app.ui.widget.components.filePicker
import android.webkit.MimeTypeMap
import androidx.compose.foundation.layout.Arrangement
@@ -41,6 +41,7 @@ fun FilePickerSheet(
title: String = stringResource(R.string.select_operation),
onSelectSysDir: (() -> Unit)? = null,
onSelectSysFile: ((Array<String>) -> Unit)? = null,
onSelectSysFiles: ((Array<String>) -> Unit)? = null,
onManualInput: (() -> Unit)? = null,
onUpload: (() -> Unit)? = null,
allowExtensions: Array<String>? = null,
@@ -84,6 +85,14 @@ fun FilePickerSheet(
)
}
onSelectSysFiles?.let {
FilePickerOptionCard(
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
text = "系统多选",
onClick = { it(typesOfExtensions(allowExtensions)) }
)
}
onManualInput?.let {
FilePickerOptionCard(
icon = Icons.Default.EditNote,
@@ -160,4 +169,4 @@ private fun typesOfExtensions(allowExtensions: Array<String>?): Array<String> {
}
}
return types.toTypedArray()
}
}
@@ -42,7 +42,6 @@ import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.SelectionActions
import io.legado.app.ui.widget.components.SelectionBottomBar
import io.legado.app.ui.widget.components.rules.ListUiState
import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -1,4 +1,4 @@
package io.legado.app.ui.widget.components.rules
package io.legado.app.ui.widget.components.list
interface ListUiState<T> {
val items: List<T>
@@ -15,6 +15,8 @@ fun ClickableSettingItem(
description: String? = null,
option: String? = null,
imageVector: ImageVector? = null,
onLongClick: (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
onClick: () -> Unit
) {
SettingItem(
@@ -23,13 +25,14 @@ fun ClickableSettingItem(
description = description,
option = option,
imageVector = imageVector,
trailingContent = {
trailingContent = trailingContent ?: {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
onClick = onClick
onClick = onClick,
onLongClick = onLongClick
)
}
}
@@ -0,0 +1,153 @@
package io.legado.app.ui.widget.components.settingItem
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.button.SmallOutlinedIconButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CompactDropdownSettingItem(
title: String,
selectedValue: String,
displayEntries: Array<String>,
entryValues: Array<String>,
description: String? = null,
imageVector: ImageVector? = null,
color: Color? = MaterialTheme.colorScheme.surface,
shape: Shape = MaterialTheme.shapes.small,
onValueChange: (String) -> Unit
) {
val currentEntry = displayEntries.getOrNull(entryValues.indexOf(selectedValue)) ?: selectedValue
SettingItem(
title = title,
description = description,
imageVector = imageVector,
color = color,
shape = shape,
trailingContent = {
TextCard(
cornerRadius = 8.dp,
horizontalPadding = 8.dp,
verticalPadding = 4.dp,
text = currentEntry,
backgroundColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface
)
},
dropdownMenu = { onDismiss ->
displayEntries.forEachIndexed { index, display ->
RoundDropdownMenuItem(
text = { Text(display) },
onClick = {
onValueChange(entryValues[index])
onDismiss()
},
trailingIcon = if (selectedValue == entryValues[index]) {
{
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
} else null
)
}
}
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CompactSliderSettingItem(
title: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
description: String? = null,
imageVector: ImageVector? = null,
color: Color? = MaterialTheme.colorScheme.surface,
shape: Shape = MaterialTheme.shapes.small,
onValueChange: (Float) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var sliderValue by remember(value) { mutableFloatStateOf(value) }
SettingItem(
title = title,
description = description,
imageVector = imageVector,
color = color,
shape = shape,
expanded = expanded,
onExpandChange = { expanded = it },
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SmallOutlinedIconButton(
onClick = {
val newValue = (value.toInt() - 1).toFloat().coerceIn(valueRange)
onValueChange(newValue)
},
icon = Icons.Default.Remove
)
TextCard(
cornerRadius = 8.dp,
horizontalPadding = 8.dp,
verticalPadding = 4.dp,
text = value.toInt().toString(),
backgroundColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface
)
SmallOutlinedIconButton(
onClick = {
val newValue = (value.toInt() + 1).toFloat().coerceIn(valueRange)
onValueChange(newValue)
},
icon = Icons.Default.Add
)
}
},
expandContent = {
Slider(
value = sliderValue,
onValueChange = {
sliderValue = it
onValueChange(it)
},
valueRange = valueRange,
steps = steps,
modifier = Modifier.fillMaxWidth()
)
}
)
}
+9 -2
View File
@@ -9,9 +9,8 @@
<string-array name="group_style">
<item>标签</item>
<item>文件夹</item>
<item>隐藏标签</item>
<item>材料标签</item>
<item>文件夹</item>
</string-array>
<string-array name="text_suffix">
@@ -132,6 +131,14 @@
<item>@string/bookshelf_px_5</item>
</string-array>
<string-array name="bookshelf_layout">
<item>@string/list</item>
<item>@string/compact_list</item>
<item>@string/grid</item>
<item>@string/compact_grid</item>
<item>@string/cover_grid</item>
</string-array>
<string-array name="themes_item">
<item>动态取色</item>
<item>草野</item>
+1 -2
View File
@@ -10,9 +10,8 @@
<string-array name="group_style">
<item>標籤</item>
<item>文件夾</item>
<item>隐藏標籤</item>
<item>材料標籤</item>
<item>文件夾</item>
</string-array>
<string-array name="theme_mode">
+1 -2
View File
@@ -9,9 +9,8 @@
<string-array name="group_style">
<item>標籤</item>
<item>資料夾</item>
<item>隐藏標籤</item>
<item>材料標籤</item>
<item>資料夾</item>
</string-array>
<string-array name="text_suffix">
+9 -2
View File
@@ -14,9 +14,8 @@
<string-array name="group_style">
<item>Tab</item>
<item>Folder</item>
<item>Hide Tab</item>
<item>Material Tab</item>
<item>Folder</item>
</string-array>
<string-array name="book_sort">
@@ -279,6 +278,14 @@
<item>@string/bookshelf_px_5</item>
</string-array>
<string-array name="bookshelf_layout">
<item>@string/list</item>
<item>@string/compact_list</item>
<item>@string/grid</item>
<item>@string/compact_grid</item>
<item>@string/cover_grid</item>
</string-array>
<string-array name="label_vis_mode" >
<item>Auto</item>
<item>Selected</item>
+2
View File
@@ -20,6 +20,7 @@ ksp = "2.3.4"
agp = "8.13.2"
appcompat = "1.7.1"
colorpicker = "1.1.0"
colorpicker-compose = "1.1.3"
commonsText = "1.15.0"
constraintlayout = "2.2.1"
core = "1.17.0"
@@ -209,6 +210,7 @@ androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
colorpicker = { module = "com.jaredrummler:colorpicker", version.ref = "colorpicker" }
colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version.ref = "colorpicker-compose" }
commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" }
desugar = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugar" }