From 8539cf818a4c5a2dd16f82b9bb757cc34f46d67b Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 16 May 2026 04:23:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../app/data/repository/BookRepository.kt | 5 + .../GetReadRecordOverviewUseCase.kt | 19 +- .../app/ui/book/info/BookInfoContract.kt | 1 + .../legado/app/ui/book/info/BookInfoScreen.kt | 180 +++++++------- .../app/ui/book/info/BookInfoViewModel.kt | 73 ++++-- .../ui/book/info/edit/BookInfoEditScreen.kt | 219 ++++++++++++------ .../book/info/edit/BookInfoEditViewModel.kt | 11 +- .../ui/book/manage/BookshelfManageScreen.kt | 37 ++- .../readRecord/ReadRecordOverviewScreen.kt | 193 ++++++++++----- .../readRecord/ReadRecordOverviewViewModel.kt | 7 +- .../ui/book/readRecord/ReadRecordScreen.kt | 62 +---- .../readRecord/component/ReadRecordCharts.kt | 69 ++++-- .../readRecord/component/ReadRecordStats.kt | 57 ++++- .../io/legado/app/ui/main/MainNavigator.kt | 11 + .../io/legado/app/ui/main/rss/RssScreen.kt | 70 ++++-- .../app/ui/rss/subscription/RuleSubScreen.kt | 10 +- .../java/io/legado/app/ui/theme/FadingEdge.kt | 81 +++++++ .../components/button/MediumIconButton.kt | 3 +- .../ui/widget/components/tabRow/AppTabRow.kt | 81 +++++-- .../io/legado/app/utils/ConvertExtensions.kt | 2 +- 21 files changed, 798 insertions(+), 396 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 288c37efb..0618fc5b5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -82,7 +82,8 @@ "Bash(findstr /i \"miuix.*\\\\.kt\")", "Bash(findstr /v \"\\\\\\\\build\\\\\\\\\")", "Bash(dir /s /b \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\.gradle\\\\*miuix*\")", - "Skill(legado-compose-review)" + "Skill(legado-compose-review)", + "Bash(cmd /c \".\\\\gradlew.bat :app:compileAppDebugKotlin\")" ] } } diff --git a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt index ec400b120..ff8bf67bd 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt @@ -5,12 +5,17 @@ import io.legado.app.data.dao.BookChapterDao import io.legado.app.data.dao.BookDao import io.legado.app.data.entities.Book import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext class BookRepository( private val bookDao: BookDao, private val bookChapterDao: BookChapterDao ) { + fun getAllBooks(): Flow> { + return bookDao.flowAll() + } + suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? { return withContext(Dispatchers.IO) { bookDao.getBook(bookName, bookAuthor)?.getDisplayCover() diff --git a/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt index 4de750ef4..f239cb4d6 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt @@ -1,5 +1,6 @@ package io.legado.app.domain.usecase.readRecord +import io.legado.app.data.entities.Book import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.ui.book.readRecord.ReadBookRanking @@ -16,7 +17,8 @@ class GetReadRecordOverviewUseCase { period: ReadPeriod, refDate: LocalDate, details: List, - latestRecords: List + latestRecords: List, + allBooks: List ): ReadRecordOverviewUiState { val (startDate, endDate) = getPeriodRange(period, refDate) @@ -41,11 +43,24 @@ class GetReadRecordOverviewUseCase { val totalBooks = periodBooks.size val shelfBooksMap = latestRecords.associateBy { it.bookName to it.bookAuthor } + val allShelfBooksMap = allBooks.associateBy { it.name to it.author } + var readingCount = 0 + var finishedCount = 0 + periodBooks.keys.forEach { key -> if (shelfBooksMap.containsKey(key)) { readingCount++ } + // 判断是否读完 + allShelfBooksMap[key]?.let { book -> + if (book.totalChapterNum > 0 && + book.durChapterIndex >= book.totalChapterNum - 1 && + book.durChapterPos != 0 + ) { + finishedCount++ + } + } } val topBooks = periodBooks.map { (key, details) -> @@ -122,7 +137,7 @@ class GetReadRecordOverviewUseCase { totalTime = totalTime, readingDays = readingDays, totalBooks = totalBooks, - finishedBooks = 0, // Placeholder + finishedBooks = finishedCount, readingBooks = readingCount, totalWords = totalWords, dailyTimeData = dailyTimeData, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 1ed4f006c..5d56452ae 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -75,6 +75,7 @@ sealed interface BookInfoIntent { data object ChangeSourceClick : BookInfoIntent data object ReadRecordClick : BookInfoIntent data object RemarkClick : BookInfoIntent + data class SaveCover(val path: String) : BookInfoIntent data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent data class UpdateRemark(val remark: String) : BookInfoIntent data class SelectGroup(val groupId: Long) : BookInfoIntent diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index f014255b1..b0cb1a9ec 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -82,6 +82,7 @@ import io.legado.app.ui.theme.LocalHazeState import io.legado.app.ui.theme.ProvideThemeOverride import io.legado.app.ui.theme.ThemeOverrideState import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.fadingEdge import io.legado.app.ui.theme.rememberImageSeedColor import io.legado.app.ui.theme.rememberThemeOverride import io.legado.app.ui.theme.responsiveHazeEffectFixedStyle @@ -737,8 +738,12 @@ private fun BookInfoHeader( } } if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) { + val kindListState = rememberLazyListState() LazyRow( - modifier = Modifier.fillMaxWidth(), + state = kindListState, + modifier = Modifier + .fillMaxWidth() + .fadingEdge(kindListState), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { groupNames?.takeIf { it.isNotBlank() }?.let { @@ -963,101 +968,106 @@ private fun BookInfoDialogs( var deleteOriginal by remember(dialog, state.deleteOriginal) { mutableStateOf(state.deleteOriginal) } var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) } - if (dialog is BookInfoDialog.DeleteBook) { - AppAlertDialog( - show = true, - onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, - title = stringResource(R.string.draw), - text = stringResource(R.string.sure_del), - confirmText = stringResource(android.R.string.ok), - onConfirm = { - onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal)) - }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, - content = { - if (dialog.isLocal) { - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.Checkbox( - checked = deleteOriginal, - onCheckedChange = { deleteOriginal = it }, - colors = androidx.compose.material3.CheckboxDefaults.colors( - checkedColor = LegadoTheme.colorScheme.primary, - checkmarkColor = LegadoTheme.colorScheme.onPrimary, - uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant, - ) + AppAlertDialog( + data = dialog as? BookInfoDialog.DeleteBook, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_del), + confirmText = stringResource(android.R.string.ok), + onConfirm = { + onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal)) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + content = { d -> + if (d.isLocal) { + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Checkbox( + checked = deleteOriginal, + onCheckedChange = { deleteOriginal = it }, + colors = androidx.compose.material3.CheckboxDefaults.colors( + checkedColor = LegadoTheme.colorScheme.primary, + checkmarkColor = LegadoTheme.colorScheme.onPrimary, + uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant, ) - Text(text = stringResource(R.string.delete_book_file)) - } + ) + Text(text = stringResource(R.string.delete_book_file)) } } - ) - } + } + ) - if (dialog is BookInfoDialog.EditRemark) { - AppAlertDialog( - show = true, - onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, - title = stringResource(R.string.edit_remark), - confirmText = stringResource(android.R.string.ok), - onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, - content = { - AppTextField( - value = remarkText, - onValueChange = { remarkText = it }, - label = "备注", - modifier = Modifier.fillMaxWidth(), - ) - } - ) - } + AppAlertDialog( + data = dialog as? BookInfoDialog.EditRemark, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.edit_remark), + confirmText = stringResource(android.R.string.ok), + onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + content = { + AppTextField( + value = remarkText, + onValueChange = { remarkText = it }, + label = stringResource(R.string.book_remark), + modifier = Modifier.fillMaxWidth(), + ) + } + ) - if (dialog is BookInfoDialog.UnsupportedWebFile) { - AppAlertDialog( - show = true, - onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, - title = stringResource(R.string.draw), - text = stringResource(R.string.file_not_supported, dialog.webFile.name), - confirmText = stringResource(R.string.open_fun), - onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(dialog.webFile)) }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, - ) - } + val unsupportedWebFile = dialog as? BookInfoDialog.UnsupportedWebFile + AppAlertDialog( + data = unsupportedWebFile, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.draw), + text = unsupportedWebFile?.let { + stringResource( + R.string.file_not_supported, + it.webFile.name + ) + }, + confirmText = stringResource(R.string.open_fun), + onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(it.webFile)) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + ) - if (dialog is BookInfoDialog.PhotoPreview) { - AppAlertDialog( - show = true, - onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, - title = stringResource(R.string.img_cover), - confirmText = stringResource(android.R.string.ok), - onConfirm = { onIntent(BookInfoIntent.DismissDialog) }, - content = { - AsyncImage( - model = dialog.path, - contentDescription = null, + AppAlertDialog( + data = dialog as? BookInfoDialog.PhotoPreview, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.img_cover), + confirmText = "保存到相册", + onConfirm = { d -> + onIntent(BookInfoIntent.SaveCover(d.path)) + onIntent(BookInfoIntent.DismissDialog) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + content = { d -> + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CoilBookCover( + name = state.book?.name, + author = state.book?.author, + path = d.path, + sourceOrigin = state.book?.origin, + ignoreUseDefaultCover = true, modifier = Modifier - .fillMaxWidth() - .heightIn(max = 420.dp), - contentScale = ContentScale.Fit, + .heightIn(max = 420.dp) + .fillMaxWidth(0.6f) ) } - ) - } + } + ) - if (state.isBusy) { - AppAlertDialog( - show = true, - onDismissRequest = {}, - content = { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - AppCircularProgressIndicator() - } + AppAlertDialog( + show = state.isBusy, + onDismissRequest = {}, + content = { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + AppCircularProgressIndicator() } - ) - } + } + ) AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) }) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 00575e1c9..46ff441b7 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -3,8 +3,12 @@ package io.legado.app.ui.book.info import android.app.Activity.RESULT_OK import android.app.Application import android.content.Intent +import android.graphics.Bitmap import android.net.Uri +import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewModelScope +import coil.imageLoader +import coil.request.SuccessResult import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog @@ -16,11 +20,11 @@ import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay -import io.legado.app.domain.usecase.ChangeBookSourceUseCase -import io.legado.app.domain.usecase.ChangeSourceMigrationOptions +import io.legado.app.data.repository.BookGroupRepository 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.ChangeBookSourceUseCase +import io.legado.app.domain.usecase.ChangeSourceMigrationOptions import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoStackTraceException @@ -33,7 +37,6 @@ import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.isWebFile import io.legado.app.help.book.removeType import io.legado.app.help.book.updateTo -import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.webdav.ObjectNotFoundException @@ -45,24 +48,29 @@ import io.legado.app.model.SourceCallBack import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.ui.widget.components.cover.buildCoverImageRequest import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ConvertUtils import io.legado.app.utils.FileDoc import io.legado.app.utils.GSON +import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.postEvent +import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream class BookInfoViewModel( application: Application, @@ -167,6 +175,9 @@ class BookInfoViewModel( BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker) BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord) BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark)) + is BookInfoIntent.SaveCover -> { + saveCoverToGallery(intent.path) + } is BookInfoIntent.ConfirmDelete -> { dismissDialog() deleteBook(intent.deleteOriginal) @@ -445,6 +456,41 @@ class BookInfoViewModel( } } + private fun saveCoverToGallery(path: String) { + val book = currentBook + val sourceOrigin = if (book?.getDisplayCover() == path) book.origin else null + execute { + setBusy(true) + val request = buildCoverImageRequest( + context = context, + data = path, + sourceOrigin = sourceOrigin, + loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, + crossfade = false + ) + val result = context.imageLoader.execute(request) + if (result is SuccessResult) { + val bitmap = result.drawable.toBitmap() + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + val byteArray = outputStream.toByteArray() + ImageSaveUtils.saveImageToGallery(context, byteArray, "Cover_") + } else { + false + } + }.onSuccess { success -> + if (success) { + context.toastOnUi("保存成功") + } else { + context.toastOnUi("保存失败") + } + }.onFinally { + setBusy(false) + }.onError { + context.toastOnUi("保存出错: ${it.localizedMessage}") + } + } + fun saveRemark(remark: String, success: (() -> Unit)? = null) { currentBook?.let { book -> execute { @@ -713,27 +759,22 @@ class BookInfoViewModel( private fun refreshMeta(book: Book) { execute { - val allKinds = book.getKindList() - val customKinds = allKinds.filter { it.startsWith("#") } - val sourceKinds = allKinds.filter { !it.startsWith("#") }.toMutableList() + val fileSizePattern = Regex("""^\d[\d,.]*\s*(b|kb|M|G|T)$""", RegexOption.IGNORE_CASE) + val kinds = (book.kind?.splitNotBlank(",", "\n").orEmpty().toList()) + .filter { !fileSizePattern.matches(it.trim()) } + .toMutableList() if (book.isLocal) { val size = FileDoc.fromFile(book.bookUrl).size if (size > 0) { - sourceKinds.add(ConvertUtils.formatFileSize(size)) + kinds.add(ConvertUtils.formatFileSize(size)) } } - val mergedKinds = (customKinds + sourceKinds).distinct() + val finalKinds = kinds.distinct() val userGroupIds = appDb.bookGroupDao.idsSum val groupAnd = userGroupIds and book.group val hasCustomGroup = book.group > 0L && groupAnd != 0L val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",") val normalizedGroupNames = groupNames.ifBlank { null } - val persistedCustomKinds = currentKindLabels.filter { it.startsWith("#") } - val finalKinds = if (persistedCustomKinds.isNotEmpty()) { - (persistedCustomKinds + sourceKinds).distinct() - } else { - mergedKinds - } book.kind = finalKinds.joinToString(",") appDb.bookDao.update(book) Triple(finalKinds, normalizedGroupNames, hasCustomGroup) diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt index ee05f7119..6ad0c0902 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt @@ -1,9 +1,9 @@ package io.legado.app.ui.book.info.edit 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.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -13,14 +13,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.Replay @@ -30,8 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,27 +42,37 @@ 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.ui.book.changecover.ChangeCoverDialog +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.fadingEdge import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton -import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton 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.settingItem.SwitchSettingItem +import io.legado.app.ui.widget.components.text.AnimatedTextLine import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults -import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.SelectImageContract import io.legado.app.utils.launch import io.legado.app.utils.showDialogFragment +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -214,6 +225,7 @@ fun BookInfoEditContent( KindEditor( kindList = uiState.kindList, onKindListChange = { viewModel.onKindListChange(it) }, + onReset = { viewModel.resetKinds() }, backgroundColor = inputBackgroundColor ) Spacer(modifier = Modifier.height(8.dp)) @@ -298,89 +310,146 @@ fun BookTypeDropdown( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun KindEditor( kindList: List, onKindListChange: (List) -> Unit, + onReset: () -> Unit, backgroundColor: Color ) { - var newKindText by remember { mutableStateOf("") } + var editingIndex by remember { mutableStateOf(null) } + var editText by remember { mutableStateOf("") } + val hapticFeedback = LocalHapticFeedback.current - Column( - modifier = Modifier.fillMaxWidth() + val listState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + val mutable = kindList.toMutableList() + mutable.add(to.index, mutable.removeAt(from.index)) + onKindListChange(mutable) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), + LazyRow( + state = listState, + modifier = Modifier + .weight(1f) + .fadingEdge(listState), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - AppTextField( - value = newKindText, - onValueChange = { newKindText = it }, - label = "标签", - backgroundColor = backgroundColor, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(8.dp)) - MediumOutlinedIconButton( - onClick = { - val trimmed = newKindText.trim() - val tag = if (trimmed.startsWith("#")) trimmed else "#$trimmed" - if (tag.isNotBlank() && tag !in kindList) { - val sortedList = (kindList + tag).sortedWith( - compareBy { !it.startsWith("#") }.thenBy { it } - ) - onKindListChange(sortedList) - newKindText = "" - } - }, - imageVector = Icons.Default.Add - ) - } - Spacer(modifier = Modifier.height(4.dp)) - if (kindList.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - kindList.forEach { kind -> - Row( - verticalAlignment = Alignment.CenterVertically - ) { - AppTextField( - value = kind, - onValueChange = { newValue -> - val index = kindList.indexOf(kind) - if (index >= 0) { - val newList = kindList.toMutableList() - newList[index] = newValue - onKindListChange(newList) + items(kindList.size, key = { kindList[it] }) { index -> + val kind = kindList[index] + ReorderableItem(reorderableState, key = kind) { isDragging -> + KindChip( + text = kind, + isDragging = isDragging, + onClick = { + editingIndex = index + editText = kind + }, + modifier = Modifier + .longPressDraggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) } - }, - label = null, - backgroundColor = backgroundColor, - singleLine = true, - maxLines = 1, - modifier = Modifier.width(80.dp) - ) - IconButton( - onClick = { - onKindListChange(kindList - kind) - }, - modifier = Modifier.padding(0.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "删除标签", - tint = Color.Red.copy(alpha = 0.7f), - modifier = Modifier.padding(4.dp) ) - } - } + .animateItem() + ) } } } + Spacer(modifier = Modifier.width(8.dp)) + MediumOutlinedIconButton( + onClick = { + onReset() + }, + imageVector = Icons.Default.Replay + ) + Spacer(modifier = Modifier.width(8.dp)) + MediumOutlinedIconButton( + onClick = { + editingIndex = -1 + editText = "" + }, + imageVector = Icons.Default.Add + ) + } + + if (editingIndex != null) { + val isAdding = editingIndex == -1 + AppAlertDialog( + show = true, + onDismissRequest = { editingIndex = null }, + title = if (isAdding) "添加标签" else "编辑标签", + content = { + AppTextField( + value = editText, + onValueChange = { editText = it }, + label = "标签", + backgroundColor = backgroundColor, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmText = stringResource(android.R.string.ok), + onConfirm = { + val trimmed = editText.trim() + if (trimmed.isNotBlank()) { + val mutable = kindList.toMutableList() + if (isAdding) { + if (trimmed !in kindList) mutable.add(trimmed) + } else { + mutable[editingIndex!!] = trimmed + } + onKindListChange(mutable) + } + editingIndex = null + }, + dismissText = if (isAdding) stringResource(android.R.string.cancel) else stringResource( + R.string.delete + ), + onDismiss = if (isAdding) { + { editingIndex = null } + } else { + { + onKindListChange(kindList.toMutableList().apply { removeAt(editingIndex!!) }) + editingIndex = null + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun KindChip( + text: String, + isDragging: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = LegadoTheme.colorScheme.surfaceContainer, + contentColor = LegadoTheme.colorScheme.onSurface, + shadowElevation = if (isDragging) 8.dp else 0.dp, + modifier = modifier + .zIndex(if (isDragging) 1f else 0f) + .clickable(onClick = onClick) + ) { + AnimatedTextLine( + text = text, + style = LegadoTheme.typography.labelLargeEmphasized, + color = LegadoTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt index 259ccade1..0b6ab5b23 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt @@ -19,7 +19,6 @@ import io.legado.app.model.ReadBook import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.inputStream -import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,6 +32,7 @@ data class BookInfoEditUiState( val intro: String? = null, val remark: String? = null, val kindList: List = emptyList(), + val originalKindList: List = emptyList(), val selectedType: String = "文本", val bookTypes: List = listOf("文本", "音频", "图片"), val fixedType: Boolean = false, @@ -53,13 +53,16 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio it.isAudio -> 1 else -> 0 } + val kinds = + it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty() _uiState.value = BookInfoEditUiState( name = it.name, author = it.author, coverUrl = it.getDisplayCover(), intro = it.getDisplayIntro(), remark = it.remark, - kindList = it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty(), + kindList = kinds, + originalKindList = kinds, selectedType = _uiState.value.bookTypes[selectedTypeIndex], fixedType = it.config.fixedType, book = it @@ -68,6 +71,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio } } + fun resetKinds() { + _uiState.value = _uiState.value.copy(kindList = _uiState.value.originalKindList.toList()) + } + fun onNameChange(name: String) { _uiState.value = _uiState.value.copy(name = name) } diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt index 8d9eca087..66d5d7d4b 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt @@ -1,23 +1,20 @@ package io.legado.app.ui.book.manage -import androidx.compose.ui.focus.focusRequester import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -26,11 +23,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuOpen +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow @@ -44,7 +41,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButtonMenu import androidx.compose.material3.FloatingActionButtonMenuItem -import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -61,9 +57,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -72,7 +69,6 @@ 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 @@ -87,12 +83,12 @@ import io.legado.app.ui.book.info.ChangeSourceSheet import io.legado.app.ui.book.info.GroupSelectSheet import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveContentPadding -import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator +import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.button.SmallTonalIconButton import io.legado.app.ui.widget.components.button.SmallTonalTextButton -import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.ReorderableSelectionItem import io.legado.app.ui.widget.components.card.SelectionItemCard @@ -100,26 +96,25 @@ import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.cover.CoilBookCover import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.filePicker.FilePickerSheet -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.icon.AppIcons import io.legado.app.ui.widget.components.list.ListScaffold import io.legado.app.ui.widget.components.list.ListUiState -import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard +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.modalBottomSheet.OptionCard import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet +import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.utils.ACache import io.legado.app.utils.FileDoc import io.legado.app.utils.checkWrite import io.legado.app.utils.isContentScheme import io.legado.app.utils.move -import io.legado.app.utils.startActivity import io.legado.app.utils.startService import io.legado.app.utils.toastOnUi import io.legado.app.utils.verificationField -import io.legado.app.ui.theme.adaptiveHorizontalPadding -import io.legado.app.ui.widget.components.button.MediumIconButton import org.koin.androidx.compose.koinViewModel import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -399,14 +394,14 @@ private fun BookshelfManageScreen( Icons.Default.SelectAll, stringResource(R.string.select_all) ) { - selectedBookUrls = state.books.mapTo(hashSetOf()) { it.bookUrl } + selectedBookUrls = filteredBooks.mapTo(hashSetOf()) { it.bookUrl } }, BookshelfManageFabAction( Icons.Default.Refresh, stringResource(R.string.revert_selection) ) { - val visibleBookUrls = booksByUrl.keys - selectedBookUrls = visibleBookUrls - selectedBookUrls + val filteredUrls = filteredBooks.map { it.bookUrl }.toSet() + selectedBookUrls = (selectedBookUrls - filteredUrls) + (filteredUrls - selectedBookUrls) }, BookshelfManageFabAction( Icons.Default.Download, diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt index 4a32327ac..6939b8c74 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt @@ -1,20 +1,47 @@ package io.legado.app.ui.book.readRecord +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowLeft import androidx.compose.material.icons.automirrored.filled.ArrowRight -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.GridOn +import androidx.compose.material.icons.filled.Leaderboard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +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.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -31,6 +58,7 @@ import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.cover.CoilBookCover import io.legado.app.ui.widget.components.heatmap.HeatmapMode +import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults @@ -83,8 +111,11 @@ fun ReadRecordOverviewScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp) ) { - item { - HeatmapCard(state) + + if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) { + item { + ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period) + } } item { @@ -99,10 +130,8 @@ fun ReadRecordOverviewScreen( StatsGridCard(title = "阅读数据", items = stats) } - if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) { - item { - ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period) - } + item { + HeatmapCard(state) } if (state.topBooks.isNotEmpty()) { @@ -124,27 +153,22 @@ fun PeriodSelector( selectedPeriod: ReadPeriod, onPeriodSelected: (ReadPeriod) -> Unit ) { - val periods = listOf( - ReadPeriod.DAY to "日", - ReadPeriod.WEEK to "周", - ReadPeriod.MONTH to "月", - ReadPeriod.YEAR to "年", - ReadPeriod.ALL to "总" - ) - - PrimaryTabRow( - selectedTabIndex = periods.indexOfFirst { it.first == selectedPeriod }, - containerColor = Color.Transparent, - divider = {} - ) { - periods.forEach { (period, label) -> - Tab( - selected = selectedPeriod == period, - onClick = { onPeriodSelected(period) }, - text = { AppText(label) } - ) - } + val periods = remember { + listOf( + ReadPeriod.DAY to "日", + ReadPeriod.WEEK to "周", + ReadPeriod.MONTH to "月", + ReadPeriod.YEAR to "年", + ReadPeriod.ALL to "总" + ) } + + AppTabRow( + tabTitles = periods.map { it.second }, + selectedTabIndex = periods.indexOfFirst { it.first == selectedPeriod }, + onTabSelected = { index -> onPeriodSelected(periods[index].first) }, + isScrollable = false + ) } @Composable @@ -156,34 +180,50 @@ fun DateNavigator( ) { if (period == ReadPeriod.ALL) return - val dateText = when (period) { - ReadPeriod.DAY -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月d日")) - ReadPeriod.WEEK -> { - val start = referenceDate.with(java.time.DayOfWeek.MONDAY) - val end = referenceDate.with(java.time.DayOfWeek.SUNDAY) - "${start.format(DateTimeFormatter.ofPattern("M.d"))} - ${end.format(DateTimeFormatter.ofPattern("M.d"))}" - } - ReadPeriod.MONTH -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月")) - ReadPeriod.YEAR -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年")) - ReadPeriod.ALL -> "" - } - Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Center, + .padding(vertical = 8.dp, horizontal = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { MediumIconButton( onClick = onPrevClick, imageVector = Icons.AutoMirrored.Filled.ArrowLeft ) - AppText( - text = dateText, - style = LegadoTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) + AnimatedContent( + targetState = referenceDate, + transitionSpec = { + if (targetState.isAfter(initialState)) { + (slideInHorizontally { it / 2 } + fadeIn()).togetherWith(slideOutHorizontally { -it / 2 } + fadeOut()) + } else { + (slideInHorizontally { -it / 2 } + fadeIn()).togetherWith(slideOutHorizontally { it / 2 } + fadeOut()) + }.using(SizeTransform(clip = false)) + }, + label = "DateNavigator" + ) { targetDate -> + val text = when (period) { + ReadPeriod.DAY -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年M月d日")) + ReadPeriod.WEEK -> { + val start = targetDate.with(java.time.DayOfWeek.MONDAY) + val end = targetDate.with(java.time.DayOfWeek.SUNDAY) + "${start.format(DateTimeFormatter.ofPattern("M.d"))} - ${ + end.format( + DateTimeFormatter.ofPattern("M.d") + ) + }" + } + + ReadPeriod.MONTH -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年M月")) + ReadPeriod.YEAR -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年")) + ReadPeriod.ALL -> "" + } + AppText( + text = text, + style = LegadoTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } MediumIconButton( onClick = onNextClick, imageVector = Icons.AutoMirrored.Filled.ArrowRight @@ -199,7 +239,16 @@ fun HeatmapCard(state: ReadRecordOverviewUiState) { .adaptiveHorizontalPadding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - AppText("阅读热力图", style = LegadoTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.GridOn, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AppText("阅读热力图", style = LegadoTheme.typography.titleMedium) + } Spacer(modifier = Modifier.height(8.dp)) HeatmapCalendarSection( dailyReadCounts = state.allReadCounts, @@ -224,11 +273,22 @@ fun TopReadingListCard( .adaptiveHorizontalPadding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(vertical = 16.dp)) { - AppText( + Row( modifier = Modifier.padding(horizontal = 16.dp), - text = "阅读时长榜", - style = LegadoTheme.typography.titleMedium - ) + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Leaderboard, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AppText( + text = "阅读时长榜", + style = LegadoTheme.typography.titleMedium + ) + } Spacer(modifier = Modifier.height(8.dp)) topBooks.forEachIndexed { index, book -> @@ -247,7 +307,7 @@ fun TopReadingListCard( AppText( text = "${index + 1}", style = LegadoTheme.typography.titleMedium, - modifier = Modifier.width(32.dp), + modifier = Modifier.padding(start = 12.dp, end = 16.dp), textAlign = TextAlign.Center, color = if (index < 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant ) @@ -259,6 +319,12 @@ fun TopReadingListCard( ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { + AppText( + modifier = Modifier.padding(end = 8.dp), + text = ReadRecordFormatter.formatDuration(book.readTime), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.primary + ) AppText( text = book.bookName, style = LegadoTheme.typography.bodyMedium, @@ -272,12 +338,6 @@ fun TopReadingListCard( color = LegadoTheme.colorScheme.onSurfaceVariant ) } - AppText( - modifier = Modifier.padding(end = 8.dp), - text = ReadRecordFormatter.formatDuration(book.readTime), - style = LegadoTheme.typography.bodySmall, - color = LegadoTheme.colorScheme.primary - ) } } } @@ -300,7 +360,16 @@ fun ReadingCalendarCard( .adaptiveHorizontalPadding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - AppText("读书日历", style = LegadoTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CalendarMonth, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AppText("读书日历", style = LegadoTheme.typography.titleMedium) + } Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt index 3afaffa82..f2814285e 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt @@ -52,9 +52,10 @@ class ReadRecordOverviewViewModel( _period, _referenceDate, repository.getAllRecordDetails(""), - repository.getLatestReadRecords("") - ) { period, refDate, details, latestRecords -> - getReadRecordOverviewUseCase(period, refDate, details, latestRecords) + repository.getLatestReadRecords(""), + bookRepository.getAllBooks() + ) { period, refDate, details, latestRecords, allBooks -> + getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), 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 815e24810..6d63a261f 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 @@ -2,8 +2,6 @@ package io.legado.app.ui.book.readRecord import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -49,14 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -69,17 +62,16 @@ import cn.hutool.core.date.DateUtil import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordSession -import io.legado.app.ui.book.readRecord.component.* import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.theme.fadingEdge import io.legado.app.ui.widget.CollapsibleHeader import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.AppIconButton -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.card.TextCard import io.legado.app.ui.widget.components.checkBox.CheckboxItem @@ -104,8 +96,9 @@ import io.legado.app.ui.widget.components.swipe.SwipeActionContainer import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults -import io.legado.app.utils.formatReadDuration +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.StringUtils.formatFriendlyDate +import io.legado.app.utils.formatReadDuration import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import java.time.LocalDate @@ -516,29 +509,6 @@ fun HeatmapCalendarSection( } } - val showLeftGradient by remember { - derivedStateOf { - listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 - } - } - val showRightGradient by remember { - derivedStateOf { - listState.canScrollForward - } - } - - val leftAlpha by animateFloatAsState( - targetValue = if (showLeftGradient) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "LeftGradientAlpha" - ) - - val rightAlpha by animateFloatAsState( - targetValue = if (showRightGradient) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "RightGradientAlpha" - ) - Column( modifier = modifier .fillMaxWidth() @@ -556,31 +526,7 @@ fun HeatmapCalendarSection( horizontalArrangement = Arrangement.spacedBy(config.cellSpacing), modifier = Modifier .weight(1f) - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - - val width = size.width - val gradientWidthPx = config.gradientWidth.toPx() - val leftStop = gradientWidthPx / width - val rightStop = 1f - (gradientWidthPx / width) - - val colorStops = arrayOf( - 0f to Color.Black.copy(alpha = 1f - leftAlpha), - leftStop to Color.Black, - rightStop to Color.Black, - 1f to Color.Black.copy(alpha = 1f - rightAlpha) - ) - - drawRect( - brush = Brush.horizontalGradient( - colorStops = colorStops, - startX = 0f, - endX = width - ), - blendMode = BlendMode.DstIn - ) - } + .fadingEdge(listState, config.gradientWidth) ) { val firstReadDate = listOfNotNull( dailyReadCounts.filterValues { it > 0 }.keys.minOrNull(), diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt index 5fedce8a8..46e583a53 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt @@ -1,15 +1,35 @@ package io.legado.app.ui.book.readRecord.component +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +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.fillMaxHeight +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.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.legado.app.ui.book.readRecord.ReadPeriod +import io.legado.app.ui.book.readRecord.ReadRecordFormatter import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.widget.components.card.GlassCard @@ -22,7 +42,16 @@ fun ReadingTimeBarChartCard( period: ReadPeriod, modifier: Modifier = Modifier ) { - val maxTime = data.maxOfOrNull { it.second }?.coerceAtLeast(1L) ?: 1L + val rawMaxTime = data.maxOfOrNull { it.second }?.coerceAtLeast(1L) ?: 1L + + // 向上取整逻辑:根据时长跨度选择合适的对齐单位 + val roundedMaxTime = when { + rawMaxTime < 60_000 -> 60_000L // 不足1分钟取1分钟 + rawMaxTime < 10 * 60_000 -> ((rawMaxTime + 59_999) / 60_000) * 60_000L // 10分钟内按1分钟对齐 + rawMaxTime < 60 * 60_000 -> ((rawMaxTime + 5 * 60_000 - 1) / (5 * 60_000)) * 5 * 60_000L // 1小时内按5分钟对齐 + rawMaxTime < 12 * 3600_000 -> ((rawMaxTime + 3600_000 - 1) / 3600_000) * 3600_000L // 12小时内按1小时对齐 + else -> ((rawMaxTime + 4 * 3600_000 - 1) / (4 * 3600_000)) * 4 * 3600_000L // 超过12小时按4小时对齐 + } GlassCard( modifier = modifier @@ -30,7 +59,16 @@ fun ReadingTimeBarChartCard( .adaptiveHorizontalPadding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - AppText("阅读时长分布", style = LegadoTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.BarChart, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AppText("阅读时长分布", style = LegadoTheme.typography.titleMedium) + } Spacer(modifier = Modifier.height(16.dp)) Row( @@ -41,13 +79,14 @@ fun ReadingTimeBarChartCard( // Y-Axis Column( modifier = Modifier + .width(32.dp) .fillMaxHeight() .padding(bottom = 20.dp), verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.End ) { AppText( - text = formatChartDuration(maxTime), + text = ReadRecordFormatter.formatDuration(roundedMaxTime), style = LegadoTheme.typography.labelSmall, fontSize = 8.sp, color = LegadoTheme.colorScheme.onSurfaceVariant @@ -71,7 +110,12 @@ fun ReadingTimeBarChartCard( horizontalArrangement = Arrangement.SpaceEvenly ) { data.forEachIndexed { index, (date, time) -> - val heightFactor = time.toFloat() / maxTime + val targetHeightFactor = time.toFloat() / roundedMaxTime + val heightFactor by animateFloatAsState( + targetValue = targetHeightFactor, + animationSpec = tween(durationMillis = 320, delayMillis = index * 20), + label = "BarHeight" + ) val showLabel = when (period) { ReadPeriod.DAY -> true @@ -98,6 +142,7 @@ fun ReadingTimeBarChartCard( Box( modifier = Modifier .weight(1f) + .widthIn(max = 16.dp) .fillMaxWidth(), contentAlignment = Alignment.BottomCenter ) { @@ -109,7 +154,7 @@ fun ReadingTimeBarChartCard( .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) .background( if (time > 0) LegadoTheme.colorScheme.primary - else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + else LegadoTheme.colorScheme.surfaceVariant ) ) } @@ -120,7 +165,10 @@ fun ReadingTimeBarChartCard( text = labelText, style = LegadoTheme.typography.labelSmall, fontSize = 8.sp, - color = LegadoTheme.colorScheme.onSurfaceVariant + color = LegadoTheme.colorScheme.onSurfaceVariant, + softWrap = false, + overflow = TextOverflow.Visible, + modifier = Modifier.wrapContentWidth(unbounded = true) ) } } @@ -131,10 +179,3 @@ fun ReadingTimeBarChartCard( } } } - -private fun formatChartDuration(millis: Long): String { - val totalMinutes = millis / (1000 * 60) - val hours = totalMinutes / 60 - val minutes = totalMinutes % 60 - return if (hours > 0) "${hours}h${if (minutes > 0) "${minutes}m" else ""}" else "${minutes}m" -} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt index 1e78d07f9..c0e52aa6e 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt @@ -1,6 +1,22 @@ package io.legado.app.ui.book.readRecord.component -import androidx.compose.foundation.layout.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.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.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Equalizer import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -10,6 +26,7 @@ import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveHorizontalPadding 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.text.AppText data class StatItem(val label: String, val value: String) @@ -26,12 +43,24 @@ fun StatsGridCard( .adaptiveHorizontalPadding(vertical = 8.dp) ) { Column(modifier = Modifier.padding(16.dp)) { - AppText(title, style = LegadoTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon( + imageVector = Icons.Default.Equalizer, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AppText(title, style = LegadoTheme.typography.titleMedium) + } Spacer(modifier = Modifier.height(16.dp)) Column { for (i in items.indices step 2) { - Row(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { StatCell(items[i], Modifier.weight(1f)) if (i + 1 < items.size) { StatCell(items[i + 1], Modifier.weight(1f)) @@ -55,18 +84,26 @@ private fun StatCell( modifier = modifier, horizontalAlignment = Alignment.Start ) { - AppText( - text = item.value, - style = LegadoTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = LegadoTheme.colorScheme.primary, - textAlign = TextAlign.Start - ) AppText( text = item.label, style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Start ) + AnimatedContent( + targetState = item.value, + transitionSpec = { + (fadeIn() + slideInVertically { it / 2 }).togetherWith(fadeOut() + slideOutVertically { -it / 2 }) + }, + label = "StatValue" + ) { targetValue -> + AppText( + text = targetValue, + style = LegadoTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = LegadoTheme.colorScheme.primary, + textAlign = TextAlign.Start + ) + } } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index ae71e697e..1a6eaed15 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -128,6 +128,17 @@ object MainNavigator { } } + MainRouteRssFavorites, + MainRouteRuleSub -> { + if (currentRoute == MainRouteHome) { + backStack.add(route) + } else { + backStack.clear() + backStack.add(MainRouteHome) + backStack.add(route) + } + } + MainRouteReadRecord -> { if (currentRoute == MainRouteHome) { backStack.add(route) diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt index 4cf54cd85..60d0e928f 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt @@ -5,13 +5,16 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.layout.width import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -51,14 +54,15 @@ import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.SourceIcon import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.divider.PillHeaderDivider +import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.list.ListScaffold import io.legado.app.ui.widget.components.menuItem.MenuItemIcon 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.text.AppText -import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.utils.openUrl import io.legado.app.utils.startActivity import kotlinx.coroutines.flow.collectLatest @@ -136,18 +140,6 @@ fun RssScreen( onSearchToggle = { viewModel.toggleSearchVisible(it) }, onSearchQueryChange = { viewModel.search(it) }, searchPlaceholder = stringResource(R.string.search_rss_source), - topBarActions = { - TopBarActionButton( - onClick = { viewModel.openRuleSub() }, - imageVector = Icons.Default.Subscriptions, - contentDescription = stringResource(R.string.rule_subscription) - ) - TopBarActionButton( - onClick = { viewModel.openFavorites() }, - imageVector = Icons.Default.Star, - contentDescription = stringResource(R.string.favorite) - ) - }, dropDownMenuContent = { dismiss -> RoundDropdownMenuItem( onClick = { @@ -185,6 +177,58 @@ fun RssScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + GlassCard( + modifier = Modifier.weight(1f), + onClick = { viewModel.openRuleSub() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AppIcon( + imageVector = Icons.Default.Subscriptions, + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + AppText( + text = stringResource(R.string.rule_subscription), + style = LegadoTheme.typography.labelMediumEmphasized + ) + } + } + GlassCard( + modifier = Modifier.weight(1f), + onClick = { viewModel.openFavorites() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AppIcon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + AppText( + text = stringResource(R.string.favorite), + style = LegadoTheme.typography.labelMediumEmphasized + ) + } + } + } + } + items(uiState.items, key = { it.sourceUrl }) { source -> RssSourceGridItem( modifier = Modifier.animateItem(), diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt index 2ce8bf638..1167b7cfb 100644 --- a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt @@ -4,8 +4,6 @@ import androidx.appcompat.app.AppCompatActivity 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -86,11 +84,7 @@ fun RuleSubScreen( RoundDropdownMenuItem( text = stringResource(R.string.sort), leadingIcon = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(12.dp)) - AppText(stringResource(R.string.sort)) - } + Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp)) }, onClick = { viewModel.resetOrder() @@ -231,12 +225,14 @@ fun RuleSubEditDialog( value = name, onValueChange = { name = it }, label = stringResource(R.string.name), + backgroundColor = LegadoTheme.colorScheme.onSheetContent, modifier = Modifier.fillMaxWidth() ) AppTextField( value = url, onValueChange = { url = it }, label = "URL", + backgroundColor = LegadoTheme.colorScheme.onSheetContent, modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt b/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt new file mode 100644 index 000000000..6c40259d7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt @@ -0,0 +1,81 @@ +package io.legado.app.ui.theme + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Applies a horizontal fading edge effect to a scrollable container. + * + * @param leftAlpha 0f = fully faded, 1f = fully visible + * @param rightAlpha 0f = fully faded, 1f = fully visible + * @param gradientWidth width of the fade gradient on each side + */ +fun Modifier.fadingEdge( + leftAlpha: Float, + rightAlpha: Float, + gradientWidth: Dp = 24.dp +): Modifier = graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + val width = size.width + val gradientWidthPx = gradientWidth.toPx() + val leftStop = gradientWidthPx / width + val rightStop = 1f - (gradientWidthPx / width) + drawRect( + brush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 1f - leftAlpha), + leftStop to Color.Black, + rightStop to Color.Black, + 1f to Color.Black.copy(alpha = 1f - rightAlpha) + ), + startX = 0f, + endX = width + ), + blendMode = BlendMode.DstIn + ) + } + +/** + * Convenience overload that derives fade alphas from a [LazyListState]. + * Left fade appears when scrolled past the start; right fade appears when more content is available. + */ +@Composable +fun Modifier.fadingEdge( + listState: LazyListState, + gradientWidth: Dp = 24.dp +): Modifier { + val showLeft by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val showRight by remember { + derivedStateOf { listState.canScrollForward } + } + val leftAlpha by animateFloatAsState( + targetValue = if (showLeft) 1f else 0f, + animationSpec = tween(200), + label = "LeftFadeAlpha" + ) + val rightAlpha by animateFloatAsState( + targetValue = if (showRight) 1f else 0f, + animationSpec = tween(200), + label = "RightFadeAlpha" + ) + return fadingEdge(leftAlpha, rightAlpha, gradientWidth) +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt index 85620c897..b17a540bc 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt @@ -93,7 +93,8 @@ fun MediumOutlinedIconButton( ) { Icon( imageVector = imageVector, - contentDescription = contentDescription + contentDescription = contentDescription, + tint = LegadoTheme.colorScheme.onSurface ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt index 2530a24ee..c302d1085 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt @@ -1,10 +1,9 @@ package io.legado.app.ui.widget.components.tabRow import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -12,6 +11,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.text.AppText import top.yukonga.miuix.kmp.basic.TabRowDefaults import top.yukonga.miuix.kmp.basic.TabRowWithContour @@ -20,7 +20,8 @@ fun AppTabRow( tabTitles: List, selectedTabIndex: Int, onTabSelected: (Int) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isScrollable: Boolean = true ) { val composeEngine = LegadoTheme.composeEngine @@ -36,29 +37,59 @@ fun AppTabRow( ) ) } else { - PrimaryScrollableTabRow( - selectedTabIndex = selectedTabIndex, - edgePadding = 0.dp, - divider = { }, - containerColor = Color.Transparent, - minTabWidth = 0.dp, - modifier = modifier - ) { - tabTitles.forEachIndexed { index, title -> - Tab( - selected = selectedTabIndex == index, - onClick = { onTabSelected(index) }, - text = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) + if (isScrollable) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp, + divider = { }, + containerColor = Color.Transparent, + minTabWidth = 0.dp, + modifier = modifier + ) { + tabTitles.forEachIndexed { index, title -> + AppTab( + selected = selectedTabIndex == index, + onClick = { onTabSelected(index) }, + title = title + ) + } + } + } else { + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + divider = { }, + containerColor = Color.Transparent, + modifier = modifier + ) { + tabTitles.forEachIndexed { index, title -> + AppTab( + selected = selectedTabIndex == index, + onClick = { onTabSelected(index) }, + title = title + ) + } } } } +} + +@Composable +private fun AppTab( + selected: Boolean, + onClick: () -> Unit, + title: String +) { + Tab( + selected = selected, + onClick = onClick, + text = { + AppText( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp), + color = if (selected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant + ) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt b/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt index 167664327..71e2bc5b4 100644 --- a/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ConvertExtensions.kt @@ -90,7 +90,7 @@ object ConvertUtils { //计算单位的,原理是利用lg,公式是 lg(1024^n) = nlg(1024),最后 nlg(1024)/lg(1024) = n。 val digitGroups = (log10(length.toDouble()) / log10(1024.0)).toInt() //计算原理是,size/单位值。单位值指的是:比如说b = 1024,KB = 1024^2 - return DecimalFormat("#,##0.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] + return DecimalFormat("#.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] } @JvmOverloads