换源时,不会再因为打开浏览器而导致重走换源流程
换源界面新增多选分组和书源功能
This commit is contained in:
@@ -172,6 +172,16 @@ interface BookSourceDao {
|
||||
)
|
||||
fun getEnabledPartByGroup(group: String): List<BookSourcePart>
|
||||
|
||||
@Query(
|
||||
"""select * from book_sources_part
|
||||
where bookSourceGroup = :group
|
||||
or bookSourceGroup like :group || ',%'
|
||||
or bookSourceGroup like '%,' || :group
|
||||
or bookSourceGroup like '%,' || :group || ',%'
|
||||
order by customOrder asc"""
|
||||
)
|
||||
fun getPartByGroup(group: String): List<BookSourcePart>
|
||||
|
||||
@Query(
|
||||
"""select * from book_sources
|
||||
where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc"""
|
||||
|
||||
@@ -11,6 +11,10 @@ class BookGroupRepository(private val bookGroupDao: BookGroupDao) {
|
||||
return bookGroupDao.flowAll()
|
||||
}
|
||||
|
||||
fun flowSelect(): Flow<List<BookGroup>> {
|
||||
return bookGroupDao.flowSelect()
|
||||
}
|
||||
|
||||
fun flowShow(): Flow<List<BookGroup>> {
|
||||
return bookGroupDao.show.asFlow()
|
||||
}
|
||||
|
||||
+88
-10
@@ -1,14 +1,42 @@
|
||||
package io.legado.app.ui.book.changesource
|
||||
|
||||
import android.app.Application
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.data.entities.SearchBook
|
||||
import io.legado.app.data.repository.SearchRepository
|
||||
import io.legado.app.ui.book.search.SearchScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class ChangeBookSourceComposeViewModel(application: Application) :
|
||||
ChangeBookSourceViewModel(application) {
|
||||
class ChangeBookSourceComposeViewModel(
|
||||
application: Application,
|
||||
private val searchRepository: SearchRepository,
|
||||
) : ChangeBookSourceViewModel(application) {
|
||||
|
||||
val searchGroup: String
|
||||
get() = ChangeSourceConfig.searchGroup
|
||||
val enabledGroups = searchRepository.enabledGroups
|
||||
val enabledSources = searchRepository.enabledSources
|
||||
|
||||
val searchScope = SearchScope(ChangeSourceConfig.searchScope)
|
||||
|
||||
data class ScopeUiState(
|
||||
val isAll: Boolean,
|
||||
val isSource: Boolean,
|
||||
val displayNames: List<String>,
|
||||
val sourceUrls: List<String>
|
||||
)
|
||||
|
||||
private val _scopeUiState = MutableStateFlow(
|
||||
ScopeUiState(
|
||||
isAll = searchScope.isAll(),
|
||||
isSource = searchScope.isSource(),
|
||||
displayNames = searchScope.displayNames,
|
||||
sourceUrls = searchScope.sourceUrls
|
||||
)
|
||||
)
|
||||
val scopeUiState = _scopeUiState.asStateFlow()
|
||||
|
||||
val checkAuthor: Boolean
|
||||
get() = ChangeSourceConfig.checkAuthor
|
||||
@@ -22,12 +50,6 @@ class ChangeBookSourceComposeViewModel(application: Application) :
|
||||
val loadWordCount: Boolean
|
||||
get() = ChangeSourceConfig.loadWordCount
|
||||
|
||||
fun onSearchGroupSelected(group: String) {
|
||||
if (ChangeSourceConfig.searchGroup == group) return
|
||||
ChangeSourceConfig.searchGroup = group
|
||||
if (refresh()) startSearch()
|
||||
}
|
||||
|
||||
fun onCheckAuthorChange(enabled: Boolean) {
|
||||
if (ChangeSourceConfig.checkAuthor == enabled) return
|
||||
ChangeSourceConfig.checkAuthor = enabled
|
||||
@@ -62,4 +84,60 @@ class ChangeBookSourceComposeViewModel(application: Application) :
|
||||
val currentScore = ObservableSourceConfig.getBookScore(searchBook)
|
||||
setBookScore(searchBook, if (currentScore > 0) 0 else 1)
|
||||
}
|
||||
|
||||
fun selectAllScope() {
|
||||
searchScope.update("")
|
||||
saveScope()
|
||||
}
|
||||
|
||||
fun toggleScopeGroup(groupName: String) {
|
||||
if (searchScope.isSource()) {
|
||||
searchScope.update("")
|
||||
}
|
||||
val selected = searchScope.displayNames.toMutableSet()
|
||||
if (selected.contains(groupName)) {
|
||||
selected.remove(groupName)
|
||||
} else {
|
||||
selected.add(groupName)
|
||||
}
|
||||
searchScope.update(selected.toList())
|
||||
saveScope()
|
||||
}
|
||||
|
||||
fun toggleScopeSource(source: BookSourcePart) {
|
||||
val selectedUrls = if (searchScope.isSource()) {
|
||||
searchScope.sourceUrls.toMutableSet()
|
||||
} else {
|
||||
mutableSetOf()
|
||||
}
|
||||
|
||||
if (selectedUrls.contains(source.bookSourceUrl)) {
|
||||
selectedUrls.remove(source.bookSourceUrl)
|
||||
} else {
|
||||
selectedUrls.add(source.bookSourceUrl)
|
||||
}
|
||||
|
||||
if (selectedUrls.isEmpty()) {
|
||||
searchScope.update("")
|
||||
} else {
|
||||
val selectedSources = appDb.bookSourceDao.allEnabledPart.filter {
|
||||
selectedUrls.contains(it.bookSourceUrl)
|
||||
}
|
||||
searchScope.updateSources(selectedSources)
|
||||
}
|
||||
saveScope()
|
||||
}
|
||||
|
||||
private fun saveScope() {
|
||||
ChangeSourceConfig.searchScope = searchScope.toString()
|
||||
_scopeUiState.update {
|
||||
ScopeUiState(
|
||||
isAll = searchScope.isAll(),
|
||||
isSource = searchScope.isSource(),
|
||||
displayNames = searchScope.displayNames,
|
||||
sourceUrls = searchScope.sourceUrls
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -97,6 +98,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
.thenBy { it.originOrder }
|
||||
}
|
||||
private var task: Job? = null
|
||||
private var isPaused = false
|
||||
private var wasSearching = false
|
||||
val bookMap = ConcurrentHashMap<String, Book>()
|
||||
val searchDataFlow = callbackFlow {
|
||||
|
||||
@@ -119,13 +122,14 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
|
||||
}
|
||||
|
||||
getDbSearchBooks().let {
|
||||
searchBooks.clear()
|
||||
searchBooks.addAll(it)
|
||||
trySend(arrayOf(searchBooks))
|
||||
}
|
||||
|
||||
if (searchBooks.isEmpty()) {
|
||||
getDbSearchBooks().let {
|
||||
searchBooks.addAll(it)
|
||||
}
|
||||
}
|
||||
trySend(arrayOf(searchBooks))
|
||||
|
||||
if (searchBooks.isEmpty() && !_isSearching.value) {
|
||||
startSearch()
|
||||
}
|
||||
|
||||
@@ -207,18 +211,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
bookMap.clear()
|
||||
tocMapChapterCount = 0
|
||||
_changeSourceProgress.value = 0 to ""
|
||||
val searchGroup = AppConfig.searchGroup
|
||||
if (searchGroup.isBlank()) {
|
||||
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
|
||||
} else {
|
||||
val sources = appDb.bookSourceDao.getEnabledPartByGroup(searchGroup)
|
||||
if (sources.isEmpty()) {
|
||||
AppConfig.searchGroup = ""
|
||||
bookSourceParts.addAll(appDb.bookSourceDao.allEnabledPart)
|
||||
} else {
|
||||
bookSourceParts.addAll(sources)
|
||||
}
|
||||
}
|
||||
bookSourceParts.addAll(io.legado.app.ui.book.search.SearchScope(ChangeSourceConfig.searchScope).getBookSourceParts())
|
||||
initSearchPool()
|
||||
search()
|
||||
}
|
||||
@@ -250,6 +243,9 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
searchStateData.postValue(true)
|
||||
_isSearching.value = true
|
||||
}.mapParallel(threadCount) {
|
||||
while (isPaused) {
|
||||
kotlinx.coroutines.delay(100)
|
||||
}
|
||||
try {
|
||||
withTimeout(60000L) {
|
||||
search(it)
|
||||
@@ -465,6 +461,17 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
searchPool?.close()
|
||||
searchStateData.postValue(false)
|
||||
_isSearching.value = false
|
||||
wasSearching = false
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
isPaused = true
|
||||
wasSearching = _isSearching.value
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
isPaused = false
|
||||
wasSearching = false
|
||||
}
|
||||
|
||||
fun getToc(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.legado.app.ui.book.changesource
|
||||
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
|
||||
import io.legado.app.ui.config.prefDelegate
|
||||
|
||||
object ChangeSourceConfig {
|
||||
@@ -10,6 +11,11 @@ object ChangeSourceConfig {
|
||||
defaultValue = ""
|
||||
)
|
||||
|
||||
var searchScope by prefDelegate(
|
||||
key = "changeSourceSearchScope",
|
||||
defaultValue = ""
|
||||
)
|
||||
|
||||
var checkAuthor by prefDelegate(
|
||||
key = PreferKey.changeSourceCheckAuthor,
|
||||
defaultValue = false
|
||||
@@ -29,4 +35,61 @@ object ChangeSourceConfig {
|
||||
key = PreferKey.changeSourceLoadWordCount,
|
||||
defaultValue = false
|
||||
)
|
||||
|
||||
var migrateChapters by prefDelegate(
|
||||
key = "migrateChapters",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateReadingProgress by prefDelegate(
|
||||
key = "migrateReadingProgress",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateGroup by prefDelegate(
|
||||
key = "migrateGroup",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateCover by prefDelegate(
|
||||
key = "migrateCover",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateCategory by prefDelegate(
|
||||
key = "migrateCategory",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateRemark by prefDelegate(
|
||||
key = "migrateRemark",
|
||||
defaultValue = true
|
||||
)
|
||||
var migrateReadConfig by prefDelegate(
|
||||
key = "migrateReadConfig",
|
||||
defaultValue = true
|
||||
)
|
||||
var deleteDownloadedChapters by prefDelegate(
|
||||
key = "deleteDownloadedChapters",
|
||||
defaultValue = false
|
||||
)
|
||||
|
||||
fun getMigrationOptions(): ChangeSourceMigrationOptions {
|
||||
return ChangeSourceMigrationOptions(
|
||||
migrateChapters = migrateChapters,
|
||||
migrateReadingProgress = migrateReadingProgress,
|
||||
migrateGroup = migrateGroup,
|
||||
migrateCover = migrateCover,
|
||||
migrateCategory = migrateCategory,
|
||||
migrateRemark = migrateRemark,
|
||||
migrateReadConfig = migrateReadConfig,
|
||||
deleteDownloadedChapters = deleteDownloadedChapters,
|
||||
)
|
||||
}
|
||||
|
||||
fun setMigrationOptions(options: ChangeSourceMigrationOptions) {
|
||||
migrateChapters = options.migrateChapters
|
||||
migrateReadingProgress = options.migrateReadingProgress
|
||||
migrateGroup = options.migrateGroup
|
||||
migrateCover = options.migrateCover
|
||||
migrateCategory = options.migrateCategory
|
||||
migrateRemark = options.migrateRemark
|
||||
migrateReadConfig = options.migrateReadConfig
|
||||
deleteDownloadedChapters = options.deleteDownloadedChapters
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -30,13 +30,27 @@ fun ChangeSourceMigrationOptionsSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (ChangeSourceMigrationOptions) -> Unit,
|
||||
) {
|
||||
var migrateReadingProgress by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var migrateGroup by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var migrateCover by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var migrateCategory by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var migrateRemark by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var migrateReadConfig by rememberSaveable(show) { mutableStateOf(true) }
|
||||
var deleteDownloadedChapters by rememberSaveable(show) { mutableStateOf(false) }
|
||||
var migrateReadingProgress by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateReadingProgress)
|
||||
}
|
||||
var migrateGroup by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateGroup)
|
||||
}
|
||||
var migrateCover by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateCover)
|
||||
}
|
||||
var migrateCategory by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateCategory)
|
||||
}
|
||||
var migrateRemark by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateRemark)
|
||||
}
|
||||
var migrateReadConfig by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.migrateReadConfig)
|
||||
}
|
||||
var deleteDownloadedChapters by rememberSaveable(show) {
|
||||
mutableStateOf(ChangeSourceConfig.deleteDownloadedChapters)
|
||||
}
|
||||
|
||||
AppModalBottomSheet(
|
||||
show = show,
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import io.legado.app.R
|
||||
@@ -282,12 +283,16 @@ private fun BookInfoScreenContent(
|
||||
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
|
||||
onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
|
||||
)
|
||||
BookInfoSheet.GroupPicker -> GroupSelectSheet(
|
||||
show = currentSheet == BookInfoSheet.GroupPicker,
|
||||
currentGroupId = state.book?.group ?: 0L,
|
||||
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
|
||||
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
|
||||
)
|
||||
BookInfoSheet.GroupPicker -> {
|
||||
val groups by koinInject<io.legado.app.data.repository.BookGroupRepository>().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
GroupSelectSheet(
|
||||
show = currentSheet == BookInfoSheet.GroupPicker,
|
||||
groups = groups,
|
||||
currentGroupId = state.book?.group ?: 0L,
|
||||
onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
|
||||
onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
|
||||
)
|
||||
}
|
||||
BookInfoSheet.SourcePicker -> state.book?.let { book ->
|
||||
ChangeSourceSheet(
|
||||
show = currentSheet == BookInfoSheet.SourcePicker,
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
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.Check
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -45,19 +47,22 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.BookChapter
|
||||
import io.legado.app.data.entities.BookGroup
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.data.entities.SearchBook
|
||||
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
|
||||
import io.legado.app.help.book.isSameNameAuthor
|
||||
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
|
||||
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
|
||||
import io.legado.app.ui.book.changesource.ChangeSourceConfig
|
||||
import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
|
||||
import io.legado.app.ui.book.group.GroupEditSheet
|
||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||
@@ -65,6 +70,7 @@ import io.legado.app.ui.book.source.manage.BookSourceActivity
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
|
||||
import io.legado.app.ui.widget.components.AppTextField
|
||||
import io.legado.app.ui.widget.components.EmptyMessage
|
||||
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
|
||||
import io.legado.app.ui.widget.components.button.MediumIconButton
|
||||
@@ -76,6 +82,7 @@ import io.legado.app.ui.widget.components.cover.CoilBookCover
|
||||
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.AppModalBottomSheet
|
||||
import io.legado.app.ui.widget.components.tabRow.AppTabRow
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.utils.StartActivityContract
|
||||
import io.legado.app.utils.startActivity
|
||||
@@ -118,11 +125,11 @@ fun WebFileSheet(
|
||||
@Composable
|
||||
fun GroupSelectSheet(
|
||||
show: Boolean,
|
||||
groups: List<BookGroup>,
|
||||
currentGroupId: Long,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (Long) -> Unit,
|
||||
) {
|
||||
val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) }
|
||||
var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
|
||||
|
||||
@@ -138,7 +145,7 @@ fun GroupSelectSheet(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(groups, key = { it.groupId }) { group ->
|
||||
val isSelected = selectedGroupId and group.groupId > 0
|
||||
val isSelected = (selectedGroupId and group.groupId) != 0L
|
||||
SelectionItemCard(
|
||||
title = group.groupName,
|
||||
isSelected = isSelected,
|
||||
@@ -194,7 +201,7 @@ fun ChangeCoverSheet(
|
||||
onSelect: (String) -> Unit,
|
||||
viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
|
||||
) {
|
||||
val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
|
||||
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(name, author) {
|
||||
@@ -244,22 +251,25 @@ fun ChangeSourceSheet(
|
||||
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
|
||||
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
|
||||
val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
|
||||
val groups by appDb.bookSourceDao.flowEnabledGroups().collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList<String>())
|
||||
val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList<io.legado.app.data.entities.BookSourcePart>())
|
||||
val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle()
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
val selectedGroup = viewModel.searchGroup
|
||||
val checkAuthor = viewModel.checkAuthor
|
||||
val loadInfo = viewModel.loadInfo
|
||||
val loadToc = viewModel.loadToc
|
||||
val loadWordCount = viewModel.loadWordCount
|
||||
var actionBook by remember { mutableStateOf<SearchBook?>(null) }
|
||||
var mismatchBook by remember { mutableStateOf<SearchBook?>(null) }
|
||||
var pendingMigration by remember { mutableStateOf<PendingSourceMigration?>(null) }
|
||||
var showMigrationOptions by remember { mutableStateOf(false) }
|
||||
var loadingAction by remember { mutableStateOf(false) }
|
||||
var showOptionsMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showFilterMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
|
||||
var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf)
|
||||
|
||||
val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
|
||||
@@ -270,6 +280,21 @@ fun ChangeSourceSheet(
|
||||
LaunchedEffect(oldBook.bookUrl) {
|
||||
viewModel.initData(oldBook.name, oldBook.author, oldBook, false)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, viewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> viewModel.resume()
|
||||
Lifecycle.Event.ON_PAUSE -> viewModel.pause()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(oldBook.bookUrl) {
|
||||
onDispose {
|
||||
viewModel.stopSearch()
|
||||
@@ -323,10 +348,17 @@ fun ChangeSourceSheet(
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.book_source_manage),
|
||||
onClick = {
|
||||
context.startActivity<BookSourceActivity>()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
MediumIconButton(
|
||||
onClick = { context.startActivity<BookSourceActivity>() },
|
||||
onClick = { showMigrationOptions = true },
|
||||
imageVector = Icons.Outlined.Settings
|
||||
)
|
||||
}
|
||||
@@ -337,35 +369,10 @@ fun ChangeSourceSheet(
|
||||
onClick = { viewModel.startOrStopSearch() },
|
||||
imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
|
||||
)
|
||||
Box {
|
||||
MediumIconButton(
|
||||
onClick = { showFilterMenu = true },
|
||||
imageVector = Icons.Default.FilterList
|
||||
)
|
||||
RoundDropdownMenu(
|
||||
expanded = showFilterMenu,
|
||||
onDismissRequest = { showFilterMenu = false }
|
||||
) { dismiss ->
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.all_source),
|
||||
isSelected = selectedGroup.isBlank(),
|
||||
onClick = {
|
||||
viewModel.onSearchGroupSelected("")
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
groups.forEach { group ->
|
||||
RoundDropdownMenuItem(
|
||||
text = group,
|
||||
isSelected = selectedGroup == group,
|
||||
onClick = {
|
||||
viewModel.onSearchGroupSelected(group)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MediumIconButton(
|
||||
onClick = { showFilterSheet = true },
|
||||
imageVector = Icons.Default.FilterList
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -389,95 +396,116 @@ fun ChangeSourceSheet(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(items, key = { it.bookUrl + it.origin }) { item ->
|
||||
val bookScore by remember(item.origin, item.name, item.author) {
|
||||
viewModel.bookScoreFlow(item)
|
||||
}.collectAsStateWithLifecycle()
|
||||
SelectionItemCard(
|
||||
title = item.originName,
|
||||
containerColor = LegadoTheme.colorScheme.onSheetContent,
|
||||
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
|
||||
leadingContent = {
|
||||
MediumIconButton(
|
||||
onClick = {
|
||||
viewModel.onBookScoreClick(item)
|
||||
},
|
||||
imageVector = Icons.Default.PushPin,
|
||||
tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
AppText(
|
||||
text = item.author,
|
||||
style = LegadoTheme.typography.labelLargeEmphasized
|
||||
)
|
||||
AppText(
|
||||
text = item.getDisplayLastChapterTitle(),
|
||||
style = LegadoTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
item.chapterWordCountText?.takeIf { loadWordCount }?.let {
|
||||
AppText(
|
||||
text = it,
|
||||
style = LegadoTheme.typography.labelSmallEmphasized,
|
||||
color = LegadoTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
isSelected = item.bookUrl == oldBook.bookUrl,
|
||||
onToggleSelection = {
|
||||
if (item.bookUrl != oldBook.bookUrl) {
|
||||
if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = item
|
||||
}
|
||||
},
|
||||
dropdownContent = { onDismiss: () -> Unit ->
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.to_top),
|
||||
onClick = {
|
||||
viewModel.topSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = "置底",
|
||||
onClick = {
|
||||
viewModel.bottomSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.edit),
|
||||
onClick = {
|
||||
onDismiss()
|
||||
editSourceResult.launch { putExtra("sourceUrl", item.origin) }
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = "禁用",
|
||||
onClick = {
|
||||
viewModel.disableSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.delete),
|
||||
color = LegadoTheme.colorScheme.error,
|
||||
onClick = {
|
||||
viewModel.del(item)
|
||||
if (oldBook.bookUrl == item.bookUrl) {
|
||||
viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
|
||||
pendingMigration = PendingSourceMigration(source, book, toc)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
EmptyMessage(
|
||||
message = stringResource(R.string.search_empty)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(items, key = { it.bookUrl + it.origin }) { item ->
|
||||
val bookScore by remember(item.origin, item.name, item.author) {
|
||||
viewModel.bookScoreFlow(item)
|
||||
}.collectAsStateWithLifecycle()
|
||||
SelectionItemCard(
|
||||
title = item.originName,
|
||||
containerColor = LegadoTheme.colorScheme.onSheetContent,
|
||||
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
|
||||
leadingContent = {
|
||||
MediumIconButton(
|
||||
onClick = {
|
||||
viewModel.onBookScoreClick(item)
|
||||
},
|
||||
imageVector = Icons.Default.PushPin,
|
||||
tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
AppText(
|
||||
text = item.author,
|
||||
style = LegadoTheme.typography.labelLargeEmphasized
|
||||
)
|
||||
AppText(
|
||||
text = item.getDisplayLastChapterTitle(),
|
||||
style = LegadoTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
item.chapterWordCountText?.takeIf { loadWordCount }?.let {
|
||||
AppText(
|
||||
text = it,
|
||||
style = LegadoTheme.typography.labelSmallEmphasized,
|
||||
color = LegadoTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
isSelected = item.bookUrl == oldBook.bookUrl,
|
||||
onToggleSelection = {
|
||||
if (item.bookUrl != oldBook.bookUrl) {
|
||||
if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook =
|
||||
item
|
||||
}
|
||||
},
|
||||
dropdownContent = { onDismiss: () -> Unit ->
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.to_top),
|
||||
onClick = {
|
||||
viewModel.topSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = "置底",
|
||||
onClick = {
|
||||
viewModel.bottomSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.edit),
|
||||
onClick = {
|
||||
onDismiss()
|
||||
editSourceResult.launch { putExtra("sourceUrl", item.origin) }
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = "禁用",
|
||||
onClick = {
|
||||
viewModel.disableSource(item)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.delete),
|
||||
color = LegadoTheme.colorScheme.error,
|
||||
onClick = {
|
||||
viewModel.del(item)
|
||||
if (oldBook.bookUrl == item.bookUrl) {
|
||||
viewModel.autoChangeSource(oldBook.type) { book, toc, source ->
|
||||
onReplace(
|
||||
source,
|
||||
book,
|
||||
toc,
|
||||
ChangeSourceConfig.getMigrationOptions()
|
||||
)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
@@ -485,19 +513,24 @@ fun ChangeSourceSheet(
|
||||
val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
|
||||
loadingAction = true
|
||||
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
|
||||
viewModel.getToc(book, { toc, source ->
|
||||
loadingAction = false
|
||||
if (replace) {
|
||||
pendingMigration = PendingSourceMigration(source, book, toc)
|
||||
} else {
|
||||
onAddAsNew(book, toc)
|
||||
context.toastOnUi(bookAddedToShelfText)
|
||||
viewModel.getToc(
|
||||
book,
|
||||
onSuccess = { toc, source ->
|
||||
loadingAction = false
|
||||
if (replace) {
|
||||
onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions())
|
||||
onDismissRequest()
|
||||
} else {
|
||||
onAddAsNew(book, toc)
|
||||
context.toastOnUi(bookAddedToShelfText)
|
||||
}
|
||||
actionBook = null
|
||||
},
|
||||
onError = {
|
||||
loadingAction = false
|
||||
context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
|
||||
}
|
||||
actionBook = null
|
||||
}, {
|
||||
loadingAction = false
|
||||
context.toastOnUi(if (replace) "换源失败" else "添加书籍失败")
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
AppAlertDialog(
|
||||
@@ -531,30 +564,103 @@ fun ChangeSourceSheet(
|
||||
}
|
||||
}
|
||||
)
|
||||
val migration = pendingMigration
|
||||
ChangeSourceMigrationOptionsSheet(
|
||||
show = migration != null,
|
||||
show = showMigrationOptions,
|
||||
title = "换源选项",
|
||||
subtitle = migration?.let {
|
||||
val sameNameAuthor = oldBook.isSameNameAuthor(it.book)
|
||||
if (sameNameAuthor && oldBook.origin != it.book.origin) {
|
||||
"检测到书名、作者相同但书源不同,可选择本次要迁移的数据。"
|
||||
} else {
|
||||
"选择本次替换当前书籍时要迁移的数据。"
|
||||
}
|
||||
},
|
||||
onDismissRequest = { pendingMigration = null },
|
||||
onDismissRequest = { showMigrationOptions = false },
|
||||
onConfirm = { options ->
|
||||
val pending = pendingMigration ?: return@ChangeSourceMigrationOptionsSheet
|
||||
onReplace(pending.source, pending.book, pending.toc, options)
|
||||
pendingMigration = null
|
||||
onDismissRequest()
|
||||
ChangeSourceConfig.setMigrationOptions(options)
|
||||
showMigrationOptions = false
|
||||
}
|
||||
)
|
||||
|
||||
AppModalBottomSheet(
|
||||
show = showFilterSheet,
|
||||
onDismissRequest = { showFilterSheet = false },
|
||||
title = stringResource(R.string.search_select_group),
|
||||
endAction = {
|
||||
MediumIconButton(
|
||||
onClick = {
|
||||
viewModel.startSearch()
|
||||
showFilterSheet = false
|
||||
},
|
||||
imageVector = Icons.Default.Check
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
SelectionItemCard(
|
||||
title = stringResource(R.string.all_source),
|
||||
isSelected = scopeState.isAll,
|
||||
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||
inSelectionMode = true,
|
||||
onToggleSelection = {
|
||||
viewModel.selectAllScope()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
AppTabRow(
|
||||
tabTitles = listOf(
|
||||
stringResource(R.string.group),
|
||||
stringResource(R.string.book_source),
|
||||
),
|
||||
selectedTabIndex = scopeSheetTab,
|
||||
onTabSelected = { scopeSheetTab = it },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
if (scopeSheetTab == 0) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(groups, key = { it }) {
|
||||
val selected = !scopeState.isSource && scopeState.displayNames.contains(it)
|
||||
SelectionItemCard(
|
||||
title = it,
|
||||
isSelected = selected,
|
||||
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||
inSelectionMode = true,
|
||||
onToggleSelection = {
|
||||
viewModel.toggleScopeGroup(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (enabledSources.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.search_empty),
|
||||
style = LegadoTheme.typography.bodyMedium,
|
||||
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(enabledSources, key = { it.bookSourceUrl }) {
|
||||
val selected = scopeState.sourceUrls.contains(it.bookSourceUrl)
|
||||
SelectionItemCard(
|
||||
title = it.bookSourceName,
|
||||
subtitle = it.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
|
||||
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||
isSelected = selected,
|
||||
inSelectionMode = true,
|
||||
onToggleSelection = {
|
||||
viewModel.toggleScopeSource(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingSourceMigration(
|
||||
val source: BookSource,
|
||||
val book: Book,
|
||||
val toc: List<BookChapter>,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import io.legado.app.domain.usecase.ChangeBookSourceUseCase
|
||||
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
|
||||
import io.legado.app.data.repository.ReadRecordRepository
|
||||
import io.legado.app.data.repository.RemoteBookRepository
|
||||
import io.legado.app.data.repository.BookGroupRepository
|
||||
import io.legado.app.domain.usecase.ClearBookCacheUseCase
|
||||
import io.legado.app.exception.NoBooksDirException
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
@@ -68,9 +69,12 @@ class BookInfoViewModel(
|
||||
private val remoteBookRepository: RemoteBookRepository,
|
||||
private val readRecordRepository: ReadRecordRepository,
|
||||
private val changeBookSourceUseCase: ChangeBookSourceUseCase,
|
||||
private val clearBookCacheUseCase: ClearBookCacheUseCase
|
||||
private val clearBookCacheUseCase: ClearBookCacheUseCase,
|
||||
private val bookGroupRepository: BookGroupRepository,
|
||||
) : BaseViewModel(application) {
|
||||
|
||||
val allGroups = bookGroupRepository.flowAll()
|
||||
|
||||
private val _uiState = MutableStateFlow(BookInfoUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import io.legado.app.R
|
||||
import io.legado.app.constant.IntentAction
|
||||
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.domain.usecase.BatchChangeSourcePreviewItem
|
||||
@@ -1056,6 +1057,7 @@ private fun BookshelfManageScreen(
|
||||
|
||||
GroupSelectSheet(
|
||||
show = showGroupSelectSheet,
|
||||
groups = state.groupList,
|
||||
currentGroupId = groupPickerCurrentGroupId,
|
||||
onDismissRequest = { showGroupSelectSheet = false },
|
||||
onConfirm = { groupId ->
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -103,7 +104,7 @@ fun SearchScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var queryInput by rememberSaveable { mutableStateOf(state.query) }
|
||||
var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
|
||||
var scopeSheetTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showSuggestionPanel = state.showSuggestions
|
||||
val latestQuery by rememberUpdatedState(state.query)
|
||||
@@ -259,6 +260,13 @@ fun SearchScreen(
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
viewModel.onIntent(SearchIntent.OpenSourceManage)
|
||||
},
|
||||
imageVector = AppIcons.Settings,
|
||||
contentDescription = stringResource(R.string.book_source_manage)
|
||||
)
|
||||
TopBarAnimatedActionButton(
|
||||
checked = state.isPrecisionSearch,
|
||||
onCheckedChange = { checked ->
|
||||
@@ -289,13 +297,6 @@ fun SearchScreen(
|
||||
activeText = stringResource(R.string.screen),
|
||||
inactiveText = stringResource(R.string.screen),
|
||||
)
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
viewModel.onIntent(SearchIntent.OpenSourceManage)
|
||||
},
|
||||
imageVector = AppIcons.Settings,
|
||||
contentDescription = stringResource(R.string.book_source_manage)
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
|
||||
@@ -968,8 +968,11 @@ private fun BookshelfOverlays(
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
)
|
||||
|
||||
val groups by viewModel.allGroupsFlow.collectAsStateWithLifecycle()
|
||||
|
||||
GroupSelectSheet(
|
||||
show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
|
||||
groups = groups,
|
||||
currentGroupId = 0L,
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
onConfirm = { groupId ->
|
||||
|
||||
Reference in New Issue
Block a user