diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 44e239764..bb32e0455 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -44,7 +44,12 @@ "Bash(jar tf *)", "Read(//d/tmp/**)", "Bash(jar xf *)", - "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)" + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 5)", + "PowerShell(.\\\\gradlew.bat test 2>&1 | Select-Object -Last 10)", + "Bash(npx skills *)", + "Bash(npx *)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 20)" ] } } diff --git a/app/src/main/java/io/legado/app/base/BaseService.kt b/app/src/main/java/io/legado/app/base/BaseService.kt index 16888af31..72f62aef8 100644 --- a/app/src/main/java/io/legado/app/base/BaseService.kt +++ b/app/src/main/java/io/legado/app/base/BaseService.kt @@ -16,13 +16,13 @@ import io.legado.app.utils.LogUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Semaphore import kotlin.coroutines.CoroutineContext abstract class BaseService : LifecycleService() { private val simpleName = this::class.simpleName.toString() + @Volatile private var isForeground = false fun execute( @@ -37,6 +37,8 @@ abstract class BaseService : LifecycleService() { @CallSuper override fun onCreate() { super.onCreate() + startForegroundNotification() + isForeground = true LifecycleHelp.onServiceCreate(this) if (!AppConfig.permissionChecked) { AppConfig.permissionChecked = true @@ -49,10 +51,6 @@ abstract class BaseService : LifecycleService() { LogUtils.d(simpleName) { "onStartCommand $intent ${intent?.toUri(0)}" } - if (!isForeground) { - startForegroundNotification() - isForeground = true - } return super.onStartCommand(intent, flags, startId) } @@ -95,11 +93,6 @@ abstract class BaseService : LifecycleService() { PermissionsCompat.Builder() .addPermissions(Permissions.POST_NOTIFICATIONS) .rationale(R.string.notification_permission_rationale) - .onGranted { - if (lifecycleScope.isActive) { - startForegroundNotification() - } - } .request() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PermissionsCompat.Builder() diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt index febe4a1a8..79e992932 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt @@ -1,16 +1,19 @@ package io.legado.app.ui.book.search +import androidx.compose.runtime.Stable import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookshelf.BookShelfItem +@Stable data class SearchResultItemUi( val book: SearchBook, val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF, ) +@Stable data class SearchUiState( val query: String = "", val committedQuery: String = "", @@ -34,6 +37,8 @@ data class SearchUiState( val showClearHistoryDialog: Boolean = false, val showSuggestions: Boolean = true, val emptyScopeAction: SearchEmptyScopeAction? = null, + val savedScrollIndex: Int = 0, + val savedScrollOffset: Int = 0, ) data class SearchEmptyScopeAction( @@ -47,6 +52,7 @@ sealed interface SearchIntent { data object SubmitSearch : SearchIntent data object LoadMore : SearchIntent data object StopSearch : SearchIntent + data object ClearSearchResults : SearchIntent data object PauseEngine : SearchIntent data object ResumeEngine : SearchIntent data class UseHistoryKeyword(val keyword: String) : SearchIntent @@ -64,6 +70,7 @@ sealed interface SearchIntent { data object ConfirmEmptyScopeAction : SearchIntent data object DismissEmptyScopeAction : SearchIntent data object OpenSourceManage : SearchIntent + data class SaveScrollState(val index: Int, val offset: Int) : SearchIntent } sealed interface SearchEffect { diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 52fcd2b79..c2f024e6f 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -1,10 +1,10 @@ package io.legado.app.ui.book.search +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,35 +17,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.AssistChip import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults -import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -63,8 +50,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow 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.entities.SearchKeyword @@ -84,11 +73,9 @@ import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallTextButton import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.SelectionItemCard -import io.legado.app.ui.widget.components.button.ToggleChip import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton -import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.list.TopFloatingStickyItem @@ -113,6 +100,7 @@ fun SearchScreen( val context = LocalContext.current val state by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val lifecycleOwner = LocalLifecycleOwner.current var queryInput by rememberSaveable { mutableStateOf(state.query) } var scopeSheetTab by rememberSaveable { mutableStateOf(0) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf(null) } @@ -190,9 +178,50 @@ fun SearchScreen( } } + // Activity lifecycle (e.g., Home button, switching apps) + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.onIntent(SearchIntent.ResumeEngine) + Lifecycle.Event.ON_PAUSE -> viewModel.onIntent(SearchIntent.PauseEngine) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Composition lifecycle (e.g., navigating to BookInfo and back) DisposableEffect(viewModel) { onDispose { - viewModel.onIntent(SearchIntent.StopSearch) + viewModel.onIntent(SearchIntent.PauseEngine) + } + } + + LaunchedEffect(viewModel) { + viewModel.onIntent(SearchIntent.ResumeEngine) + } + + // Save scroll position before composable leaves composition (e.g., navigating to BookInfo) + DisposableEffect(viewModel) { + onDispose { + val first = listState.firstVisibleItemIndex + val offset = listState.firstVisibleItemScrollOffset + if (first > 0 || offset > 0) { + viewModel.onIntent(SearchIntent.SaveScrollState(first, offset)) + } + } + } + + // Restore scroll position when composable re-enters with saved state + LaunchedEffect(state.savedScrollIndex, state.savedScrollOffset) { + val idx = state.savedScrollIndex + val off = state.savedScrollOffset + if (idx > 0 || off > 0) { + listState.scrollToItem(idx, off) + viewModel.onIntent(SearchIntent.SaveScrollState(0, 0)) } } @@ -207,6 +236,11 @@ fun SearchScreen( viewModel.onIntent(SearchIntent.SubmitSearch) } } + + BackHandler { + onBack() + } + val searchLabel = stringResource(R.string.search_book_key) val showResultCountCard = state.committedQuery.isNotBlank() || state.isSearching val showSourceProgressCard = state.totalSources > 0 @@ -249,7 +283,7 @@ fun SearchScreen( viewModel.onIntent(SearchIntent.OpenSourceManage) }, imageVector = AppIcons.Settings, - contentDescription = "书源管理" + contentDescription = stringResource(R.string.book_source_manage) ) }, scrollBehavior = scrollBehavior @@ -390,8 +424,8 @@ fun SearchScreen( TopFloatingStickyItem( item = if (showResultCountCard || showSourceProgressCard) { SearchFloatingSummary( - resultText = if (showResultCountCard) "结果 ${state.results.size}" else null, - sourceText = if (showSourceProgressCard) " · 进度 ${state.processedSources}/${state.totalSources}" else null, + resultText = if (showResultCountCard) stringResource(R.string.search_result_count, state.results.size) else null, + sourceText = if (showSourceProgressCard) stringResource(R.string.search_source_progress, state.processedSources, state.totalSources) else null, ) } else { null @@ -451,9 +485,9 @@ fun SearchScreen( content = { Text( text = if (it.wasPrecisionSearch) { - "${it.scopeDisplay}分组搜索结果为空,是否关闭精准搜索?" + stringResource(R.string.search_empty_scope_disable_precision, it.scopeDisplay) } else { - "${it.scopeDisplay}分组搜索结果为空,是否切换到全部分组?" + stringResource(R.string.search_empty_scope_switch_all, it.scopeDisplay) } ) } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 9025f7c84..9b62dfb2e 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -62,6 +62,7 @@ class SearchViewModel( private var searchJob: Job? = null private var currentSearchPage = 1 + private var wasSearching = false init { syncScopeState() @@ -79,8 +80,22 @@ class SearchViewModel( SearchIntent.SubmitSearch -> submitSearch() SearchIntent.LoadMore -> loadMore() SearchIntent.StopSearch -> stopSearch() - SearchIntent.PauseEngine -> searchControl.pause() - SearchIntent.ResumeEngine -> searchControl.resume() + SearchIntent.ClearSearchResults -> clearSearchResults() + SearchIntent.PauseEngine -> { + wasSearching = wasSearching || (searchJob?.isActive == true) + searchControl.pause() + } + + SearchIntent.ResumeEngine -> { + searchControl.resume() + if (wasSearching) { + val state = _uiState.value + if (state.committedQuery.isNotBlank() && searchJob?.isActive != true) { + startSearch(state.committedQuery, currentSearchPage) + } + wasSearching = false + } + } is SearchIntent.UseHistoryKeyword -> { updateQuery(intent.keyword, showSuggestions = false) submitSearch(intent.keyword) @@ -151,6 +166,15 @@ class SearchViewModel( } SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage) + + is SearchIntent.SaveScrollState -> { + _uiState.update { + it.copy( + savedScrollIndex = intent.index, + savedScrollOffset = intent.offset, + ) + } + } } } @@ -299,6 +323,7 @@ class SearchViewModel( private fun startSearch(keyword: String, page: Int) { searchJob?.cancel() searchControl.resume() + wasSearching = true searchJob = viewModelScope.launch { try { searchBooksUseCase @@ -369,6 +394,7 @@ class SearchViewModel( private fun stopSearch(manualStop: Boolean = true) { searchJob?.cancel() searchJob = null + wasSearching = false _uiState.update { it.copy( isSearching = false, @@ -377,6 +403,25 @@ class SearchViewModel( } } + private fun clearSearchResults() { + stopSearch(manualStop = true) + searchResultBooks.clear() + _uiState.update { + it.copy( + query = "", + committedQuery = "", + results = emptyList(), + processedSources = 0, + totalSources = 0, + isSearching = false, + isManualStop = false, + hasMore = true, + showSuggestions = true, + emptyScopeAction = null, + ) + } + } + private fun toggleScopeGroup(groupName: String) { val oldScope = searchScope.toString() if (searchScope.isSource()) { diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index add2cc6f9..18d19fb26 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -622,7 +622,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } ) { route -> val searchViewModel = koinViewModel() - val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(route.key, route.scopeRaw, searchViewModel) { searchViewModel.onIntent( @@ -633,29 +632,12 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { ) } - DisposableEffect(lifecycleOwner, searchViewModel) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - searchViewModel.onIntent(SearchIntent.ResumeEngine) - } - - Lifecycle.Event.ON_PAUSE -> { - searchViewModel.onIntent(SearchIntent.PauseEngine) - } - - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - SearchScreen( viewModel = searchViewModel, - onBack = { navigateBack(backStack) }, + onBack = { + searchViewModel.onIntent(SearchIntent.ClearSearchResults) + navigateBack(backStack) + }, onOpenBookInfo = { name, author, bookUrl -> navigateToRoute( backStack, @@ -715,14 +697,20 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { entry( metadata = NavDisplay.transitionSpec { - fadeIn(animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(300)) + if (initialState.key is MainRouteExploreShow) { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } else null } + NavDisplay.popTransitionSpec { - fadeIn(animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(300)) + if (targetState.key is MainRouteExploreShow) { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } else null } + NavDisplay.predictivePopTransitionSpec { _ -> - fadeIn(animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(300)) + if (targetState.key is MainRouteExploreShow) { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } else null } ) { route -> val bookInfoViewModel = koinViewModel() diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6949c85f3..62089756e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1474,4 +1474,8 @@ 没有书籍,尝试全局搜索 全局搜索 缓存管理 + 结果 %1$d + · 进度 %1$d/%2$d + %1$s分组搜索结果为空,是否关闭精准搜索? + %1$s分组搜索结果为空,是否切换到全部分组? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3761c6ce4..35fb56585 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1518,4 +1518,8 @@ No books here. Try global search. Global search Cache management + Results %1$d + · Progress %1$d/%2$d + %1$s group search returned no results. Disable precise search? + %1$s group search returned no results. Switch to all groups?