diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e99c90a27..95dbdc1b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -339,11 +339,6 @@ android:enableOnBackInvokedCallback="true" android:configChanges="orientation|screenSize" android:hardwareAccelerated="true" /> - () { - - private val adapter by lazy { RecordAdapter(this) } - private var sortMode - get() = LocalConfig.getInt("readRecordSort") - set(value) { - LocalConfig.putInt("readRecordSort", value) - } - private val searchView: SearchView by lazy { - binding.titleBar.findViewById(R.id.search_view) - } - - override val binding by viewBinding(ActivityReadRecordBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - initView() - initAllTime() - initData() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_read_record, menu) - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - menu.findItem(R.id.menu_enable_record)?.isChecked = AppConfig.enableReadRecord - when (sortMode) { - 1 -> menu.findItem(R.id.menu_sort_read_long)?.isChecked = true - 2 -> menu.findItem(R.id.menu_sort_read_time)?.isChecked = true - else -> menu.findItem(R.id.menu_sort_name)?.isChecked = true - } - return super.onMenuOpened(featureId, menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_sort_name -> { - sortMode = 0 - item.isChecked = true - initData() - } - - R.id.menu_sort_read_long -> { - sortMode = 1 - item.isChecked = true - initData() - } - - R.id.menu_sort_read_time -> { - sortMode = 2 - item.isChecked = true - initData() - } - - R.id.menu_enable_record -> { - AppConfig.enableReadRecord = !item.isChecked - } - } - return super.onCompatOptionsItemSelected(item) - } - - private fun initView() { - initSearchView() - binding.tvBookName.setText(R.string.all_read_time) - binding.tvRemove.setOnClickListener { - alert(R.string.delete, R.string.sure_del) { - yesButton { - appDb.readRecordDao.clear() - initData() - } - noButton() - } - } - binding.recyclerView.adapter = adapter - binding.recyclerView.applyNavigationBarPadding() - } - - private fun initSearchView() { - //searchView.applyTint(primaryTextColor) - searchView.isSubmitButtonEnabled = true - searchView.queryHint = getString(R.string.search) - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchView.clearFocus() - return false - } - - override fun onQueryTextChange(newText: String?): Boolean { - initData(newText) - return false - } - }) - } - - private fun initAllTime() { - lifecycleScope.launch { - val allTime = withContext(IO) { - appDb.readRecordDao.getTotalReadTime() - } - binding.tvReadingTime.text = formatDuring(allTime) - } - } - - private fun initData(searchKey: String? = null) { - lifecycleScope.launch { - val readRecords = withContext(IO) { - appDb.readRecordDao.search(searchKey ?: "").let { records -> - when (sortMode) { - 1 -> records.sortedByDescending { it.readTime } - 2 -> records.sortedByDescending { it.lastRead } - else -> records.sortedWith { o1, o2 -> - o1.bookName.cnCompare(o2.bookName) - } - } - } - } - adapter.setItems(readRecords) - } - } - - inner class RecordAdapter(context: Context) : - RecyclerAdapter(context) { - - private val dateFormat = SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault()) - - override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding { - return ItemReadRecordBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemReadRecordBinding, - item: ReadRecordShow, - payloads: MutableList, - ) { - binding.apply { - tvBookName.text = item.bookName - tvReadingTime.text = formatDuring(item.readTime) - if (item.lastRead > 0) { - tvLastReadTime.text = dateFormat.format(item.lastRead) - } else { - tvLastReadTime.text = "时间未知" - } - - lifecycleScope.launch { - val book = withContext(IO) { - appDb.bookDao.findByName(item.bookName).firstOrNull() - } - if (book != null) { - BookCover.load(root.context, book.getDisplayCover(), false, book.origin) - .into(ivCover) - } - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) { - binding.apply { - root.setOnClickListener { - val item = getItem(holder.layoutPosition) ?: return@setOnClickListener - lifecycleScope.launch { - val book = withContext(IO) { - appDb.bookDao.findByName(item.bookName).firstOrNull() - } - if (book == null) { - SearchActivity.start(this@ReadRecordActivity, item.bookName) - } else { - startActivityForBook(book) - } - } - } - tvRemove.setOnClickListener { - getItem(holder.layoutPosition)?.let { item -> - sureDelAlert(item) - } - } - } - } - - private fun sureDelAlert(item: ReadRecordShow) { - alert(R.string.delete) { - setMessage(getString(R.string.sure_del_any, item.bookName)) - yesButton { - appDb.readRecordDao.deleteByName(item.bookName) - initData() - } - noButton() - } - } - - } - - fun formatDuring(mss: Long): String { - val days = mss / (1000 * 60 * 60 * 24) - val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60) - val minutes = mss % (1000 * 60 * 60) / (1000 * 60) - val seconds = mss % (1000 * 60) / 1000 - val d = if (days > 0) "${days}天" else "" - val h = if (hours > 0) "${hours}小时" else "" - val m = if (minutes > 0) "${minutes}分钟" else "" - val s = if (seconds > 0) "${seconds}秒" else "" - var time = "$d$h$m$s" - if (time.isBlank()) { - time = "0秒" - } - return time - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt index 2fa6d4bdc..bbc0cdedf 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState @@ -33,12 +34,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.Bookmark +import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.lazylist.Scroller import io.legado.app.ui.widget.components.SearchBarSection import org.koin.androidx.compose.koinViewModel -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, + ExperimentalMaterial3ExpressiveApi::class +) @Composable fun AllBookmarkScreen( viewModel: AllBookmarkViewModel = koinViewModel(), @@ -46,7 +50,7 @@ fun AllBookmarkScreen( ) { val context = LocalContext.current val searchText by viewModel.searchQuery.collectAsState() - val bookmarksGrouped by viewModel.bookmarksState.collectAsState() + val uiState by viewModel.bookmarksState.collectAsState() val collapsedGroups by viewModel.collapsedGroups.collectAsState() var showMenu by remember { mutableStateOf(false) } @@ -56,6 +60,7 @@ fun AllBookmarkScreen( var pendingExportIsMd by remember { mutableStateOf(false) } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap() val allKeys = bookmarksGrouped.keys val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) } @@ -137,54 +142,89 @@ fun AllBookmarkScreen( SearchBarSection( query = searchText, onQueryChange = { viewModel.onSearchQueryChanged(it) }, - placeholder = "模糊搜索" + placeholder = "搜索..." ) } } } ) { paddingValues -> - FastScrollLazyColumn( + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - bookmarksGrouped.forEach { (headerKey, bookmarks) -> - - val isCollapsed = collapsedGroups.contains(headerKey.toString()) - - stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { - BookAuthorHeader( - bookTitle = headerKey.bookName, - bookAuthor = headerKey.bookAuthor, - isCollapsed = isCollapsed, - onToggle = { viewModel.toggleGroupCollapse(headerKey) } - ) - } - - item(key = "content_${headerKey}") { - AnimatedVisibility( - visible = !isCollapsed, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( + AnimatedContent( + targetState = uiState, + label = "bookmarkTransition" + ) { state -> + when (state) { + BookmarkUiState.Loading -> { + EmptyMessageView( + message = "加载中...", + isLoading = true, modifier = Modifier - .animateContentSize() - ) { - bookmarks.forEach { bookmark -> - BookmarkItem( - bookmark = bookmark, - modifier = Modifier - .animateItem() - .fillMaxWidth(), - onClick = { - editingBookmark = bookmark - showBottomSheet = true + .fillMaxSize() + ) + } + + is BookmarkUiState.Success -> { + if (state.bookmarks.isEmpty()) { + EmptyMessageView( + message = "没有书签!", + modifier = Modifier + .fillMaxSize() + ) + } else { + FastScrollLazyColumn( + modifier = Modifier.fillMaxSize() + ) { + state.bookmarks.forEach { (headerKey, bookmarks) -> + + val isCollapsed = collapsedGroups.contains(headerKey.toString()) + + stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { + BookAuthorHeader( + bookTitle = headerKey.bookName, + bookAuthor = headerKey.bookAuthor, + isCollapsed = isCollapsed, + onToggle = { viewModel.toggleGroupCollapse(headerKey) } + ) } - ) + + item(key = "content_${headerKey}") { + AnimatedVisibility( + visible = !isCollapsed, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.animateContentSize() + ) { + bookmarks.forEach { bookmark -> + BookmarkItem( + bookmark = bookmark, + modifier = Modifier + .animateItem() + .fillMaxWidth(), + onClick = { + editingBookmark = bookmark + showBottomSheet = true + } + ) + } + } + } + } + } } } } + + is BookmarkUiState.Error -> { + EmptyMessageView( + message = state.throwable.localizedMessage ?: "发生错误", + ) + } } } } @@ -407,4 +447,4 @@ fun BookmarkEditSheet( } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt index 4a4e41eed..2c812f259 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt @@ -15,14 +15,11 @@ import io.legado.app.utils.writeToOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn @@ -39,7 +36,13 @@ data class BookmarkGroupHeader( val bookName: String, val bookAuthor: String ) { - override fun toString(): String = "$bookName|${bookAuthor}" + override fun toString(): String = "$bookName|$bookAuthor" +} + +sealed class BookmarkUiState { + object Loading : BookmarkUiState() + data class Success(val bookmarks: Map>) : BookmarkUiState() + data class Error(val throwable: Throwable) : BookmarkUiState() } class AllBookmarkViewModel( @@ -55,24 +58,28 @@ class AllBookmarkViewModel( @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - val bookmarksState: StateFlow>> = _searchQuery + val bookmarksState: StateFlow = _searchQuery .debounce(300L) .flatMapLatest { query -> - if (query.isBlank()) { + val flow = if (query.isBlank()) { bookmarkDao.flowAll() } else { bookmarkDao.flowSearchAll(query) } + flow.map, BookmarkUiState> { list -> + BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) }) + } + .onStart { emit(BookmarkUiState.Loading) } + .catch { e -> + e.printStackTrace() + emit(BookmarkUiState.Error(e)) + } } - .map { list -> - list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) } - } - .catch { e -> e.printStackTrace() } .flowOn(Dispatchers.IO) .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptyMap() + BookmarkUiState.Loading ) fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) { diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index 12467efe4..da26f7d66 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -1318,7 +1318,7 @@ class ReadBookActivity : BaseReadBookActivity(), val book = ReadBook.book ?: return searchContentActivity.launch { putExtra("bookUrl", book.bookUrl) - putExtra("searchWord", searchWord ?: viewModel.searchContentQuery) + putExtra("searchWord", searchWord) putExtra("searchResultIndex", viewModel.searchResultIndex) viewModel.searchResultList?.first()?.let { if (it.query == viewModel.searchContentQuery) { diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 60dd6f346..e5e1984b1 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -1,8 +1,7 @@ package io.legado.app.ui.book.readRecord +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -182,128 +182,134 @@ fun ReadRecordScreen( } ) { padding -> Column(modifier = Modifier.padding(padding).fillMaxSize()) { - val isEmpty = when (displayMode) { - DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty() - DisplayMode.TIMELINE -> state.timelineRecords.isEmpty() - DisplayMode.LATEST -> state.latestRecords.isEmpty() + val contentState = when { + state.isLoading -> "LOADING" + (displayMode == DisplayMode.AGGREGATE && state.groupedRecords.isEmpty()) || + (displayMode == DisplayMode.TIMELINE && state.timelineRecords.isEmpty()) || + (displayMode == DisplayMode.LATEST && state.latestRecords.isEmpty()) -> "EMPTY" + else -> "CONTENT" } - Crossfade( - targetState = isEmpty, - animationSpec = tween(durationMillis = 500), - label = "ContentCrossfade" - ) { isListEmpty -> - - LazyColumn( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - ) { - - item { - val selectedDate = state.selectedDate - - if (selectedDate != null) { - val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE) - val dailyDetails = state.groupedRecords[dateKey] ?: emptyList() - - if (dailyDetails.isNotEmpty()) { - val distinctBooks = dailyDetails.map { it.bookName }.distinct() - val dailyTime = dailyDetails.sumOf { it.readTime } - - ReadingSummaryCard( - title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")), - bookCount = distinctBooks.size, - totalTimeMillis = dailyTime, - bookNamesForCover = distinctBooks.take(3), - viewModel = viewModel, - onClick = { } - ) - } - } else { - val allBooksCount = state.latestRecords.size - val totalTime = state.totalReadTime - - if (allBooksCount > 0) { - ReadingSummaryCard( - title = "累计阅读成就", - bookCount = allBooksCount, - totalTimeMillis = totalTime, - bookNamesForCover = state.latestRecords.take(5).map { it.bookName }, - viewModel = viewModel, - onClick = { } - ) - } - } + AnimatedContent( + targetState = contentState, + label = "MainContentAnimation" + ) { targetState -> + when (targetState) { + "LOADING" -> { + EmptyMessageView( + modifier = Modifier.fillMaxSize(), + message = "加载中" + ) } - item { - if (isListEmpty){ - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - EmptyMessageView( - message = "没有记录" - ) - } - } + "EMPTY" -> { + EmptyMessageView( + modifier = Modifier.fillMaxSize(), + message = "没有记录" + ) } - when(displayMode){ - DisplayMode.AGGREGATE -> { - state.groupedRecords.forEach { (date, details) -> - - val dailyTotalTime = details.sumOf { it.readTime } - - stickyHeader { - DateHeader(date, dailyTotalTime) - } - - items( - items = details, - key = { it.bookName + it.readTime.toString() } - ) { detail -> - ReadRecordItem( - detail = detail, - viewModel = viewModel, - onClick = { onBookClick(detail.bookName) }, - onDelete = { viewModel.deleteDetail(detail) }, - modifier = Modifier.animateItem() - ) - } + "CONTENT" -> { + LazyColumn( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item(key = "summary_card") { + SummarySection(state, viewModel) } - } - DisplayMode.TIMELINE -> { - state.timelineRecords.forEach { (date, sessions) -> - stickyHeader { DateHeader(date) } - val timelineItems = sessions.mapIndexed { index, session -> - val showHeader = true - TimelineItem(session, showHeader) - } - - items(items = timelineItems, key = { it.session.id }) { item -> - TimelineSessionItem( - item = item, - onBookClick = onBookClick, - viewModel = viewModel - ) - } - } - } - DisplayMode.LATEST -> { - items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record -> - LatestReadItem( - record = record, - viewModel = viewModel, - onClick = { onBookClick(record.bookName) }, - modifier = Modifier.animateItem() - ) - } + renderListByMode(displayMode, state, viewModel, onBookClick) } } } + } + } + } +} +@Composable +fun SummarySection( + state: ReadRecordUiState, + viewModel: ReadRecordViewModel +) { + val selectedDate = state.selectedDate + + if (selectedDate != null) { + val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + val dailyDetails = state.groupedRecords[dateKey] ?: emptyList() + + if (dailyDetails.isNotEmpty()) { + val distinctBooks = dailyDetails.map { it.bookName }.distinct() + val dailyTime = dailyDetails.sumOf { it.readTime } + + ReadingSummaryCard( + title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")), + bookCount = distinctBooks.size, + totalTimeMillis = dailyTime, + bookNamesForCover = distinctBooks.take(3), + viewModel = viewModel, + onClick = { } + ) + } + } else { + val allBooksCount = state.latestRecords.size + val totalTime = state.totalReadTime + + if (allBooksCount > 0) { + ReadingSummaryCard( + title = "累计阅读成就", + bookCount = allBooksCount, + totalTimeMillis = totalTime, + bookNamesForCover = state.latestRecords.take(5).map { it.bookName }, + viewModel = viewModel, + onClick = { } + ) + } + } +} + +fun LazyListScope.renderListByMode( + displayMode: DisplayMode, + state: ReadRecordUiState, + viewModel: ReadRecordViewModel, + onBookClick: (String) -> Unit +) { + when (displayMode) { + DisplayMode.AGGREGATE -> { + state.groupedRecords.forEach { (date, details) -> + stickyHeader(key = "header_$date") { + DateHeader(date, details.sumOf { it.readTime }) + } + items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail -> + ReadRecordItem( + detail = detail, + viewModel = viewModel, + onClick = { onBookClick(detail.bookName) }, + onDelete = { viewModel.deleteDetail(detail) }, + modifier = Modifier.animateItem() + ) + } + } + } + DisplayMode.TIMELINE -> { + state.timelineRecords.forEach { (date, sessions) -> + stickyHeader(key = "timeline_header_$date") { DateHeader(date) } + items(items = sessions, key = { it.id }) { session -> + TimelineSessionItem( + item = TimelineItem(session, true), + onBookClick = onBookClick, + viewModel = viewModel + ) + } + } + } + DisplayMode.LATEST -> { + items(items = state.latestRecords, key = { it.bookName }) { record -> + LatestReadItem( + record = record, + viewModel = viewModel, + onClick = { onBookClick(record.bookName) }, + modifier = Modifier.animateItem() + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt index 0ec4aa699..e5024822d 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt @@ -8,10 +8,14 @@ import io.legado.app.base.BaseComposeActivity class SearchContentActivity : BaseComposeActivity() { private var bookUrl: String? = null + private var searchWord: String? = null + private var searchResultIndex: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bookUrl = intent.getStringExtra("bookUrl") + searchWord = intent.getStringExtra("searchWord") + searchResultIndex = intent.getIntExtra("searchResultIndex", 0) } @Composable @@ -20,6 +24,7 @@ class SearchContentActivity : BaseComposeActivity() { bookUrl?.let { SearchContentScreen( bookUrl = it, + searchWord = searchWord, onBack = { finish() } ) } diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt index 32ac28071..890907884 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -31,9 +32,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -48,17 +55,17 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import io.legado.app.ui.widget.components.AnimatedText -import io.legado.app.ui.widget.components.AnimatedTextLine import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.TextCard import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import org.koin.androidx.compose.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun SearchContentScreen( bookUrl: String, + searchWord: String?, onBack: () -> Unit, viewModel: SearchContentViewModel = koinViewModel() ) { @@ -73,8 +80,15 @@ fun SearchContentScreen( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - LaunchedEffect(bookUrl) { + var searchQuery by remember(searchWord) { mutableStateOf(searchWord ?: "") } + var replaceEnabled by remember { mutableStateOf(false) } + var regexReplace by remember { mutableStateOf(false) } + + LaunchedEffect(bookUrl, searchWord) { viewModel.initBook(bookUrl) + if (!searchWord.isNullOrBlank()) { + viewModel.startSearch(searchWord, replaceEnabled, regexReplace) + } } /* @@ -98,9 +112,6 @@ fun SearchContentScreen( } }*/ - var searchQuery by remember { mutableStateOf("") } - var replaceEnabled by remember { mutableStateOf(false) } - var regexReplace by remember { mutableStateOf(false) } val contentState = when { error != null -> SearchContentState.Error(error) isSearching -> SearchContentState.Loading @@ -150,12 +161,22 @@ fun SearchContentScreen( ) { FilterChip( selected = replaceEnabled, - onClick = { replaceEnabled = !replaceEnabled }, + onClick = { + replaceEnabled = !replaceEnabled + if (searchQuery.isNotBlank()) { + viewModel.startSearch(searchQuery, replaceEnabled, regexReplace) + } + }, label = { Text("启用替换") } ) FilterChip( selected = regexReplace, - onClick = { regexReplace = !regexReplace }, + onClick = { + regexReplace = !regexReplace + if (searchQuery.isNotBlank()) { + viewModel.startSearch(searchQuery, replaceEnabled, regexReplace) + } + }, label = { Text("正则匹配") } ) } @@ -168,8 +189,19 @@ fun SearchContentScreen( } }, floatingActionButton = { - if (isSearching) { - FloatingActionButton(onClick = { viewModel.stopSearch() }) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Localized description") } }, + state = rememberTooltipState(), + ) { + FloatingActionButton( + modifier = Modifier.animateFloatingActionButton( + visible = isSearching, + alignment = Alignment.BottomEnd, + ), + onClick = { viewModel.stopSearch() } + ) { Icon(Icons.Default.Stop, contentDescription = "停止搜索") } } @@ -206,7 +238,7 @@ fun SearchContentScreen( SearchContentState.EmptyResult -> { EmptyMessageView( - message = "没有找到相关内容", + message = "没有找到相关内容!", modifier = Modifier .fillMaxSize() .wrapContentSize() @@ -323,5 +355,3 @@ fun SearchResultItem( } } } - - diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index f3164bceb..d7872a4ad 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -12,12 +12,9 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource @@ -30,10 +27,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* +import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.* import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -332,10 +331,10 @@ fun ReplaceRuleScreen( Column { MediumTopAppBar( title = { - val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) { + val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) { when { isUploading -> "正在上传..." - inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}" + inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}" else -> "替换规则" } } @@ -434,8 +433,8 @@ fun ReplaceRuleScreen( placeholder = stringResource(id = R.string.replace_purify_search) ) } - AnimatedVisibility(visible = !inSelectionMode) { - val allString = stringResource(R.string.all) + val allString = stringResource(R.string.all) + AnimatedVisibility(visible = groups.isNotEmpty()) { PrimaryScrollableTabRow( selectedTabIndex = selectedTabIndex, edgePadding = 0.dp, @@ -470,14 +469,21 @@ fun ReplaceRuleScreen( } }, floatingActionButton = { - AnimatedVisibility( - visible = !inSelectionMode, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Localized description") } }, + state = rememberTooltipState(), ) { - FloatingActionButton(onClick = { - context.startActivity(ReplaceEditActivity.startIntent(context)) - }) { + FloatingActionButton( + modifier = Modifier.animateFloatingActionButton( + visible = !inSelectionMode, + alignment = Alignment.BottomEnd, + ), + onClick = { + context.startActivity(ReplaceEditActivity.startIntent(context)) + } + ) { Icon(Icons.Default.Add, contentDescription = "Add Rule") } } @@ -568,9 +574,8 @@ fun ReplaceRuleScreen( } AnimatedVisibility( visible = inSelectionMode, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 24.dp), + modifier = + Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f), enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut() ) { @@ -842,13 +847,9 @@ fun ReplaceRuleItem( ) Card( + onClick = { onToggleSelection() }, modifier = modifier - .fillMaxWidth() - .combinedClickable( - onClick = { onToggleSelection() }, - interactionSource = remember { MutableInteractionSource() }, - indication = null - ), + .fillMaxWidth(), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = containerColor @@ -900,26 +901,26 @@ fun ReplaceRuleItem( onDismissRequest = { showRuleMenu = false } ) { DropdownMenuItem( - text = { Text("删除") }, - onClick = { - onDelete() - showRuleMenu = false - } - ) - DropdownMenuItem( - text = { Text("置顶") }, + text = { Text("移至顶部") }, onClick = { onToTop() showRuleMenu = false } ) DropdownMenuItem( - text = { Text("置底") }, + text = { Text("移至底部") }, onClick = { onToBottom() showRuleMenu = false } ) + DropdownMenuItem( + text = { Text("删除") }, + onClick = { + onDelete() + showRuleMenu = false + } + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt b/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt index bccdc24a9..f57880fad 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.widget.components import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -21,10 +24,12 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun EmptyMessageView( message: String, modifier: Modifier = Modifier, + isLoading: Boolean = false, faces: List = listOf( "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" @@ -40,16 +45,25 @@ fun EmptyMessageView( .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - AnimatedTextLine( - text = currentFace, - fontSize = faceTextSize, - textAlign = TextAlign.Center, - modifier = Modifier - .clickable { - currentFace = faces.random() - onFaceClick?.invoke() - } - ) + AnimatedContent( + targetState = isLoading, + label = "LoadingStateAnimation" + ) { loading -> + if (loading) { + LoadingIndicator() + } else { + AnimatedTextLine( + text = currentFace, + fontSize = faceTextSize, + textAlign = TextAlign.Center, + modifier = Modifier + .clickable { + currentFace = faces.random() + onFaceClick?.invoke() + } + ) + } + } Spacer(modifier = Modifier.height(8.dp)) @@ -67,6 +81,7 @@ fun EmptyMessageView( fun EmptyMessageView( @StringRes messageResId: Int, modifier: Modifier = Modifier, + isLoading: Boolean = false, faces: List = listOf( "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" @@ -78,6 +93,7 @@ fun EmptyMessageView( EmptyMessageView( message = message, modifier = modifier, + isLoading = isLoading, faces = faces, faceTextSize = faceTextSize, onFaceClick = onFaceClick diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index 707d2df02..ae52694dd 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt @@ -103,7 +103,7 @@ fun SourceInputDialog( } }, confirmButton = { - TextButton( + OutlinedButton( onClick = { if (text.isNotBlank()) onConfirm(text) } ) { Text(stringResource(android.R.string.ok)) } },