From dee97bc034d0b04cd6eda91c93ad27a68839963d Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:14:37 +0700 Subject: [PATCH 01/17] fix: restore gamification progress calculation --- .../analytics/MascotProgressCalculator.kt | 21 ++++++++++++------- .../domain/analytics/MrComicMascotState.kt | 3 ++- .../analytics/MrComicMascotStateTest.kt | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MascotProgressCalculator.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MascotProgressCalculator.kt index e94f16b7e..832269a54 100644 --- a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MascotProgressCalculator.kt +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MascotProgressCalculator.kt @@ -1,19 +1,26 @@ package io.leostrange.mrcomic.core.domain.analytics import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.MascotStage import io.leostrange.mrcomic.core.model.MascotProgressState import io.leostrange.mrcomic.core.model.MascotStageTimelineEntry import io.leostrange.mrcomic.core.model.MascotStageTimeline import io.leostrange.mrcomic.core.model.MascotStageArchiveEntry import io.leostrange.mrcomic.core.model.MascotStageArchive +import javax.inject.Inject -typealias MascotStage = MascotStage -typealias MascotProgressState = MascotProgressState -typealias MascotStageTimelineEntry = MascotStageTimelineEntry -typealias MascotStageTimeline = MascotStageTimeline -typealias MascotStageArchiveEntry = MascotStageArchiveEntry -typealias MascotStageArchive = MascotStageArchive +/** Injectable facade for the pure mascot progress calculation. */ +class MascotProgressCalculator @Inject constructor() { + fun calculate(comics: List): MascotProgressState = calculateMascotProgress(comics) +} + +typealias MascotStage = io.leostrange.mrcomic.core.model.MascotStage +typealias MascotProgressState = io.leostrange.mrcomic.core.model.MascotProgressState +typealias MascotStageTimelineEntry = io.leostrange.mrcomic.core.model.MascotStageTimelineEntry +typealias MascotStageTimeline = io.leostrange.mrcomic.core.model.MascotStageTimeline +typealias MascotStageArchiveEntry = io.leostrange.mrcomic.core.model.MascotStageArchiveEntry +typealias MascotStageArchive = io.leostrange.mrcomic.core.model.MascotStageArchive fun calculateMascotProgress(comics: List): MascotProgressState { val approxPagesRead = comics.sumOf { comic -> approximateReadPages(comic) } @@ -128,7 +135,7 @@ fun resolveMascotStageArchive( private fun approximateReadPages(comic: Comic): Int = when { comic.isCompleted && comic.pageCount > 0 -> comic.pageCount comic.isCompleted -> maxOf(comic.currentPage + 1, 1) - comic.readingProgress <= 0f && comic.currentPage <= 0 && comic.lastReadDate == null -> 0 + comic.displayReadingProgress() <= 0f && comic.currentPage <= 0 && comic.lastReadDate == null -> 0 comic.pageCount > 0 -> (comic.currentPage + 1).coerceIn(1, comic.pageCount) else -> maxOf(comic.currentPage + 1, 1) } diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotState.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotState.kt index 5480d82d0..c8dee0cf3 100644 --- a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotState.kt +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotState.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.core.domain.analytics import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress enum class MrComicMascotContext { HOME, @@ -48,7 +49,7 @@ fun resolveMrComicMascotState( acknowledgedStageName: String? = null, previewEnabled: Boolean = true ): MrComicMascotState { - val hasActiveRead = recentComic?.readingProgress?.let { it in 0.05f..0.98f } == true + val hasActiveRead = recentComic?.displayReadingProgress()?.let { it in 0.05f..0.98f } == true val hasArchiveTrail = bookmarkedTitles >= 2 || quotesCount >= 3 val hasAchievementShelf = secretUnlocked || (totalCount > 0 && unlockedCount * 2 >= totalCount) val hasLockedRhythm = goalState.enabled && diff --git a/android/core-domain/src/test/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotStateTest.kt b/android/core-domain/src/test/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotStateTest.kt index c6029eb80..f70649beb 100644 --- a/android/core-domain/src/test/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotStateTest.kt +++ b/android/core-domain/src/test/java/io/leostrange/mrcomic/core/domain/analytics/MrComicMascotStateTest.kt @@ -29,7 +29,7 @@ class MrComicMascotStateTest { progress = MascotProgressState(stage = MascotStage.TEEN), totalTitles = 4, completedTitles = 1, - recentComic = Comic(title = "Test", readingProgress = 0.42f) + recentComic = Comic(title = "Test", readingProgress = 0.42f, lastReadDate = System.currentTimeMillis()) ) assertEquals(MrComicMascotMood.LOCKED_IN, state.mood) From ff9b983c55b4e9f8b6a47ce78d4048d7f1227ce3 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:15:06 +0700 Subject: [PATCH 02/17] fix: restore readable library cover labels --- .../library/components/ComicGridItem.kt | 80 +++++++++++-------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt index 261d46e17..46fb39414 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.feature.library.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,6 +19,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -26,7 +28,6 @@ import io.leostrange.mrcomic.core.model.ComicReadingStatus import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.readingStatus import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface -import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill import io.leostrange.mrcomic.core.ui.designsystem.MrComicProgressLine import io.leostrange.mrcomic.core.ui.designsystem.MrComicStatusBadge import io.leostrange.mrcomic.core.ui.designsystem.MrComicStatusTone @@ -221,6 +222,7 @@ private fun GridCard( comic = comic, shape = cardShape, graphicCoverStyle = graphicCoverStyle, + showCompletedFold = false, modifier = Modifier.fillMaxSize() ) GridCardBadges( @@ -260,7 +262,6 @@ private fun BoxScope.GridCardBadges( readingStatus = readingStatus, readingProgress = comic.displayReadingProgress() ) - val titleBottomPadding = 8.dp // Format badge — top-left corner (design system spec) if (formatLabel != null) { FormatBadge( @@ -272,52 +273,61 @@ private fun BoxScope.GridCardBadges( ) } if (showCoverTitles) { - MrComicPill( + // Keep the title backing opaque enough to remain readable on artwork. + // A transparent gradient lets the title collide with the cover/icon. + val titlePanelColor = MaterialTheme.colorScheme.surface.copy( + alpha = (0.92f + titlePanelOpacity.coerceIn(0f, 1f) * 0.08f).coerceIn(0.92f, 1f) + ) + val scaledFontSize = 12.sp * titleScale.coerceIn(0.85f, 1.3f) + Column( modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 6.dp, end = 6.dp, bottom = titleBottomPadding) - .fillMaxWidth(0.88f), - containerColor = MaterialTheme.colorScheme.surface.copy( - alpha = (0.82f + titlePanelOpacity.coerceIn(0.18f, 0.78f) * 0.12f).coerceIn(0.84f, 0.94f) - ), - border = BorderStroke( - 0.6.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f) - ), - contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.96f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(titlePanelColor) + .padding(horizontal = 8.dp, vertical = 5.dp) ) { Text( text = comic.title, - style = MaterialTheme.typography.labelMedium.copy(fontSize = (12.sp * titleScale.coerceIn(0.85f, 1.3f))), + style = MaterialTheme.typography.labelLarge.copy( + fontSize = scaledFontSize, + lineHeight = scaledFontSize * 1.32f, + fontWeight = FontWeight.SemiBold + ), maxLines = titleLines.coerceIn(1, 3), overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.96f) + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.97f) ) } } - if (showCompletedChip) { - val completedColor = mrComicCompletedColor() - MrComicStatusBadge( - text = "100%", - tone = MrComicStatusTone.Success, - leadingIcon = Icons.Filled.CheckCircle, - contentDescription = strings.libraryStatusCompleted, + if (showCompletedChip || showProgressChip) { + // BUG-B2: All top-end badges share a Row to prevent overlap when + // multiple indicators are present simultaneously. + Row( modifier = Modifier .align(Alignment.TopEnd) .padding(end = 6.dp, top = 6.dp), - containerColor = completedColor.copy(alpha = 0.18f), - contentColor = completedColor - ) - } else if (showProgressChip) { - MrComicStatusBadge( - text = "${(comic.displayReadingProgress() * 100).toInt()}%", - tone = MrComicStatusTone.Info, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(end = 6.dp, top = 6.dp) - ) + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (showCompletedChip) { + val completedColor = mrComicCompletedColor() + MrComicStatusBadge( + text = "100%", + tone = MrComicStatusTone.Success, + leadingIcon = Icons.Filled.CheckCircle, + contentDescription = strings.libraryStatusCompleted, + containerColor = completedColor.copy(alpha = 0.18f), + contentColor = completedColor + ) + } else if (showProgressChip) { + // BUG-B1: white surface background instead of Info tone gray. + MrComicStatusBadge( + text = "${(comic.displayReadingProgress() * 100).toInt()}%", + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + } } if (showProgressLine) { From 97cff928b7f8627379b07375c987ee02add2d280 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:15:43 +0700 Subject: [PATCH 03/17] feat: add dictionary module import and export --- .../data/dictionary/DictionaryAssetCatalog.kt | 27 +- .../data/dictionary/DictionaryDownloader.kt | 236 +++++++- .../DictionaryDownloaderGzipAndSqliteTest.kt | 104 ++++ .../dictionary/DictionaryDownloaderTest.kt | 4 +- .../mrcomic/core/ui/locale/AppStrings.kt | 56 +- .../core/ui/locale/DictionaryStrings.kt | 256 ++++++++ .../settings/ui/SettingsDictionarySection.kt | 562 +++++++++++------- .../feature/settings/ui/SettingsViewModel.kt | 18 +- .../ui/SettingsViewModelDictionary.kt | 452 ++++++++++++++ .../settings/ui/DictionaryImportKindTest.kt | 30 + 10 files changed, 1489 insertions(+), 256 deletions(-) create mode 100644 android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderGzipAndSqliteTest.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt create mode 100644 android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt create mode 100644 android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/DictionaryImportKindTest.kt diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryAssetCatalog.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryAssetCatalog.kt index a2e7b5c03..060e1343c 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryAssetCatalog.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryAssetCatalog.kt @@ -1,24 +1,27 @@ package io.leostrange.mrcomic.core.data.dictionary -internal data class DictionaryAssetConfig( +/** Public: consumed by feature-settings dictionary management UI. */ +data class DictionaryAssetConfig( val language: String, val assetPath: String, val databaseName: String, val extractedFileName: String, + /** Approximate download size in bytes (compressed, from v2.3.0 release). */ + val approxDownloadBytes: Long = 0L, ) -internal object DictionaryAssetCatalog { +object DictionaryAssetCatalog { private val configs = listOf( - DictionaryAssetConfig("en", "databases/dictionary_en.dbpack", "dictionary_en_room_asset_v3.db", "dictionary_en_room_asset_v3.db"), - DictionaryAssetConfig("fr", "databases/dictionary_fr.dbpack", "dictionary_fr_room_asset_v3.db", "dictionary_fr_room_asset_v3.db"), - DictionaryAssetConfig("it", "databases/dictionary_it.dbpack", "dictionary_it_room_asset_v3.db", "dictionary_it_room_asset_v3.db"), - DictionaryAssetConfig("ja", "databases/dictionary_ja.dbpack", "dictionary_ja_room_asset_v3.db", "dictionary_ja_room_asset_v3.db"), - DictionaryAssetConfig("ko", "databases/dictionary_ko.dbpack", "dictionary_ko_room_asset_v3.db", "dictionary_ko_room_asset_v3.db"), - DictionaryAssetConfig("pl", "databases/dictionary_pl.dbpack", "dictionary_pl_room_asset_v3.db", "dictionary_pl_room_asset_v3.db"), - DictionaryAssetConfig("pt", "databases/dictionary_pt.dbpack", "dictionary_pt_room_asset_v3.db", "dictionary_pt_room_asset_v3.db"), - DictionaryAssetConfig("ru", "databases/dictionary_ru.dbpack", "dictionary_ru_room_asset_v3.db", "dictionary_ru_room_asset_v3.db"), - DictionaryAssetConfig("tr", "databases/dictionary_tr.dbpack", "dictionary_tr_room_asset_v3.db", "dictionary_tr_room_asset_v3.db"), - DictionaryAssetConfig("zh", "databases/dictionary_zh.dbpack", "dictionary_zh_room_asset_v3.db", "dictionary_zh_room_asset_v3.db"), + DictionaryAssetConfig("en", "databases/dictionary_en.dbpack", "dictionary_en_room_asset_v3.db", "dictionary_en_room_asset_v3.db", approxDownloadBytes = 18_800_000L), + DictionaryAssetConfig("fr", "databases/dictionary_fr.dbpack", "dictionary_fr_room_asset_v3.db", "dictionary_fr_room_asset_v3.db", approxDownloadBytes = 310_600_000L), + DictionaryAssetConfig("it", "databases/dictionary_it.dbpack", "dictionary_it_room_asset_v3.db", "dictionary_it_room_asset_v3.db", approxDownloadBytes = 36_400_000L), + DictionaryAssetConfig("ja", "databases/dictionary_ja.dbpack", "dictionary_ja_room_asset_v3.db", "dictionary_ja_room_asset_v3.db", approxDownloadBytes = 59_700_000L), + DictionaryAssetConfig("ko", "databases/dictionary_ko.dbpack", "dictionary_ko_room_asset_v3.db", "dictionary_ko_room_asset_v3.db", approxDownloadBytes = 15_200_000L), + DictionaryAssetConfig("pl", "databases/dictionary_pl.dbpack", "dictionary_pl_room_asset_v3.db", "dictionary_pl_room_asset_v3.db", approxDownloadBytes = 42_000_000L), + DictionaryAssetConfig("pt", "databases/dictionary_pt.dbpack", "dictionary_pt_room_asset_v3.db", "dictionary_pt_room_asset_v3.db", approxDownloadBytes = 39_100_000L), + DictionaryAssetConfig("ru", "databases/dictionary_ru.dbpack", "dictionary_ru_room_asset_v3.db", "dictionary_ru_room_asset_v3.db", approxDownloadBytes = 141_800_000L), + DictionaryAssetConfig("tr", "databases/dictionary_tr.dbpack", "dictionary_tr_room_asset_v3.db", "dictionary_tr_room_asset_v3.db", approxDownloadBytes = 34_800_000L), + DictionaryAssetConfig("zh", "databases/dictionary_zh.dbpack", "dictionary_zh_room_asset_v3.db", "dictionary_zh_room_asset_v3.db", approxDownloadBytes = 19_200_000L), ) fun configForLanguage(language: String): DictionaryAssetConfig? = diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt index 6dd37f44b..4a828a2a0 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt @@ -5,12 +5,25 @@ import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream import java.net.HttpURLConnection import java.net.URL import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream import javax.inject.Inject import javax.inject.Singleton +/** + * Information about an installed dictionary. + */ +data class DictionaryInstallInfo( + val language: String, + val isBundled: Boolean, + val downloadedFile: File?, + val sizeBytes: Long, +) + /** * Downloads dictionary databases from GitHub Releases on first use. * @@ -64,15 +77,15 @@ class DictionaryDownloader @Inject constructor( val downloadUrl = buildReleaseUrl(config) Log.i(TAG, "Downloading dictionary for ${config.language} from $downloadUrl") - val compressedFile = File(downloadsDir, "${config.language}.dbpack.gz") - downloadFile(downloadUrl, compressedFile, onProgress) + val downloadedFile = File(downloadsDir, "${config.language}.dbpack") + downloadFile(downloadUrl, downloadedFile, onProgress) onProgress?.invoke(95) val extractedFile = File(extractedDir, config.extractedFileName) - extractGzip(compressedFile, extractedFile) + extractDatabase(downloadedFile, extractedFile) - // Clean up compressed file - compressedFile.delete() + // Clean up downloaded file + downloadedFile.delete() onProgress?.invoke(100) Log.i(TAG, "Successfully downloaded and extracted dictionary for ${config.language}") @@ -84,8 +97,10 @@ class DictionaryDownloader @Inject constructor( } private fun buildReleaseUrl(config: DictionaryAssetConfig): String { - // Format: https://github.com/Leostrange/Mr.Comic/releases/download/v2.3.0/dictionary_en.dbpack.gz - return "https://github.com/Leostrange/Mr.Comic/releases/download/$DICTIONARY_RELEASE_TAG/${config.language}.dbpack.gz" + // Release v2.3.0 assets are published as `dictionary_.dbpack` (plain). + // Keep the gzip auto-detection in extractDatabase so future .gz uploads + // continue to work without a code change. + return "https://github.com/Leostrange/Mr.Comic/releases/download/$DICTIONARY_RELEASE_TAG/dictionary_${config.language}.dbpack" } private fun downloadFile( @@ -124,12 +139,18 @@ class DictionaryDownloader @Inject constructor( } } - private fun extractGzip(compressedFile: File, targetFile: File) { - compressedFile.inputStream().use { raw -> - GZIPInputStream(raw).use { gzip -> - FileOutputStream(targetFile).use { out -> - gzip.copyTo(out) - } + /** + * Extracts the downloaded database into [targetFile]. + * Detects gzip by magic bytes (1F 8B) so both plain .dbpack and + * gzipped uploads are supported transparently. + */ + private fun extractDatabase(downloadedFile: File, targetFile: File) { + val gzipped = downloadedFile.inputStream().use { isGzip(it) } + downloadedFile.inputStream().use { raw -> + if (gzipped) { + GZIPInputStream(raw).use { gzip -> FileOutputStream(targetFile).use { out -> gzip.copyTo(out) } } + } else { + FileOutputStream(targetFile).use { out -> raw.copyTo(out) } } } } @@ -139,8 +160,197 @@ class DictionaryDownloader @Inject constructor( true }.getOrDefault(false) + // ───────────────────────────────────────────────────────────────────────── + // Public API: dictionary management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Returns info about every shipped dictionary — whether it is bundled, + * downloaded, and the size of the on-disk file (or zero for not-yet-fetched). + */ + fun installedDictionaries(): List { + return DictionaryAssetCatalog.shippedLanguages().map { lang -> + val config = DictionaryAssetCatalog.configForLanguage(lang)!! + val extractedFile = File(extractedDir, config.extractedFileName) + val isExtracted = extractedFile.exists() && extractedFile.length() > 0L + val hasBundled = hasAsset(config.assetPath) + val downloadedFile = File(downloadsDir, "$lang.dbpack") + val downloadedExists = downloadedFile.exists() && downloadedFile.length() > 0L + val sizeBytes = when { + isExtracted -> extractedFile.length() + downloadedExists -> downloadedFile.length() + else -> 0L + } + DictionaryInstallInfo( + language = lang, + isBundled = hasBundled, + downloadedFile = if (downloadedExists) downloadedFile else null, + sizeBytes = sizeBytes, + ) + } + } + + /** + * Deletes the extracted database and any downloaded .dbpack for the given language. + * Note: does NOT check if a download is currently in progress — callers should + * guard via [DictionaryOperationState] before calling. + * + * @return true if at least one file was deleted + */ + fun deleteDictionary(language: String): Boolean { + val config = DictionaryAssetCatalog.configForLanguage(language) ?: return false + val extractedFile = File(extractedDir, config.extractedFileName) + val downloadedFile = File(downloadsDir, "$language.dbpack") + var deleted = false + if (extractedFile.exists()) { extractedFile.delete(); deleted = true } + if (downloadedFile.exists()) { downloadedFile.delete(); deleted = true } + return deleted + } + + /** + * Exports the extracted database for [language] as gzip into [target]. + * The caller is responsible for closing the stream. + * + * @return true if the export succeeded + */ + fun exportDictionary(language: String, target: OutputStream): Boolean { + val config = DictionaryAssetCatalog.configForLanguage(language) ?: return false + val extractedFile = File(extractedDir, config.extractedFileName) + if (!extractedFile.exists() || extractedFile.length() == 0L) return false + return try { + extractedFile.inputStream().use { input -> + GZIPOutputStream(target).use { gzip -> + input.copyTo(gzip) + } + } + true + } catch (e: Exception) { + Log.e(TAG, "Failed to export dictionary for $language", e) + false + } + } + + /** + * Imports a dictionary from [source] for the given [language]. + * The source may be gzip-compressed (auto-detected via magic bytes) or plain SQLite. + * The extracted file is validated to be a SQLite database (header starts with "SQLite format 3"). + * + * @return The extracted File on success, or null if the source is invalid / not SQLite. + */ + fun importDictionary(language: String, source: InputStream): File? { + val config = DictionaryAssetCatalog.configForLanguage(language) ?: return null + val tempFile = File(downloadsDir, "${language}_import_tmp.dbpack") + val extractedFile = File(extractedDir, config.extractedFileName) + val stagedExtractedFile = File(extractedDir, "${language}_import_tmp.db") + val backupFile = File(extractedDir, "${language}_import_backup.db") + return try { + // Write source to temp file + tempFile.outputStream().use { out -> source.copyTo(out) } + if (tempFile.length() == 0L) { + tempFile.delete() + return null + } + + // Detect gzip and decompress + val isGz = tempFile.inputStream().use { isGzip(it) } + extractedFile.parentFile?.mkdirs() + stagedExtractedFile.delete() + backupFile.delete() + + tempFile.inputStream().use { raw -> + if (isGz) { + GZIPInputStream(raw).use { gzip -> + FileOutputStream(stagedExtractedFile).use { out -> gzip.copyTo(out) } + } + } else { + FileOutputStream(stagedExtractedFile).use { out -> raw.copyTo(out) } + } + } + + // Validate SQLite header + if (!isValidSqlite(stagedExtractedFile)) { + stagedExtractedFile.delete() + tempFile.delete() + Log.w(TAG, "Imported file for $language is not a valid SQLite database") + return null + } + + // Keep the old database recoverable until the staged replacement + // has been installed successfully. + if (extractedFile.exists() && !extractedFile.renameTo(backupFile)) { + throw IllegalStateException("Could not stage existing dictionary") + } + if (!stagedExtractedFile.renameTo(extractedFile)) { + if (backupFile.exists()) backupFile.renameTo(extractedFile) + throw IllegalStateException("Could not install imported dictionary") + } + backupFile.delete() + + tempFile.delete() + Log.i(TAG, "Successfully imported dictionary for $language") + extractedFile + } catch (e: Exception) { + Log.e(TAG, "Failed to import dictionary for $language", e) + tempFile.delete() + stagedExtractedFile.delete() + if (!extractedFile.exists() && backupFile.exists()) { + backupFile.renameTo(extractedFile) + } + backupFile.delete() + null + } + } + + /** + * Sum of all extracted dictionary file sizes in bytes. + */ + fun dictionariesTotalSizeBytes(): Long { + var total = 0L + for (lang in DictionaryAssetCatalog.shippedLanguages()) { + val config = DictionaryAssetCatalog.configForLanguage(lang) ?: continue + val extractedFile = File(extractedDir, config.extractedFileName) + if (extractedFile.exists()) total += extractedFile.length() + } + return total + } + companion object { private const val TAG = "DictionaryDownloader" private const val DICTIONARY_RELEASE_TAG = "v2.3.0" + + /** + * Detects gzip magic bytes (0x1F, 0x8B) on a stream. + * Uses a BufferedInputStream so the peeked bytes are not lost. + * The stream is NOT closed — the caller owns it. + */ + internal fun isGzip(stream: InputStream): Boolean { + val buffered = if (stream is java.io.BufferedInputStream) stream else java.io.BufferedInputStream(stream) + buffered.mark(2) + val b1 = buffered.read() + val b2 = buffered.read() + buffered.reset() + return b1 == 0x1F && b2 == 0x8B + } + + /** + * Validates that the file starts with the SQLite header "SQLite format 3\000". + * The magic string is exactly 16 bytes including the trailing NUL. + */ + internal fun isValidSqlite(file: File): Boolean { + return try { + val expected = byteArrayOf( + 0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, // "SQLite f" + 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00, // "ormat 3\0" + ) + file.inputStream().use { input -> + val header = ByteArray(16) + val read = input.read(header) + if (read < 16) return false + header.contentEquals(expected) + } + } catch (_: Exception) { + false + } + } } } diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderGzipAndSqliteTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderGzipAndSqliteTest.kt new file mode 100644 index 000000000..8852f9d62 --- /dev/null +++ b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderGzipAndSqliteTest.kt @@ -0,0 +1,104 @@ +package io.leostrange.mrcomic.core.data.dictionary + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.GZIPOutputStream + +/** + * Unit tests for gzip detection and SQLite validation logic in [DictionaryDownloader]. + * These test the pure static helpers and the import-validation path without + * requiring Robolectric or a full Context. + */ +class DictionaryDownloaderGzipAndSqliteTest { + + // ── isGzip ─────────────────────────────────────────────────────────── + + @Test + fun isGzip_returnsTrueForGzippedContent() { + val payload = "hello world".toByteArray() + val compressed = ByteArrayOutputStream().use { bos -> + GZIPOutputStream(bos).use { it.write(payload) } + bos.toByteArray() + } + val stream = ByteArrayInputStream(compressed) + assertTrue(DictionaryDownloader.isGzip(stream)) + } + + @Test + fun isGzip_returnsFalseForPlainSqliteHeader() { + val header = SQLITE_HEADER.copyOf() + val stream = ByteArrayInputStream(header) + assertFalse(DictionaryDownloader.isGzip(stream)) + } + + @Test + fun isGzip_returnsFalseForEmptyStream() { + val stream = ByteArrayInputStream(ByteArray(0)) + assertFalse(DictionaryDownloader.isGzip(stream)) + } + + @Test + fun isGzip_returnsFalseForArbitraryBytes() { + val stream = ByteArrayInputStream(byteArrayOf(0x00, 0x01, 0x02)) + assertFalse(DictionaryDownloader.isGzip(stream)) + } + + @Test + fun isGzip_returnsTrueForOnlyMagicBytes() { + // Only the two gzip magic bytes — valid enough for detection + val stream = ByteArrayInputStream(byteArrayOf(0x1F.toByte(), 0x8B.toByte())) + assertTrue(DictionaryDownloader.isGzip(stream)) + } + + // ── isValidSqlite ──────────────────────────────────────────────────── + + @Test + fun isValidSqlite_returnsTrueForCorrectHeader() { + val file = writeTempFile(SQLITE_HEADER) + assertTrue(DictionaryDownloader.isValidSqlite(file)) + file.delete() + } + + @Test + fun isValidSqlite_returnsFalseForGzipHeader() { + val gzipMagic = byteArrayOf(0x1F.toByte(), 0x8B.toByte(), 0x08, 0x00, 0x00, 0x00, 0x00, 0x00) + val file = writeTempFile(gzipMagic + ByteArray(8)) + assertFalse(DictionaryDownloader.isValidSqlite(file)) + file.delete() + } + + @Test + fun isValidSqlite_returnsFalseForTooShortFile() { + val file = writeTempFile(ByteArray(4)) + assertFalse(DictionaryDownloader.isValidSqlite(file)) + file.delete() + } + + @Test + fun isValidSqlite_returnsFalseForWrongText() { + val content = "Not a database!!".toByteArray(Charsets.US_ASCII) + val file = writeTempFile(content) + assertFalse(DictionaryDownloader.isValidSqlite(file)) + file.delete() + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private fun writeTempFile(content: ByteArray): File { + val file = File.createTempFile("sqlite_test_", ".db", null) + file.writeBytes(content) + return file + } + + companion object { + /** The exact 16-byte SQLite header magic. */ + private val SQLITE_HEADER = byteArrayOf( + 0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, // "SQLite f" + 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00, // "ormat 3\0" + ) + } +} diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderTest.kt index 002a9109a..df85e07c7 100644 --- a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderTest.kt +++ b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloaderTest.kt @@ -19,8 +19,8 @@ class DictionaryDownloaderTest { extractedFileName = "dictionary_en_room_asset_v3.db" ) - // Expected URL format - val expectedUrl = "https://github.com/Leostrange/Mr.Comic/releases/download/v2.3.0/en.dbpack.gz" + // Expected URL format: release assets are published as dictionary_.dbpack + val expectedUrl = "https://github.com/Leostrange/Mr.Comic/releases/download/v2.3.0/dictionary_en.dbpack" // We can't easily test the private buildReleaseUrl method without reflection // But we can verify the config structure is correct diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt index b59920c41..c2e116db5 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt @@ -24,22 +24,6 @@ data class AppStrings( val actionSquare: String, val actionFile: String, val actionFolder: String, - val opdsCatalog: String, - val opdsCatalogs: String, - val opdsSearch: String, - val opdsSearchPlaceholder: String, - val opdsCatalogPickerTitle: String, - val opdsCategories: String, - val opdsBooks: String, - val opdsLoadMore: String, - val opdsDownload: String, - val opdsRetry: String, - val opdsProjectGutenberg: String, - val opdsProjectGutenbergDescription: String, - val opdsFeedbooks: String, - val opdsFeedbooksDescription: String, - val opdsManyBooks: String, - val opdsManyBooksDescription: String, val readerPages: String, val readerBookmark: String, val readerBookmarked: String, @@ -289,7 +273,45 @@ data class AppStrings( val achSecretCat: String, val achSecretCatDesc: String, val achSecretHint: String -) +) { + + // ──── Dictionary strings: delegated to DictionaryStrings (JVM 254-param ctor limit fix) ──── + + private val dictStrings: DictionaryStrings by lazy { DictionaryStrings.forLanguage(languageCode) } + val dictSectionTitle: String get() = dictStrings.dictSectionTitle + val dictSectionHint: String get() = dictStrings.dictSectionHint + val dictInstalledLabel: String get() = dictStrings.dictInstalledLabel + val dictTotalSizeLabel: String get() = dictStrings.dictTotalSizeLabel + val dictLangEnglish: String get() = dictStrings.dictLangEnglish + val dictLangFrench: String get() = dictStrings.dictLangFrench + val dictLangItalian: String get() = dictStrings.dictLangItalian + val dictLangJapanese: String get() = dictStrings.dictLangJapanese + val dictLangKorean: String get() = dictStrings.dictLangKorean + val dictLangPolish: String get() = dictStrings.dictLangPolish + val dictLangPortuguese: String get() = dictStrings.dictLangPortuguese + val dictLangRussian: String get() = dictStrings.dictLangRussian + val dictLangTurkish: String get() = dictStrings.dictLangTurkish + val dictLangChinese: String get() = dictStrings.dictLangChinese + val dictStatusBundled: String get() = dictStrings.dictStatusBundled + val dictStatusInstalled: String get() = dictStrings.dictStatusInstalled + val dictStatusNotInstalled: String get() = dictStrings.dictStatusNotInstalled + val dictBtnDownload: String get() = dictStrings.dictBtnDownload + val dictBtnDelete: String get() = dictStrings.dictBtnDelete + val dictBtnExport: String get() = dictStrings.dictBtnExport + val dictConfirmDownloadTitle: String get() = dictStrings.dictConfirmDownloadTitle + val dictConfirmDownloadMessage: String get() = dictStrings.dictConfirmDownloadMessage + val dictBtnDownloadAll: String get() = dictStrings.dictBtnDownloadAll + val dictBtnExportAll: String get() = dictStrings.dictBtnExportAll + val dictBtnImport: String get() = dictStrings.dictBtnImport + val dictImportSelectLanguage: String get() = dictStrings.dictImportSelectLanguage + val dictOpDeleting: String get() = dictStrings.dictOpDeleting + val dictOpImporting: String get() = dictStrings.dictOpImporting + val dictOpExporting: String get() = dictStrings.dictOpExporting + val dictOpError: String get() = dictStrings.dictOpError + val dictImportSuccess: String get() = dictStrings.dictImportSuccess + val dictImportInvalidFile: String get() = dictStrings.dictImportInvalidFile + val dictExportSuccess: String get() = dictStrings.dictExportSuccess +} // ───────────────────────────────────────────────────────────────────────────── // CompositionLocal diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt new file mode 100644 index 000000000..b70e967c8 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt @@ -0,0 +1,256 @@ +package io.leostrange.mrcomic.core.ui.locale + +/** + * Dictionary section strings, split out of [AppStrings] because its primary + * constructor exceeded the JVM 254-parameter limit (ClassFormatError: + * "Too many arguments in method signature"). Accessed through delegation + * properties on AppStrings, so `strings.dictXxx` call sites are unchanged. + */ +data class DictionaryStrings( + /** "Dictionaries" */ + val dictSectionTitle: String, + /** Section hint / description */ + val dictSectionHint: String, + /** Installed count label */ + val dictInstalledLabel: String, + /** Total size label */ + val dictTotalSizeLabel: String, + /** Language name: English */ + val dictLangEnglish: String, + /** Language name: French */ + val dictLangFrench: String, + /** Language name: Italian */ + val dictLangItalian: String, + /** Language name: Japanese */ + val dictLangJapanese: String, + /** Language name: Korean */ + val dictLangKorean: String, + /** Language name: Polish */ + val dictLangPolish: String, + /** Language name: Portuguese */ + val dictLangPortuguese: String, + /** Language name: Russian */ + val dictLangRussian: String, + /** Language name: Turkish */ + val dictLangTurkish: String, + /** Language name: Chinese */ + val dictLangChinese: String, + /** Status chip: Bundled */ + val dictStatusBundled: String, + /** Status chip: Installed */ + val dictStatusInstalled: String, + /** Status chip: Not installed */ + val dictStatusNotInstalled: String, + /** Download button label */ + val dictBtnDownload: String, + /** Delete button label */ + val dictBtnDelete: String, + /** Export button label */ + val dictBtnExport: String, + /** Download confirmation dialog title template (%s = language name) */ + val dictConfirmDownloadTitle: String, + /** Download confirmation dialog message template (%s = size) */ + val dictConfirmDownloadMessage: String, + /** Button: Download all */ + val dictBtnDownloadAll: String, + /** Button: Export all installed */ + val dictBtnExportAll: String, + /** Button: Import */ + val dictBtnImport: String, + /** Import prompt: choose language */ + val dictImportSelectLanguage: String, + /** Operation state: Deleting */ + val dictOpDeleting: String, + /** Operation state: Importing */ + val dictOpImporting: String, + /** Operation state: Exporting */ + val dictOpExporting: String, + /** Operation error */ + val dictOpError: String, + /** Import success */ + val dictImportSuccess: String, + /** Import failure (not SQLite) */ + val dictImportInvalidFile: String, + /** Export success */ + val dictExportSuccess: String, +) { + companion object { + fun forLanguage(languageCode: String): DictionaryStrings = when (normalizeAppLanguageCode(languageCode)) { + "en" -> DictionaryStrings( + dictSectionTitle = "Dictionaries", + dictSectionHint = "Download offline dictionaries for translation and lookup.", + dictInstalledLabel = "Installed", + dictTotalSizeLabel = "Total size", + dictLangEnglish = "English", + dictLangFrench = "French", + dictLangItalian = "Italian", + dictLangJapanese = "Japanese", + dictLangKorean = "Korean", + dictLangPolish = "Polish", + dictLangPortuguese = "Portuguese", + dictLangRussian = "Russian", + dictLangTurkish = "Turkish", + dictLangChinese = "Chinese", + dictStatusBundled = "Bundled", + dictStatusInstalled = "Installed", + dictStatusNotInstalled = "Not installed", + dictBtnDownload = "Download", + dictBtnDelete = "Delete", + dictBtnExport = "Export", + dictConfirmDownloadTitle = "Download dictionary?", + dictConfirmDownloadMessage = "Download %s dictionary? ~%s", + dictBtnDownloadAll = "Download all", + dictBtnExportAll = "Export all", + dictBtnImport = "Import", + dictImportSelectLanguage = "Select language", + dictOpDeleting = "Deleting…", + dictOpImporting = "Importing…", + dictOpExporting = "Exporting…", + dictOpError = "Error", + dictImportSuccess = "Dictionary imported successfully", + dictImportInvalidFile = "Invalid dictionary file", + dictExportSuccess = "Export completed", + ) + "ja" -> DictionaryStrings( + dictSectionTitle = "辞書", + dictSectionHint = "翻訳・辞書検索用のオフライン辞書をダウンロードします。", + dictInstalledLabel = "インストール済み", + dictTotalSizeLabel = "合計サイズ", + dictLangEnglish = "英語", + dictLangFrench = "フランス語", + dictLangItalian = "イタリア語", + dictLangJapanese = "日本語", + dictLangKorean = "韓国語", + dictLangPolish = "ポーランド語", + dictLangPortuguese = "ポルトガル語", + dictLangRussian = "ロシア語", + dictLangTurkish = "トルコ語", + dictLangChinese = "中国語", + dictStatusBundled = "バンドル済み", + dictStatusInstalled = "インストール済み", + dictStatusNotInstalled = "未インストール", + dictBtnDownload = "ダウンロード", + dictBtnDelete = "削除", + dictBtnExport = "エクスポート", + dictConfirmDownloadTitle = "辞書をダウンロードしますか?", + dictConfirmDownloadMessage = "辞書「%s」をダウンロードしますか? 約%s", + dictBtnDownloadAll = "すべてダウンロード", + dictBtnExportAll = "すべてエクスポート", + dictBtnImport = "インポート", + dictImportSelectLanguage = "言語を選択", + dictOpDeleting = "削除中…", + dictOpImporting = "インポート中…", + dictOpExporting = "エクスポート中…", + dictOpError = "エラー", + dictImportSuccess = "辞書のインポートが完了しました", + dictImportInvalidFile = "無効な辞書ファイルです", + dictExportSuccess = "エクスポートが完了しました", + ) + "zh" -> DictionaryStrings( + dictSectionTitle = "词典", + dictSectionHint = "下载离线词典用于翻译和查询。", + dictInstalledLabel = "已安装", + dictTotalSizeLabel = "总大小", + dictLangEnglish = "英语", + dictLangFrench = "法语", + dictLangItalian = "意大利语", + dictLangJapanese = "日语", + dictLangKorean = "韩语", + dictLangPolish = "波兰语", + dictLangPortuguese = "葡萄牙语", + dictLangRussian = "俄语", + dictLangTurkish = "土耳其语", + dictLangChinese = "中文", + dictStatusBundled = "内置", + dictStatusInstalled = "已安装", + dictStatusNotInstalled = "未安装", + dictBtnDownload = "下载", + dictBtnDelete = "删除", + dictBtnExport = "导出", + dictConfirmDownloadTitle = "下载词典?", + dictConfirmDownloadMessage = "下载词典「%s」?约%s", + dictBtnDownloadAll = "全部下载", + dictBtnExportAll = "全部导出", + dictBtnImport = "导入", + dictImportSelectLanguage = "选择语言", + dictOpDeleting = "删除中…", + dictOpImporting = "导入中…", + dictOpExporting = "导出中…", + dictOpError = "错误", + dictImportSuccess = "词典导入成功", + dictImportInvalidFile = "无效的词典文件", + dictExportSuccess = "导出完成", + ) + "ko" -> DictionaryStrings( + dictSectionTitle = "사전", + dictSectionHint = "번역 및 조회를 위한 오프라인 사전을 다운로드합니다.", + dictInstalledLabel = "설치됨", + dictTotalSizeLabel = "총 크기", + dictLangEnglish = "영어", + dictLangFrench = "프랑스어", + dictLangItalian = "이탈리아어", + dictLangJapanese = "일본어", + dictLangKorean = "한국어", + dictLangPolish = "폴란드어", + dictLangPortuguese = "포르투갈어", + dictLangRussian = "러시아어", + dictLangTurkish = "터키어", + dictLangChinese = "중국어", + dictStatusBundled = "번들 포함", + dictStatusInstalled = "설치됨", + dictStatusNotInstalled = "미설치", + dictBtnDownload = "다운로드", + dictBtnDelete = "삭제", + dictBtnExport = "내보내기", + dictConfirmDownloadTitle = "사전을 다운로드하시겠습니까?", + dictConfirmDownloadMessage = "사전 '%s'을(를) 다운로드하시겠습니까? 약 %s", + dictBtnDownloadAll = "모두 다운로드", + dictBtnExportAll = "모두 내보내기", + dictBtnImport = "가져오기", + dictImportSelectLanguage = "언어 선택", + dictOpDeleting = "삭제 중…", + dictOpImporting = "가져오는 중…", + dictOpExporting = "내보내는 중…", + dictOpError = "오류", + dictImportSuccess = "사전 가져오기 완료", + dictImportInvalidFile = "유효하지 않은 사전 파일", + dictExportSuccess = "내보내기 완료", + ) + else -> DictionaryStrings( + dictSectionTitle = "Словари", + dictSectionHint = "Скачать оффлайн-словари для перевода и поиска.", + dictInstalledLabel = "Установлено", + dictTotalSizeLabel = "Общий размер", + dictLangEnglish = "Английский", + dictLangFrench = "Французский", + dictLangItalian = "Итальянский", + dictLangJapanese = "Японский", + dictLangKorean = "Корейский", + dictLangPolish = "Польский", + dictLangPortuguese = "Португальский", + dictLangRussian = "Русский", + dictLangTurkish = "Турецкий", + dictLangChinese = "Китайский", + dictStatusBundled = "В комплекте", + dictStatusInstalled = "Установлено", + dictStatusNotInstalled = "Не установлено", + dictBtnDownload = "Скачать", + dictBtnDelete = "Удалить", + dictBtnExport = "Экспорт", + dictConfirmDownloadTitle = "Скачать словарь?", + dictConfirmDownloadMessage = "Скачать словарь «%s»? ~%s", + dictBtnDownloadAll = "Скачать все", + dictBtnExportAll = "Экспорт всех", + dictBtnImport = "Импорт", + dictImportSelectLanguage = "Выберите язык", + dictOpDeleting = "Удаление…", + dictOpImporting = "Импорт…", + dictOpExporting = "Экспорт…", + dictOpError = "Ошибка", + dictImportSuccess = "Словарь успешно импортирован", + dictImportInvalidFile = "Недопустимый файл словаря", + dictExportSuccess = "Экспорт завершён", + ) + } + } +} diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt index 9c636bed0..ef4742057 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt @@ -1,25 +1,52 @@ package io.leostrange.mrcomic.feature.settings.ui +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.leostrange.mrcomic.core.ui.designsystem.MrComicButton -import io.leostrange.mrcomic.core.ui.designsystem.MrComicButtonVariant +import io.leostrange.mrcomic.core.data.dictionary.DictionaryAssetCatalog +import io.leostrange.mrcomic.core.data.dictionary.DictionaryInstallInfo +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface import io.leostrange.mrcomic.core.ui.locale.AppStrings +// ───────────────────────────────────────────────────────────────────────────── +// Language metadata +// ───────────────────────────────────────────────────────────────────────────── + +private val languageEmojis = mapOf( + "en" to "\uD83C\uDDEC\uD83C\uDDE7", // 🇬🇧 + "fr" to "\uD83C\uDDEB\uD83C\uDDF7", // 🇫🇷 + "it" to "\uD83C\uDDEE\uD83C\uDDF9", // 🇮🇹 + "ja" to "\uD83C\uDDEF\uD83C\uDDF5", // 🇯🇵 + "ko" to "\uD83C\uDDF0\uD83C\uDDF7", // 🇰🇷 + "pl" to "\uD83C\uDDF5\uD83C\uDDF1", // 🇵🇱 + "pt" to "\uD83C\uDDF5\uD83C\uDDF9", // 🇵🇹 + "ru" to "\uD83C\uDDF7\uD83C\uDDFA", // 🇷🇺 + "tr" to "\uD83C\uDDF9\uD83C\uDDF7", // 🇹🇷 + "zh" to "\uD83C\uDDE8\uD83C\uDDF3", // 🇨🇳 +) + +// ───────────────────────────────────────────────────────────────────────────── +// Main section composable +// ───────────────────────────────────────────────────────────────────────────── + /** - * Section for managing dictionary downloads. - * Allows users to download dictionary databases on demand. + * Dictionary management section — full rewrite with M3 cards, per-language + * cards, download/delete/export, SAF import/export, zip backup. */ @Composable internal fun DictionarySection( @@ -28,249 +55,370 @@ internal fun DictionarySection( viewModel: SettingsViewModel, modifier: Modifier = Modifier ) { - val language = strings.languageCode - val downloadState by viewModel.dictionaryDownloadState.collectAsStateWithLifecycle() - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - item { - SettingsCompactSummaryCard( - title = dictionarySectionTitle(language), - hint = dictionarySectionHint(language), - items = listOf( - dictionaryStatusLabel(language) to "10", - dictionaryDownloadedLabel(language) to "${downloadState.downloadedLanguages.size}/10" - ) - ) - } - item { - DictionaryDownloadCard( - downloadState = downloadState, + val dictItems by viewModel.dictionaryItems.collectAsStateWithLifecycle() + val operationState by viewModel.dictionaryOperationState.collectAsStateWithLifecycle() + val pendingDownload by viewModel.pendingDownloadLanguage.collectAsStateWithLifecycle() + val needsLangSelection by viewModel.needsImportLanguageSelection.collectAsStateWithLifecycle() + + val context = LocalContext.current + + // ── SAF launchers ──────────────────────────────────────────────────── + + val exportAllLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + viewModel.exportAllDictionaries(uri, context.contentResolver) + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + viewModel.importDictionaryFromUri(uri, context.contentResolver) + } + + // ── Summary counts ─────────────────────────────────────────────────── + + val installedCount = dictItems.count { it.sizeBytes > 0L } + val totalSizeBytes = dictItems.sumOf { it.sizeBytes } + val totalSizeFormatted = formatDictionarySize(totalSizeBytes) + + // ── Language picker dialog for single-file import ───────────────────── + + if (needsLangSelection) { + var showLangPicker by remember { mutableStateOf(true) } + if (showLangPicker) { + ImportLanguagePickerDialog( strings = strings, - viewModel = viewModel + items = dictItems, + onSelect = { lang -> + showLangPicker = false + viewModel.completePendingImport(lang) + }, + onDismiss = { + showLangPicker = false + viewModel.cancelPendingImport() + } ) } - item { Spacer(Modifier.height(16.dp)) } } -} -@Composable -private fun DictionaryDownloadCard( - downloadState: DictionaryDownloadState, - strings: AppStrings, - viewModel: SettingsViewModel -) { - val language = strings.languageCode - val isDownloading = downloadState.isDownloading - - SettingsCard(title = dictionaryDownloadTitle(language)) { - Text( - text = dictionaryDownloadDescription(language), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + // ── Download confirmation dialog ────────────────────────────────────── + + pendingDownload?.let { lang -> + val config = DictionaryAssetCatalog.configForLanguage(lang) + val sizeStr = formatDictionarySize(config?.approxDownloadBytes ?: 0L) + val displayName = dictDisplayName(viewModel, lang) + AlertDialog( + onDismissRequest = { viewModel.cancelPendingDownload() }, + title = { Text(strings.dictConfirmDownloadTitle) }, + text = { Text(strings.dictConfirmDownloadMessage.format(displayName, sizeStr)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmPendingDownload() }) { + Text(strings.dictBtnDownload) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelPendingDownload() }) { + Text(strings.cancel) + } + } ) - Spacer(Modifier.height(12.dp)) - - if (isDownloading) { - // Show overall progress - val totalProgress = downloadState.progress.values.average().toInt() - Column( - modifier = Modifier.fillMaxWidth() + } + + // ── Main content ────────────────────────────────────────────────────── + + Column( + modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Summary card + SettingsCompactSummaryCard( + title = strings.dictSectionTitle, + hint = strings.dictSectionHint, + items = listOf( + strings.dictInstalledLabel to "$installedCount / ${dictItems.size}", + strings.dictTotalSizeLabel to totalSizeFormatted + ) + ) + + // Toolbar: Export all + Import + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { exportAllLauncher.launch("mrcomic_dictionaries.zip") }, + enabled = installedCount > 0 && operationState is DictionaryOperationState.Idle, + modifier = Modifier.weight(1f) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - CircularProgressIndicator( - progress = { totalProgress / 100f }, - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp - ) - Spacer(Modifier.width(8.dp)) - Text( - text = downloadState.currentLanguage?.let { lang -> - "${languageNames[lang] ?: lang}... $totalProgress%" - } ?: "Downloading...", - style = MaterialTheme.typography.bodyMedium - ) - } - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { totalProgress / 100f }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) + Icon(Icons.Default.FileUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(strings.dictBtnExportAll, style = MaterialTheme.typography.labelMedium) } - } else { - MrComicButton( - onClick = { viewModel.downloadAllDictionaries() }, - enabled = !isDownloading, - modifier = Modifier.fillMaxWidth(), - variant = MrComicButtonVariant.Tonal + OutlinedButton( + onClick = { + importLauncher.launch( + arrayOf( + "application/zip", + "application/gzip", + "application/x-gzip", + "application/octet-stream", + "application/x-sqlite3", + "*/*" + ) + ) + }, + enabled = operationState is DictionaryOperationState.Idle, + modifier = Modifier.weight(1f) ) { - Icon( - Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Default.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) - Text(dictionaryDownloadAllButton(language)) + Text(strings.dictBtnImport, style = MaterialTheme.typography.labelMedium) } } - - Spacer(Modifier.height(12.dp)) - - // List of downloaded dictionaries - DictionaryStatusList( - downloadedLanguages = downloadState.downloadedLanguages, - progressMap = downloadState.progress, - isDownloading = isDownloading, - language = language - ) + + // Language cards (plain Column — only 10 items, no laziness needed) + dictItems.forEach { info -> + DictionaryLanguageCard( + info = info, + strings = strings, + viewModel = viewModel, + operationState = operationState, + ) + } + + Spacer(Modifier.height(16.dp)) } } -private val languageNames = mapOf( - "en" to "English", - "fr" to "Français", - "it" to "Italiano", - "ja" to "日本語", - "ko" to "한국어", - "pl" to "Polski", - "pt" to "Português", - "ru" to "Русский", - "tr" to "Türkçe", - "zh" to "中文" -) - -private val allLanguages = listOf("en", "fr", "it", "ja", "ko", "pl", "pt", "ru", "tr", "zh") +// ───────────────────────────────────────────────────────────────────────────── +// Per-language card +// ───────────────────────────────────────────────────────────────────────────── @Composable -private fun DictionaryStatusList( - downloadedLanguages: Set, - progressMap: Map, - isDownloading: Boolean, - language: String +private fun DictionaryLanguageCard( + info: DictionaryInstallInfo, + strings: AppStrings, + viewModel: SettingsViewModel, + operationState: DictionaryOperationState, ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - allLanguages.forEach { lang -> - val isDownloaded = lang in downloadedLanguages - val progress = progressMap[lang] - val isCurrentLang = isDownloading && progress != null - + val context = LocalContext.current + val isInstalled = info.sizeBytes > 0L + val isDownloadingThis = operationState is DictionaryOperationState.Downloading && operationState.language == info.language + val isAnyOperationActive = operationState !is DictionaryOperationState.Idle + + val displayName = dictDisplayName(viewModel, info.language) + val emoji = languageEmojis[info.language] ?: "🌐" + + // Export launcher for this language + val langCode = info.language + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/x-sqlite3") + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + viewModel.exportDictionary(langCode, uri, context.contentResolver) + } + + MrComicCardSurface( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Header row: emoji + name + status chip Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = when { - isDownloaded -> Icons.Default.Check - isCurrentLang -> Icons.Default.CloudDownload - else -> Icons.Default.Language - }, - contentDescription = null, - tint = when { - isDownloaded -> MaterialTheme.colorScheme.primary - isCurrentLang -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(8.dp)) Text( - text = languageNames[lang] ?: lang, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f) + text = emoji, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 8.dp) ) - if (isDownloaded) { + Column(modifier = Modifier.weight(1f)) { Text( - text = "✓", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + text = displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold ) - } else if (isCurrentLang) { + // Size info + val sizeText = if (isInstalled) { + formatDictionarySize(info.sizeBytes) + } else { + val approx = DictionaryAssetCatalog.configForLanguage(info.language)?.approxDownloadBytes ?: 0L + "~${formatDictionarySize(approx)}" + } Text( - text = "${progress}%", + text = sizeText, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - // Show progress bar for current language - if (isCurrentLang) { - LinearProgressIndicator( - progress = { (progress ?: 0) / 100f }, - modifier = Modifier - .fillMaxWidth() - .height(2.dp) - .padding(start = 24.dp), - trackColor = MaterialTheme.colorScheme.surfaceVariant + // Status chip + DictionaryStatusChip( + info = info, + strings = strings ) } + + // Progress indicator when downloading + if (isDownloadingThis) { + val progress = (operationState as DictionaryOperationState.Downloading).progress + Column { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = "$progress%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + if (!isInstalled) { + // Download button + IconButton( + onClick = { viewModel.requestDownload(info.language) }, + enabled = !isAnyOperationActive, + ) { + Icon(Icons.Default.CloudDownload, contentDescription = strings.dictBtnDownload) + } + } else { + // Delete button (not for bundled-only) + if (!info.isBundled) { + IconButton( + onClick = { viewModel.deleteDictionary(info.language) }, + enabled = !isAnyOperationActive, + ) { + Icon(Icons.Default.Delete, contentDescription = strings.dictBtnDelete, tint = MaterialTheme.colorScheme.error) + } + } + // Export button + IconButton( + onClick = { exportLauncher.launch("dictionary_${info.language}.dbpack") }, + enabled = !isAnyOperationActive, + ) { + Icon(Icons.Default.FileDownload, contentDescription = strings.dictBtnExport) + } + } + } } } } -// Text functions for i18n -private fun dictionarySectionTitle(language: String): String = when (language) { - "en" -> "Dictionaries" - "ja" -> "辞書" - "zh" -> "词典" - "ko" -> "사전" - else -> "Словари" -} +// ───────────────────────────────────────────────────────────────────────────── +// Status chip +// ───────────────────────────────────────────────────────────────────────────── -private fun dictionarySectionHint(language: String): String = when (language) { - "en" -> "Download offline dictionaries for translation and lookup." - "ja" -> "翻訳・辞書検索用のオフライン辞書をダウンロード。" - "zh" -> "下载离线词典用于翻译和查询。" - "ko" -> "번역 및 조회를 위한 오프라인 사전을 다운로드합니다." - else -> "Скачать оффлайн-словари для перевода и поиска." +@Composable +private fun DictionaryStatusChip( + info: DictionaryInstallInfo, + strings: AppStrings +) { + val (label, color) = when { + info.sizeBytes > 0L && info.isBundled -> strings.dictStatusBundled to MaterialTheme.colorScheme.tertiaryContainer + info.sizeBytes > 0L -> strings.dictStatusInstalled to MaterialTheme.colorScheme.primaryContainer + else -> strings.dictStatusNotInstalled to MaterialTheme.colorScheme.surfaceVariant + } + val onColor = when { + info.sizeBytes > 0L && info.isBundled -> MaterialTheme.colorScheme.onTertiaryContainer + info.sizeBytes > 0L -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + shape = MaterialTheme.shapes.small, + color = color, + contentColor = onColor + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } } -private fun dictionaryStatusLabel(language: String): String = when (language) { - "en" -> "Available" - "ja" -> "利用可能" - "zh" -> "可用" - "ko" -> "사용 가능" - else -> "Доступно" -} +// ───────────────────────────────────────────────────────────────────────────── +// Language picker dialog (for single-file import) +// ───────────────────────────────────────────────────────────────────────────── -private fun dictionaryDownloadedLabel(language: String): String = when (language) { - "en" -> "Downloaded" - "ja" -> "ダウンロード済み" - "zh" -> "已下载" - "ko" -> "다운로드됨" - else -> "Загружено" +@Composable +private fun ImportLanguagePickerDialog( + strings: AppStrings, + items: List, + onSelect: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.dictImportSelectLanguage) }, + text = { + LazyColumn { + items(items = items) { info -> + val displayName = when (info.language) { + "en" -> strings.dictLangEnglish + "fr" -> strings.dictLangFrench + "it" -> strings.dictLangItalian + "ja" -> strings.dictLangJapanese + "ko" -> strings.dictLangKorean + "pl" -> strings.dictLangPolish + "pt" -> strings.dictLangPortuguese + "ru" -> strings.dictLangRussian + "tr" -> strings.dictLangTurkish + "zh" -> strings.dictLangChinese + else -> info.language.uppercase() + } + val emoji = languageEmojis[info.language] ?: "🌐" + TextButton( + onClick = { onSelect(info.language) }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$emoji $displayName", + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(strings.cancel) + } + } + ) } -private fun dictionaryDownloadTitle(language: String): String = when (language) { - "en" -> "Download dictionaries" - "ja" -> "辞書をダウンロード" - "zh" -> "下载词典" - "ko" -> "사전 다운로드" - else -> "Скачать словари" -} +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── -private fun dictionaryDownloadDescription(language: String): String = when (language) { - "en" -> "Download dictionary databases for offline translation. Files are ~750 MB total." - "ja" -> "オフライン翻訳用の辞書データベースをダウンロード。合計約750 MB。" - "zh" -> "下载离线翻译词典数据库。文件总计约750 MB。" - "ko" -> "오프라인 번역을 위한 사전 데이터베이스를 다운로드합니다. 총 약 750 MB." - else -> "Скачать базы данных словарей для оффлайн-перевода. Общий размер ~750 МБ." +private fun dictDisplayName(viewModel: SettingsViewModel, lang: String): String { + return viewModel.dictDisplayName(lang) } -private fun dictionaryDownloadAllButton(language: String): String = when (language) { - "en" -> "Download all dictionaries" - "ja" -> "すべての辞書をダウンロード" - "zh" -> "下载所有词典" - "ko" -> "모든 사전 다운로드" - else -> "Скачать все словари" +private fun formatDictionarySize(bytes: Long): String { + if (bytes <= 0L) return "0 B" + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + val gb = mb / 1024.0 + return String.format("%.2f GB", gb) } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt index 5c24638a3..0ba21bb5c 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt @@ -33,6 +33,7 @@ import io.leostrange.mrcomic.core.ui.theme.style import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import io.leostrange.mrcomic.core.data.dictionary.DictionaryDownloader +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject // Preset data classes and parsing extracted to SettingsPresets.kt @@ -69,7 +71,7 @@ class SettingsViewModel @Inject constructor( private val dictionaryEngine: DictionaryEngine, private val offlineTranslationEngine: OfflineTranslationEngine, private val onlineTranslationEngine: OnlineTranslationEngine, - private val dictionaryDownloader: DictionaryDownloader + internal val dictionaryDownloader: DictionaryDownloader ) : ViewModel() { internal val preferences = UserPreferences(context.dataStore) @@ -169,6 +171,12 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState() ) + // Must be initialized before initDictionaryState() starts its coroutine. + // Kotlin initializes class properties in source order; keeping this below + // the init block leaves the backing field null during constructor startup. + internal val _dictionaryDownloadState = MutableStateFlow(DictionaryDownloadState()) + val dictionaryDownloadState: StateFlow = _dictionaryDownloadState + init { viewModelScope.launch { val existingEntries = parseReaderStylePresetEntries( @@ -185,6 +193,7 @@ class SettingsViewModel @Inject constructor( persistReaderStylePresetEntries(migrated) } } + initDictionaryState() } // Phase Z (2026-08-04): setter functions → SettingsViewModelSetters.kt. @@ -327,10 +336,6 @@ class SettingsViewModel @Inject constructor( // Phase X (2026-08-04): backup/cache/repair → SettingsViewModelBackup.kt. - // Dictionary download state - private val _dictionaryDownloadState = MutableStateFlow(DictionaryDownloadState()) - val dictionaryDownloadState: StateFlow = _dictionaryDownloadState - fun downloadAllDictionaries() { viewModelScope.launch { _dictionaryDownloadState.value = DictionaryDownloadState( @@ -339,6 +344,8 @@ class SettingsViewModel @Inject constructor( try { val allLanguages = listOf("en", "fr", "it", "ja", "ko", "pl", "pt", "ru", "tr", "zh") val downloaded = mutableSetOf() + @Suppress("InjectDispatcher") // IO work inside withContext below + withContext(Dispatchers.IO) { allLanguages.forEach { lang -> _dictionaryDownloadState.update { it.copy(currentLanguage = lang) } val result = dictionaryDownloader.ensureDictionary(lang) { progress -> @@ -356,6 +363,7 @@ class SettingsViewModel @Inject constructor( } } } + } // end withContext(IO) _dictionaryDownloadState.value = DictionaryDownloadState( downloadedLanguages = downloaded.toSet() ) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt new file mode 100644 index 000000000..0f4fc7d9e --- /dev/null +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt @@ -0,0 +1,452 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.viewModelScope +import io.leostrange.mrcomic.core.data.dictionary.DictionaryInstallInfo +import io.leostrange.mrcomic.core.data.dictionary.DictionaryAssetCatalog +import io.leostrange.mrcomic.core.ui.locale.DictionaryStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +// ───────────────────────────────────────────────────────────────────────────── +// Dictionary management extensions for SettingsViewModel. +// Keeps the ViewModel constructor stable; new methods live here. +// ───────────────────────────────────────────────────────────────────────────── + +/** Language code → display name (no Android resources needed). */ +internal fun SettingsViewModel.dictDisplayName(lang: String): String { + val code = uiState.value.appLanguage + val s = DictionaryStrings.forLanguage(code) + return when (lang) { + "en" -> s.dictLangEnglish + "fr" -> s.dictLangFrench + "it" -> s.dictLangItalian + "ja" -> s.dictLangJapanese + "ko" -> s.dictLangKorean + "pl" -> s.dictLangPolish + "pt" -> s.dictLangPortuguese + "ru" -> s.dictLangRussian + "tr" -> s.dictLangTurkish + "zh" -> s.dictLangChinese + else -> lang.uppercase() + } +} + +// ─── State flows ───────────────────────────────────────────────────────────── + +/** List of all shipped dictionaries with install status. Refreshed on init + after operations. */ +val SettingsViewModel.dictionaryItems: StateFlow> + get() = _dictionaryItems + +/** The single active dictionary operation (or Idle). */ +val SettingsViewModel.dictionaryOperationState: StateFlow + get() = _dictionaryOperationState + +/** Pending download language awaiting user confirmation (null = no pending). */ +val SettingsViewModel.pendingDownloadLanguage: StateFlow + get() = _pendingDownloadLanguage + +private val _dictionaryItems = MutableStateFlow>(emptyList()) +private val _dictionaryOperationState = MutableStateFlow(DictionaryOperationState.Idle) +private val _pendingDownloadLanguage = MutableStateFlow(null) + +// ─── Init hook (call from SettingsViewModel.init) ───────────────────────────── + +fun SettingsViewModel.initDictionaryState() { + refreshDictionaryItems() +} + +private fun SettingsViewModel.refreshDictionaryItems() { + viewModelScope.launch { + val items = dictionaryDownloader.installedDictionaries() + _dictionaryItems.value = items + // Also sync the legacy downloadState so the rest of the codebase stays happy + val downloaded = items.filter { it.sizeBytes > 0L }.map { it.language }.toSet() + _dictionaryDownloadState.update { it.copy(downloadedLanguages = downloaded) } + } +} + +// ─── Confirm-download flow ──────────────────────────────────────────────────── + +fun SettingsViewModel.requestDownload(lang: String) { + _pendingDownloadLanguage.value = lang +} + +fun SettingsViewModel.confirmPendingDownload() { + val lang = _pendingDownloadLanguage.value ?: return + _pendingDownloadLanguage.value = null + downloadDictionary(lang) +} + +fun SettingsViewModel.cancelPendingDownload() { + _pendingDownloadLanguage.value = null +} + +fun SettingsViewModel.cancelPendingImport() { + _needsImportLanguageSelection.value = false + _pendingImportBytes.value = null + _dictionaryOperationState.value = DictionaryOperationState.Idle +} + +// ─── Single dictionary download ─────────────────────────────────────────────── + +// Per-language download progress (0f–1f). null = no active download. +private val _dictionaryDownloadProgress = MutableStateFlow(null) +val SettingsViewModel.dictionaryDownloadProgress: StateFlow + get() = _dictionaryDownloadProgress + +fun SettingsViewModel.downloadDictionary(lang: String) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Downloading(lang, 0) + _dictionaryDownloadProgress.value = 0f + _dictionaryDownloadState.update { it.copy(isDownloading = true, currentLanguage = lang) } + val notifId = ensureDictNotificationChannelAndShowStart(context, lang) + try { + val result = withContext(Dispatchers.IO) { + dictionaryDownloader.ensureDictionary(lang) { progress -> + _dictionaryOperationState.value = DictionaryOperationState.Downloading(lang, progress) + _dictionaryDownloadProgress.value = progress / 100f + updateDictNotification(context, notifId, lang, progress) + @Suppress("InjectDispatcher") // collection on main-safe StateFlow + _dictionaryDownloadState.update { state -> + state.copy(progress = state.progress + (lang to progress)) + } + } + } + if (result != null) { + _dictionaryDownloadState.update { state -> + state.copy( + downloadedLanguages = state.downloadedLanguages + lang, + progress = state.progress + (lang to 100) + ) + } + } + } catch (_: Exception) { + // error swallowed — UI stays on Idle after finally + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + _dictionaryDownloadProgress.value = null + dismissDictNotification(context, notifId, lang) + _dictionaryDownloadState.update { it.copy(isDownloading = false, currentLanguage = null) } + refreshDictionaryItems() + } + } +} + +// ─── Delete ─────────────────────────────────────────────────────────────────── + +fun SettingsViewModel.deleteDictionary(lang: String) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Deleting(lang) + try { + withContext(Dispatchers.IO) { dictionaryDownloader.deleteDictionary(lang) } + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + refreshDictionaryItems() + } + } +} + +// ─── Export single dictionary ───────────────────────────────────────────────── + +fun SettingsViewModel.exportDictionary(lang: String, uri: Uri, contentResolver: ContentResolver) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Exporting(lang) + try { + val success = withContext(Dispatchers.IO) { + contentResolver.openOutputStream(uri)?.use { output -> + dictionaryDownloader.exportDictionary(lang, output) + } ?: false + } + statusState.update { + it.copy(message = if (success) formatDictionaryExportSuccess() else formatDictionaryOpError()) + } + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + } +} + +// ─── Import single dictionary ───────────────────────────────────────────────── + +fun SettingsViewModel.importDictionary(lang: String, uri: Uri, contentResolver: ContentResolver) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Importing(lang) + try { + val result = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.use { input -> + dictionaryDownloader.importDictionary(lang, input) + } + } + statusState.update { + it.copy(message = if (result != null) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) + } + if (result != null) refreshDictionaryItems() + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + } +} + +// ─── Export all (zip) ───────────────────────────────────────────────────────── + +fun SettingsViewModel.exportAllDictionaries(uri: Uri, contentResolver: ContentResolver) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Exporting("__all__") + try { + val success = withContext(Dispatchers.IO) { + val installed = dictionaryDownloader.installedDictionaries() + .filter { it.sizeBytes > 0L } + if (installed.isEmpty()) return@withContext false + contentResolver.openOutputStream(uri)?.use { output -> + ZipOutputStream(output).use { zos -> + for (info in installed) { + val entryName = "dictionary_${info.language}.dbpack" + zos.putNextEntry(ZipEntry(entryName)) + dictionaryDownloader.exportDictionary(info.language, zos) + zos.closeEntry() + } + } + true + } ?: false + } + statusState.update { + it.copy(message = if (success) formatDictionaryExportSuccess() else formatDictionaryOpError()) + } + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + } +} + +// ─── Import from zip or single file ─────────────────────────────────────────── + +fun SettingsViewModel.importDictionaryFromUri(uri: Uri, contentResolver: ContentResolver) { + if (_dictionaryOperationState.value !is DictionaryOperationState.Idle) return + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Importing("__auto__") + try { + val bytes = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + when (detectDictionaryImportKind(bytes)) { + DictionaryImportKind.ZIP -> { + val imported = withContext(Dispatchers.IO) { + importFromZip(requireNotNull(bytes)) + } + statusState.update { + it.copy(message = if (imported) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) + } + if (imported) refreshDictionaryItems() + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + DictionaryImportKind.SINGLE -> { + // SAF streams are scoped to the activity result callback. Keep bytes, + // not the closed stream, while the user chooses the language. + _pendingImportBytes.value = requireNotNull(bytes) + _needsImportLanguageSelection.value = true + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + DictionaryImportKind.INVALID -> { + statusState.update { it.copy(message = formatDictionaryImportInvalid()) } + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + } + } catch (_: Exception) { + statusState.update { it.copy(message = formatDictionaryImportInvalid()) } + _dictionaryOperationState.value = DictionaryOperationState.Idle + } finally { + // Every branch above sets the operation back to Idle. This is also + // required after a SAF/provider failure so the import button recovers. + } + } +} + +/** Called after the user picks a language for the pending single-file import. */ +fun SettingsViewModel.completePendingImport(lang: String) { + val bytes = _pendingImportBytes.value ?: return + _needsImportLanguageSelection.value = false + _pendingImportBytes.value = null + viewModelScope.launch { + _dictionaryOperationState.value = DictionaryOperationState.Importing(lang) + try { + val result = withContext(Dispatchers.IO) { + dictionaryDownloader.importDictionary(lang, bytes.inputStream()) + } + statusState.update { + it.copy(message = if (result != null) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) + } + if (result != null) refreshDictionaryItems() + } finally { + _dictionaryOperationState.value = DictionaryOperationState.Idle + } + } +} + +private val _pendingImportBytes = MutableStateFlow(null) +private val _needsImportLanguageSelection = MutableStateFlow(false) + +val SettingsViewModel.needsImportLanguageSelection: StateFlow + get() = _needsImportLanguageSelection + +private fun SettingsViewModel.importFromZip(bytes: ByteArray): Boolean { + var anyImported = false + ZipInputStream(bytes.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val name = entry.name + // Expect entries named dictionary_.dbpack or .dbpack + val lang = when { + name.startsWith("dictionary_") -> name.removePrefix("dictionary_").removeSuffix(".dbpack") + name.endsWith(".dbpack") -> name.removeSuffix(".dbpack") + else -> null + } + if (lang != null && DictionaryAssetCatalog.configForLanguage(lang) != null) { + val tempBytes = zis.readBytes() + val result = dictionaryDownloader.importDictionary(lang, tempBytes.inputStream()) + if (result != null) anyImported = true + } + entry = zis.nextEntry + } + } + return anyImported +} + +internal enum class DictionaryImportKind { + ZIP, + SINGLE, + INVALID +} + +internal fun detectDictionaryImportKind(bytes: ByteArray?): DictionaryImportKind { + if (bytes == null || bytes.isEmpty()) return DictionaryImportKind.INVALID + val isZip = bytes.size >= 2 && + bytes[0] == 0x50.toByte() && + bytes[1] == 0x4B.toByte() + return if (isZip) DictionaryImportKind.ZIP else DictionaryImportKind.SINGLE +} + +// ─── Download notification helpers ──────────────────────────────────────────── + +private const val DICT_NOTIFICATION_CHANNEL_ID = "downloads" +private const val DICT_NOTIFICATION_BASE_ID = 0xD1C7 // 53703 — distinctive base +private var nextDictNotifSlot = 0 + +/** Ensure channel exists (idempotent) and show an indeterminate start notification. + * Returns the notificationId to use for updates and dismissal, or -1 on failure/missing permission. */ +private fun SettingsViewModel.ensureDictNotificationChannelAndShowStart( + ctx: android.content.Context, + lang: String +): Int { + val nm = ctx.getSystemService(NotificationManager::class.java) ?: return -1 + // Create channel (no-op if already exists) + val channel = NotificationChannel( + DICT_NOTIFICATION_CHANNEL_ID, + "Downloads", + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) } + nm.createNotificationChannel(channel) + // On API 33+ POST_NOTIFICATIONS is required; skip gracefully if not granted. + // Permission request is a user flow that callers own; we just bail here. + val notifId = DICT_NOTIFICATION_BASE_ID + (nextDictNotifSlot++ and 0xFF) + val langUpper = lang.uppercase() + val n = Notification.Builder(ctx, DICT_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading dictionary…") + .setContentText(langUpper) + .setProgress(100, 0, true) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + try { + nm.notify(notifId, n) + } catch (_: SecurityException) { + // Missing POST_NOTIFICATIONS permission on API 33+ + return -1 + } + return notifId +} + +/** Update the progress notification (0–100). No-op if notifId < 0. */ +private fun SettingsViewModel.updateDictNotification( + ctx: android.content.Context, + notifId: Int, + lang: String, + progress: Int +) { + if (notifId < 0) return + val nm = ctx.getSystemService(NotificationManager::class.java) ?: return + val langUpper = lang.uppercase() + val n = Notification.Builder(ctx, DICT_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading dictionary…") + .setContentText("$langUpper — ${progress}%") + .setProgress(100, progress, false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + try { + nm.notify(notifId, n) + } catch (_: SecurityException) { /* no-op */ } +} + +/** Remove the notification (success or failure). No-op if notifId < 0. */ +private fun SettingsViewModel.dismissDictNotification( + ctx: android.content.Context, + notifId: Int, + @Suppress("UNUSED_PARAMETER") lang: String +) { + if (notifId < 0) return + val nm = ctx.getSystemService(NotificationManager::class.java) ?: return + nm.cancel(notifId) +} + +// ─── i18n message formatters (private, 5 languages) ────────────────────────── + +private fun SettingsViewModel.formatDictionaryExportSuccess(): String = when (uiState.value.appLanguage) { + "en" -> "Export completed" + "ja" -> "エクスポートが完了しました" + "zh" -> "导出完成" + "ko" -> "내보내기 완료" + else -> "Экспорт завершён" +} + +private fun SettingsViewModel.formatDictionaryImportSuccess(): String = when (uiState.value.appLanguage) { + "en" -> "Dictionary imported successfully" + "ja" -> "辞書のインポートが完了しました" + "zh" -> "词典导入成功" + "ko" -> "사전 가져오기 완료" + else -> "Словарь успешно импортирован" +} + +private fun SettingsViewModel.formatDictionaryImportInvalid(): String = when (uiState.value.appLanguage) { + "en" -> "Invalid dictionary file" + "ja" -> "無効な辞書ファイルです" + "zh" -> "无效的词典文件" + "ko" -> "유효하지 않은 사전 파일" + else -> "Недопустимый файл словаря" +} + +private fun SettingsViewModel.formatDictionaryOpError(): String = when (uiState.value.appLanguage) { + "en" -> "Error" + "ja" -> "エラー" + "zh" -> "错误" + "ko" -> "오류" + else -> "Ошибка" +} diff --git a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/DictionaryImportKindTest.kt b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/DictionaryImportKindTest.kt new file mode 100644 index 000000000..f746607c0 --- /dev/null +++ b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/DictionaryImportKindTest.kt @@ -0,0 +1,30 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DictionaryImportKindTest { + + @Test + fun emptyProviderResultIsRejected() { + assertEquals(DictionaryImportKind.INVALID, detectDictionaryImportKind(null)) + assertEquals(DictionaryImportKind.INVALID, detectDictionaryImportKind(ByteArray(0))) + } + + @Test + fun zipMagicIsDetectedWithoutConsumingAProviderStream() { + assertEquals( + DictionaryImportKind.ZIP, + detectDictionaryImportKind(byteArrayOf(0x50, 0x4B, 0x03, 0x04)) + ) + } + + @Test + fun plainAndGzipPayloadsAreKeptForLanguageSelection() { + assertEquals(DictionaryImportKind.SINGLE, detectDictionaryImportKind("SQLite format 3".toByteArray())) + assertEquals( + DictionaryImportKind.SINGLE, + detectDictionaryImportKind(byteArrayOf(0x1F, 0x8B.toByte(), 0x08)) + ) + } +} From b62fe1bca80988e846d81c8231c30e9a6c05d486 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:17:10 +0700 Subject: [PATCH 04/17] refactor: remove duplicate theme constructor --- .../core/data/preferences/PreferencesKeys.kt | 4 +- .../settings/ui/SettingsAppearanceCards.kt | 345 +----------------- .../settings/ui/SettingsAppearanceSection.kt | 28 -- .../settings/ui/SettingsAppearanceStrings.kt | 138 +------ .../settings/ui/SettingsAppearanceText.kt | 46 +-- .../feature/settings/ui/SettingsComponents.kt | 152 +++----- .../feature/settings/ui/SettingsEnums.kt | 2 +- .../settings/ui/SettingsLibrarySection.kt | 2 +- .../feature/settings/ui/SettingsPresets.kt | 59 --- .../settings/ui/SettingsPresetsController.kt | 45 +-- .../feature/settings/ui/SettingsScreen.kt | 2 - .../feature/settings/ui/SettingsUiState.kt | 12 +- .../settings/ui/SettingsViewModelFlows.kt | 21 +- .../settings/ui/SettingsViewModelHelpers.kt | 24 -- .../settings/ui/SettingsViewModelPresets.kt | 6 - .../ui/SettingsPresetsControllerTest.kt | 11 - 16 files changed, 85 insertions(+), 812 deletions(-) diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt index e2b7e5bd0..0afa9e3f3 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt @@ -88,9 +88,6 @@ object PreferencesKeys { val UI_CORNER_RADIUS = intPreferencesKey("ui_corner_radius") // 4/8/12/16/20 dp val UI_REDUCED_MOTION = booleanPreferencesKey("ui_reduced_motion") val UI_REDUCED_VISUAL_EFFECTS = booleanPreferencesKey("ui_reduced_visual_effects") - val APP_THEME_PRESET_1 = stringPreferencesKey("app_theme_preset_1") - val APP_THEME_PRESET_2 = stringPreferencesKey("app_theme_preset_2") - val APP_THEME_PRESET_3 = stringPreferencesKey("app_theme_preset_3") // Перевод val TRANSLATION_MODE = stringPreferencesKey("translation_mode") // OFF/OCR/DICTIONARY val TRANSLATION_SOURCE_LANGUAGE = stringPreferencesKey("translation_source_language") // AUTO/RU/EN/JA/ZH/KO/FR/IT/PL/TR/PT @@ -162,6 +159,7 @@ object PreferencesKeys { // Настройки текстового ридера (FB2 / EPUB) val TEXT_FONT_SIZE = intPreferencesKey("text_font_size") // 12..32, default 18 val TEXT_COLOR_SCHEME = stringPreferencesKey("text_color_scheme") // DAY/SEPIA/NIGHT + val GRAPHIC_COLOR_SCHEME = stringPreferencesKey("graphic_color_scheme") // DAY/SEPIA/NIGHT for raster reader val TEXT_FONT_FAMILY = stringPreferencesKey("text_font_family") // Georgia/Merriweather/… val TEXT_LINE_HEIGHT = floatPreferencesKey("text_line_height") // 1.0..3.0, default 1.8 val TEXT_LETTER_SPACING = floatPreferencesKey("text_letter_spacing") // 0.0..0.2 em diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceCards.kt index 9e078f7a3..b503cfe09 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceCards.kt @@ -6,25 +6,15 @@ package io.leostrange.mrcomic.feature.settings.ui import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.text.style.TextOverflow import io.leostrange.mrcomic.core.ui.designsystem.MrComicButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicButtonVariant -import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import android.content.Intent @@ -37,193 +27,7 @@ import io.leostrange.mrcomic.core.ui.theme.ThemeMode import io.leostrange.mrcomic.core.ui.theme.ThemePreset import io.leostrange.mrcomic.core.ui.theme.argbLongToThemeColor -// AppThemePresetCard moved here from SettingsScreen.kt (Phase T, 2026-08-04). -// Split from SettingsAppearanceSection.kt (2026-08-06). -// Phase T (2026-08-04): internal fun AppThemePresetCard moved here from SettingsScreen.kt -// Phase Z (2026-08-06): item blocks extracted from AppearanceSection as named cards. - -@Composable -internal fun AppThemePresetCard( - slot: AppThemePresetSlot, - strings: AppStrings, - text: AppThemePresetText, - isActive: Boolean, - onSave: () -> Unit, - onApply: () -> Unit, - onClear: () -> Unit, - modifier: Modifier = Modifier -) { - val snapshot = remember(slot.serialized) { parseAppThemePreset(slot.serialized) } - val cardShape = RoundedCornerShape(18.dp) - val slotLabel = "${text.slotPrefix} ${slot.index}" - val themePresetLabelText = snapshot?.let { themePresetLabel(strings, it.themePreset) } ?: text.empty - val modeLabel = snapshot?.let { - themeLabel( - strings, - runCatching { ThemeMode.valueOf(it.themeMode) }.getOrDefault(ThemeMode.SYSTEM) - ) - }.orEmpty() - val primaryColor = snapshot?.customPrimaryColor?.let(::argbLongToThemeColor) ?: MaterialTheme.colorScheme.primary - val surfaceColor = snapshot?.customSurfaceColor?.let(::argbLongToThemeColor) - ?: MaterialTheme.colorScheme.surface.copy(alpha = snapshot?.surfaceOpacity ?: 1f) - val backgroundColor = snapshot?.customBackgroundColor?.let(::argbLongToThemeColor) ?: MaterialTheme.colorScheme.background - - MrComicCardSurface( - modifier = modifier - .width(180.dp) - .clip(cardShape), - fillMaxWidth = false, - shape = cardShape, - containerColor = if (isActive) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.86f) - } else { - MaterialTheme.colorScheme.surface.copy(alpha = 0.92f) - }, - border = if (isActive) { - BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.42f)) - } else { - BorderStroke(0.8.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.16f)) - }, - shadowElevation = if (isActive) 5.dp else 4.dp - ) { - Column( - modifier = Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(88.dp) - .clip(RoundedCornerShape(14.dp)) - .background(backgroundColor) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.18f), - shape = RoundedCornerShape(14.dp) - ) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(10.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(primaryColor) - ) - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(surfaceColor) - ) - } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(surfaceColor) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth(0.72f) - .height(7.dp) - .clip(RoundedCornerShape(999.dp)) - .background(primaryColor.copy(alpha = 0.84f)) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(7.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) - ) - Box( - modifier = Modifier - .fillMaxWidth(0.82f) - .height(7.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) - ) - } - } - } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = slotLabel, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - if (isActive) { - Text( - text = text.current, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } - Text( - text = if (modeLabel.isBlank()) themePresetLabelText else "$themePresetLabelText · $modeLabel", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - minLines = 2, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilledTonalIconButton( - onClick = onSave, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.Save, contentDescription = text.save) - } - FilledIconButton( - onClick = onApply, - enabled = snapshot != null, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.PlayArrow, contentDescription = text.apply) - } - OutlinedIconButton( - onClick = onClear, - enabled = snapshot != null, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.DeleteOutline, contentDescription = text.clear) - } - } - } - } -} - -// Phase T (2026-08-04): internal fun AppThemePresetSnapshot.matchesAppThemeUiState moved here from SettingsScreen.kt - -internal fun AppThemePresetSnapshot.matchesAppThemeUiState(uiState: SettingsUiState): Boolean { - return themePreset == uiState.themePreset && - themeMode == uiState.themeMode.name && - useDynamicColor == uiState.useDynamicColor && - useAmoledDark == uiState.useAmoledDark && - customPrimaryColor == uiState.customPrimaryColor && - customSecondaryColor == uiState.customSecondaryColor && - customBackgroundColor == uiState.customBackgroundColor && - customSurfaceColor == uiState.customSurfaceColor && - surfaceOpacity == uiState.surfaceOpacity && - uiFontScale == uiState.uiFontScale && - uiDensityScale == uiState.uiDensityScale && - uiCornerRadius == uiState.uiCornerRadius -} +// Item blocks extracted from AppearanceSection (2026-08-06). // === Item blocks extracted from AppearanceSection (2026-08-06) === @@ -278,111 +82,6 @@ internal fun AppearanceQuickBlocksCard( } } -@Composable -internal fun AppearanceStudioOverviewCard( - uiState: SettingsUiState, - strings: AppStrings, - sectionText: AppearanceSectionText, - onPageChange: (AppearanceSettingsPage) -> Unit -) { - SettingsStudioOverviewCard( - title = appearanceThemeStudioTitle(strings.languageCode), - hint = appearanceThemeStudioDescription(strings.languageCode), - summaryItems = listOf( - appearanceThemeTitle(strings.languageCode) to "${themePresetLabel(strings, uiState.themePreset)} · ${themeLabel(strings, uiState.themeMode)}", - appearanceColorsTitle(strings.languageCode) to "${compactToggleLabel(strings.languageCode, uiState.customPrimaryColor != null || uiState.customBackgroundColor != null)} · ${(uiState.surfaceOpacity * 100).toInt()}%", - appearanceScaleTitle(strings.languageCode) to "${fontScaleLabel(strings, uiState.uiFontScale)} · ${uiDensityLabel(uiState.appLanguage, uiState.uiDensityScale)}" - ), - sectionsTitle = sectionText.quickBlocksTitle, - sections = listOf( - SettingsStudioOverviewItem( - icon = Icons.Default.Palette, - title = appearanceThemeTitle(strings.languageCode), - description = sectionText.tabHints[AppearanceSettingsTab.THEME].orEmpty(), - summary = "${themePresetLabel(strings, uiState.themePreset)} · ${themeLabel(strings, uiState.themeMode)}", - onClick = { onPageChange(AppearanceSettingsPage.THEME) } - ), - SettingsStudioOverviewItem( - icon = Icons.Default.ColorLens, - title = appearanceColorsTitle(strings.languageCode), - description = sectionText.tabHints[AppearanceSettingsTab.COLORS].orEmpty(), - summary = "${compactToggleLabel(strings.languageCode, uiState.customPrimaryColor != null || uiState.customBackgroundColor != null)} · ${(uiState.surfaceOpacity * 100).toInt()}%", - onClick = { onPageChange(AppearanceSettingsPage.COLORS) } - ), - SettingsStudioOverviewItem( - icon = Icons.Default.Tune, - title = appearanceScaleTitle(strings.languageCode), - description = sectionText.tabHints[AppearanceSettingsTab.SCALE].orEmpty(), - summary = "${fontScaleLabel(strings, uiState.uiFontScale)} · ${uiDensityLabel(uiState.appLanguage, uiState.uiDensityScale)}", - onClick = { onPageChange(AppearanceSettingsPage.SCALE) } - ) - ) - ) -} - -@Composable -internal fun AppearanceAppThemePresetsCard( - uiState: SettingsUiState, - strings: AppStrings, - text: AppThemePresetText, - viewModel: SettingsViewModel -) { - val savedAppThemeCount = remember(uiState.appThemePresetSlots) { - uiState.appThemePresetSlots.count { !it.serialized.isNullOrBlank() } - } - SettingsCard(title = "${text.title} ($savedAppThemeCount/${uiState.appThemePresetSlots.size})") { - LabelText(text.hint) - val orderedAppThemeSlots = remember(uiState.appThemePresetSlots, uiState.themePreset, uiState.themeMode) { - uiState.appThemePresetSlots.sortedWith( - compareByDescending { slot -> - parseAppThemePreset(slot.serialized)?.matchesAppThemeUiState(uiState) == true - }.thenByDescending { slot -> - !slot.serialized.isNullOrBlank() - }.thenBy { slot -> - slot.index - } - ) - } - val activeAppThemeSnapshot = orderedAppThemeSlots - .mapNotNull { parseAppThemePreset(it.serialized) } - .firstOrNull { it.matchesAppThemeUiState(uiState) } - if (activeAppThemeSnapshot != null) { - Spacer(Modifier.height(6.dp)) - SettingsPreviewBanner( - title = "${text.current}: ${themePresetLabel(strings, activeAppThemeSnapshot.themePreset)}", - subtitle = themeLabel( - strings, - runCatching { ThemeMode.valueOf(activeAppThemeSnapshot.themeMode) }.getOrDefault(ThemeMode.SYSTEM) - ), - details = listOfNotNull( - if (activeAppThemeSnapshot.customPrimaryColor != null) strings.colorPrimary else null, - if (activeAppThemeSnapshot.customSecondaryColor != null) strings.colorSecondary else null, - if (activeAppThemeSnapshot.customBackgroundColor != null) strings.colorBackground else null, - if (activeAppThemeSnapshot.customSurfaceColor != null) "surface" else null - ).joinToString(" · ").ifBlank { strings.themePresetCustom } - ) - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - orderedAppThemeSlots.forEach { slot -> - val active = parseAppThemePreset(slot.serialized)?.matchesAppThemeUiState(uiState) == true - AppThemePresetCard( - slot = slot, - strings = strings, - text = text, - isActive = active, - onSave = { viewModel.saveAppThemePreset(slot.index) }, - onApply = { viewModel.applyAppThemePreset(slot.index) }, - onClear = { viewModel.clearAppThemePreset(slot.index) } - ) - } - } - } -} - @Composable internal fun AppearanceLibraryBackgroundsCard( uiState: SettingsUiState, @@ -679,35 +378,6 @@ internal fun AppearanceAccentColorsCard( selectedColor = uiState.customSecondaryColor?.let(::argbLongToThemeColor), onColorSelected = { viewModel.setCustomSecondaryColor(it?.toArgb()?.toUInt()?.toLong()) } ) - } -} - -@Composable -internal fun AppearanceSurfacesCard( - uiState: SettingsUiState, - strings: AppStrings, - sectionText: AppearanceSectionText, - menuText: MainMenuText, - viewModel: SettingsViewModel -) { - SettingsCard(title = sectionText.surfacesTitle) { - Text( - sectionText.surfacesDescription, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) - ColorPickerRow( - label = strings.colorBackground, - selectedColor = uiState.customBackgroundColor?.let(::argbLongToThemeColor), - onColorSelected = { viewModel.setCustomBackgroundColor(it?.toArgb()?.toUInt()?.toLong()) } - ) - Spacer(Modifier.height(8.dp)) - ColorPickerRow( - label = menuText.surfaceCardsLabel, - selectedColor = uiState.customSurfaceColor?.let(::argbLongToThemeColor), - onColorSelected = { viewModel.setCustomSurfaceColor(it?.toArgb()?.toUInt()?.toLong()) } - ) Spacer(Modifier.height(8.dp)) SettingsSliderTile( title = surfaceOpacityLabel(uiState.appLanguage), @@ -717,18 +387,5 @@ internal fun AppearanceSurfacesCard( valueRange = 0.35f..1f, steps = 12 ) - MrComicButton( - onClick = { - viewModel.setCustomPrimaryColor(null) - viewModel.setCustomSecondaryColor(null) - viewModel.setCustomBackgroundColor(null) - viewModel.setCustomSurfaceColor(null) - viewModel.setSurfaceOpacity(1f) - }, - modifier = Modifier.align(Alignment.End), - variant = MrComicButtonVariant.Text - ) { - Text(sectionText.paletteResetLabel) - } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceSection.kt index 1a2b77239..836a542b8 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceSection.kt @@ -23,7 +23,6 @@ internal fun AppearanceSection( ) { val menuText = remember(uiState.appLanguage) { mainMenuText(uiState.appLanguage) } val sectionText = remember(uiState.appLanguage) { appearanceSectionText(uiState.appLanguage) } - val appThemePresetText = remember(uiState.appLanguage) { appThemePresetText(uiState.appLanguage) } val libraryText = remember(uiState.appLanguage) { librarySectionText(uiState.appLanguage) } LazyColumn( @@ -62,24 +61,6 @@ internal fun AppearanceSection( ) } } - if (currentPage == AppearanceSettingsPage.THEME_STUDIO) { - item { - AppearanceStudioOverviewCard( - uiState = uiState, - strings = strings, - sectionText = sectionText, - onPageChange = onPageChange - ) - } - item { - AppearanceAppThemePresetsCard( - uiState = uiState, - strings = strings, - text = appThemePresetText, - viewModel = viewModel - ) - } - } if (currentPage == AppearanceSettingsPage.LIBRARY) item { LibraryLayoutCard( uiState = uiState, @@ -135,15 +116,6 @@ internal fun AppearanceSection( viewModel = viewModel ) } - if (currentPage == AppearanceSettingsPage.COLORS) item { - AppearanceSurfacesCard( - uiState = uiState, - strings = strings, - sectionText = sectionText, - menuText = menuText, - viewModel = viewModel - ) - } item { Spacer(Modifier.height(16.dp)) } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceStrings.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceStrings.kt index 6459093ae..53ef2ce6f 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceStrings.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceStrings.kt @@ -7,22 +7,6 @@ package io.leostrange.mrcomic.feature.settings.ui * Pure functions mapping language codes to UI strings. */ -internal fun appearanceThemeStudioTitle(language: String): String = when (language) { - "en" -> "Theme Studio" - "ja" -> "テーマスタジオ" - "zh" -> "主题工作台" - "ko" -> "테마 스튜디오" - else -> "Конструктор темы" -} - -internal fun appearanceThemeStudioDescription(language: String): String = when (language) { - "en" -> "A compact constructor for the whole app: palette, surfaces, density, shape, service elements, and saved themes." - "ja" -> "パレット、サーフェス、密度、形、補助要素、保存テーマを一か所で整えるアプリ全体のコンストラクタです。" - "zh" -> "把配色、表面、密度、形状、辅助元素和保存主题集中到一个紧凑的构造器里。" - "ko" -> "팔레트, 표면, 밀도, 형태, 보조 요소, 저장 테마를 한곳에서 다루는 앱 전체 생성기입니다." - else -> "Компактный конструктор всего приложения: палитра, поверхности, плотность, форма, сервисные элементы и сохранённые темы." -} - internal fun appearanceThemeTitle(language: String): String = when (language) { "en" -> "Theme & mood" "ja" -> "テーマとムード" @@ -40,11 +24,11 @@ internal fun appearanceScaleTitle(language: String): String = when (language) { } internal fun appearanceColorsTitle(language: String): String = when (language) { - "en" -> "Colors & surfaces" - "ja" -> "色とサーフェス" - "zh" -> "颜色与表面" - "ko" -> "색상과 표면" - else -> "Цвета и поверхности" + "en" -> "Colors" + "ja" -> "カラー" + "zh" -> "颜色" + "ko" -> "색상" + else -> "Цвета" } internal fun appearanceExtrasTitle(language: String): String = when (language) { @@ -71,70 +55,6 @@ internal fun appearanceLibraryVisualsDescription(language: String): String = whe else -> "Здесь собраны все визуальные настройки библиотеки: вид, обложки, подписи, карточки, полки и фон." } -internal data class AppThemePresetText( - val title: String, - val hint: String, - val slotPrefix: String, - val current: String, - val save: String, - val apply: String, - val clear: String, - val empty: String -) - -internal fun appThemePresetText(language: String): AppThemePresetText = when (language) { - "en" -> AppThemePresetText( - title = "Saved app themes", - hint = "Save up to three full app looks: palette, surfaces, scale, and shape.", - slotPrefix = "Slot", - current = "Current", - save = "Save theme", - apply = "Apply theme", - clear = "Clear slot", - empty = "Empty slot" - ) - "ja" -> AppThemePresetText( - title = "保存したアプリテーマ", - hint = "パレット、サーフェス、スケール、形を含むアプリ全体の見た目を3つまで保存できます。", - slotPrefix = "スロット", - current = "現在", - save = "保存", - apply = "適用", - clear = "消去", - empty = "空き" - ) - "zh" -> AppThemePresetText( - title = "已保存的应用主题", - hint = "最多保存三个完整的应用外观:配色、表面、缩放和圆角。", - slotPrefix = "槽位", - current = "当前", - save = "保存主题", - apply = "应用主题", - clear = "清空槽位", - empty = "空槽位" - ) - "ko" -> AppThemePresetText( - title = "저장된 앱 테마", - hint = "팔레트, 표면, 스케일, 형태를 포함한 앱 전체 룩을 최대 세 개 저장합니다.", - slotPrefix = "슬롯", - current = "현재", - save = "저장", - apply = "적용", - clear = "비우기", - empty = "빈 슬롯" - ) - else -> AppThemePresetText( - title = "Сохранённые темы приложения", - hint = "Сохраняйте до трёх полных вариантов оформления приложения: палитру, поверхности, масштаб и форму.", - slotPrefix = "Слот", - current = "Сейчас", - save = "Сохранить тему", - apply = "Применить тему", - clear = "Очистить слот", - empty = "Пустой слот" - ) -} - internal fun libraryMaintenanceTitle(language: String): String = when (language) { "en" -> "Maintenance" "ja" -> "メンテナンス" @@ -191,22 +111,6 @@ internal fun libraryCacheTitle(language: String): String = when (language) { else -> "Кэш и восстановление" } -internal fun libraryThemeStudioTitle(language: String): String = when (language) { - "en" -> "Theme Studio" - "ja" -> "テーマスタジオ" - "zh" -> "主题工作台" - "ko" -> "테마 스튜디오" - else -> "Конструктор темы" -} - -internal fun libraryThemeStudioDescription(language: String): String = when (language) { - "en" -> "A dense builder for the library look. Open a specific layer instead of scrolling through one long wall of cards." - "ja" -> "長いカードの壁ではなく、レイヤーごとに開いて調整する密度の高いライブラリコンストラクタです。" - "zh" -> "不再是长长的卡片墙,而是按层进入的紧凑型书库构造器。" - "ko" -> "긴 카드 벽 대신 레이어별로 들어가는 밀도 높은 라이브러리 빌더입니다." - else -> "Плотный конструктор библиотеки: вместо длинной стены карточек здесь отдельные слои настройки." -} - internal fun libraryCanvasPageTitle(language: String): String = when (language) { "en" -> "Canvas, glass & shelves" "ja" -> "キャンバス・ガラス・棚" @@ -230,35 +134,3 @@ internal fun libraryGraphicCoverStyleTitle(language: String): String = when (lan "ko" -> "그래픽 표지 스타일" else -> "Стиль графических обложек" } - -internal fun libraryThemeStudioLayoutTitle(language: String): String = when (language) { - "en" -> "Layout and spacing" - "ja" -> "レイアウトと間隔" - "zh" -> "布局与间距" - "ko" -> "레이아웃과 간격" - else -> "Макет и ритм" -} - -internal fun libraryThemeStudioVisualsTitle(language: String): String = when (language) { - "en" -> "Cards, covers, and labels" - "ja" -> "カード・表紙・ラベル" - "zh" -> "卡片、封面与标签" - "ko" -> "카드, 표지, 라벨" - else -> "Карточки, обложки и подписи" -} - -internal fun libraryThemeStudioLayoutDescription(language: String): String = when (language) { - "en" -> "Grid or list, rhythm, strip position, and the way the shelf breathes before visual styling." - "ja" -> "グリッド/リスト、リズム、ストリップ位置など、見た目より先に棚の構造を整えます。" - "zh" -> "先调整网格/列表、节奏和条带位置,再处理视觉样式。" - "ko" -> "그리드/리스트, 리듬, 스트립 위치처럼 외형보다 먼저 선반 구조를 다듬습니다." - else -> "Сначала настраивается структура полки: grid/list, ритм и положение ленты, а уже потом внешний вид." -} - -internal fun libraryThemeStudioVisualsDescription(language: String): String = when (language) { - "en" -> "Covers, shadows, labels, progress, and thumbnail behavior are tuned as one card layer." - "ja" -> "表紙、影、ラベル、進捗、サムネイル挙動をひとつのカード層として整えます。" - "zh" -> "把封面、阴影、标签、进度和缩略图行为当作一个卡片层统一调整。" - "ko" -> "표지, 그림자, 라벨, 진행 상태, 썸네일 동작을 하나의 카드 층으로 조정합니다." - else -> "Обложки, тени, подписи, прогресс и поведение миниатюр собираются в один карточный слой." -} diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceText.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceText.kt index f1a4211a2..69b81a005 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceText.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceText.kt @@ -22,8 +22,6 @@ internal data class AppearanceSectionText( val sizeShapeTitle: String, val accentColorsTitle: String, val accentColorsDescription: String, - val surfacesTitle: String, - val surfacesDescription: String, val paletteResetLabel: String, val serviceElementsTitle: String, val mascotRecapTitle: String, @@ -101,14 +99,12 @@ internal fun appearanceSectionText(language: String): AppearanceSectionText = wh AppearanceSettingsTab.BASICS to "Language and global preview.", AppearanceSettingsTab.THEME to "Presets, light/dark mode and dynamic colors.", AppearanceSettingsTab.SCALE to "Font scale, interface density and corner radius.", - AppearanceSettingsTab.COLORS to "Accent, background, surfaces and transparency.", + AppearanceSettingsTab.COLORS to "Accent colors and surface transparency.", AppearanceSettingsTab.EXTRA to "UI sounds and service elements." ), sizeShapeTitle = "Size and shape", accentColorsTitle = "Accent and signal colors", accentColorsDescription = "Accent affects buttons, active chips, progress indicators and key actions.", - surfacesTitle = "Background and surfaces", - surfacesDescription = "Background, cards and overlays are tuned separately so light and dark themes stay coherent.", paletteResetLabel = "Reset palette", serviceElementsTitle = "Service elements", mascotRecapTitle = "Mascot companion surfaces", @@ -131,14 +127,12 @@ internal fun appearanceSectionText(language: String): AppearanceSectionText = wh AppearanceSettingsTab.BASICS to "言語と全体プレビュー。", AppearanceSettingsTab.THEME to "プリセット、ライト/ダーク、ダイナミックカラー。", AppearanceSettingsTab.SCALE to "文字倍率、UI密度、角丸。", - AppearanceSettingsTab.COLORS to "アクセント、背景、サーフェス、透明度。", + AppearanceSettingsTab.COLORS to "アクセントとサーフェスの透明度。", AppearanceSettingsTab.EXTRA to "UIサウンドと補助要素。" ), sizeShapeTitle = "サイズと形", accentColorsTitle = "アクセントとシグナル色", accentColorsDescription = "アクセントはボタン、選択チップ、進捗表示、主要アクションに使われます。", - surfacesTitle = "背景とサーフェス", - surfacesDescription = "背景、カード、オーバーレイを分けて調整し、ライト/ダークの整合性を保ちます。", paletteResetLabel = "パレットをリセット", serviceElementsTitle = "補助要素", mascotRecapTitle = "マスコット表示", @@ -161,14 +155,12 @@ internal fun appearanceSectionText(language: String): AppearanceSectionText = wh AppearanceSettingsTab.BASICS to "语言与整体预览。", AppearanceSettingsTab.THEME to "预设、明暗模式和动态配色。", AppearanceSettingsTab.SCALE to "字体比例、界面密度与圆角。", - AppearanceSettingsTab.COLORS to "强调色、背景、表面和透明度。", + AppearanceSettingsTab.COLORS to "强调色与表层透明度。", AppearanceSettingsTab.EXTRA to "界面音效与附加元素。" ), sizeShapeTitle = "尺寸与形状", accentColorsTitle = "强调与提示颜色", accentColorsDescription = "强调色影响按钮、选中标签、进度指示和主要操作。", - surfacesTitle = "背景与表面", - surfacesDescription = "背景、卡片和遮罩分开调整,让亮色和暗色主题保持一致。", paletteResetLabel = "重置配色", serviceElementsTitle = "附加元素", mascotRecapTitle = "Mr.Comic 辅助界面", @@ -191,14 +183,12 @@ internal fun appearanceSectionText(language: String): AppearanceSectionText = wh AppearanceSettingsTab.BASICS to "언어와 전체 미리보기.", AppearanceSettingsTab.THEME to "프리셋, 라이트/다크, 동적 색상.", AppearanceSettingsTab.SCALE to "글자 크기, UI 밀도, 코너 반경.", - AppearanceSettingsTab.COLORS to "강조색, 배경, 표면, 투명도.", + AppearanceSettingsTab.COLORS to "강조색과 표면 투명도.", AppearanceSettingsTab.EXTRA to "UI 사운드와 보조 요소." ), sizeShapeTitle = "크기와 형태", accentColorsTitle = "강조 및 상태 색상", accentColorsDescription = "강조색은 버튼, 활성 칩, 진행 표시, 핵심 액션에 반영됩니다.", - surfacesTitle = "배경과 표면", - surfacesDescription = "배경, 카드, 오버레이를 따로 조정해 라이트/다크가 어긋나지 않게 합니다.", paletteResetLabel = "팔레트 초기화", serviceElementsTitle = "보조 요소", mascotRecapTitle = "마스코트 보조 표면", @@ -221,14 +211,12 @@ internal fun appearanceSectionText(language: String): AppearanceSectionText = wh AppearanceSettingsTab.BASICS to "Язык интерфейса и общее превью.", AppearanceSettingsTab.THEME to "Пресеты, светлая/тёмная тема и динамические цвета.", AppearanceSettingsTab.SCALE to "Масштаб шрифта, плотность интерфейса и скругления.", - AppearanceSettingsTab.COLORS to "Акцент, фон, поверхности и прозрачность.", + AppearanceSettingsTab.COLORS to "Акцент и прозрачность поверхностей.", AppearanceSettingsTab.EXTRA to "Звуки интерфейса и служебные элементы." ), sizeShapeTitle = "Размер и форма", accentColorsTitle = "Акцент и сигнальные цвета", accentColorsDescription = "Акцент влияет на кнопки, активные чипы, индикаторы прогресса и ключевые элементы интерфейса.", - surfacesTitle = "Фон и поверхности", - surfacesDescription = "Фон, карточки и наложения настраиваются отдельно, чтобы светлые и тёмные темы не конфликтовали.", paletteResetLabel = "Сбросить палитру", serviceElementsTitle = "Служебные элементы", mascotRecapTitle = "Поверхности с маскотом", @@ -286,11 +274,11 @@ internal fun appearancePageNavItems( title = appearanceColorsTitle(language), description = text.tabHints[AppearanceSettingsTab.COLORS].orEmpty(), summary = when (language) { - "en" -> "Accent, surfaces, transparency" - "ja" -> "アクセント、面、透明度" - "zh" -> "强调色、表面、透明度" - "ko" -> "강조색, 표면, 투명도" - else -> "Акцент, поверхности, прозрачность" + "en" -> "Accent colors, surface transparency" + "ja" -> "アクセント色、サーフェス透明度" + "zh" -> "强调色、表层透明度" + "ko" -> "강조색, 표면 투명도" + else -> "Акцентные цвета, прозрачность" }, icon = Icons.Default.ColorLens ), @@ -306,7 +294,7 @@ internal fun appearancePageNavItems( else -> "Шрифт, плотность, скругления" }, icon = Icons.Default.Tune - ) + ), ) /* ──── appearancePageTitle (fun) ──── */ @@ -318,7 +306,6 @@ internal fun appearancePageTitle( AppearanceSettingsPage.OVERVIEW -> text.leadTitle AppearanceSettingsPage.BASICS -> text.tabLabels[AppearanceSettingsTab.BASICS].orEmpty() AppearanceSettingsPage.LIBRARY -> appearanceLibraryVisualsTitle(language) - AppearanceSettingsPage.THEME_STUDIO -> appearanceThemeStudioTitle(language) AppearanceSettingsPage.THEME -> appearanceThemeTitle(language) AppearanceSettingsPage.SCALE -> appearanceScaleTitle(language) AppearanceSettingsPage.COLORS -> appearanceColorsTitle(language) @@ -334,7 +321,6 @@ internal fun appearancePageDescription( AppearanceSettingsPage.OVERVIEW -> text.leadDescription AppearanceSettingsPage.BASICS -> text.tabHints[AppearanceSettingsTab.BASICS].orEmpty() AppearanceSettingsPage.LIBRARY -> appearanceLibraryVisualsDescription(language) - AppearanceSettingsPage.THEME_STUDIO -> appearanceThemeStudioDescription(language) AppearanceSettingsPage.THEME -> when (language) { "en" -> "Presets, light and dark modes, dynamic color, and the overall mood of the app." "ja" -> "プリセット、ライト/ダーク、ダイナミックカラーなど、アプリ全体の雰囲気をまとめます。" @@ -344,11 +330,11 @@ internal fun appearancePageDescription( } AppearanceSettingsPage.SCALE -> text.tabHints[AppearanceSettingsTab.SCALE].orEmpty() AppearanceSettingsPage.COLORS -> when (language) { - "en" -> "Accent, background, cards, overlays, and transparency are tuned as one palette layer." - "ja" -> "アクセント、背景、カード、オーバーレイ、透明度を1つのパレットとして整えます。" - "zh" -> "把强调色、背景、卡片、遮罩和透明度当作一层调色板来统一调整。" - "ko" -> "강조색, 배경, 카드, 오버레이, 투명도를 하나의 팔레트 층으로 정리합니다." - else -> "Акцент, фон, карточки, наложения и прозрачность собраны в один палитровый слой." + "en" -> "Accent colors and surface transparency." + "ja" -> "アクセントとサーフェスの透明度を整えます。" + "zh" -> "调整强调色和表层透明度。" + "ko" -> "강조색과 표면 투명도를 조정합니다." + else -> "Акцентные цвета и прозрачность поверхностей." } AppearanceSettingsPage.EXTRA -> text.tabHints[AppearanceSettingsTab.EXTRA].orEmpty() } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt index d1026bd0d..6981c61d7 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt @@ -42,15 +42,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface -import io.leostrange.mrcomic.core.ui.designsystem.MrComicCompactValueRow -import io.leostrange.mrcomic.core.ui.designsystem.MrComicPanelCard +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSliderTile -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow -import io.leostrange.mrcomic.core.ui.library.RootChromePillShape -import io.leostrange.mrcomic.core.ui.library.RootChromeTone +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSectionHeader +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider import io.leostrange.mrcomic.core.ui.library.rootChromeIconContainerColor -import io.leostrange.mrcomic.core.ui.library.rootChromePanelColor import io.leostrange.mrcomic.core.ui.library.rootChromePillContainerColor import io.leostrange.mrcomic.core.ui.library.rootChromePillContentColor import io.leostrange.mrcomic.core.ui.locale.LocalStrings @@ -73,10 +70,18 @@ internal fun SettingsCard( title: String, content: @Composable ColumnScope.() -> Unit ) { - MrComicPanelCard( - title = title, - content = content - ) + // Editorial Ink: section header (no card frame). Sub-cards inside + // (e.g. AppearanceLanguageCard) still render their own previews, but + // the outer wrapper no longer adds a second layer of background and + // border. Subsequent passes will flatten the inner rows to MrComicListItem. + Column(modifier = Modifier.fillMaxWidth()) { + MrComicSectionHeader(title = title) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } } @Composable @@ -104,15 +109,19 @@ internal fun SwitchRow( onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true ) { - MrComicSwitchRow( + MrComicListItem( title = title, subtitle = subtitle, - checked = checked, enabled = enabled, - onCheckedChange = { value -> - UIFeedback.playSelect() - onCheckedChange(value) - } + onClick = null, + trailing = MrComicListItemTrailing.Switch( + checked = checked, + onCheckedChange = { value -> + UIFeedback.playSelect() + onCheckedChange(value) + }, + enabled = enabled, + ), ) } @@ -122,17 +131,16 @@ internal fun SettingsPickerTile( value: String, onClick: () -> Unit, subtitle: String? = null, - compact: Boolean = false + @Suppress("UNUSED_PARAMETER") compact: Boolean = false ) { - MrComicCompactValueRow( + MrComicListItem( title = title, subtitle = subtitle, - value = value, + trailing = MrComicListItemTrailing.Value(value), onClick = { UIFeedback.playSelect() onClick() }, - compact = compact ) } @@ -146,15 +154,24 @@ internal fun SettingsSliderTile( steps: Int = 0, subtitle: String? = null ) { - MrComicSliderTile( - title = title, - valueLabel = valueLabel, - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - subtitle = subtitle - ) + Column(modifier = Modifier.fillMaxWidth()) { + MrComicListItem( + title = title, + subtitle = subtitle, + trailing = MrComicListItemTrailing.Value(valueLabel), + onClick = null, + divider = false, + ) + MrComicSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + ) + } } @Composable @@ -344,81 +361,6 @@ internal fun SettingsCompactSummaryCard( } } -internal data class SettingsStudioOverviewItem( - val icon: ImageVector, - val title: String, - val description: String, - val summary: String? = null, - val onClick: () -> Unit -) - -@Composable -internal fun SettingsStudioOverviewCard( - title: String, - hint: String, - summaryItems: List>, - sectionsTitle: String, - sections: List -) { - SettingsCard(title = title) { - Text( - text = hint, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (summaryItems.isNotEmpty()) { - Spacer(Modifier.height(10.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - summaryItems.forEach { (label, value) -> - Surface( - shape = RootChromePillShape, - color = rootChromePanelColor(MaterialTheme.colorScheme, RootChromeTone.SOFT) - ) { - Column( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = value, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } - } - Spacer(Modifier.height(12.dp)) - Text( - text = sectionsTitle, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - sections.forEachIndexed { index, item -> - SettingsNavItem( - icon = item.icon, - title = item.title, - description = item.description, - summary = item.summary, - onClick = item.onClick - ) - if (index != sections.lastIndex) { - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) - ) - } - } - } -} @Composable internal fun SettingsPreviewBanner( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsEnums.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsEnums.kt index e6b87a8f3..94ef11439 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsEnums.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsEnums.kt @@ -12,7 +12,7 @@ internal enum class SettingsSection { READ_ALOUD, TRANSLATION, AI_SERVICES, STORAGE, ADVANCED, ABOUT } -internal enum class AppearanceSettingsPage { OVERVIEW, BASICS, LIBRARY, THEME_STUDIO, THEME, SCALE, COLORS, EXTRA } +internal enum class AppearanceSettingsPage { OVERVIEW, BASICS, LIBRARY, THEME, SCALE, COLORS, EXTRA } internal enum class ReaderSettingsPage { OVERVIEW, TEXT_APPEARANCE, PAGE_LAYOUT, HEADERS, PAGING, BEHAVIOR } internal enum class LibrarySettingsPage { OVERVIEW, ACCESS, CACHE, IMPORT_EXPORT } internal enum class SyncSettingsPage { OVERVIEW, BACKUP } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibrarySection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibrarySection.kt index 201c8d6ec..83e5d8311 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibrarySection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibrarySection.kt @@ -440,7 +440,7 @@ internal fun libraryGroupByLabel(groupBy: String, language: String): String = wh // Phase T (2026-08-04): private fun parseLibrarySettingsPage moved here from SettingsScreen.kt internal fun parseLibrarySettingsPage(raw: String): LibrarySettingsPage = when (raw) { - "DISPLAY", "COVERS", "CANVAS", "THEME_STUDIO", "SORTING" -> LibrarySettingsPage.OVERVIEW + "DISPLAY", "COVERS", "CANVAS", "SORTING" -> LibrarySettingsPage.OVERVIEW else -> runCatching { LibrarySettingsPage.valueOf(raw) }.getOrDefault(LibrarySettingsPage.OVERVIEW) } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresets.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresets.kt index 9403fb36f..33a95173b 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresets.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresets.kt @@ -1,8 +1,6 @@ package io.leostrange.mrcomic.feature.settings.ui import io.leostrange.mrcomic.core.ui.theme.ReadingPreset -import io.leostrange.mrcomic.core.ui.theme.ThemeMode -import io.leostrange.mrcomic.core.ui.theme.ThemePreset import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -19,11 +17,6 @@ data class LibraryThemePresetSlot( val serialized: String? = null ) -data class AppThemePresetSlot( - val index: Int, - val serialized: String? = null -) - data class ReaderStylePresetSlot( val index: Int, val serialized: String? = null @@ -121,58 +114,6 @@ internal fun JSONObject.optReaderColorLong(vararg keys: String): Long? = keys.fi } } -data class AppThemePresetSnapshot( - val themePreset: String, - val themeMode: String, - val useDynamicColor: Boolean, - val useAmoledDark: Boolean, - val customPrimaryColor: Long?, - val customSecondaryColor: Long?, - val customBackgroundColor: Long?, - val customSurfaceColor: Long?, - val surfaceOpacity: Float, - val uiFontScale: Float, - val uiDensityScale: Float, - val uiCornerRadius: Int -) { - fun serialize(): String = JSONObject().apply { - put("themePreset", themePreset) - put("themeMode", themeMode) - put("useDynamicColor", useDynamicColor) - put("useAmoledDark", useAmoledDark) - put("customPrimaryColor", customPrimaryColor?.toString()) - put("customSecondaryColor", customSecondaryColor?.toString()) - put("customBackgroundColor", customBackgroundColor?.toString()) - put("customSurfaceColor", customSurfaceColor?.toString()) - put("surfaceOpacity", surfaceOpacity.toDouble()) - put("uiFontScale", uiFontScale.toDouble()) - put("uiDensityScale", uiDensityScale.toDouble()) - put("uiCornerRadius", uiCornerRadius) - }.toString() -} - -fun parseAppThemePreset(serialized: String?): AppThemePresetSnapshot? = serialized - ?.takeIf { it.isNotBlank() } - ?.let { raw -> - runCatching { - val json = JSONObject(raw) - AppThemePresetSnapshot( - themePreset = json.optString("themePreset", ThemePreset.CUSTOM.name), - themeMode = json.optString("themeMode", ThemeMode.SYSTEM.name), - useDynamicColor = json.optBoolean("useDynamicColor", true), - useAmoledDark = json.optBoolean("useAmoledDark", false), - customPrimaryColor = json.optString("customPrimaryColor").takeIf { it.isNotBlank() }?.toLongOrNull(), - customSecondaryColor = json.optString("customSecondaryColor").takeIf { it.isNotBlank() }?.toLongOrNull(), - customBackgroundColor = json.optString("customBackgroundColor").takeIf { it.isNotBlank() }?.toLongOrNull(), - customSurfaceColor = json.optString("customSurfaceColor").takeIf { it.isNotBlank() }?.toLongOrNull(), - surfaceOpacity = json.optDouble("surfaceOpacity", 1.0).toFloat().coerceIn(0.35f, 1f), - uiFontScale = json.optDouble("uiFontScale", 1.0).toFloat().coerceIn(0.85f, 1.3f), - uiDensityScale = json.optDouble("uiDensityScale", 1.0).toFloat().coerceIn(0.82f, 1.18f), - uiCornerRadius = json.optInt("uiCornerRadius", 12).coerceIn(0, 32) - ) - }.getOrNull() - } - fun parseReaderStylePreset(serialized: String?): ReaderStylePresetSnapshot? = serialized ?.takeIf { it.isNotBlank() } ?.let { raw -> diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsController.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsController.kt index 2c6236b1a..c6cfdaf20 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsController.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsController.kt @@ -15,7 +15,6 @@ import io.leostrange.mrcomic.core.ui.library.LibraryThemePresetSnapshot import io.leostrange.mrcomic.core.ui.library.parseLibraryThemePreset import io.leostrange.mrcomic.core.ui.library.libraryQuickPresetSpec import io.leostrange.mrcomic.core.ui.library.normalizeLibraryBackgroundStyle -import io.leostrange.mrcomic.core.ui.theme.ThemeMode import io.leostrange.mrcomic.core.ui.theme.ThemePreset import io.leostrange.mrcomic.core.ui.theme.ThemePreferencesRepository import kotlinx.coroutines.CoroutineScope @@ -51,28 +50,6 @@ internal class SettingsPresetsController( } } - fun saveAppThemePreset(slot: Int) { - val snapshot = uiState().toAppThemePresetSnapshot() - scope.launch { - preferences.set(appThemePresetKey(slot), snapshot.serialize()) - } - } - - fun applyAppThemePreset(slot: Int) { - val snapshot = parseAppThemePreset( - uiState().appThemePresetSlots.firstOrNull { it.index == slot }?.serialized - ) ?: return - scope.launch { - applyAppThemePresetSnapshot(snapshot) - } - } - - fun clearAppThemePreset(slot: Int) { - scope.launch { - preferences.set(appThemePresetKey(slot), "") - } - } - fun saveReaderStylePreset(slot: Int) { val normalizedSlot = slot.coerceIn(1, 3) val existingEntry = uiState().readerStylePresetEntries.getOrNull(normalizedSlot - 1) @@ -295,33 +272,13 @@ internal class SettingsPresetsController( } } - private suspend fun applyAppThemePresetSnapshot( - snapshot: AppThemePresetSnapshot - ) { - themePreferencesRepository.setThemePreset( - runCatching { ThemePreset.valueOf(snapshot.themePreset) }.getOrDefault(ThemePreset.CUSTOM) - ) - themePreferencesRepository.setThemeMode( - runCatching { ThemeMode.valueOf(snapshot.themeMode) }.getOrDefault(ThemeMode.SYSTEM) - ) - themePreferencesRepository.setUseDynamicColor(snapshot.useDynamicColor) - themePreferencesRepository.setUseAmoledDark(snapshot.useAmoledDark) - themePreferencesRepository.setCustomPrimaryColor(snapshot.customPrimaryColor) - themePreferencesRepository.setCustomSecondaryColor(snapshot.customSecondaryColor) - themePreferencesRepository.setCustomBackgroundColor(snapshot.customBackgroundColor) - themePreferencesRepository.setCustomSurfaceColor(snapshot.customSurfaceColor) - themePreferencesRepository.setSurfaceOpacity(snapshot.surfaceOpacity) - preferences.set(PreferencesKeys.UI_FONT_SCALE, snapshot.uiFontScale) - preferences.set(PreferencesKeys.UI_DENSITY_SCALE, snapshot.uiDensityScale) - preferences.set(PreferencesKeys.UI_CORNER_RADIUS, snapshot.uiCornerRadius) - } - private suspend fun applyReaderStylePresetSnapshot( snapshot: ReaderStylePresetSnapshot ) { preferences.set(PreferencesKeys.READER_PRESET, snapshot.readerPreset) preferences.set(PreferencesKeys.TEXT_FONT_SIZE, snapshot.textFontSize) preferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, snapshot.textColorScheme) + preferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, snapshot.textColorScheme) persistNullableColor(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, snapshot.textCustomTextColor) persistNullableColor(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, snapshot.textCustomBackgroundColor) persistNullableColor(PreferencesKeys.TEXT_CUSTOM_ACCENT_COLOR, snapshot.textCustomAccentColor) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsScreen.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsScreen.kt index b7547d3a6..e08570410 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsScreen.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsScreen.kt @@ -88,7 +88,6 @@ fun SettingsScreen( .let { when (it) { AppearanceSettingsPage.BASICS, - AppearanceSettingsPage.THEME_STUDIO, AppearanceSettingsPage.EXTRA -> AppearanceSettingsPage.OVERVIEW else -> it } @@ -285,7 +284,6 @@ fun SettingsScreen( AppearanceSettingsPage.OVERVIEW -> settingsSectionMeta(SettingsSection.APPEARANCE, strings.languageCode, strings).title AppearanceSettingsPage.BASICS -> appearanceText.tabLabels[AppearanceSettingsTab.BASICS].orEmpty() AppearanceSettingsPage.LIBRARY -> appearanceLibraryVisualsTitle(strings.languageCode) - AppearanceSettingsPage.THEME_STUDIO -> appearanceThemeStudioTitle(strings.languageCode) AppearanceSettingsPage.THEME -> appearanceThemeTitle(strings.languageCode) AppearanceSettingsPage.SCALE -> appearanceText.tabLabels[AppearanceSettingsTab.SCALE].orEmpty() AppearanceSettingsPage.COLORS -> appearanceColorsTitle(strings.languageCode) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsUiState.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsUiState.kt index d9bbcc840..9acbf2151 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsUiState.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsUiState.kt @@ -166,7 +166,6 @@ data class SettingsUiState( val libraryGraphicCoverStyle: String = DEFAULT_LIBRARY_GRAPHIC_COVER_STYLE, val librarySortOrder: String = "DATE_ADDED_DESC", val libraryGroupBy: String = "FOLDER", - val appThemePresetSlots: List = emptyList(), val libraryThemePresetSlots: List = emptyList(), val readerStylePresetSlots: List = emptyList(), val readerStylePresetEntries: List = emptyList(), @@ -251,6 +250,16 @@ data class DictionaryDownloadState( val progress: Map = emptyMap() // language -> progress (0-100) ) +/* ──── DictionaryOperationState (sealed) ──── */ +sealed interface DictionaryOperationState { + data object Idle : DictionaryOperationState + data class Downloading(val language: String, val progress: Int = 0) : DictionaryOperationState + data class Deleting(val language: String) : DictionaryOperationState + data class Importing(val language: String) : DictionaryOperationState + data class Exporting(val language: String) : DictionaryOperationState + data class Error(val message: String) : DictionaryOperationState +} + /* ──── StatusState (data class) ──── */ internal data class StatusState( val isClearingCache: Boolean = false, @@ -266,4 +275,3 @@ internal data class SettingsTranslationAvailabilityState( val snapshot: TranslationAvailabilitySnapshot = TranslationAvailabilitySnapshot(), val pairKnown: Boolean = false ) - diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt index 69f6f5fb9..82dfd7724 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt @@ -197,24 +197,12 @@ internal fun SettingsUiStateFlowBuilder.createExtrasFlow6c() = combine( ) } -internal fun SettingsUiStateFlowBuilder.createExtrasFlow6d() = combine( - preferences.get(PreferencesKeys.APP_THEME_PRESET_1, ""), - preferences.get(PreferencesKeys.APP_THEME_PRESET_2, ""), - preferences.get(PreferencesKeys.APP_THEME_PRESET_3, "") - ) { preset1, preset2, preset3 -> - listOf( - AppThemePresetSlot(index = 1, serialized = preset1.ifBlank { null }), - AppThemePresetSlot(index = 2, serialized = preset2.ifBlank { null }), - AppThemePresetSlot(index = 3, serialized = preset3.ifBlank { null }) - ) - } - internal fun SettingsUiStateFlowBuilder.createExtrasFlow345() = combine(createExtrasFlow3(), createExtrasFlow4(), createExtrasFlow5()) { e3, e4, e5 -> e3 + e4 + e5 } internal fun SettingsUiStateFlowBuilder.createExtrasFlow6() = combine(createExtrasFlow6a(), createExtrasFlow6b(), createExtrasFlow6c(), createExtrasFlow6e()) { left, middle, right, style -> left + middle + right + style } -internal fun SettingsUiStateFlowBuilder.createExtrasFlow3456() = combine(createExtrasFlow345(), createExtrasFlow6(), createExtrasFlow6d()) { left, middle, right -> - left + middle + right +internal fun SettingsUiStateFlowBuilder.createExtrasFlow3456() = combine(createExtrasFlow345(), createExtrasFlow6()) { left, right -> + left + right } internal fun SettingsUiStateFlowBuilder.createExtrasFlow7a() = combine( preferences.get(PreferencesKeys.READER_EYE_REST_ENABLED, false), @@ -409,11 +397,6 @@ internal fun SettingsUiStateFlowBuilder.createCombinedSettingsUiState(): Flow PreferencesKeys.LIBRARY_THEME_PRESET_3 } -/* ──── appThemePresetKey ──── */ -internal fun appThemePresetKey(slot: Int) = when (slot.coerceIn(1, 3)) { - 1 -> PreferencesKeys.APP_THEME_PRESET_1 - 2 -> PreferencesKeys.APP_THEME_PRESET_2 - else -> PreferencesKeys.APP_THEME_PRESET_3 -} - /* ──── readerStylePresetKey ──── */ internal fun readerStylePresetKey(slot: Int) = when (slot.coerceIn(1, 3)) { 1 -> PreferencesKeys.READER_STYLE_PRESET_1 @@ -150,23 +143,6 @@ internal fun SettingsUiState.toLibraryThemePresetSnapshot(): LibraryThemePresetS surfaceOpacity = surfaceOpacity ) -/* ──── toAppThemePresetSnapshot ──── */ -internal fun SettingsUiState.toAppThemePresetSnapshot(): AppThemePresetSnapshot = - AppThemePresetSnapshot( - themePreset = themePreset, - themeMode = themeMode.name, - useDynamicColor = useDynamicColor, - useAmoledDark = useAmoledDark, - customPrimaryColor = customPrimaryColor, - customSecondaryColor = customSecondaryColor, - customBackgroundColor = customBackgroundColor, - customSurfaceColor = customSurfaceColor, - surfaceOpacity = surfaceOpacity, - uiFontScale = uiFontScale, - uiDensityScale = uiDensityScale, - uiCornerRadius = uiCornerRadius - ) - /* ──── toReaderStylePresetSnapshot ──── */ internal fun SettingsUiState.toReaderStylePresetSnapshot( displayName: String? = null diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelPresets.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelPresets.kt index 90ad820f8..bca1fc5f3 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelPresets.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelPresets.kt @@ -12,12 +12,6 @@ internal fun SettingsViewModel.applyLibraryThemePreset(slot: Int) = presetsContr internal fun SettingsViewModel.clearLibraryThemePreset(slot: Int) = presetsController.clearLibraryThemePreset(slot) -internal fun SettingsViewModel.saveAppThemePreset(slot: Int) = presetsController.saveAppThemePreset(slot) - -internal fun SettingsViewModel.applyAppThemePreset(slot: Int) = presetsController.applyAppThemePreset(slot) - -internal fun SettingsViewModel.clearAppThemePreset(slot: Int) = presetsController.clearAppThemePreset(slot) - internal fun SettingsViewModel.saveReaderStylePreset(slot: Int) = presetsController.saveReaderStylePreset(slot) internal fun SettingsViewModel.applyReaderStylePreset(slot: Int) = presetsController.applyReaderStylePreset(slot) diff --git a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsControllerTest.kt b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsControllerTest.kt index 1b129d264..916dd96bb 100644 --- a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsControllerTest.kt +++ b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsPresetsControllerTest.kt @@ -74,17 +74,6 @@ class SettingsPresetsControllerTest { coVerify(exactly = 1) { preferences.set(PreferencesKeys.LIBRARY_THEME_PRESET_2, "") } } - @Test - fun saveAppThemePresetPersistsSerializedSnapshot() = runTest { - coEvery { preferences.set(any(), any()) } returns Unit - val controller = createController { SettingsUiState() } - - controller.saveAppThemePreset(3) - advanceUntilIdle() - - coVerify(exactly = 1) { preferences.set(PreferencesKeys.APP_THEME_PRESET_3, any()) } - } - @Test fun applyLibraryZonePresetWritesDarkStudyChain() = runTest { coEvery { preferences.set(any(), any()) } returns Unit From aedc90198342bbb42cd97786531fb1bcf93bef87 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:19:42 +0700 Subject: [PATCH 05/17] fix: stabilize reader pagination and controls --- .../formats/base/UnifiedReaderCssBuilder.kt | 32 ++++- .../formats/text/TextFormatReaderHtml.kt | 4 +- .../formats/text/TextFormatReaderMarkdown.kt | 2 +- .../formats/text/TextFormatReaderSource.kt | 34 ++---- .../formats/text/TextHtmlTransformUtils.kt | 50 +++++--- .../base/UnifiedReaderCssBuilderTest.kt | 27 +++++ .../engine/formats/text/HtmlSupportTest.kt | 71 ++++++++++- .../reader/ui/ReaderFootnoteAnchorPolicy.kt | 6 +- .../reader/ui/ReaderNavigationController.kt | 24 ++-- .../feature/reader/ui/ReaderPagedLayoutJs.kt | 8 +- .../feature/reader/ui/ReaderReadingTab.kt | 15 ++- .../mrcomic/feature/reader/ui/ReaderScreen.kt | 43 ++++--- .../reader/ui/ReaderSettingsController.kt | 9 +- .../feature/reader/ui/ReaderStyleTab.kt | 110 ++++++++++-------- .../feature/reader/ui/ReaderTabWidgets.kt | 16 ++- .../reader/ui/ReaderTextChromeLayoutPolicy.kt | 5 +- .../feature/reader/ui/ReaderTextSettingsJs.kt | 27 +++-- .../feature/reader/ui/ReaderUiState.kt | 33 +++++- .../feature/reader/ui/ReaderWebView.kt | 7 ++ .../reader/ui/ReaderWebViewJavaScript.kt | 108 ++++++++++++++--- .../reader/ui/ReaderWebViewTouchController.kt | 52 +++++---- .../reader/ui/ReaderChromeInsetPolicyTest.kt | 1 + .../ui/ReaderFootnoteAnchorPolicyTest.kt | 34 ++++++ .../ui/ReaderSectionPagingPolicyTest.kt | 34 ++++++ .../ui/ReaderWebViewTouchControllerTest.kt | 95 +++++++++++++++ 25 files changed, 666 insertions(+), 181 deletions(-) diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilder.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilder.kt index 538c3036e..449e8db37 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilder.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilder.kt @@ -72,7 +72,8 @@ internal fun buildReaderDocumentCss( append(""" p, div.paragraph { margin: var(--mrcomic-paragraph-spacing) 0; - ${if (!textAlignOnBody) "text-align: var(--mrcomic-text-align); " else ""}text-indent: 1.5em; + ${if (!textAlignOnBody) "text-align: var(--mrcomic-text-align); " else ""}text-align-last: start; + text-indent: 1.5em; } p:first-child, div.paragraph:first-child, @@ -87,6 +88,11 @@ internal fun buildReaderDocumentCss( font-weight: var(--mrcomic-heading-font-weight); line-height: var(--mrcomic-heading-line-height); margin: var(--mrcomic-heading-margin); + overflow-wrap: break-word; + word-break: normal; + width: auto !important; + max-width: 100% !important; + box-sizing: border-box; $hyphenDecl } h1 { font-size: clamp(1.1em, 1.7em, 2rem); letter-spacing: 0.04em; text-transform: uppercase; } @@ -97,7 +103,9 @@ internal fun buildReaderDocumentCss( } append(""" - img { width: auto; max-width: 100% !important; height: auto !important; display: block; margin: var(--mrcomic-img-margin); page-break-inside: avoid; break-inside: avoid; } + img { width: auto; max-width: 100% !important; height: auto !important; display: block; margin: var(--mrcomic-img-margin); page-break-inside: avoid; break-inside: avoid; box-sizing: border-box; } + table { max-width: 100% !important; box-sizing: border-box; } + hr { max-width: 100%; box-sizing: border-box; } a[href] { -webkit-user-select: text; user-select: text; } """.trimIndent()) @@ -137,6 +145,26 @@ internal fun buildReaderDocumentCss( center, [align="center"], .center { text-align: center; text-indent: 0; } [align="right"], .right { text-align: right; text-indent: 0; } [align="left"], .left { text-align: left; } + body > h1:first-child, .mc-title-block, .titlepage h1, .title-page h1 { + width: auto !important; + max-width: 100% !important; + font-size: clamp(1.6em, 10vw, 2.6em) !important; + line-height: 1.2 !important; + text-align: center !important; + text-indent: 0 !important; + overflow-wrap: anywhere !important; + word-break: normal !important; + white-space: normal !important; + } + nav.toc, .toc, .table-of-contents { + display: table; + width: auto; + max-width: 100%; + margin-inline: auto; + text-align: start; + text-indent: 0; + } + nav.toc table, .toc table, .table-of-contents table { width: auto; max-width: 100%; margin-inline: auto; } """.trimIndent()) } diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderHtml.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderHtml.kt index 2f9389517..d129f217b 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderHtml.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderHtml.kt @@ -84,7 +84,7 @@ internal fun TextFormatReader.markupPages( internal fun TextFormatReader.splitMarkupPages(raw: String): List { val delimiter = Regex( - """<(?:mbp:pagebreak|pagebreak|hr)\b[^>]*(?:/?>|>.*?)""", + """<(?:mbp:pagebreak|pagebreak)\b[^>]*(?:/?>|>.*?)|]*(?:data-mrcomic-pagebreak\s*=\s*["']?true|class\s*=\s*["'][^"']*mrcomic-pagebreak))[^>]*/?>""", setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE) ) return raw.split(delimiter) @@ -181,7 +181,7 @@ internal fun TextFormatReader.paginateHtmlDocument( title.isNotBlank() && childBlocks.none { it.contains(title, ignoreCase = true) } ) { - childBlocks.add(0, "

${htmlEscapeText(title)}

") + childBlocks.add(0, "

${htmlEscapeText(title)}

") } if (childBlocks.isEmpty()) { diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderMarkdown.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderMarkdown.kt index 48f20896f..57ec69962 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderMarkdown.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderMarkdown.kt @@ -264,7 +264,7 @@ internal fun TextFormatReader.processTechnicalMarkdown(raw: String): List${htmlEscapeText(title)}") + append("

${htmlEscapeText(title)}

") } if (author.isNotBlank()) { append("

Author: ${htmlEscapeText(author)}

") diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderSource.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderSource.kt index 785bf6ff0..b7acaf19e 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderSource.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextFormatReaderSource.kt @@ -27,29 +27,19 @@ internal fun TextFormatReader.sectionHtmlDocument(raw: String): TextDocumentData val footnotes = extractReaderHtmlFootnotes(raw) val contentHtml = footnotes.contentHtml val preservePublisherLayout = shouldPreserveHtmlPublisherLayout(contentHtml) - val pages = if (isGutenbergHtml(contentHtml)) { - paginateHtmlDocument( - raw = contentHtml, - baseUrl = readerBaseUrl, - preservePublisherLayout = true, - baseCss = PRESERVE_LAYOUT_HTML_CSS, - keepWholeDocument = true - ) - } else { - paginateHtmlDocument( - raw = contentHtml, - baseUrl = readerBaseUrl, - preservePublisherLayout = preservePublisherLayout, - baseCss = if (preservePublisherLayout) { - PRESERVE_LAYOUT_HTML_CSS - } else { - DEFAULT_READER_HTML_CSS - }, - keepWholeDocument = true - ) - } + val pages = paginateHtmlDocument( + raw = contentHtml, + baseUrl = readerBaseUrl, + preservePublisherLayout = preservePublisherLayout, + baseCss = if (preservePublisherLayout) { + PRESERVE_LAYOUT_HTML_CSS + } else { + DEFAULT_READER_HTML_CSS + }, + keepWholeDocument = true + ) val anchored = addHtmlHeadingAnchorsToPages(pages) - val sections = if (preservePublisherLayout || isGutenbergHtml(contentHtml)) { + val sections = if (preservePublisherLayout) { anchored.pages.mapIndexed { index, html -> TextDocumentSection(index = index, html = html, baseUrl = readerBaseUrl) } diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextHtmlTransformUtils.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextHtmlTransformUtils.kt index 7dcff69cd..64a3997e6 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextHtmlTransformUtils.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/text/TextHtmlTransformUtils.kt @@ -18,7 +18,7 @@ internal val HTML_READER_SAFE_LIST: Safelist = Safelist.relaxed() ) .addAttributes(":all", "id", "class", "title", "lang", "dir", "style", "align", "data-mrcomic-pagebreak") .addAttributes("img", "src", "alt", "title", "width", "height", "loading", "align") - .addAttributes("a", "href", "name", "target") + .addAttributes("a", "href", "name", "target", "role", "epub:type", "data-footnote-id", "data-footnote") .addAttributes("font", "size", "face", "color") .addAttributes("th", "colspan", "rowspan") .addAttributes("td", "colspan", "rowspan") @@ -44,6 +44,8 @@ internal fun extractReaderHtmlFootnotes(raw: String): ReaderHtmlFootnoteExtracti val noteBodies = document.allElements.filter(::isReaderHtmlFootnoteBody) .filter { element -> element.parents().none(::isReaderHtmlFootnoteBody) } + linkPlainNumericFootnoteMarkers(document, noteBodies) + noteBodies.forEach { note -> val anchorId = note.id().trim() val text = note.text().replace(Regex("\\s+"), " ").trim() @@ -58,6 +60,26 @@ internal fun extractReaderHtmlFootnotes(raw: String): ReaderHtmlFootnoteExtracti ) } +private fun linkPlainNumericFootnoteMarkers(document: Document, noteBodies: List) { + val targetIds = noteBodies.map(Element::id).filter(String::isNotBlank) + if (targetIds.isEmpty()) return + + document.select("sup, sub, span.footnote-ref, span.noteref, span.note-ref") + .filterNot { marker -> marker.parents().any { it.normalName() == "a" || isReaderHtmlFootnoteBody(it) } } + .forEach { marker -> + val markerNumber = Regex("""\d{1,4}""").find(marker.text())?.value ?: return@forEach + val targetId = targetIds.firstOrNull { id -> + id == markerNumber || Regex("""(?:^|[-_])${Regex.escape(markerNumber)}$""", RegexOption.IGNORE_CASE) + .containsMatchIn(id) + } ?: return@forEach + val link = Element("a") + .attr("href", "#$targetId") + .addClass("mrcomic-generated-noteref") + marker.replaceWith(link) + link.appendChild(marker) + } +} + private fun isReaderHtmlFootnoteBody(element: Element): Boolean { val epubTypeTokens = element.attr("epub:type") .lowercase() @@ -134,21 +156,13 @@ internal fun preserveGutenbergHtmlDocument(raw: String, baseUrl: String?): Strin } internal fun shouldPreserveHtmlPublisherLayout(raw: String): Boolean { - if (isGutenbergHtml(raw)) return true val lowerRaw = raw.lowercase() - if (Regex("""<(table|thead|tbody|tfoot|tr|td|th|frameset|frame|svg|canvas)\b""", RegexOption.IGNORE_CASE) - .containsMatchIn(raw) - ) { - return true - } - if (Regex( - """style\s*=\s*["'][^"']*(?:position\s*:|left\s*:|top\s*:|right\s*:|bottom\s*:|float\s*:|display\s*:\s*(?:grid|flex|inline-block|table)|width\s*:\s*\d|height\s*:\s*\d)""", - RegexOption.IGNORE_CASE - ).containsMatchIn(raw) - ) { - return true - } - return "]*(?:/?>|>.*?)"""), - """
""" + "" ) val preservePublisherLayout = shouldPreserveHtmlPublisherLayout(normalizedRaw) val baseCss = if (preservePublisherLayout) PRESERVE_LAYOUT_HTML_CSS else DEFAULT_READER_HTML_CSS @@ -219,12 +233,12 @@ internal fun renderHtmlToReaderDocument(raw: String, baseUrl: String? = null): S !cleanedBody.contains(title, ignoreCase = true) && !document.body().hasAttr("data-mrcomic-preserve-layout") } - ?.let { "

${htmlEscapeText(it)}

" } + ?.let { "

${htmlEscapeText(it)}

" } .orEmpty() val content = when { cleanedBody.isNotBlank() -> titleBlock + normalizeReaderHtmlFragment(cleanedBody) body.text().isNotBlank() -> titleBlock + "

${htmlEscapeText(body.text())}

" - title.isNotBlank() -> "

${htmlEscapeText(title)}

" + title.isNotBlank() -> "

${htmlEscapeText(title)}

" else -> "

" } return buildReaderHtmlDocument( diff --git a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilderTest.kt b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilderTest.kt index e6aa47e58..fc72704b0 100644 --- a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilderTest.kt +++ b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/base/UnifiedReaderCssBuilderTest.kt @@ -155,6 +155,33 @@ class UnifiedReaderCssBuilderTest { assertTrue("-webkit-hyphens: auto", css.contains("-webkit-hyphens: auto")) } + @Test + fun documentCss_doesNotBreakOrdinaryWordsAndKeepsJustifiedLastLinesNatural() { + val css = buildReaderDocumentCss(includeHyphens = true) + + assertTrue(css.contains("overflow-wrap: break-word")) + assertTrue(css.contains("word-break: normal")) + assertTrue(css.contains("text-align-last: start")) + val bodyRule = Regex("body \\{(.*?)\\n \\}", setOf(RegexOption.DOT_MATCHES_ALL)) + .find(css) + ?.groupValues + ?.get(1) + .orEmpty() + assertFalse(bodyRule.contains("overflow-wrap: anywhere")) + } + + @Test + fun darkReaderOverrideDoesNotForceBlurryFontRasterization() { + val head = buildReaderDocumentHead( + baseCss = READER_BASE_DOCUMENT_CSS, + textColorOverride = "#E8E1D4", + backgroundColorOverride = "#000000" + ) + + assertFalse(head.contains("font-smoothing")) + assertFalse(head.contains("geometricPrecision")) + } + @Test fun buildReaderDocumentCss_customExtraCss() { val extra = ".custom { color: red; }" diff --git a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/text/HtmlSupportTest.kt b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/text/HtmlSupportTest.kt index df485f521..59fe2b20a 100644 --- a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/text/HtmlSupportTest.kt +++ b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/text/HtmlSupportTest.kt @@ -28,6 +28,22 @@ class HtmlSupportTest { assertEquals("2 Endnote body.", extraction.footnoteMap["end-2"]) } + @Test + fun linksPlainNumericSuperscriptToMatchingSemanticFootnoteBody() { + val extraction = extractReaderHtmlFootnotes( + """ + +

Visible text[1].

+ + + """.trimIndent() + ) + + assertTrue(extraction.contentHtml.contains("href=\"#note-1\"")) + assertTrue(extraction.contentHtml.contains("mrcomic-generated-noteref")) + assertEquals("[1] Footnote body.", extraction.footnoteMap["note-1"]) + } + @Test fun rendersUtf8HtmlCorpusSample() { val samplePath = locateCorpusFile("html_utf8_tika.html") @@ -94,7 +110,7 @@ class HtmlSupportTest { } @Test - fun complexHtmlKeepsPreserveLayoutMode() { + fun semanticTableReflowsInsteadOfLockingTheWholeDocumentWidth() { val raw = """
GridLayout
@@ -103,7 +119,58 @@ class HtmlSupportTest { val html = renderHtmlToReaderDocument(raw) - assertTrue(html.contains("data-mrcomic-preserve-layout=\"true\"", ignoreCase = true)) + val bodyTag = Regex("]*>", RegexOption.IGNORE_CASE).find(html)?.value ?: "" + assertFalse(bodyTag.contains("data-mrcomic-preserve-layout", ignoreCase = true)) + } + + @Test + fun gutenbergHtmlReflowsSoLongTitleCannotBeClippedByPublisherGeometry() { + val raw = """ + + Alice's Adventures in Wonderland +

Alice's Adventures in Wonderland

+

Project Gutenberg sample.

+ + """.trimIndent() + + val html = renderHtmlToReaderDocument(raw) + val bodyTag = Regex("]*>", RegexOption.IGNORE_CASE).find(html)?.value ?: "" + + assertFalse(bodyTag.contains("data-mrcomic-preserve-layout", ignoreCase = true)) + assertTrue(html.contains(".mc-title-block")) + assertTrue(html.contains("overflow-wrap: anywhere")) + } + + @Test + fun legacySourcePagebreakDoesNotCreateAStandaloneMostlyEmptyReaderPage() { + val html = renderHtmlToReaderDocument( + """ + +

Short front matter.

+ +

TEXT FORMATTING

+ + """.trimIndent() + ) + + assertFalse(html.contains("mrcomic-pagebreak")) + assertTrue(html.contains("Short front matter")) + assertTrue(html.contains("TEXT FORMATTING")) + } + + @Test + fun explicitReaderPagebreakRemainsAvailableForSemanticBoundaries() { + val html = renderHtmlToReaderDocument( + """ + +

Cover.

+
+

Chapter One

+ + """.trimIndent() + ) + + assertTrue(html.contains("data-mrcomic-pagebreak=\"true\"")) } private fun locateCorpusFile(name: String): java.io.File { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicy.kt index bf4bc2585..32afae15e 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicy.kt @@ -15,7 +15,11 @@ internal object ReaderFootnoteAnchorPolicy { RegexOption.IGNORE_CASE ) private val identifierPattern = Regex( - """^(?:fn|fnt|note|footnote|endnote|rearnote|back|sup|text-fn|pn|ann|annotation|FbAutId|id|fbanchor|ref|kobo-side-note)[-_]?\d*$""", + // BUG-T4: bare numeric ids ("1", "42") are the most common EPUB/HTML + // footnote anchor form and must classify as footnote anchors; prefixed + // forms stay as before. Word-like chapter anchors ("chapter-1", + // "contents") still never match. + """^(?:fn|fnt|note|footnote|endnote|rearnote|back|sup|text-fn|pn|ann|annotation|FbAutId|id|fbanchor|ref|kobo-side-note)[-_]?\d*$|^\d+$""", RegexOption.IGNORE_CASE ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderNavigationController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderNavigationController.kt index 9ffb94869..1b13ad7b6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderNavigationController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderNavigationController.kt @@ -84,17 +84,25 @@ internal class ReaderNavigationController( fun navigateToTocEntry(page: Int, anchorId: String, sectionIndex: Int = -1, charOffset: Int = -1) { val current = _uiState.value.currentPage - if (sectionIndex >= 0 && sectionIndex != current) { - _uiState.update { it.copy(pendingScrollToAnchor = anchorId) } - navigateTo(sectionIndex, progressSource = ReaderNavigationProgressSource.JUMP) + val targetSection = if (sectionIndex >= 0) sectionIndex else page + // BUG-READER-07: Normalize anchor — empty string should be treated as null + // to avoid setting pendingScrollToAnchor to a blank value that gets filtered out. + val normalizedAnchor = anchorId.takeIf { it.isNotBlank() } + if (targetSection != current) { + _uiState.update { it.copy(pendingScrollToAnchor = normalizedAnchor) } + navigateTo(targetSection, progressSource = ReaderNavigationProgressSource.JUMP) return } - if (page == current) { - _uiState.update { it.copy(pendingScrollToAnchor = anchorId) } - return + // Same section: reset sub-page to start and apply anchor scroll. + // This handles the case where the user is mid-section and taps a TOC entry + // that points to the same section — we still need to scroll to the anchor. + _uiState.update { + it.copy( + pendingScrollToAnchor = normalizedAnchor, + sectionCurrentPage = 0, + sectionCharacterOffset = if (charOffset >= 0) charOffset else 0 + ) } - _uiState.update { it.copy(pendingScrollToAnchor = anchorId) } - navigateTo(page, progressSource = ReaderNavigationProgressSource.JUMP) } fun nextPage() = navigateTo( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt index 7b5e8c25c..51da75563 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt @@ -118,7 +118,7 @@ internal fun readerPagedCoreJs( viewport.style.boxSizing='border-box'; viewport.style.paddingTop='0px'; viewport.style.paddingBottom='0px'; - var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom-Math.max(4,lineHeight*0.18)); + var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom-Math.max(6,Math.ceil(lineHeight*0.25))); var usableLineCount=Math.max(3,Math.floor(rawUsableHeight/lineHeight)); var usableHeight=Math.max(lineHeight*3,usableLineCount*lineHeight); root.style.setProperty('--mrcomic-page-visible-height',usableHeight+'px'); @@ -327,7 +327,7 @@ internal fun readerPagedCoreJs( while(currentcurrent+lineHeight*2){ var nextStartAfterMedia=contentHeight; for(var frontIdx=0;frontIdx 0f } ?: 1f ) + var lastPageTurnTimeMs by remember { mutableLongStateOf(0L) } val handleTapZoneAction: (ReaderTapZoneAction) -> Unit = remember( tapZoneLayout, uiState.currentPage, @@ -350,8 +358,20 @@ fun ReaderScreen( ) { { action -> when (action) { - ReaderTapZoneAction.PREVIOUS_PAGE -> viewModel.navigationController.prevPage() - ReaderTapZoneAction.NEXT_PAGE -> viewModel.navigationController.nextPage() + ReaderTapZoneAction.PREVIOUS_PAGE -> { + val now = System.currentTimeMillis() + if (now - lastPageTurnTimeMs >= 300) { + lastPageTurnTimeMs = now + viewModel.navigationController.prevPage() + } + } + ReaderTapZoneAction.NEXT_PAGE -> { + val now = System.currentTimeMillis() + if (now - lastPageTurnTimeMs >= 300) { + lastPageTurnTimeMs = now + viewModel.navigationController.nextPage() + } + } ReaderTapZoneAction.MENU, ReaderTapZoneAction.TOGGLE_UI -> { showBrightnessRow = false @@ -523,18 +543,12 @@ fun ReaderScreen( } else { Modifier.windowInsetsPadding(WindowInsets.safeDrawing) } - val autoScrollDockHeight = readerAutoScrollDockHeightDp( - containerKind = uiState.readerContainerKind, - chromeHidden = uiState.chromeState == ReaderChromeState.HIDDEN, - enabled = uiState.autoScrollEnabled, - ).dp val textReaderModifier = Modifier .fillMaxSize() .then(textSystemInsetsModifier) .padding( vertical = with(density) { textSentenceInsetPx.toDp() } ) - .padding(bottom = autoScrollDockHeight) val textChromeLayoutInsets = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = viewportGeometry.chromeTopInsetCssPx, measuredBottomCssPx = viewportGeometry.chromeBottomInsetCssPx, @@ -551,7 +565,6 @@ fun ReaderScreen( Modifier.windowInsetsPadding(WindowInsets.safeDrawing) } ) - .padding(bottom = autoScrollDockHeight) ReaderContainerHost( uiState = uiState, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt index b6741a629..7f798e6c1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt @@ -56,7 +56,12 @@ class ReaderSettingsController( _uiState.update { ReaderStylePresetReducer.setColorScheme(it, scheme) } viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_PRESET, ReadingPreset.CUSTOM.name) - readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, scheme) + val isText = _uiState.value.readerContainerKind.isTextContainer() + if (isText) { + readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, scheme) + } else { + readerPreferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, scheme) + } } } @@ -146,6 +151,7 @@ class ReaderSettingsController( viewModelScope.launch { readerPreferences.set(PreferencesKeys.TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE) readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, DEFAULT_TEXT_COLOR_SCHEME) + readerPreferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, DEFAULT_GRAPHIC_COLOR_SCHEME) readerPreferences.set(PreferencesKeys.TEXT_FONT_FAMILY, DEFAULT_TEXT_FONT_FAMILY) readerPreferences.set(PreferencesKeys.TEXT_LINE_HEIGHT, DEFAULT_TEXT_LINE_HEIGHT) readerPreferences.set(PreferencesKeys.TEXT_LETTER_SPACING, DEFAULT_TEXT_LETTER_SPACING) @@ -178,6 +184,7 @@ class ReaderSettingsController( readerPreferences.set(PreferencesKeys.READER_IMMERSIVE_MODE, style.immersiveMode) readerPreferences.set(PreferencesKeys.READER_PAGE_ANIMATION, style.pageAnimation) readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, style.textColorScheme) + readerPreferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, style.textColorScheme) readerPreferences.set(PreferencesKeys.TEXT_FONT_FAMILY, style.fontFamily) readerPreferences.set(PreferencesKeys.TEXT_LINE_HEIGHT, style.lineHeight) readerPreferences.set(PreferencesKeys.TEXT_LETTER_SPACING, style.letterSpacing) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index 269c8396b..d6eef9889 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -232,6 +232,24 @@ internal fun ReaderStyleTab( ) } } + item { ReaderSectionTitle(readerText.colorSchemeTitle) } + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items( + listOf( + "DAY" to readerText.day, + "SEPIA" to readerText.sepia, + "NIGHT" to readerText.night + ) + ) { (id, label) -> + ReaderChoiceChip( + selected = uiState.graphicColorScheme == id, + onClick = { onColorSchemeChange(id) }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) } + ) + } + } + } } else { item { ReaderSectionTitle(readerText.quickPresetsTitle) } item { @@ -277,55 +295,55 @@ internal fun ReaderStyleTab( } } } - item { - Text( - text = readerSavedStylesHint(strings.languageCode), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - items( - uiState.readerStylePresetEntries.sortedWith( - compareByDescending { entry -> - entry.snapshot.matchesUiState(uiState) - }.thenByDescending { entry -> - entry.snapshot.displayName?.isNotBlank() == true - }.thenBy { entry -> - entry.snapshot.displayName ?: entry.id - } - ), - key = { "reader_style_${it.id}" } - ) { entry -> - val active = entry.snapshot.matchesUiState(uiState) - ReaderStylePresetListItem( - slot = ReaderStylePresetSlot( - index = uiState.readerStylePresetEntries.indexOfFirst { it.id == entry.id } + 1, - serialized = entry.snapshot.serialize() + if (isTextReader) { + item { + Text( + text = readerSavedStylesHint(strings.languageCode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + items( + uiState.readerStylePresetEntries.sortedWith( + compareByDescending { entry -> + entry.snapshot.matchesUiState(uiState) + }.thenByDescending { entry -> + entry.snapshot.displayName?.isNotBlank() == true + }.thenBy { entry -> + entry.snapshot.displayName ?: entry.id + } ), - language = strings.languageCode, - isActive = active, - onSave = { onOverwriteReaderStylePreset(entry.id) }, - onApply = { onApplyReaderStylePreset(entry.id) }, - onClear = { onDeleteReaderStylePreset(entry.id) } - ) - } - item { ReaderSectionTitle(readerText.colorSchemeTitle) } - item { - LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - items(listOf( - "DAY" to readerText.day, - "SEPIA" to readerText.sepia, - "NIGHT" to readerText.night - )) { (id, label) -> - ReaderChoiceChip( - selected = uiState.textColorScheme == id, - onClick = { onColorSchemeChange(id) }, - label = { Text(label, style = MaterialTheme.typography.labelSmall) } - ) + key = { "reader_style_${it.id}" } + ) { entry -> + val active = entry.snapshot.matchesUiState(uiState) + ReaderStylePresetListItem( + slot = ReaderStylePresetSlot( + index = uiState.readerStylePresetEntries.indexOfFirst { it.id == entry.id } + 1, + serialized = entry.snapshot.serialize() + ), + language = strings.languageCode, + isActive = active, + onSave = { onOverwriteReaderStylePreset(entry.id) }, + onApply = { onApplyReaderStylePreset(entry.id) }, + onClear = { onDeleteReaderStylePreset(entry.id) } + ) + } + item { ReaderSectionTitle(readerText.colorSchemeTitle) } + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items(listOf( + "DAY" to readerText.day, + "SEPIA" to readerText.sepia, + "NIGHT" to readerText.night + )) { (id, label) -> + ReaderChoiceChip( + selected = uiState.textColorScheme == id, + onClick = { onColorSchemeChange(id) }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) } + ) + } } } - } - if (isTextReader) { item { ReaderSectionTitle(readerText.fontTitle) } item { ReaderOutlinedActionButton( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTabWidgets.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTabWidgets.kt index 1bf1744c8..f86d8b0c4 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTabWidgets.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTabWidgets.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.text.font.FontWeight @@ -147,7 +148,12 @@ internal fun ReaderSwitchRow( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp) ) { - Text(text = title, style = MaterialTheme.typography.bodySmall) + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) subtitle?.let { Text( text = it, @@ -191,7 +197,13 @@ internal fun ReaderSliderRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = title, style = MaterialTheme.typography.bodySmall) + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) Text( text = valueText, style = MaterialTheme.typography.labelSmall, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt index ca69746f2..2592a41cc 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt @@ -6,8 +6,9 @@ internal data class ReaderTextChromeLayoutInsets( ) /** - * Reader chrome is an overlay and must not change text wrapping or page boundaries. - * Safe system bars and the auto-scroll dock are handled by the Compose modifier. + * Reader chrome is an overlay and must not change text wrapping or page + * boundaries. Safe system bars and the persistent one-line text gutter are + * handled by the Compose modifier around the WebView. */ internal fun resolveReaderTextChromeLayoutInsets( measuredTopCssPx: Int, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextSettingsJs.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextSettingsJs.kt index 370701dc1..256905694 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextSettingsJs.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextSettingsJs.kt @@ -68,10 +68,20 @@ internal fun textSettingsJs( } else { "" } - val pagedTocLinkCss = if (pagedMode) { - "body table a[href],body table[summary] a[href],body #pgepubid00002 a[href]{pointer-events:none !important;cursor:default !important;-webkit-tap-highlight-color:transparent !important;text-decoration:none !important;color:inherit !important;}" + val paragraphAlignmentCss = if (effectiveAlign == "justify") { + "body:not([data-mrcomic-preserve-layout='true']) p:not([align]):not(.center):not(.right):not(.left)," + + "body:not([data-mrcomic-preserve-layout='true']) div.paragraph," + + "body:not([data-mrcomic-preserve-layout='true']) li:not([align])," + + "body:not([data-mrcomic-preserve-layout='true']) blockquote:not([align])," + + "body:not([data-mrcomic-preserve-layout='true']) dd:not([align])" + + "{text-align:justify !important;text-align-last:start !important;hyphens:auto !important;-webkit-hyphens:auto !important;}" } else { - "" + "body:not([data-mrcomic-preserve-layout='true']) p:not([align]):not(.center):not(.right):not(.left)," + + "body:not([data-mrcomic-preserve-layout='true']) div.paragraph," + + "body:not([data-mrcomic-preserve-layout='true']) li:not([align])," + + "body:not([data-mrcomic-preserve-layout='true']) blockquote:not([align])," + + "body:not([data-mrcomic-preserve-layout='true']) dd:not([align])" + + "{text-align:$effectiveAlign !important;text-align-last:auto !important;}" } val selectionBackgroundColor = readerSelectionOverlayColor( color = resolvedAccentColor, @@ -102,6 +112,9 @@ internal fun textSettingsJs( "body:not([data-mrcomic-preserve-layout='true']) p,body:not([data-mrcomic-preserve-layout='true']) div,body:not([data-mrcomic-preserve-layout='true']) li{width:auto !important;}"+ "body:not([data-mrcomic-preserve-layout='true']) p,body:not([data-mrcomic-preserve-layout='true']) li,body:not([data-mrcomic-preserve-layout='true']) blockquote,body:not([data-mrcomic-preserve-layout='true']) dd{orphans:2 !important;widows:2 !important;}"+ "body:not([data-mrcomic-preserve-layout='true']) h1,body:not([data-mrcomic-preserve-layout='true']) h2,body:not([data-mrcomic-preserve-layout='true']) h3,body:not([data-mrcomic-preserve-layout='true']) h4,body:not([data-mrcomic-preserve-layout='true']) h5,body:not([data-mrcomic-preserve-layout='true']) h6{page-break-after:avoid !important;break-after:avoid-page !important;page-break-inside:avoid !important;break-inside:avoid !important;}"+ + "body:not([data-mrcomic-preserve-layout='true'])>h1:first-child,body:not([data-mrcomic-preserve-layout='true']) .mc-title-block,body:not([data-mrcomic-preserve-layout='true']) .titlepage h1,body:not([data-mrcomic-preserve-layout='true']) .title-page h1{width:auto !important;max-width:100% !important;font-size:clamp(1.6em,10vw,2.6em) !important;line-height:1.2 !important;white-space:normal !important;overflow-wrap:anywhere !important;word-break:normal !important;text-align:center !important;}"+ + "body:not([data-mrcomic-preserve-layout='true']) nav.toc,body:not([data-mrcomic-preserve-layout='true']) .toc,body:not([data-mrcomic-preserve-layout='true']) .table-of-contents{display:table !important;width:auto !important;max-width:100% !important;margin-left:auto !important;margin-right:auto !important;text-align:start !important;}"+ + "body:not([data-mrcomic-preserve-layout='true']) nav.toc table,body:not([data-mrcomic-preserve-layout='true']) .toc table,body:not([data-mrcomic-preserve-layout='true']) .table-of-contents table{width:auto !important;max-width:100% !important;margin-left:auto !important;margin-right:auto !important;}"+ "body:not([data-mrcomic-preserve-layout='true']) blockquote,body:not([data-mrcomic-preserve-layout='true']) figure,body:not([data-mrcomic-preserve-layout='true']) table,body:not([data-mrcomic-preserve-layout='true']) dt,body:not([data-mrcomic-preserve-layout='true']) dd{page-break-inside:avoid !important;break-inside:avoid !important;}"+ "body:not([data-mrcomic-preserve-layout='true']) .mrcomic-footnote-block{break-inside:avoid !important;page-break-inside:avoid !important;}"+ "body:not([data-mrcomic-preserve-layout='true']) img,body:not([data-mrcomic-preserve-layout='true']) video,body:not([data-mrcomic-preserve-layout='true']) canvas,body:not([data-mrcomic-preserve-layout='true']) figure,body:not([data-mrcomic-preserve-layout='true']) table{page-break-inside:avoid !important;break-inside:avoid !important;}"+ @@ -132,7 +145,7 @@ internal fun textSettingsJs( "a.fn,a.fnt,a.footnote-ref,a.noteref,a.doc-noteref,a.doc-fn,a.doc-backref,a.backnote,a.supnote,a.text-fn,a.pagenote,a.annref,a.annotation,a[role='doc-noteref'],a[role='noteref'],a[role='footnote'],a[role='doc-fn'],a[role='doc-backref'],a[epub\\\\:type~='noteref'],a[epub\\\\:type~='footnote'],a[epub\\\\:type~='annref'],a[epub\\\\:type~='annotation'],a[data-footnote-id],a[data-footnote],a[data-type='annotation'],a[href*='FbAutId_'],a[href*='#FbAutId_'],a[href^='fbanchor://'],a[href^='noteref:'],a[href^='#fn'],a[href^='#fnt'],a[href^='#note'],a[href^='#footnote'],a[href^='#endnote'],a[href^='#rearnote'],a[href^='#text-fn'],a[href^='#pagenote'],a[href^='#ann'],a[href^='#annotation'],a[href^='#sup'],a[href^='#back'],a[href^='#docx-footnote'],a[href*='filepos'],a[href*='#filepos']{color:$noteColor !important;text-decoration:none !important;font-weight:bold !important;}"+ "a.fn *,a.fnt *,a.footnote-ref *,a.noteref *,a.doc-noteref *,a.doc-fn *,a.doc-backref *,a.backnote *,a.supnote *,a.text-fn *,a.pagenote *,a.annref *,a.annotation *,a[role='doc-noteref'] *,a[role='noteref'] *,a[role='footnote'] *,a[role='doc-fn'] *,a[role='doc-backref'] *,a[epub\\\\:type~='noteref'] *,a[epub\\\\:type~='footnote'] *,a[epub\\\\:type~='annref'] *,a[epub\\\\:type~='annotation'] *,a[data-footnote-id] *,a[data-footnote] *,a[data-type='annotation'] *,a[href*='FbAutId_'] *,a[href*='#FbAutId_'] *,a[href^='fbanchor://'] *,a[href^='noteref:'] *,a[href^='#fn'] *,a[href^='#fnt'] *,a[href^='#note'] *,a[href^='#footnote'] *,a[href^='#endnote'] *,a[href^='#rearnote'] *,a[href^='#text-fn'] *,a[href^='#pagenote'] *,a[href^='#ann'] *,a[href^='#annotation'] *,a[href^='#sup'] *,a[href^='#back'] *,a[href^='#docx-footnote'] *,a[href*='filepos'] *,a[href*='#filepos'] *{color:$noteColor !important;}"+ ".note-num,.footnote-label{color:$noteColor !important;}"+ - "$pagedTocLinkCss"+ + "$paragraphAlignmentCss"+ "$rtlBodyCss"; if(!themeStyle.parentNode){(__mrcomicHead||document.head||document.documentElement).appendChild(themeStyle);} """.trimIndent() @@ -303,12 +316,6 @@ internal fun textSettingsJs( document.body.style.setProperty('text-align-last','auto','important'); document.body.style.setProperty('padding-top','0px','important'); document.body.style.setProperty('padding-bottom','0px','important'); - try{ - Array.prototype.forEach.call(document.body.querySelectorAll('p,div,section,article,blockquote,li,td,th,h1,h2,h3,h4,h5,h6'),function(el){ - el.style.setProperty('text-align','$effectiveAlign','important'); - el.style.setProperty('text-align-last','auto','important'); - }); - }catch(e){} try{window.scrollTo(0,0);document.documentElement.scrollTop=0;(document.scrollingElement||document.documentElement).scrollTop=0;}catch(e){} """.trimIndent() } else { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt index b08989bb3..7a8d092a9 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt @@ -26,6 +26,7 @@ data class PreparedReaderOpen( internal const val DEFAULT_TEXT_FONT_SIZE = 18 internal const val DEFAULT_TEXT_COLOR_SCHEME = "DAY" +internal const val DEFAULT_GRAPHIC_COLOR_SCHEME = "NIGHT" internal const val DEFERRED_PAGE_COUNT_MAX_RETRIES = 2 internal const val DEFERRED_PAGE_COUNT_RETRY_DELAY_MILLIS = 750L internal const val DEFAULT_TEXT_FONT_FAMILY = "Georgia" @@ -137,6 +138,8 @@ data class ReaderUiState( val textFontSize: Int = 18, /** Color scheme for text books: "DAY" | "SEPIA" | "NIGHT" */ val textColorScheme: String = "DAY", + /** Color scheme for graphic (raster) reader: "DAY" | "SEPIA" | "NIGHT" */ + val graphicColorScheme: String = "NIGHT", /** Optional manual text color override for text books. */ val textCustomTextColor: Long? = null, /** Optional manual background color override for text books. */ @@ -180,6 +183,8 @@ data class ReaderUiState( /** DOM anchor used to preserve the free-scroll text position across rotations. */ val freeScrollCharacterOffset: Int = -1, val freeScrollProgression: Double = -1.0, + /** BUG-VERTICAL-01: Scroll progression (0..1) for raster webtoon seekbar sync. */ + val rasterWebtoonScrollProgression: Double = -1.0, /** Accumulated total visual pages across all visited EPUB sections (0 when not EPUB or no data). */ val epubAccumulatedTotalPages: Int = 0, /** Accumulated current visual page position across all visited EPUB sections. */ @@ -247,7 +252,33 @@ data class ReaderUiState( val chromeShowTranslateIcon: Boolean = true, val chromeShowBrightnessIcon: Boolean = true, val chromeShowAutoScrollIcon: Boolean = true -) +) { + /** + * BUG-READER-01: Unified effective total pages. + * Returns accumulated visual pages for EPUB when available, otherwise raw totalPages. + * All UI components should use this instead of accessing totalPages directly. + */ + val effectiveTotalPages: Int + get() = if (epubAccumulatedTotalPages > 0) epubAccumulatedTotalPages else totalPages.coerceAtLeast(1) + + /** + * BUG-READER-01: Unified effective current page. + * Returns accumulated current page for EPUB when available, otherwise raw currentPage. + */ + val effectiveCurrentPage: Int + get() = if (epubAccumulatedTotalPages > 0) epubAccumulatedCurrentPage else currentPage.coerceAtLeast(0) + + /** + * BUG-READER-01: Unified reading progress (0..100). + * All UI components should use this for consistent progress display. + */ + val effectiveProgressPercent: Int + get() { + val total = effectiveTotalPages + val current = effectiveCurrentPage + return if (total > 1) (current.toFloat() / (total - 1) * 100f).toInt().coerceIn(0, 100) else 0 + } +} data class SelectedTextTranslationState( val originalText: String, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt index 66ce98f23..a5189676f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt @@ -156,6 +156,13 @@ internal class ReaderWebView(context: android.content.Context) : WebView(context isHapticFeedbackEnabled = actEnabled } }, + setUserSelectNone = { enabled -> + val value = if (enabled) "none" else "auto" + evaluateJavascript( + """try{document.body.style.userSelect="$value";document.body.style.webkitUserSelect="$value";}catch(e){}""", + null + ) + }, onFreeScrollGestureFinished = { if (pendingFreeScrollRestoreTarget == null) { scheduleFreeScrollPositionCapture() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewJavaScript.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewJavaScript.kt index d0002629f..bbc786b21 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewJavaScript.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewJavaScript.kt @@ -159,17 +159,44 @@ internal const val JS_TAP_HANDLER = """(function(){ },false); // Check if an element (or its ancestor) is a clickable link/footnote. // Used to prevent page-turn taps from consuming footnote clicks. + // BUG-T4: Walk up to 10 ancestor levels (instead of all the way to body) so + // we stop quickly on deep DOM trees; also check for inside and + // href patterns that indicate footnote references. function __isClickableLink(el){ try{ var probe=el; - while(probe&&probe!==document.body){ + var depth=0; + while(probe&&probe!==document.body&&depth<10){ + depth++; // Skip text/comment nodes — they don't have tagName or getAttribute. if(probe.nodeType!==1){probe=probe.parentNode;continue;} - if(probe.tagName==='A'&&probe.getAttribute('href'))return true; + if(probe.tagName==='A'){ + // BUG-T4: Even if this is not itself a footnote, if its href + // points to a footnote anchor, treat it as a link so the native + // layer doesn't steal the tap as a page turn. + var href=probe.getAttribute('href')||''; + if(/#(?:fn|fnt|note|footnote|endnote|rearnote|back|sup|text-fn|pn|ann|annotation|docx-footnote)[-_]?\w*/i.test(href))return true; + if(href.indexOf('fbanchor://')===0||href.indexOf('FbAutId_')>=0||href.indexOf('noteref:')===0)return true; + if(probe.getAttribute('data-footnote-id')||probe.getAttribute('data-footnote'))return true; + return true; + } if(probe.tagName==='BUTTON'||probe.tagName==='INPUT'||probe.tagName==='SELECT')return true; var role=probe.getAttribute('role')||''; if(role==='button'||role==='link')return true; if(probe.getAttribute('data-footnote-id')||probe.getAttribute('data-footnote'))return true; + if(probe.tagName==='SUP'||probe.tagName==='SUB')return true; + // EPUB footnote markers: epub:type or type attribute + var epubType=(probe.getAttribute('epub:type')||probe.getAttribute('type')||''); + if(/\b(noteref|footnote|annref|annotation)\b/i.test(epubType))return true; + // Footnote-related CSS classes + var cls=probe.getAttribute('class')||''; + if(/\b(noteref|footnote-ref|doc-noteref|fnt|backnote|supnote|text-fn|pagenote|annref|annotation|fn)\b/i.test(cls))return true; + // BUG-PAGED-03: Also check for footnote markers in id/class/name using the + // same comprehensive regex that isFootnoteTarget uses. + var probeId=(probe.id||''); + var probeName=(probe.getAttribute('name')||''); + var marker=[probeId,cls,epubType,role,probeName].join(' '); + if(_fn.marker.test(marker))return true; probe=probe.parentNode; } }catch(e){} @@ -179,14 +206,27 @@ internal const val JS_TAP_HANDLER = """(function(){ window.__readerTouchStartTs=Date.now(); window.__readerTouchMoved=false; window.__readerTouchOnLink=false; + window.__readerTouchEdgeZone=false; + try{ + var existingSelection=window.getSelection&&window.getSelection(); + window.__readerHadSelectionAtTouchStart=!!(existingSelection&&String(existingSelection).trim().length>0); + }catch(selectionError){window.__readerHadSelectionAtTouchStart=false;} if(e.touches&&e.touches.length===1){ - window.__readerTouchStartX=e.touches[0].clientX; - window.__readerTouchStartY=e.touches[0].clientY; + var tx=e.touches[0].clientX; + var ty=e.touches[0].clientY; + window.__readerTouchStartX=tx; + window.__readerTouchStartY=ty; // Mark if the touch started on a clickable link — the Kotlin handler // checks this flag to avoid consuming footnote clicks as page turns. var onLink=e.target?__isClickableLink(e.target):false; window.__readerTouchOnLink=onLink; - if((window.__mrcomicPagedModeScrollLock||hasActivePagedLayout())&&!onLink){ + // Suppress selection in edge zones (12% from each side) in all modes + // to prevent text selection flash when the user intends a page turn. + var winW=window.innerWidth||document.documentElement.clientWidth||360; + var xRatio=winW>0?(tx/winW):0.5; + var inEdgeZone=xRatio<0.12||xRatio>0.88; + window.__readerTouchEdgeZone=inEdgeZone; + if((inEdgeZone||(window.__mrcomicPagedModeScrollLock||hasActivePagedLayout()))&&!onLink){ try{ var selection=window.getSelection&&window.getSelection(); if(selection)selection.removeAllRanges(); @@ -196,30 +236,64 @@ internal const val JS_TAP_HANDLER = """(function(){ try{if(typeof _NativeReader!=='undefined')_NativeReader.setTouchOnLink(onLink);}catch(ex){} } },{passive:true}); - // Prevent spontaneous text selection from accidental short taps, but allow - // deliberate long-press selection for dictionary/quote features. A long press - // (touchstart held > 350ms without move) signals user intent to select text. + // BUG-PAGED-01: Prevent ALL text selection in paged mode. Selection is only + // allowed after a deliberate long-press (>500ms) or inside links. + // The previous approach relied on __mrcomicPagedModeScrollLock which may not + // be set when the WebView first loads. Now we also check the CSS user-select + // property and a global flag set by the Kotlin side. document.addEventListener('selectstart',function(e){ if(!e.target||!e.target.closest)return; - if(window.__mrcomicPagedModeScrollLock||hasActivePagedLayout()){ + // Always allow selection inside links (for footnote/dictionary interaction) + if(e.target.closest('a'))return; + // BUG-T5: If the finger has already moved, suppress selection immediately. + // This prevents accidental selection during a slow swipe that hasn't yet + // reached the native MOVE_THRESHOLD but has moved enough in JS coordinates. + if(window.__readerTouchMoved&&!window.__readerHadSelectionAtTouchStart){ e.preventDefault(); return; } - // Allow selection inside links always - if(e.target.closest('a'))return; - // Allow selection if the user has been holding touch for > 350ms (deliberate long-press) + // In paged mode, block ALL selection unless deliberate long-press + var isPaged=window.__mrcomicPagedModeScrollLock||hasActivePagedLayout()|| + window.__readerPagedSelectionDisabled; + if(isPaged||window.__readerTouchEdgeZone){ + // Allow only after 500ms hold (deliberate long-press for dictionary/quote) + var holdDuration=window.__readerTouchStartTs?(Date.now()-window.__readerTouchStartTs):0; + if(holdDuration>500)return; + // Allow extending an existing selection + if(window.__readerSelectionTs&&((Date.now()-window.__readerSelectionTs)<2000))return; + e.preventDefault(); + return; + } + // Non-paged mode: allow selection only after 500ms hold var holdDuration=window.__readerTouchStartTs?(Date.now()-window.__readerTouchStartTs):0; - if(holdDuration>350)return; - // Allow selection if a recent selection exists (user is extending an existing selection) + if(holdDuration>500)return; if(window.__readerSelectionTs&&((Date.now()-window.__readerSelectionTs)<2000))return; - // Block spontaneous selection from accidental taps e.preventDefault(); }); - document.addEventListener('touchmove',function(){ - window.__readerTouchMoved=true; + document.addEventListener('touchmove',function(e){ + // BUG-T5: Only set __readerTouchMoved when movement exceeds a small + // pixel threshold (4px) to avoid false positives from sub-pixel jitter. + // Once set, it stays true for the rest of this touch sequence. + if(window.__readerTouchMoved)return; + if(!e.touches||!e.touches.length)return; + var dx=e.touches[0].clientX-window.__readerTouchStartX; + var dy=e.touches[0].clientY-window.__readerTouchStartY; + if(Math.abs(dx)>4||Math.abs(dy)>4){ + window.__readerTouchMoved=true; + // BUG-T5: Actively suppress any in-progress selection caused by a + // long-press that turned into a drag. Without this, the browser may + // keep the selection alive even after __readerTouchMoved is set. + try{ + var sel=window.getSelection&&window.getSelection(); + if(sel&&sel.rangeCount>0&&!window.__readerHadSelectionAtTouchStart){ + sel.removeAllRanges(); + } + }catch(err){} + } },{passive:true}); document.addEventListener('touchend',function(e){ var now=Date.now(); + window.__readerTouchEdgeZone=false; var elapsed=now-window.__readerTouchStartTs; if(elapsed<=0||elapsed>260)return; var selected=''; diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchController.kt index 45c67b2c7..8dae96c12 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchController.kt @@ -17,13 +17,14 @@ internal class ReaderWebViewTouchController( private val suppressNextClick: () -> Unit, private val clearSelection: () -> Unit, private val setSelectionEnabled: (Boolean) -> Unit, - private val onFreeScrollGestureFinished: () -> Unit + private val onFreeScrollGestureFinished: () -> Unit, + private val setUserSelectNone: ((Boolean) -> Unit)? = null ) { private var touchStartX: Float = 0f private var touchStartY: Float = 0f private var touchStartTimeMs: Long = 0L - private var nativePagedEdgeTapXPercent: Float? = null private var nativePagedGestureMoved: Boolean = false + private var selectionWasActiveAtDown: Boolean = false var pagedDragSuppressesSelection: Boolean = false private set @Volatile var nativeTapConsumed: Boolean = false @@ -52,16 +53,9 @@ internal class ReaderWebViewTouchController( touchStartY = event.y touchStartTimeMs = SystemClock.uptimeMillis() nativePagedGestureMoved = false + selectionWasActiveAtDown = hasActiveSelection pagedDragSuppressesSelection = false - val xPercent = if (viewWidth > 0) (event.x / viewWidth).coerceIn(0f, 1f) else 0.5f - val isEdgeTap = PagedGesturePolicy.isEdgeTap(xPercent) - nativePagedEdgeTapXPercent = xPercent.takeIf { isEdgeTap } - if (nativePagedEdgeTapXPercent != null && !touchStartedOnLink) { - return true - } - nativePagedEdgeTapXPercent = null - superOnTouchEvent(event) - return true + touchStartedOnLink = false } MotionEvent.ACTION_MOVE -> { val dx = event.x - touchStartX @@ -69,13 +63,10 @@ internal class ReaderWebViewTouchController( val moved = PagedGesturePolicy.hasMoved(dx, dy) if (moved) { nativePagedGestureMoved = true - nativePagedEdgeTapXPercent = null - if (PagedGesturePolicy.shouldSuppressSelectionOnMove(moved, !hasActiveSelection)) { - suppressPagedDragSelection() - } + if (!selectionWasActiveAtDown) suppressPagedDragSelection() suppressNextClick() } - if (PagedGesturePolicy.shouldInterceptMove(dx, dy, hasActiveSelection)) { + if (PagedGesturePolicy.shouldInterceptMove(dx, dy, selectionWasActiveAtDown)) { return true } } @@ -84,29 +75,38 @@ internal class ReaderWebViewTouchController( val dy = event.y - touchStartY val elapsed = SystemClock.uptimeMillis() - touchStartTimeMs val xPercent = if (viewWidth > 0) (event.x / viewWidth).coerceIn(0f, 1f) else 0.5f - val isEdgeTap = nativePagedEdgeTapXPercent != null + // Every stationary tap goes through WebView/DOM first. The JS handler + // resolves links and footnotes at the exact tap point before routing an + // ordinary tap to page navigation. Native interception is only needed + // for real swipes after the movement threshold. + if (!nativePagedGestureMoved && !PagedGesturePolicy.hasMoved(dx, dy)) { + touchStartedOnLink = false + selectionWasActiveAtDown = false + restorePagedDragSelection() + return superOnTouchEvent(event) + } val gesture = PagedGesturePolicy.classifyPagedGesture( dx = dx, dy = dy, elapsed = elapsed, - xPercent = nativePagedEdgeTapXPercent ?: xPercent, - isEdgeTap = isEdgeTap, + xPercent = xPercent, + isEdgeTap = false, hasMoved = nativePagedGestureMoved, - hasActiveSelection = hasActiveSelection, + hasActiveSelection = selectionWasActiveAtDown, touchStartedOnLink = touchStartedOnLink ) when (gesture) { PagedGestureAction.PASS_THROUGH -> { touchStartedOnLink = false - nativePagedEdgeTapXPercent = null + selectionWasActiveAtDown = false restorePagedDragSelection() } PagedGestureAction.RESOLVED -> { suppressNextClick() nativePagedGestureMoved = false - nativePagedEdgeTapXPercent = null + selectionWasActiveAtDown = false restorePagedDragSelection() return true } @@ -116,7 +116,7 @@ internal class ReaderWebViewTouchController( suppressNextClick() nativeTapConsumed = true nativePagedGestureMoved = false - nativePagedEdgeTapXPercent = null + selectionWasActiveAtDown = false onNativePagedTap(0.1f) return true } @@ -126,15 +126,15 @@ internal class ReaderWebViewTouchController( suppressNextClick() nativeTapConsumed = true nativePagedGestureMoved = false - nativePagedEdgeTapXPercent = null + selectionWasActiveAtDown = false onNativePagedTap(0.9f) return true } } } MotionEvent.ACTION_CANCEL -> { - nativePagedEdgeTapXPercent = null nativePagedGestureMoved = false + selectionWasActiveAtDown = false touchStartedOnLink = false restorePagedDragSelection() } @@ -193,11 +193,13 @@ internal class ReaderWebViewTouchController( pagedDragSuppressesSelection = true clearSelection() setSelectionEnabled(false) + setUserSelectNone?.invoke(true) } private fun restorePagedDragSelection() { if (!pagedDragSuppressesSelection) return pagedDragSuppressesSelection = false setSelectionEnabled(true) + setUserSelectNone?.invoke(false) } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeInsetPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeInsetPolicyTest.kt index 9b92fea85..65853e5ee 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeInsetPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeInsetPolicyTest.kt @@ -28,4 +28,5 @@ class ReaderChromeInsetPolicyTest { ) ) } + } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicyTest.kt index a740c661a..24adfabaf 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderFootnoteAnchorPolicyTest.kt @@ -38,4 +38,38 @@ class ReaderFootnoteAnchorPolicyTest { assertFalse(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("chapter-1")) assertFalse(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("contents")) } + + // ── BUG-T4 regression: common footnote patterns at screen edges ──────── + + /** Numeric-only markers like "1" or "42" — common in EPUB footnotes. */ + @Test + fun recognizesNumericOnlyFootnoteAnchors() { + assertTrue(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("1")) + assertTrue(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("42")) + assertTrue(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("100")) + } + + /** Patterns used by FBReader/FB2 "fbanchor" scheme. */ + @Test + fun normalizesFbanchorScheme() { + assertEquals("FbAutId_5", ReaderFootnoteAnchorPolicy.normalize("fbanchor://FbAutId_5")) + assertEquals("id_10", ReaderFootnoteAnchorPolicy.normalize("fbanchor:id_10")) + } + + /** Kobo-style footnote references. */ + @Test + fun recognizesKoboFootnotePattern() { + assertTrue(ReaderFootnoteAnchorPolicy.isFootnoteAnchor("kobo-side-note-1")) + } + + /** Verify lookupCandidates handles multi-file footnote hrefs. */ + @Test + fun lookupCandidatesForCrossFileFootnote() { + val candidates = ReaderFootnoteAnchorPolicy.lookupCandidates( + "chapter2.xhtml#fn-3", + "fn-3" + ) + assertTrue(candidates.contains("fn-3")) + assertTrue(candidates.contains("#fn-3")) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSectionPagingPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSectionPagingPolicyTest.kt index 66155b2c3..690e3ad84 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSectionPagingPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSectionPagingPolicyTest.kt @@ -30,4 +30,38 @@ class ReaderSectionPagingPolicyTest { assertEquals(7, state.pageCount) assertEquals(6, state.pageIndex) } + + /** + * T6 regression: when navigating within the same section, the visual subpage + * count and index must be preserved so the display doesn't flash "1/1". + */ + @Test + fun preservesVisualSubpageOnForwardNavigationWithinSection() { + val state = sectionPagingStateAfterNavigation( + previousSection = 5, + nextSection = 5, + previousPageCount = 12, + previousPageIndex = 3 + ) + + assertEquals(12, state.pageCount) + assertEquals(3, state.pageIndex) + } + + /** + * T6 regression: cross-section navigation must always reset to page 1/1 + * to avoid showing stale subpage data from the previous section. + */ + @Test + fun alwaysResetsToFirstPageWhenCrossingSectionBoundary() { + val state = sectionPagingStateAfterNavigation( + previousSection = 3, + nextSection = 4, + previousPageCount = 15, + previousPageIndex = 14 + ) + + assertEquals(1, state.pageCount) + assertEquals(0, state.pageIndex) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchControllerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchControllerTest.kt index 18147d1a2..6b1729382 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchControllerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewTouchControllerTest.kt @@ -1,8 +1,13 @@ package io.leostrange.mrcomic.feature.reader.ui +import android.view.MotionEvent import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class ReaderWebViewTouchControllerTest { @Test @@ -19,4 +24,94 @@ class ReaderWebViewTouchControllerTest { assertFalse(controller.consumeNativeTapIfPresent()) assertFalse(controller.consumeNativeTapIfPresent()) } + + @Test + fun actionDownKeepsLongPressSelectionAvailableUntilGestureActuallyMoves() { + val selectionEnabled = mutableListOf() + val userSelectNone = mutableListOf() + val controller = controller( + setSelectionEnabled = selectionEnabled::add, + setUserSelectNone = userSelectNone::add + ) + + controller.handlePagedTouchEvent( + event = event(MotionEvent.ACTION_DOWN, x = 500f, y = 800f), + viewWidth = 1_000, + hasActiveSelection = false, + superOnTouchEvent = { true } + ) + + assertFalse(controller.pagedDragSuppressesSelection) + assertTrue(selectionEnabled.isEmpty()) + assertTrue(userSelectNone.isEmpty()) + } + + @Test + fun pageSwipeSuppressesSelectionOnlyAfterMovementThreshold() { + val selectionEnabled = mutableListOf() + val userSelectNone = mutableListOf() + var clearCount = 0 + val controller = controller( + clearSelection = { clearCount++ }, + setSelectionEnabled = selectionEnabled::add, + setUserSelectNone = userSelectNone::add + ) + + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_DOWN, 500f, 800f), 1_000, false) { true } + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_MOVE, 530f, 800f), 1_000, false) { true } + + assertTrue(controller.pagedDragSuppressesSelection) + assertTrue(clearCount > 0) + assertTrue(selectionEnabled.contains(false)) + assertTrue(userSelectNone.contains(true)) + } + + @Test + fun existingSelectionHandleDragIsNeverDisabled() { + val selectionEnabled = mutableListOf() + val controller = controller(setSelectionEnabled = selectionEnabled::add) + + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_DOWN, 500f, 800f), 1_000, true) { true } + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_MOVE, 540f, 800f), 1_000, true) { true } + + assertFalse(controller.pagedDragSuppressesSelection) + assertTrue(selectionEnabled.isEmpty()) + } + + @Test + fun edgeTapPassesThroughToDomSoFootnoteWinsOverPageTurnZone() { + val nativeTaps = mutableListOf() + var superCalls = 0 + val controller = controller(onNativePagedTap = nativeTaps::add) + + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_DOWN, 20f, 800f), 1_000, false) { + superCalls++ + true + } + controller.handlePagedTouchEvent(event(MotionEvent.ACTION_UP, 20f, 800f), 1_000, false) { + superCalls++ + true + } + + assertTrue(nativeTaps.isEmpty()) + assertTrue(superCalls >= 2) + } + + private fun controller( + onNativePagedTap: (Float) -> Unit = {}, + clearSelection: () -> Unit = {}, + setSelectionEnabled: (Boolean) -> Unit = {}, + setUserSelectNone: (Boolean) -> Unit = {} + ) = ReaderWebViewTouchController( + onNativePagedTap = onNativePagedTap, + onVerticalBoundaryNavigation = {}, + suppressNextClick = {}, + clearSelection = clearSelection, + setSelectionEnabled = setSelectionEnabled, + onFreeScrollGestureFinished = {}, + setUserSelectNone = setUserSelectNone + ) + + private fun event(action: Int, x: Float, y: Float): MotionEvent = + MotionEvent.obtain(0L, 0L, action, x, y, 0) } From 8cbd6e8706b1bfb82400cd666779b0f994253b75 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Mon, 24 Aug 2026 07:20:37 +0700 Subject: [PATCH 06/17] fix: apply themes atomically and refresh previews --- .../ui/theme/ThemePreferencesRepository.kt | 24 +++++ .../settings/ui/SettingsAppearanceTheme.kt | 100 +++--------------- .../settings/ui/SettingsReaderPreviews.kt | 47 +++++++- .../ui/SettingsViewModelAppearanceSetters.kt | 23 +--- .../ui/SettingsSettersControllerTest.kt | 17 +-- 5 files changed, 92 insertions(+), 119 deletions(-) diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/ThemePreferencesRepository.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/ThemePreferencesRepository.kt index 0596277cf..4437c5318 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/ThemePreferencesRepository.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/ThemePreferencesRepository.kt @@ -74,6 +74,30 @@ class ThemePreferencesRepository @Inject constructor( dataStore.edit { prefs -> prefs[THEME_PRESET_KEY] = preset.name } } + /** + * Writes a preset and every value it owns in one DataStore transaction. + * A preset must never be observed as a mixture of its old and new colors. + */ + suspend fun applyThemePreset(preset: ThemePreset) { + dataStore.edit { prefs -> + prefs[THEME_PRESET_KEY] = preset.name + if (preset == ThemePreset.CUSTOM) return@edit + + val config = preset.toConfig() + prefs[THEME_MODE_KEY] = config.themeMode.name + prefs[USE_DYNAMIC_COLOR_KEY] = config.useDynamicColor + prefs[USE_AMOLED_KEY] = config.useAmoledDark + if (config.primaryColor == null) prefs.remove(CUSTOM_PRIMARY_COLOR_KEY) + else prefs[CUSTOM_PRIMARY_COLOR_KEY] = config.primaryColor.toString() + if (config.secondaryColor == null) prefs.remove(CUSTOM_SECONDARY_COLOR_KEY) + else prefs[CUSTOM_SECONDARY_COLOR_KEY] = config.secondaryColor.toString() + if (config.backgroundColor == null) prefs.remove(CUSTOM_BACKGROUND_COLOR_KEY) + else prefs[CUSTOM_BACKGROUND_COLOR_KEY] = config.backgroundColor.toString() + prefs.remove(CUSTOM_SURFACE_COLOR_KEY) + prefs[SURFACE_OPACITY_KEY] = 1f + } + } + /** Pass null to reset to theme default. Non-null values are persisted as decimal Long strings. */ suspend fun setCustomPrimaryColor(color: Long?) { dataStore.edit { prefs -> diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt index 7a06d3a48..16c09f8ad 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt @@ -69,27 +69,35 @@ internal fun ThemePreviewCard( ThemeMode.SYSTEM, ThemeMode.DYNAMIC -> currentScheme.background.luminance() < 0.45f } - val previewBackground = uiState.customBackgroundColor?.let(::argbLongToThemeColor) ?: when { + val previewBackgroundTarget = uiState.customBackgroundColor?.let(::argbLongToThemeColor) ?: when { uiState.themeMode == ThemeMode.LIGHT -> Color(0xFFF7F3EE) uiState.themeMode == ThemeMode.DARK && uiState.useAmoledDark -> Color(0xFF000000) uiState.themeMode == ThemeMode.DARK -> Color(0xFF121216) uiState.themeMode == ThemeMode.SYSTEM && uiState.useAmoledDark && isDarkPreview -> Color(0xFF000000) else -> currentScheme.background } - val previewSurface = uiState.customSurfaceColor?.let(::argbLongToThemeColor) ?: when { - previewBackground == Color(0xFF000000) -> Color(0xFF0A0A0A) + val previewSurfaceTarget = uiState.customSurfaceColor?.let(::argbLongToThemeColor) ?: when { + previewBackgroundTarget == Color(0xFF000000) -> Color(0xFF0A0A0A) isDarkPreview -> Color(0xFF1B1B1F) uiState.themeMode == ThemeMode.LIGHT -> Color(0xFFFFFFFF) else -> currentScheme.surface.copy(alpha = 1f) } - val previewPrimary = uiState.customPrimaryColor?.let(::argbLongToThemeColor) ?: currentScheme.primary - val previewSecondary = uiState.customSecondaryColor?.let(::argbLongToThemeColor) ?: currentScheme.secondary - val previewPrimaryContainer = uiState.customPrimaryColor?.let { - lerp(previewSurface, previewPrimary, if (isDarkPreview) 0.36f else 0.18f) + val previewPrimaryTarget = uiState.customPrimaryColor?.let(::argbLongToThemeColor) ?: currentScheme.primary + val previewSecondaryTarget = uiState.customSecondaryColor?.let(::argbLongToThemeColor) ?: currentScheme.secondary + val previewPrimaryContainerTarget = uiState.customPrimaryColor?.let { + lerp(previewSurfaceTarget, previewPrimaryTarget, if (isDarkPreview) 0.36f else 0.18f) } ?: currentScheme.primaryContainer.copy(alpha = 1f) - val previewSecondaryContainer = uiState.customSecondaryColor?.let { - lerp(previewSurface, previewSecondary, if (isDarkPreview) 0.34f else 0.18f) + val previewSecondaryContainerTarget = uiState.customSecondaryColor?.let { + lerp(previewSurfaceTarget, previewSecondaryTarget, if (isDarkPreview) 0.34f else 0.18f) } ?: currentScheme.secondaryContainer.copy(alpha = 1f) + + // Keep the preview a single, coherent snapshot of the selected theme. + // Per-element animations made the preview show mixed old/new themes. + val previewBackground = previewBackgroundTarget + val previewSurface = previewSurfaceTarget + val previewPrimary = previewPrimaryTarget + val previewPrimaryContainer = previewPrimaryContainerTarget + val previewSecondaryContainer = previewSecondaryContainerTarget val onPreview = if (previewBackground.luminance() > 0.18f) { Color(0xFF000000) } else { @@ -195,40 +203,6 @@ internal fun ThemePreviewCard( .background(onPreviewSurface.copy(alpha = 0.14f)) ) } - // surfaceContainer preview — nested card - val previewSurfaceContainer = lerp( - previewSurface, if (isDarkPreview) Color.Black else Color.White, - if (isDarkPreview) 0.4f else 0.2f - ).let { anchor -> lerp(anchor, previewSurface, if (isDarkPreview) 0.62f else 0.78f) } - Surface( - shape = MaterialTheme.shapes.medium, - color = previewSurfaceContainer - ) { - Text( - strings.previewCard, - style = MaterialTheme.typography.bodySmall, - color = onPreviewSurface, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) - ) - } - // error preview - val previewError = if (isDarkPreview) Color(0xFFF2B8B5) else Color(0xFFBA1A1A) - val previewErrorContainer = if (isDarkPreview) Color(0xFF93000A) else Color(0xFFFFDAD6) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Surface( - shape = MaterialTheme.shapes.small, - color = previewErrorContainer - ) { - Text( - "⚠", - style = MaterialTheme.typography.bodySmall, - color = previewError, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - } Surface( shape = MaterialTheme.shapes.large, color = previewSecondaryContainer @@ -265,46 +239,6 @@ internal fun ThemePreviewCard( } } } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - modifier = Modifier.weight(1f), - shape = MaterialTheme.shapes.extraLarge, - color = previewSurface - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.BookmarkBorder, - contentDescription = null, - tint = previewPrimary, - modifier = Modifier.size(18.dp) - ) - Text( - strings.previewCard, - style = MaterialTheme.typography.bodySmall, - color = onPreviewSurface - ) - } - } - FilledTonalButton( - onClick = {}, - modifier = Modifier.height(36.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = previewPrimary, - contentColor = if (previewPrimary.luminance() > 0.18f) Color.Black else Color.White - ) - ) { - Text(strings.previewButton) - } - } } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt index 61262ad6e..ca2949a26 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt @@ -17,9 +17,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.model.ReaderTapZoneMode @@ -29,6 +31,7 @@ import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill import io.leostrange.mrcomic.core.ui.designsystem.MrComicProgressLine import io.leostrange.mrcomic.core.ui.locale.AppStrings import io.leostrange.mrcomic.core.ui.theme.style +import io.leostrange.mrcomic.core.ui.theme.argbLongToThemeColor /** * Reader preview cards (Phase M, 2026-08-03): pure visual composables @@ -48,6 +51,30 @@ internal fun ReaderTextAppearancePreviewCard( "center" -> Alignment.CenterHorizontally else -> Alignment.Start } + val textAlign = when (uiState.textAlignment) { + "right" -> TextAlign.End + "center" -> TextAlign.Center + else -> if (uiState.textAlignment == "justify") TextAlign.Justify else TextAlign.Start + } + val schemeColors = when (uiState.textColorScheme.uppercase()) { + "SEPIA" -> Color(0xFFF4ECD8) to Color(0xFF4B3822) + "NIGHT" -> Color(0xFF101216) to Color(0xFFE8E1D4) + else -> Color(0xFFF6F1E7) to Color(0xFF2B2118) + } + val previewBackground = uiState.textCustomBackgroundColor + ?.let(::argbLongToThemeColor) + ?: schemeColors.first + val previewText = uiState.textCustomTextColor + ?.let(::argbLongToThemeColor) + ?: schemeColors.second + val previewAccent = uiState.textCustomAccentColor + ?.let(::argbLongToThemeColor) + ?: MaterialTheme.colorScheme.primary + val previewFontFamily = when (uiState.textFontFamily.lowercase()) { + "roboto", "open sans", "sans-serif" -> FontFamily.SansSerif + "monospace", "source code pro" -> FontFamily.Monospace + else -> FontFamily.Serif + } SettingsCard(title = strings.preview) { Column( modifier = Modifier.fillMaxWidth(), @@ -55,7 +82,7 @@ internal fun ReaderTextAppearancePreviewCard( ) { MrComicCardSurface( shape = MaterialTheme.shapes.large, - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + containerColor = previewBackground ) { Column( modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), @@ -65,8 +92,11 @@ internal fun ReaderTextAppearancePreviewCard( text = strings.readerTextPreviewTitle, style = MaterialTheme.typography.titleMedium.copy( fontWeight = if (uiState.textBold) FontWeight.Bold else FontWeight.SemiBold, - fontSize = (uiState.textFontSize + 2).sp - ) + fontSize = (uiState.textFontSize + 2).sp, + fontFamily = previewFontFamily, + color = previewAccent + ), + color = previewAccent ) Column( modifier = Modifier.fillMaxWidth(), @@ -77,8 +107,15 @@ internal fun ReaderTextAppearancePreviewCard( style = MaterialTheme.typography.bodyMedium.copy( fontSize = uiState.textFontSize.sp, lineHeight = (uiState.textFontSize * uiState.textLineHeight).sp, - fontWeight = if (uiState.textBold) FontWeight.SemiBold else FontWeight.Normal - ) + fontWeight = if (uiState.textBold) FontWeight.SemiBold else FontWeight.Normal, + fontFamily = previewFontFamily, + letterSpacing = uiState.textLetterSpacing.em, + color = previewText + ), + color = previewText, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), + maxLines = 8 ) } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelAppearanceSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelAppearanceSetters.kt index fc3857008..faff2b50e 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelAppearanceSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelAppearanceSetters.kt @@ -2,31 +2,16 @@ package io.leostrange.mrcomic.feature.settings.ui import io.leostrange.mrcomic.core.ui.theme.ThemeMode import io.leostrange.mrcomic.core.ui.theme.ThemePreset -import io.leostrange.mrcomic.core.ui.theme.toConfig import kotlinx.coroutines.launch internal fun SettingsSettersController.setAppLanguage(code: String) = settingsPreferencesController.setAppLanguage(code) - /** - * Applies a theme preset: writes all preset color values and flags into DataStore, - * then marks the active preset. Selecting CUSTOM only marks the preset key. - */ + /** Applies a complete preset as one observable DataStore snapshot. */ internal fun SettingsSettersController.setThemePreset(preset: ThemePreset) { - scope.launch { - themePreferencesRepository.setThemePreset(preset) - if (preset != ThemePreset.CUSTOM) { - val cfg = preset.toConfig() - themePreferencesRepository.setThemeMode(cfg.themeMode) - themePreferencesRepository.setUseDynamicColor(cfg.useDynamicColor) - themePreferencesRepository.setUseAmoledDark(cfg.useAmoledDark) - themePreferencesRepository.setCustomPrimaryColor(cfg.primaryColor) - themePreferencesRepository.setCustomSecondaryColor(cfg.secondaryColor) - themePreferencesRepository.setCustomBackgroundColor(cfg.backgroundColor) - themePreferencesRepository.setCustomSurfaceColor(null) - themePreferencesRepository.setSurfaceOpacity(1f) - } - } + scope.launch { + themePreferencesRepository.applyThemePreset(preset) } +} internal fun SettingsSettersController.setThemeMode(mode: ThemeMode) { scope.launch { diff --git a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsSettersControllerTest.kt b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsSettersControllerTest.kt index b928e86c0..c390ab476 100644 --- a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsSettersControllerTest.kt +++ b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/SettingsSettersControllerTest.kt @@ -53,23 +53,16 @@ class SettingsSettersControllerTest { ) @Test - fun setThemePresetAppliesConfigAndMarksPreset() = runTest { - coEvery { themePreferencesRepository.setThemePreset(any()) } returns Unit - coEvery { themePreferencesRepository.setThemeMode(any()) } returns Unit - coEvery { themePreferencesRepository.setUseDynamicColor(any()) } returns Unit - coEvery { themePreferencesRepository.setUseAmoledDark(any()) } returns Unit - coEvery { themePreferencesRepository.setCustomPrimaryColor(any()) } returns Unit - coEvery { themePreferencesRepository.setCustomSecondaryColor(any()) } returns Unit - coEvery { themePreferencesRepository.setCustomBackgroundColor(any()) } returns Unit - coEvery { themePreferencesRepository.setCustomSurfaceColor(any()) } returns Unit - coEvery { themePreferencesRepository.setSurfaceOpacity(any()) } returns Unit + fun setThemePresetAppliesWholePresetAtomically() = runTest { + coEvery { themePreferencesRepository.applyThemePreset(any()) } returns Unit val controller = createController() controller.setThemePreset(ThemePreset.AMOLED) advanceUntilIdle() - coVerify(exactly = 1) { themePreferencesRepository.setThemePreset(ThemePreset.AMOLED) } - coVerify { themePreferencesRepository.setUseDynamicColor(any()) } + coVerify(exactly = 1) { themePreferencesRepository.applyThemePreset(ThemePreset.AMOLED) } + coVerify(exactly = 0) { themePreferencesRepository.setThemeMode(any()) } + coVerify(exactly = 0) { themePreferencesRepository.setCustomBackgroundColor(any()) } } @Test From 65ddfd614c16ebdc579fb68521c32b688b3b82aa Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 10:59:02 +0700 Subject: [PATCH 07/17] release: prepare Mr.Comic v2.5.0 --- BUGTRACKER_REDESIGN_2026-08-21.md | 149 +++++ BUGTRACKER_VIDEO_2026-08-16.md | 590 ++++++++++++++++++ CHANGELOG.md | 20 + IMPLEMENTATION_SUMMARY.md | 255 ++++++++ README.md | 21 +- RELEASE_NOTES.md | 28 + android/app/build.gradle.kts | 4 +- .../assets/databases/dictionary_fr.dbpack | 3 - .../assets/databases/dictionary_it.dbpack | 3 - .../assets/databases/dictionary_ja.dbpack | 3 - .../assets/databases/dictionary_ko.dbpack | 3 - .../assets/databases/dictionary_pl.dbpack | 3 - .../assets/databases/dictionary_pt.dbpack | 3 - .../assets/databases/dictionary_tr.dbpack | 3 - .../assets/databases/dictionary_zh.dbpack | 3 - .../src/main/assets/fonts/AccessibleDfA.otf | Bin 0 -> 145384 bytes .../assets/fonts/LiberationSans-Regular.ttf | Bin 0 -> 410712 bytes .../assets/fonts/OpenDyslexic-Regular.otf | Bin 0 -> 41088 bytes .../assets/fonts/iAWriterDuospace-Regular.ttf | Bin 0 -> 81636 bytes .../mrcomic/mrcomic/ComicApplication.kt | 15 +- .../mrcomic/backup/AutoBackupManager.kt | 3 +- .../mrcomic/home/ContinueReadingCards.kt | 7 +- .../mrcomic/mrcomic/home/ContinueViewModel.kt | 5 +- .../mrcomic/navigation/AppNavigation.kt | 54 +- .../mrcomic/mrcomic/navigation/Screen.kt | 2 - .../mrcomic/core/data/db/AppDatabase.kt | 2 +- .../core/data/db/AppDatabaseMigrations.kt | 85 +++ .../mrcomic/core/data/db/ComicDao.kt | 2 +- .../mrcomic/core/data/db/entity/SavedQuote.kt | 8 +- .../mrcomic/core/data/di/DatabaseModule.kt | 15 +- .../mrcomic/core/data/di/OpdsModule.kt | 17 - .../data/dictionary/DictionaryDownloader.kt | 94 ++- .../mrcomic/core/data/opds/OpdsFeedParser.kt | 159 ----- .../core/data/opds/OpdsNetworkClient.kt | 140 ----- .../mrcomic/core/data/opds/OpdsRepository.kt | 157 ----- .../core/data/repository/ComicBackupMerge.kt | 3 +- .../data/repository/ComicFormatDetector.kt | 2 +- .../core/data/repository/QuoteRepository.kt | 17 +- .../data/opds/OpdsNetworkIntegrationTest.kt | 126 ---- .../core/data/opds/OpdsRepositoryTest.kt | 44 -- .../domain/analytics/AchievementTracker.kt | 249 ++++++++ .../analytics/GamificationIntegration.kt | 131 ++++ .../analytics/WeeklyChallengeTracker.kt | 169 +++++ .../core/model/AchievementDefinitions.kt | 313 ++++++++++ .../mrcomic/core/model/AchievementModels.kt | 99 +++ .../io/leostrange/mrcomic/core/model/Comic.kt | 50 +- .../mrcomic/core/model/OpdsModels.kt | 84 --- .../core/model/WeeklyChallengeModels.kt | 136 ++++ .../core/model/ComicReadingStatusTest.kt | 206 ++++++ android/core-ui/build.gradle.kts | 1 + .../mrcomic/core/ui/designsystem/Buttons.kt | 4 +- .../mrcomic/core/ui/designsystem/Cards.kt | 75 +-- .../mrcomic/core/ui/designsystem/Chips.kt | 48 +- .../mrcomic/core/ui/designsystem/Controls.kt | 86 +-- .../ui/designsystem/MrComicAlphaTokens.kt | 22 + .../core/ui/designsystem/MrComicBottomBar.kt | 154 +++++ .../ui/designsystem/MrComicCornerScale.kt | 26 + .../ui/designsystem/MrComicDesignTokens.kt | 11 +- .../ui/designsystem/MrComicLibraryCard.kt | 216 +++++++ .../core/ui/designsystem/MrComicListItem.kt | 222 +++++++ .../ui/designsystem/MrComicSectionHeader.kt | 65 ++ .../core/ui/designsystem/MrComicTopAppBar.kt | 225 +++++++ .../core/ui/designsystem/MrComicType.kt | 126 ++++ .../{Navigation.kt => Navigation.kt.bak} | 0 .../{SettingsRows.kt => SettingsRows.kt.bak} | 0 .../core/ui/fonts/ReaderTextFontCatalog.kt | 4 + .../core/ui/gamification/AchievementCard.kt | 263 ++++++++ .../gamification/AchievementDetailScreen.kt | 343 ++++++++++ .../ui/gamification/AchievementListScreen.kt | 205 ++++++ .../gamification/AchievementNotification.kt | 163 +++++ .../gamification/GamificationStatsScreen.kt | 362 +++++++++++ .../core/ui/gamification/ReadingCharts.kt | 255 ++++++++ .../ui/gamification/WeeklyChallengeCard.kt | 210 +++++++ .../ui/gamification/WeeklyChallengesScreen.kt | 99 +++ .../core/ui/library/LibraryBackdropLayers.kt | 28 + .../core/ui/library/LibraryVisualSpecs.kt | 12 +- .../mrcomic/core/ui/locale/AppStrings.kt | 2 + .../mrcomic/core/ui/locale/AppStringsEn.kt | 16 - .../mrcomic/core/ui/locale/AppStringsJa.kt | 16 - .../mrcomic/core/ui/locale/AppStringsKo.kt | 16 - .../mrcomic/core/ui/locale/AppStringsRu.kt | 16 - .../mrcomic/core/ui/locale/AppStringsZh.kt | 16 - .../core/ui/locale/DictionaryStrings.kt | 16 +- .../leostrange/mrcomic/core/ui/theme/Theme.kt | 7 +- .../ui/designsystem/EditorialInkTokensTest.kt | 143 +++++ .../engine/formats/epub/EpubTocResolver.kt | 8 +- .../engine/formats/fb2/Fb2FormatReader.kt | 2 +- .../feature/library/AudiobookPlayerScreen.kt | 67 +- .../feature/library/LibraryComicInfoSheet.kt | 5 +- .../feature/library/LibraryFilterSheet.kt | 1 + .../mrcomic/feature/library/LibraryFolders.kt | 63 +- .../mrcomic/feature/library/LibraryScreen.kt | 20 +- .../feature/library/LibraryScreenContent.kt | 19 +- .../feature/library/LibrarySections.kt | 9 +- .../feature/library/MiniAudiobookPlayer.kt | 25 +- .../feature/library/MrComicHubStrings.kt | 3 +- .../library/components/ComicGridItem.kt | 9 +- .../library/components/LibraryContentDecor.kt | 31 +- .../components/LibraryFallbackCover.kt | 438 +++++++++++++ .../library/components/LibraryTopBar.kt | 22 +- .../library/opds/OpdsCatalogController.kt | 241 ------- .../feature/library/opds/OpdsCatalogScreen.kt | 317 ---------- .../library/opds/OpdsCatalogUiState.kt | 25 - .../library/opds/OpdsCatalogViewModel.kt | 71 --- .../library/LibraryContentPipelineTest.kt | 23 + .../library/LibraryStatelessHelpersTest.kt | 6 + .../components/LibraryFallbackCoverTest.kt | 315 ++++++++++ .../components/LibraryTopBarOpdsTest.kt | 88 --- .../library/opds/OpdsCatalogControllerTest.kt | 289 --------- .../library/opds/OpdsCatalogViewModelTest.kt | 125 ---- .../feature/onboarding/OnboardingViewModel.kt | 1 + .../progress/EpubSectionPageCountStore.kt | 5 + .../reader/ui/EpubProgressCalculator.kt | 46 +- .../mrcomic/feature/reader/ui/HtmlPageView.kt | 9 +- .../feature/reader/ui/ReaderAudioSheet.kt | 8 +- .../ui/ReaderAutoScrollChromeControls.kt | 67 +- .../reader/ui/ReaderAutoScrollRuntime.kt | 10 +- .../reader/ui/ReaderBookOpeningController.kt | 59 +- .../feature/reader/ui/ReaderBottomSheets.kt | 11 +- .../reader/ui/ReaderChromeBottomPanel.kt | 8 +- .../reader/ui/ReaderChromeComponents.kt | 20 +- .../feature/reader/ui/ReaderChromeOverlays.kt | 4 +- .../reader/ui/ReaderContentPathResolver.kt | 4 + .../reader/ui/ReaderControlCenterSheet.kt | 14 +- .../feature/reader/ui/ReaderHeaderFooterUi.kt | 4 +- .../reader/ui/ReaderMaterialColorScheme.kt | 176 +++++- .../reader/ui/ReaderPreferenceRestorer.kt | 4 + .../reader/ui/ReaderProgressController.kt | 181 +++++- .../reader/ui/ReaderReadingModeController.kt | 14 +- .../reader/ui/ReaderSaveQuoteController.kt | 50 +- .../feature/reader/ui/ReaderServicesTab.kt | 18 +- .../feature/reader/ui/ReaderStyleTab.kt | 19 +- .../feature/reader/ui/ReaderUiState.kt | 2 + .../feature/reader/ui/ReaderViewModel.kt | 45 +- .../feature/reader/ui/ReaderWebView.kt | 39 +- .../reader/ui/ReaderWebViewLoadController.kt | 33 +- .../reader/ui/ReaderWebViewRuntimeEffect.kt | 3 +- .../reader/ui/ReaderWebViewRuntimeOwner.kt | 23 +- .../reader/ui/ReaderWebViewRuntimeState.kt | 2 + .../reader/ui/TextBookSessionBridge.kt | 8 +- .../feature/reader/ui/components/PageView.kt | 14 +- .../reader/ui/components/ReaderBottomBar.kt | 61 +- .../components/ReaderBottomProgressPolicy.kt | 5 + .../reader/ui/components/WebtoonView.kt | 42 +- .../ui/geometry/ReaderViewportGeometry.kt | 34 +- .../reader/ui/gesture/PagedLayoutParams.kt | 2 +- .../reader/ui/gesture/ReaderColorScheme.kt | 3 + .../ui/preset/ReaderStylePresetPersistence.kt | 1 + .../ui/preset/ReaderStylePresetReducer.kt | 30 +- .../preset/ReaderStylePresetUiStateMapper.kt | 1 + .../progress/ReaderPositionCodecTest.kt | 17 + .../reader/ui/EpubProgressCalculatorTest.kt | 114 ++++ .../reader/ui/ReaderAutoScrollDispatchTest.kt | 49 +- .../ui/ReaderBookOpeningControllerTest.kt | 42 +- .../feature/reader/ui/ReaderHtmlCssJsTest.kt | 32 +- .../ui/ReaderMaterialColorSchemeTest.kt | 121 ++++ .../ui/ReaderWebViewRuntimeControllerTest.kt | 22 + .../ReaderBottomProgressPolicyTest.kt | 80 +++ .../ui/geometry/ReaderViewportGeometryTest.kt | 25 +- .../ui/gesture/PagedGesturePolicyTest.kt | 185 ++++++ .../ui/gesture/ReaderColorSchemeTest.kt | 5 + .../ui/preset/ReaderStylePresetReducerTest.kt | 51 +- .../settings/ui/GamificationViewModel.kt | 149 +++++ .../settings/ui/PerformanceDetailSection.kt | 59 +- .../settings/ui/SettingsAboutSection.kt | 32 +- .../settings/ui/SettingsBackupController.kt | 3 +- .../settings/ui/SettingsDictionarySection.kt | 58 +- .../settings/ui/SettingsReaderLabels.kt | 16 +- .../settings/ui/SettingsReaderLayoutCards.kt | 10 +- .../settings/ui/SettingsReaderPreviews.kt | 58 +- .../settings/ui/SettingsReaderSection.kt | 24 +- .../settings/ui/SettingsReaderTextCards.kt | 2 +- .../settings/ui/SettingsTranslationSection.kt | 21 +- .../ui/SettingsViewModelReaderSetters.kt | 1 + .../ui/SettingsViewModelTextSetters.kt | 1 + .../ui/ReaderModeSelectionPolicyTest.kt | 20 + docs/bug-analysis-report.md | 584 +++++++++++++++++ docs/bug-analysis-summary.md | 78 +++ 178 files changed, 9483 insertions(+), 2636 deletions(-) create mode 100644 BUGTRACKER_REDESIGN_2026-08-21.md create mode 100644 BUGTRACKER_VIDEO_2026-08-16.md create mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 android/app/src/main/assets/databases/dictionary_fr.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_it.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_ja.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_ko.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_pl.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_pt.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_tr.dbpack delete mode 100644 android/app/src/main/assets/databases/dictionary_zh.dbpack create mode 100644 android/app/src/main/assets/fonts/AccessibleDfA.otf create mode 100644 android/app/src/main/assets/fonts/LiberationSans-Regular.ttf create mode 100644 android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf create mode 100644 android/app/src/main/assets/fonts/iAWriterDuospace-Regular.ttf delete mode 100644 android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt delete mode 100644 android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt delete mode 100644 android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt delete mode 100644 android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt delete mode 100644 android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt delete mode 100644 android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt create mode 100644 android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt create mode 100644 android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt create mode 100644 android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt create mode 100644 android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt create mode 100644 android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt delete mode 100644 android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt create mode 100644 android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt rename android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/{Navigation.kt => Navigation.kt.bak} (100%) rename android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/{SettingsRows.kt => SettingsRows.kt.bak} (100%) create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt create mode 100644 android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt create mode 100644 android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt delete mode 100644 android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt delete mode 100644 android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt delete mode 100644 android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt delete mode 100644 android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt create mode 100644 android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt delete mode 100644 android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt delete mode 100644 android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt delete mode 100644 android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt create mode 100644 android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt create mode 100644 android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt create mode 100644 docs/bug-analysis-report.md create mode 100644 docs/bug-analysis-summary.md diff --git a/BUGTRACKER_REDESIGN_2026-08-21.md b/BUGTRACKER_REDESIGN_2026-08-21.md new file mode 100644 index 000000000..f1a1469fc --- /dev/null +++ b/BUGTRACKER_REDESIGN_2026-08-21.md @@ -0,0 +1,149 @@ +# Bug Tracker — 2026-08-21 + +Source: `C:\Users\xmeta\.minimax\v2\assets\2026\08\21\22-56-55-091-asset_20260821-225655-091_c61d5ed3341c_2fd6bd7f-Цель.md` +Status: **NEW — not yet triaged. Acceptance criteria not met for any item.** + +## Цель + +Зафиксировать отдельным issue актуальные нерешённые дефекты Mr.Comic, выявленные по видео, прикреплённому багтрекеру и статическому анализу APK. Этот issue самостоятельный и не заменяет, не объединяет и не изменяет ранее созданные issues. + +## Актуальный scope + +В список входят **16 активных багов**. Подтверждённо исправленные симптомы в этот issue не входят: + +- автоскролл в вертикальном режиме, из-за которого появлялась лишняя пустая область/ошибка viewport; +- чёрная полоса снизу при автопрокрутке PDF/CBR; +- отображение файлов CBR как архивов RAR — подтверждено исправленным пользователем; +- crash при входе в «Настройки → Перевод → Словари» — исключён по требованию владельца текущего списка. + +## Bugs + +### Reader / vertical mode + +#### `BUG-VERTICAL-01` — Рассинхронизация фактической позиции чтения и ползунка + +**Priority:** P1 +**Area:** Reader State / Scroll / Progress + +В режиме «Вертикальная лента» положение ползунка перестаёт соответствовать фактической позиции текста. Необходимо свести состояние к цепочке `DocumentPosition → ScrollPosition → ReadingProgress → SeekBar`; ползунок не должен иметь независимый источник позиции. + +#### `BUG-PAGED-01` — Случайное выделение текста при перелистывании + +**Priority:** P2 +**Area:** Gesture / Text Selection + +Обычный swipe/tap периодически инициирует выделение текста. Выделение должно запускаться только намеренным жестом, например long press. Требуется разделить приоритеты `Tap → Long Press → Selection → Page Swipe`. + +#### `BUG-PAGED-02` — Неодинаковые верхние и нижние отступы в постраничном режиме + +**Priority:** P2 +**Area:** Layout / Pagination / Insets + +На разных страницах верхние и нижние отступы текста визуально отличаются; в отдельных форматах появляются пустые области или обрезается текст. Автоскролльный viewport defect из вертикального режима сюда не входит — он отмечен как исправленный. + +Расчёт должен быть централизованным: `Screen height − System Insets − Reader Insets − Reader Padding = Page Viewport`. + +#### `BUG-PAGED-03` — Сноски конфликтуют с зонами перелистывания + +**Priority:** P1 +**Area:** Gesture / Hit Testing / Footnotes + +Если сноска находится у края экрана и попадает в область page gesture, нажатие перехватывается перелистыванием. Приоритет hit-test должен быть: `Footnote/Link → Interactive Content → Selection → Page Navigation`. + +### Reader state / pagination / navigation + +#### `BUG-READER-01` — Некорректный подсчёт количества страниц + +**Priority:** P1 +**Area:** Pagination Engine + +Одно и то же текстовое произведение отображает разные значения общего объёма: могут смешиваться страницы главы, секции и всего документа. Pagination должна зависеть от `Document + Viewport + Font Metrics + Spacing + Padding`, а логическая позиция документа должна быть отделена от visual page count. + +#### `BUG-READER-02` — Не сохраняется выбранный режим чтения + +**Priority:** P1 +**Area:** Persistence / Reader Preferences + +После повторного открытия книги «Страницы» могут смениться на «Вертикальную ленту» или наоборот. `ReadingMode` должен храниться и восстанавливаться единообразно для каждой книги. + +#### `BUG-READER-03` — Не сохраняется фактическая позиция чтения + +**Priority:** P0 +**Area:** Persistence / Reading Position + +После выхода из книги или переключения режима пользователь возвращается не к последнему месту. Восстановление должно использовать canonical document location: section/chapter, content anchor и relative offset; visual page может быть только fallback. + +#### `BUG-READER-04` — Глобальная рассинхронизация прогресса чтения + +**Priority:** P0/P1 +**Area:** Reader State / Progress + +Chrome-панель, toolbar, информация о файле и карточка библиотеки могут показывать разные page count и проценты. Нужна единая модель `DocumentPosition → ReadingProgress 0..1 → Chrome/Toolbar/File Info/Library`, отделённая от `PaginationState`. + +#### `BUG-READER-05` — Смена режима изменяет тему-пресет + +**Priority:** P2 +**Area:** State Isolation / Theme + +Переключение «Страницы ↔ Вертикальная лента» изменяет или сбрасывает выбранный пресет. `ReadingMode` и `ReaderTheme` должны быть независимыми состояниями. + +#### `BUG-READER-06` — HTML-название книги не помещается по ширине + +**Priority:** P3 +**Area:** HTML Reader / Layout + +Длинное название книги выходит за доступную ширину и обрезается. Нужны ellipsis, перенос строк или ограничение количества строк без перекрытия соседних элементов. + +#### `BUG-READER-07` — Оглавление не работает в некоторых форматах + +**Priority:** P1 +**Area:** TOC / Document Navigation + +Оглавление отображается, но переход по некоторым главам не открывает соответствующее место документа. Нужна унификация `Format Parser → TableOfContents → DocumentLocation → Reader` для EPUB, FB2, HTML и других поддерживаемых форматов. + +### Library / visual system + +#### `BUG-UI-01` — Несогласованное оформление карточек библиотеки + +**Priority:** P2 +**Area:** Design System / Library + +Format badge и progress badge на некоторых обложках имеют недостаточный контраст; плашки используют разные формы несмотря на общую настройку скругления. Нужны единые `ShapeTokens`, `ColorTokens`, `TypographyTokens` и гарантированный контраст. + +#### `BUG-UI-02` — Некорректный пресет «День» в графическом ридере + +**Priority:** P2 +**Area:** Graphic Reader / Theme + +Пресет «День» применяется неправильно или визуально похож на другой режим. Day/Sepia/Night должны давать предсказуемые и независимые наборы фона, текста и overlay colors. + +#### `BUG-UI-04` — Неконсистентное применение цветов фона, поверхностей и карточек + +**Priority:** P1 +**Area:** Theme / Library / Contrast + +Выбранный фон применяется не ко всем поверхностям: библиотека может оставаться с blur, а отдельные элементы теряют контраст. Все reader/library surfaces должны использовать единый theme token pipeline. + +#### `BUG-UI-05` — Сломанный компонент preview кастомизации + +**Priority:** P2 +**Area:** Customization / Preview + +В preview вместо ожидаемого элемента отображается warning-like значок или некорректный placeholder. Preview должен визуально соответствовать реальному компоненту и выбранным настройкам. + +### Additional bugs confirmed by video + +#### `BUG-CANDIDATE-01` — «Цитатник» не открывает исходную страницу или якорь + +**Priority:** P1 +**Area:** Quotes / Document Location / Navigation + +Нажатие на сохранённую цитату не возвращает пользователя к точному месту, откуда она была создана. Нужно сохранять и разрешать `href`, fragment, DOM anchor, character offset или другую mode-independent document location; page number может оставаться только legacy fallback. + +## Acceptance criteria + +Для каждого бага должны быть добавлены воспроизводимый тестовый сценарий, ожидаемый результат, regression test и проверка минимум на затронутых форматах/режимах. Исправление считается готовым только после прохождения runtime-тестов на реальном Android-устройстве или emulator и проверки, что исправление не нарушает сохранение позиции, прогресс, TOC, selection и theme state. + +## Не входит в этот issue + +Этот issue не включает Dictionaries crash, исправленную чёрную полосу PDF/CBR, исправленный auto-scroll viewport defect в вертикальном режиме, исправленное отображение CBR как RAR и любые новые симптомы, которые не были подтверждены повторным воспроизведением. diff --git a/BUGTRACKER_VIDEO_2026-08-16.md b/BUGTRACKER_VIDEO_2026-08-16.md new file mode 100644 index 000000000..20062778c --- /dev/null +++ b/BUGTRACKER_VIDEO_2026-08-16.md @@ -0,0 +1,590 @@ +# Багтрекер Mr.Comic — по видеозаписи от 16 августа 2026 + +## 1. Источник и методика + +Проанализирована запись экрана `Record_2026-08-16-16-04-41_ca83fc14b354edfa350da4cec69c70da.mp4` +(длительность 39:46, 864×1920, HEVC + аудиодорожка AAC mono 48 kHz). + +Методика: + +- полная транскрипция голосовых комментариев пользователя (faster-whisper, RU); +- покадровая выборка (119 кадров, шаг 20 с) с выборочной визуальной верификацией; +- анализ аудио-таймлайна (детекция пауз/тишины: 189 событий, аномалий записи нет — + вся дорожка является авторским комментарием, TTS/аудиокниги в записи не задействованы); +- сопоставление каждого бага с **локальным кодом** репозитория + `Mr.Comic_fresh_clone`, HEAD `db1e8272` (2026-08-16 14:44); +- кросс-проверка с независимым анализом Manus (commit `1df14dd` в GitHub-зеркале). + +**Важная временная метка.** Видео записано 16.08 в 16:04, HEAD локального репозитория — +16.08 14:44, т.е. демонстрируемая сборка соответствует текущему HEAD или немного старее. +Коммит `27d30d56` (15.08, «preserve text position across mode changes») **предшествует** +записи, но потеря позиции при смене режима в видео всё равно воспроизводится — +фикс неполный либо установленная сборка старше. Требует пере-верификации на свежей сборке. + +**Классификация.** *Глобальный* баг — воспроизводится в нескольких форматах/режимах и имеет +общую причину (координаты позиции, viewport/insets, жести, тема). *Одиночный* — ограничен +конкретным форматом, экраном или лейблом. Пожелания по развитию вынесены отдельно. + +--- + +## 2. Сводная таблица + +| ID | Заголовок | Область | Severity | Priority | Подтверждение | +|---|---|---|---|---|---| +| GLOB-001 | Несогласованные координаты страницы/прогресса | Все текстовые форматы | Blocker | P0 | многократно, 03:20–20:35 | +| GLOB-002 | Потеря позиции и режима при смене режима/выходе | Текстовый reader | Critical | P0 | 14:13–18:53 | +| GLOB-003 | Высота нижней панели протекает в viewport (полоса снизу) | Все форматы, page mode + autoscroll | High | P1 | 17:06–36:24 | +| GLOB-003-V | Отступ при авточтении в ВЕРТИКАЛЬНОЙ ленте | Текстовый vertical + autoscroll | High | P1 | 17:49–18:11, кадр ~18:00 | +| GLOB-003-T | Позиция текста «гуляет» на строку сверху/снизу от страницы к странице | Все текстовые форматы | Medium | P1 | 21:14–23:10, 28:22–28:33 | +| GLOB-004 | Нестабильный page-turn: повтор/возврат/белая вспышка | Текстовый paged reader | High | P1 | 06:52–31:05 | +| GLOB-005 | Ложное выделение текста при перелистывании | Все текстовые форматы | High | P1 | 09:43, 28:12 | +| GLOB-006 | Edge-swipe перекрывает кликабельную сноску | EPUB, FB2 | High | P1 | 16:10, 21:56 | +| GLOB-007 | Поэтапная перекраска при смене темы | Глобальная тема | Medium | P1 | 36:42–37:04 | +| GLOB-008 | Custom background/surface: затемнение и плохой контраст | Библиотека, карточки | Medium | P1 | 37:15–39:43 | +| QUOTE-001 | Переход из цитатника не открывает якорь | Цитаты + навигация | High | P1 | 13:23–14:06 | +| LIB-001 | Процентный бейдж сливается с обложкой | Библиотека | Medium | P2 | 00:42–02:00 | +| LIB-002 | Галочка «прочитано» на непрочитанной книге | Библиотека | Medium | P2 | 01:56–03:00 | +| ARCH-001 | Медленное открытие RAR/CBR-архива (спиннер) | Архивы | Low | P3 | ~01:25 | +| RTF-001 | RTF: 2 из 2, 100%, ползунок в конце в начале книги | RTF | High | P1 | 03:20–05:14 | +| RTF-002 | RTF: обрезание текста снизу и скачущая высота страницы | RTF (page mode) | High | P1 | 23:51–24:26, кадр ~24:00 | +| EPUB-001 | Обложка и заголовочная страница залезают под верхний chrome | EPUB | Medium | P2 | 07:48–08:15 | +| EPUB-002 | Обычные страницы EPUB обрезаются сверху/снизу при видимом chrome | EPUB (page mode) | High | P1 | 08:21–09:42, кадр ~8:20 | +| DOCX-001 | Page mode использует ~треть высоты страницы; vertical идеален | DOCX | Medium | P2 | 24:37–26:21, кадр ~25:40 | +| HTML-001 | Заголовок обрезается справа при кастомной типографике | HTML | Medium | P2 | 26:21–27:30 | +| TXT-001 | Первая страница TXT сжата, не использует ширину/высоту | TXT | Low | P3 | 30:38–30:55 | +| FB2-001 | Одна страница FB2 на строку ниже ожидаемого | FB2 | Low | P3 | 21:14–21:47 | +| MOBI-001 | Одна страница MOBI со смещённой раскладкой | MOBI | Low | P3 | 22:16–23:10 | +| MARKDOWN-001 | Page mode: заголовок пропадает, блоки нарезаны неестественно | Markdown | Medium | P3 | 28:40–30:37, кадр ~29:20 | +| FORMAT-001 | CBR отображается как RAR | Лейбл формата | Medium | P2 | 34:53–35:14 | +| HIGHLIGHT-001 | Цветное выделение нельзя удалить: delete есть в контроллере, но нет UI-действия | Все текстовые форматы | High | P1 | 11:10–11:16, 12:11–12:28 + код | +| AUTOSCROLL-001 | Скорость автолистания: единая неперсистентная модель (⚠ частично закрыто в db1e8272) | Reader settings | Medium | P2 | 11:16–12:11 + код | +| FEATURE-001…006 | Пожелания (не баги) | — | — | Backlog | см. §6 | + +--- + +## 3. Глобальные баги + +### GLOB-001 — Единая координата страницы не соблюдается (Blocker, P0) + +**Наблюдение по видео (таймкоды):** + +- 03:20–05:14, RTF: внизу «2 из 2», в правом нижнем углу 100%, ползунок почти в конце — + при этом открыта первая страница; в библиотеке книга отображается как 100% прочитанная. + Отдельно (03:35): «в этой книге явно не тысяча страниц» — тотал в тулбаре завышен на порядки. +- 07:20–07:44, EPUB: пролистано 3 страницы — счётчик показал 7, после ещё одной — 6; + итог «6 из 91», хотя страниц в книге «намного больше». +- 18:53–20:35, EPUB: библиотека говорит 464 страницы / 63% / позиция 17; + тулбар ридера — «5 из 116», после переходов «83 из 116», «85 из 116». + Пользователь: «в два раза уменьшил размер отображаемых страниц… она, видимо, + разбивает главы кусками и показывает прогресс» — прогресс считается по chunk/section, + а не по всей книге. + +**Ожидаемое поведение.** Одна авторитетная координата чтения на книгу. UI-номер страницы, +total, слайдер, процент, флаг завершённости, сохраняемый `currentPage` и библиотечный +`readingProgress` — из одной модели. Значения монотонны, не превышают total, +100% не появляется до фактического конца. + +**Код (локально, подтверждено):** + +- `android/feature-reader/.../ui/ReaderProgressPolicy.kt:13` — `pageForPersistence(...)`; +- `android/feature-reader/.../ui/TextReaderNavigation.kt:27` — `tocDisplayPage(...)`, + `:42` — `enginePageForUiPage(...)`; +- `android/feature-reader/.../ui/TextPagePaginationController.kt`; +- EPUB chunker/progress: `EpubHtmlChunker`, EPUB page resolver, `EpubProgressCalculator`; +- `ReaderScreen.kt` (публикация `totalPages`/`currentPage` в UI). + +Параллельные представления позиции (engine page / UI page / chunk index) существуют, +но часть форматов публикует локальный chunk-индекс как глобальную страницу. RTF, +судя по «2 из 2», вообще отдаёт количество секций вместо страниц. + +**Регрессия:** RTF, EPUB (multi-spine), FB2, MOBI, HTML, TXT, Markdown, DOCX; +начало/середина/конец; page/vertical; chrome visible/hidden — сверять page, total, +percent, slider и карточку библиотеки. + +### GLOB-002 — Потеря позиции и режима чтения (Critical, P0) + +**Наблюдение:** + +- 14:13–14:47: включение вертикальной ленты после чтения в середине книги возвращает + «грубо говоря, в самое начало»; +- 15:09–15:40: смена режима «возвращает назад, не сохраняет позицию»; выход в библиотеку + и обратный вход тоже не восстанавливают прогресс; +- 17:53–18:53: в vertical режиме «пару раз страница по новой в начало перекидывала»; + после выхода/входа включился page mode вместо сохранённого vertical — сбрасывается + не только позиция, но и сам режим; +- Контроль (32:00–32:40): в PDF выход/вход восстанавливает страницу корректно — + дефект локализован в текстовом pipeline, не в платформе. + +**Ожидаемое поведение.** PAGE ↔ VERTICAL, закрытие/открытие ридера сохраняют +семантическую позицию (CFI/anchor для reflowable, абсолютная страница для raster) +и выбранный режим чтения (per-comic). + +**Код:** + +- `ReaderReadingModeController.kt`, `ReaderSessionCoordinator.kt`, + `ReaderBookOpeningController.kt`, `ReaderInteractionPolicy.kt`; +- уже есть преобразования `readerWebtoonRestoreSectionIndex`, `readerPageFromWebtoonSection`; +- `27d30d56` (15.08) добавил `ReaderPositionRestorePolicy.kt` (+160 строк тестов), + но баг воспроизводится в записи от 16.08 — **фикс неполный или сборка старше**. + Проверить порядок `save → teardown → apply mode → restore` и ключи персистентности + (comicId + format + orientation + readingMode). + +### GLOB-003 — Нижняя полоса = высота панели chrome в page mode (High, P1) + +**Наблюдение:** + +- 17:06–17:49: автопрокрутка добавляет снизу отступ «на ширину панели, в которой + кнопка Продолжить, Библиотека, Перевод и Настройки» — пагинация по высоте считается + с этим отступом; +- 31:45–33:44, PDF: чёрная полоса снизу ровно на высоту нижней панели (page mode; + в vertical — нет); +- 33:48–34:44, CBZ: та же чёрная полоса в page mode; «в текстовых форматах она белая»; +- 34:13–35:28, CBR: то же; 35:32–36:24, DJVU: то же; +- 06:25–06:47 и 08:21–09:42, EPUB: отступы сверху/снизу «задраны», «поверху и понизу» — + высота страницы меняется в зависимости от состояния chrome; +- 23:51–24:26, RTF: после каждого перелистывания текст на разной высоте, «подрезается». + +**Ожидаемое поведение.** Page viewport не зависит от видимости chrome, автопрокрутки +и нижней навигации: один владелец inset, одна вычисленная высота страницы. + +**Код:** + +- `ChromeInsetsPlan.kt` (measured/auto-hide/stable/final reserve, CSS-инсеты); +- `ReaderScreen.kt:339` — `bottomToolbarHeightPx = if (chromeIsVisible) plan.bottomChromeReservePx else 0` + (при скрытом chrome здесь 0, но полоса в видео появляется именно при скрытом chrome — + значит, reserve протекает через `stableBottomChromeReservePx`/baseline или дублируется + в CSS body inset / raster-контейнере); +- `ReaderPagedLayoutMetrics.kt`, `HtmlPageView.kt`, растровый page container; +- 27d30d56 упоминал «paged-layout shield fix» — частично, не для raster/autoscroll. + +**Тест:** матрица `chromeVisible × chromeHidden × autoScroll × PAGE/VERTICAL` для текста +и raster; высота страницы должна совпадать во всех клетках. + +### GLOB-003-V — Отступ при авточтении в вертикальной ленте (High, P1) + +**Наблюдение.** 17:49–18:11: после включения режима вертикальной ленты при активной +автопрокрутке внизу текстовой колонки появляется пустой отступ. Визуально подтверждено +на кадре ~18:00: пустая полоса ~10–15% высоты экрана между последней строкой текста +и нижним краем, под ней оверлей «17 / 464». Пользователь дополнительно подтвердил +этот баг отдельно. Там же: при включении vertical «пару раз страница по новой в начало +перекидывала» (пересекается с GLOB-002). + +**Ожидаемое поведение.** В vertical-режиме текстовая колонка занимает всю высоту +viewport; автопрокрутка не добавляет нижний отступ; контент не отскакивает в начало. + +**Код:** тот же inset-pipeline (`ChromeInsetsPlan`, webtoon/vertical container, +`TextWebtoonSessionController`), плюс логика autoscroll-высоты в vertical-режиме. +В PDF vertical полосы нет (33:27–33:36) — дефект в текстовом vertical-пути. + +### GLOB-003-T — Позиция текста «гуляет» на строку от страницы к странице (Medium, P1) + +**Наблюдение.** Пользователь прямо обобщает (28:22–28:33): «то, что разная высота +снизу, сверху, тоже ко всем [текстовым форматам] относится». Конкретные экземпляры: +FB2 21:40–21:47 («вот здесь уже неправильно, на одну строчку ниже»), MOBI 22:44–23:10 +(«кроме вот этой страницы все страницы были нормальные», «местами на одну строчку +снизу, чуть выше, хотя на одну строчку должно быть ниже»), RTF 24:17–24:26 («сверху +нормально, а снизу сейчас на одну больше»). + +**Ожидаемое поведение.** Верхняя и нижняя границы текстового блока одинаковы на всех +страницах книги (кроме, возможно, последней короткой страницы главы — слова +пользователя, 09:19–09:29). + +**Код:** `TextPagePaginationController.kt` (шаг разбиения/высота строки), +`EpubHtmlChunker`, типографика (`lineHeight`/шрифты) и скругление высоты страницы. +Не чинить формат-специфичными оффсетами — сначала общий inset/viewport (GLOB-003). + +### GLOB-004 — Нестабильный page-turn (High, P1) + +**Наблюдение:** + +- 06:52–07:03: «бывает, не с первого раза пролистывается, и страница повторяется»; +- 23:28–23:35, RTF: «я нажал пролистнуть — он меня перелистнул обратно»; +- 23:41–23:51, RTF: от первой страницы «был белый экран и потом только пролистнуло»; +- 30:55–31:05, TXT: «пытался пролистнуть — вернулось на той же странице; походу, + практически во всех текстовых форматах». + +**Ожидаемое поведение.** Один завершённый жест = максимум один переход строго `current ± step`; +отложенные колбэки старой страницы не переопределяют новое состояние; фон при загрузке +непрозрачный (нет white flash). + +**Код:** `ReaderInteractionPolicy.kt`, `PagedGesturePolicy.kt`, +`TextPagePaginationController.kt`, `ReaderPageLoader.kt`, `ReaderWebViewLoadController.kt` +(в т.ч. `readerHtmlReloadResetsScroll`, ReaderInteractionPolicy.kt:76). +История проекта уже знает white-flash/WebView-reload регрессии — проверить оставшийся путь. + +**Тест:** быстрые последовательные свайпы; колбэк старой страницы после новой; первый +переход со страницы 0; RTF/TXT-specific. + +### GLOB-005 — Ложное выделение текста при листании (High, P1) + +**Наблюдение:** 09:43–10:13: «листаю — автоматически бывает текст выделяется и появляется +менюшка выделения; оно должно быть наоборот, по выделению появляться»; 28:12–28:33 — +подтверждено как общий дефект всех текстовых форматов. + +**Ожидаемое поведение.** Горизонтальный свайп = page-turn; selection/контекстное меню — +только после long-press или явного selection-жеста; после перехода selection-меню +не открывается самопроизвольно. + +**Код:** `ReaderInteractionPolicy.kt:74` — `readerHtmlSelectionActionsEnabled(...)` +(сейчас безусловно `true`); arbitration touch-slop/long-press между +`PagedGesturePolicy.kt`, WebView native selection и `ReaderSelectionSheets`. + +### GLOB-006 — Edge-swipe перекрывает сноску (High, P1) + +**Наблюдение:** 16:10–16:53, EPUB: сноска кликабельна и работает (popup с примечанием), +но «сноска на краю страницы — не могу нажать, перелистывается страница; жест +перелистывания не должен перекрывать сноску»; 21:56–22:02 — то же в FB2. + +**Ожидаемое поведение.** Касание по anchor сноски имеет приоритет над page-turn +в его hitbox; начатый на anchor жест не листает страницу. + +**Код:** `ReaderFootnoteAnchorPolicy.kt`, `ReaderFootnotePopupPolicy.kt`, +`PagedGesturePolicy.kt` (edge tap zones), `HtmlPageView.kt` (WebView bridge, +hit-testing DOM anchor vs Compose gesture layer). +Тест: anchor в 0–10% и 90–100% ширины viewport. + +### GLOB-007 — Поэтапная перекраска темы (Medium, P1) + +**Наблюдение:** 36:42–37:04: sepia → dark → light — «интерфейс перестраивается, хотя +должен мгновенно меняться, и не должно быть видно, что он перестраивается кусками». + +**Код:** `core-ui/.../theme/ThemePreferencesRepository.kt` — набор раздельных +`setThemeMode/setThemePreset/setCustomPrimaryColor/...` (строки 61–106+), каждый пишет +свой ключ DataStore; `themeConfig` собирается из многих ключей → промежуточные эмиции; +`MainActivity.kt:133` (`collectAsState`) рисует смешанные кадры. + +**Фикс:** публиковать один immutable `ThemeConfig` (versioned snapshot preset), +записывать атомарно; screenshot-тест переключения. + +### GLOB-008 — Custom background/surface: затемнение и контраст (Medium, P1) + +**Наблюдение (37:15–39:43):** + +- выбранный синий «фон экрана» не применился к экрану библиотеки — «стал только тёмным, + как будто заблюренный»; после выключения эффекта — «здесь всё нормально»; +- «поверхности карточек, панели — всё покрасилось, но кнопка "Продолжить" почему-то + тёмная на синем фоне» (плохой контраст выбранного состояния); +- цвета «кислотные»; пожелание — пастельные/акцентные либо убрать настройку, + оставив только прозрачность (см. FEATURE-004). + +**Код:** `core-ui/.../theme/Theme.kt` (построение `surfaceVariant/surfaceDim/surfaceContainer*` +через `lerp` от custom-цветов + alpha + `contrastingOnColor()`), `ThemePreferencesRepository.kt`, +`feature-settings/.../SettingsAppearanceTheme.kt`, `ContinueGamificationCards.kt` +(кнопка «Продолжить»). +Фикс: contrast-аудит ролей контейнеров + выбранных состояний; детерминированная +трансформация пользовательского цвета вместо чистого lerp. + +--- + +## 4. Одиночные баги + +### QUOTE-001 — Переход из цитатника не открывает якорь (High, P1) + +13:23–14:06: сохранённая цитата при нажатии «не сработала» — книга должна открываться +на странице-якоре цитаты независимо от режима (page/vertical). + +**Код:** `AppNavigation.kt:414–421` — `onQuoteClick` передаёт только числовой `page` +в `Screen.Reader.createForComic(...)`; для текстовых форматов числовой индекс нестабилен +(GLOB-001), locator не передаётся вовсе. Сохранение: `ReaderSaveQuoteController.kt` +(цитата хранит `comicId`, `page`, `contentHash` — см. `SavedQuote.kt`). +Фикс: хранить и передавать locator/CFI якоря, fallback на page только для raster. + +### LIB-001 — Процентный бейдж сливается с обложкой (Medium, P2) + +00:42–02:00 (подтверждено и кадрами): на светлых/ярких обложках «облачко» с процентом +не читается; требование пользователя — полностью белый фон бейджа, чёрный текст. + +**Код (корень подтверждён):** `core-ui/.../designsystem/Chips.kt:117` — тон `Info`: +`containerColor = colorScheme.primary.copy(alpha = 0.14f)` — полупрозрачный; +вызов — `feature-library/.../components/ComicGridItem.kt:315` (`MrComicStatusBadge` +для READING/NEW). Завершённый бейдж тоже полупрозрачный: `completedColor.copy(alpha = 0.18f)` +(ComicGridItem.kt:306). +Фикс: непрозрачный белый контейнер + чёрный текст (зелёный текст при 100% — по желанию +пользователя, FEATURE-003). + +### LIB-002 — Галочка «прочитано» на непрочитанной книге (Medium, P2) + +01:56–03:00: значок прочитанного (CheckCircle) поверх облачка с процентами у книги, +которая не прочитана. Бейдж `CheckCircle + "100%"` показывается при +`readingStatus() == COMPLETED` (ComicGridItem.kt:301–318). + +**Корень:** `core-model/.../Comic.kt:59–80` — `completedByProgress = normalizedProgress >= 0.999f`; +при ложных 100% из GLOB-001 (RTF «2 из 2» → progress = 1.0) книга получает COMPLETED. +Комментарий в коде уже запрещает завершение по факту «стояния на последней странице», +но лживый progress ≥ 0.999 всё ещё даёт ложный COMPLETED. Чинить в связке с GLOB-001; +UX-предложение пользователя (зелёная полоса под обложкой) — FEATURE-003. + +### ARCH-001 — Медленное открытие RAR/CBR-архива со спиннером (Low, P3) + +~01:25: при открытии CBR/RAR-архива в библиотеке длительная загрузка с индикатором +(зафиксировано в независимой хронологии анализа; пользователь сказал «он открывается, +но потом к нему вернёмся» и вернулся к CBR только на 34:53). Требует замера: сколько +секунд занимает открытие и сколько — кэшированное повторное. + +**Код:** `ArchiveDelegatingFormatReader.kt` (lazy delegate + извлечение во temp), +junrar-путь, `FormatDetector`. Возможные меры: async-открытие с progress-UI, +кэш списка файлов архива. + +### RTF-001 — Неверный total/page/progress в RTF (High, P1) + +03:20–05:14: «2 из 2», 100%, ползунок в конце, ~1000 страниц в тулбаре при малом документе. +Самый наглядный экземпляр GLOB-001: похоже, `totalPages` берётся из количества секций, +а не страниц. + +**Код:** `FormatFactory.kt` → RTF в `TextFormatReader`; `TextPagePaginationController.kt`; +публикация `totalPages` в `ReaderUiState`. Нужен fixture с известным количеством страниц. + +### RTF-002 — RTF: обрезание текста снизу и разная высота страницы при каждом листании (High, P1) + +23:51–24:26: «текст намного ниже, слишком низко, и он ПОДРЕЗАЕТСЯ, но он вообще не должен +на такой высоте быть… при каждом перелистывании он каждый раз ПО-РАЗНОМУ отображает высоту, +что сверху, что снизу; сверху нормально, а снизу сейчас на одну больше». +Сюда же из этого же фрагмента: 23:25–23:32 нажал перелистнуть — «перелистнул обратно» +(GLOB-004); 23:41–23:51 от первой страницы «был белый экран и потом только пролистнуло». + +**Визуально подтверждено** (кадр ~24:00): верхний отступ текста всего ~5–8% высоты +экрана, нижний ~0%, последняя строка обрезана примерно наполовину высоты строки; +счётчик внизу «2 / 2». + +**Ожидаемое поведение.** Одинаковые поля на всех страницах; строки не обрезаются; +высота блока стабильна между перелистываниями. + +**Код:** RTF → HTML конверсия, `TextPagePaginationController.kt`, CSS body inset +(`ChromeInsetsPlan.textContentBottomInsetCssPx`) и `HtmlPageView.kt`. Вероятно, +пагинация RTF считает высоту viewport без учёта inset/scroll и отдаёт WebView +больше строк, чем видно. + +### EPUB-001 — Обложка и заголовочная страница залезают под верхний chrome (Medium, P2) + +07:48–08:15: «обложка слишком высоко задрана, она должна быть чуть ниже, чтобы она +не задевала этот тулбар»; 08:53–09:16: титульная страница — «вот сейчас как должно +быть, чтобы оно не задевало, вот так это стандарт». + +**Код:** EPUB cover/титул в chunker (`EpubHtmlChunker`), CSS body inset injection, +`HtmlPageView.kt`. + +### EPUB-002 — Обычные страницы EPUB обрезаются сверху и снизу при видимом chrome (High, P1) + +08:21–09:42: «тут задевает, тут тоже неправильно… оно слишком задрано ВСЁ, и ПОВЕРХУ, +и ПОНИЗУ… масштабирование по высоте — пагинация почти правильная, но ещё чуть-чуть +надо подработать, чтобы высота была одинаковая вне зависимости от режима скрытия +тулбаров… опять всё вверх задевает, неправильная пагинация, масштабирование точнее; +здесь так же, здесь так же». Стандарт пользователя (09:19–09:29): одинаковые отступы +на ВСЕХ страницах, кроме последней короткой страницы главы. + +**Визуально подтверждено** (кадр ~8:20): первая строка обычной текстовой страницы +частично уходит под верхний тулбар, верхнее поле ~3–5% высоты экрана. + +**Код:** `ChromeInsetsPlan` (двойное применение inset — при чанкинге страницы и при +отрисовке WebView), `ReaderScreen.kt:339`, `ReaderPagedLayoutMetrics.kt`, +`EpubHtmlRenderer`. Высота viewport для пагинации не совпадает с фактической +высотой WebView при видимом chrome. + +### DOCX-001 — Page mode использует ~треть высоты страницы; vertical идеален (Medium, P2) + +24:37–26:21: в vertical «прям вообще идеально… очень классно работает и отображается +правильно»; в page mode «он уже неправильно отображается… как минимум вот эта вот +часть, вот верхние два абзаца, вот эти заголовки и вот этот абзац можно было поместить +на одну страницу… тут уже можно было вот так разбить… в формате DOCX починить только +режим страницы». + +**Визуально подтверждено** (кадр ~25:40): страница «Fun and Fonts / Spacing» занята +текстом лишь на ~30–35% высоты, 65–70% — пустота; следующий блок поместился бы. + +Примечание 25:01: «почему-то он не листается… а, это режим вертикальной ленты» — +кнопка/жест перелистывания в vertical-режиме не действует (пользователь сам понял, +что находился в ленте). Отметить как UX: в vertical-режиме tap-зоны/кнопка листания +не должны дезориентировать. + +**Код:** DOCX → HTML, `TextPagePaginationController.kt` (правила разбиения блоков, +keep-with-next, min-fill страницы в page mode). Fixture: один документ в обоих режимах. + +### HTML-001 — Заголовок обрезается справа при кастомной типографике (Medium, P2) + +26:21–27:30: «заголовок слишком куда-то уходит вправо, потому что буковки должны +влезать вне зависимости от того, выставлены у меня настройки масштабирования шрифта, +межбуквенного, межабзацного, межсловного интервала; текст должен помещаться вне +зависимости от размера экрана, он должен масштабироваться под экран». В page mode — +«не считая того, что заголовок с буквами не полностью входит» (27:26). + +**Код:** HTML sanitizer/CSS builder (`UnifiedReaderCssBuilder`, `ReaderHtmlCssTest`), +`HtmlPageView.kt`. Проверить `overflow-wrap:anywhere`, `max-width:100%`, box-sizing +и наложение пользовательской типографики на заголовки. +Минор-пожелания (27:36–28:02): TOC по центру, chapter начинать с новой страницы +(FEATURE-006). + +### TXT-001 — Первая страница TXT сжата, не использует страницу (Low, P3) + +30:38–30:55: «не знаю, здесь нормально или нет — по-моему, слишком сильно сжато, +можно было на всю страницу всё это аккуратненько распределить красиво; здесь +нормально». При этом стили и vertical mode хвалятся (31:19–31:33: «просто офигенно, +отражаются стили»). + +**Код:** plain-text→HTML builder в `TextFormatReader`, CSS `pre`/`white-space`, +горизонтальные padding/margin, `TextPagePaginationController.kt` (min-fill). + +### FB2-001 — Одна страница FB2 на строку ниже ожидаемого (Low, P3) + +21:14–21:47: «отступы правильные… первая страница ладно, она как начало… и здесь +они сейчас правильно идут, здесь правильно, и вот здесь уже неправильно — на одну +строчку ниже; здесь правильно, тут правильно». Плюс сноска у края (GLOB-006, 22:00). +Частный случай GLOB-003-T. + +### MOBI-001 — Одна страница MOBI со смещённой раскладкой (Low, P3) + +22:16–23:10: «отступы нормальные, здесь тоже, здесь тоже… в формате MOBI нормально +всё, кроме вот этой страницы»; 23:03: «местами на одну строчку снизу, чуть выше, +хотя на одну строчку должно быть ниже». Частный случай GLOB-003-T. + +### MARKDOWN-001 — Page mode: заголовок пропадает, блоки нарезаны неестественно (Medium, P3) + +28:40–30:37: в vertical — «идеально, идеально, идеально… отображение нормальное»; +в page mode — «в начале был вот этот заголовочек, его сверху нету… Introduction +смещается вот сюда… About the document становится вот так… Leave the blocks на +следующую страницу переходит… он неправильно бьётся, разбивается как-то странно, +я не знаю, может быть это нормально». + +**Визуально подтверждено** (кадр ~29:20, page mode): страница начинается сразу +с «Introduction» — заголовок «What is this?» вверху ОТСУТСТВУЕТ; H3-заголовки +прижаты к предыдущему блоку, разбиение неестественное. + +**Код:** Markdown → HTML, `TextPagePaginationController.kt`: заголовок «съедается» +при разбиении (вероятно, остаётся на предыдущей странице-границе блока) — проверить +keep-with-next для heading и отображение первого заголовка страницы. + +### FORMAT-001 — CBR отображается как RAR (Medium, P2) + +34:53–35:14: «почему-то помечается как RAR, но он ничерта не RAR… должен подписываться +как CBR, а не RAR». + +**Код (корень найден точно):** `engine-formats/.../archive/ArchiveDelegatingFormatReader.kt:520`: + +```kotlin +ComicFormat.CBR, ComicFormat.RAR -> "rar" +``` + +лейбл возвращает `"rar"` для обоих форматов. Детекция в `FormatFactory.kt` +(`archiveContainerFormatFromPath`) различает cbr/rar корректно — ошибка только в лейбле. +Фикс: `ComicFormat.CBR -> "cbr"` (+ тест). + +### HIGHLIGHT-001 — Цветное выделение создаётся, но удалить его из UI нельзя (High, P1) + +Выделение работает: 12:11–12:28 — «Подсветить» → выбор цвета → подсвечивается и +сохраняется. Но удалить созданную пометку из читалки нельзя; пользователь прямо просит +(11:10–11:16): «если я книгу какую-то читаю, я мог эту пометку из текста удалить, убрать». + +**Код (проверено локально):** операция удаления существует во всей цепочке, кроме UI: + +- `ReaderHighlightController.kt:84` — `deleteHighlight(id)` → репозиторий → DAO; +- `TextHighlightRepository.kt:36` и `TextHighlightDao.kt:36` — delete есть; +- но `ReaderSelectionSheets.kt` (`SelectedTextActionSheet`) предлагает только + Перевести (`:66`) и Объяснить (`:80`); `HighlightColorPickerSheet` — только выбор цвета; +- вызовов `highlightController.deleteHighlight(...)` из `ReaderScreen.kt` / + `ReaderBottomSheets.kt` / `ReaderSelectionSheets.kt` нет вообще. + +Дефект — UI wiring: нет способа выбрать существующий highlight (tap по `` с +`dataset.highlightId`, см. `HighlightJsGenerator.kt:47`) и передать его id в контроллер. +Тесты `HighlightJsGeneratorTest` и `ReaderHighlightRuntimeControllerTest` в HEAD уже есть +(утверждение внешнего аудита об их отсутствии устарело) — добавить тест сценария удаления. + +### AUTOSCROLL-001 — Модель скорости автолистания (Medium, P2; ⚠ частично закрыто в HEAD) + +Запрос пользователя (11:16–12:11): раздельные настройки — секунды на страницу (page mode, +на видео виден отсчёт «12.0 с/стр») и пиксели в секунду (vertical, «45 px/s»). + +**Состояние в локальном HEAD `db1e8272` (проверено):** `ReaderAutoScrollProfiles.kt` уже +реализует раздельные по режимам скорости с персистентностью (precise-ключи +`reader_auto_scroll_precise_speed_*_v2` для PAGE_LTR/PAGE_RTL/DUAL_PAGE/WEBTOON, +legacy-миграция с пресетов 30/80/180, `restoreSpeedFor(mode)` без автостарта, +`autoScrollCountdownProgress` — отсчёт для page mode). Утверждение внешнего аудита +о единственной неперсистентной `autoScrollSpeed: Float` верно для коммита `1df14dd`, +но устарело для HEAD. + +**Остаток:** UX-доступ из «Сервисов» (FEATURE-001A) и явные единицы «сек/страница» +для paged-режима; убедиться, что значения переживают смену режима и переоткрытие книги. + +--- + +## 5. Манифестации GLOB-003 в графических форматах (не отдельные баги) + +| Формат | Время | Проявление | +|---|---|---| +| Текст + autoscroll, page mode | 17:06–17:49 | нижний отступ = высота панели; «пагинация по высоте неправильно рассчитывается» | +| Текст + autoscroll, vertical | 17:49–18:11 (кадр ~18:00) | пустая полоса ~10–15% высоты внизу колонки (см. GLOB-003-V) | +| PDF + autoscroll | 31:45–33:44 | чёрная полоса снизу = высота нижней панели; в vertical нет | +| CBZ | 33:48–34:44 | чёрная полоса в page mode; «в текстовых — белая» | +| CBR | 34:13–35:28 | то же | +| DJVU | 35:32–36:24 | «отступ, которого не должно быть в режиме страницы, что в текстовом ридере, что в графическом» | +| EPUB (текст) | 06:25–09:42 | «задрано» поверху/понизу при видимом chrome (см. EPUB-001/002) | +| RTF (текст) | 23:51–24:26 | текст «слишком низко… подрезается», высота меняется каждое листание (см. RTF-002) | + +Чинить одним общим фиксом viewport/inset, не отдельными патчами. +Положительный контроль: 32:00–32:40 — в PDF сохранение позиции работает (контраст +к GLOB-002 в текстовых форматах); в PDF vertical полосы нет (33:27–33:36). + +## 6. Пожелания (не баги) + +| ID | Запрос | Таймкод | +|---|---|---| +| FEATURE-001 | В «Сервисах» ридера: управление выделенным текстом и заметками (смена цвета, список пометок). Удаление пометки — не пожелание, а обязательный баг HIGHLIGHT-001 | 10:24–11:16 | +| FEATURE-002 | В «Сервисах»: доступ к скорости автопрокрутки (page: сек/стр; vertical: px/s). Раздельная персистентная модель уже есть — `ReaderAutoScrollProfiles.kt` (AUTOSCROLL-001), нужен UI | 11:21–12:11 | +| FEATURE-003 | Прочитанная книга: зелёное подчеркивание края обложки + зелёный текст процентов вместо галочки | 02:29–03:00 | +| FEATURE-004 | Custom colors: пастельные не-кислотные тона или убрать настройку, оставив только прозрачность | 38:48–39:29 | +| FEATURE-005 | Цитатник: группировка/колонки | 12:35–12:57 | +| FEATURE-006 | HTML TOC по центру; chapter с новой страницы | 27:36–28:02 | + +## 7. Рекомендуемый порядок исправления + +1. **GLOB-001** — авторитетная координата позиции (semantic anchor/CFI для reflowable, + абсолютная страница для raster); унифицировать total/progress/completion/UI. + Снимает RTF-001, LIB-002 и большую часть «прыгающих» счётчиков. +2. **GLOB-002** — транзакционная смена режима и reopen per-comic (позиция + режим). + Сначала пере-верифицировать `27d30d56` на свежей сборке. +3. **GLOB-003 / GLOB-003-V / GLOB-003-T** — один владелец bottom inset; отвязать высоту + страницы от chrome/autoscroll; стабильная высота текстового блока на всех страницах. + Снимает полосы PDF/CBZ/CBR/DJVU, отступ при авточтении в вертикальной ленте, + обрезание и «задранные» отступы EPUB-002/RTF-002, смещения на строку FB2/MOBI. +4. **GLOB-004/005/006** — gesture arbitration (page-turn vs selection vs footnote anchor) + с приоритетами и cancellation. +5. **GLOB-007/008** — атомарный ThemeConfig + contrast-аудит. +6. Форматные: FORMAT-001 (однострочный фикс лейбла), QUOTE-001 (locator в навигации), + затем DOCX/HTML/TXT/Markdown верстка. + +## 8. Регрессионная матрица (критерии готовности) + +- Форматы: EPUB, FB2, MOBI, RTF, DOCX, HTML, Markdown, TXT, PDF, CBZ, CBR, DJVU. +- Режимы: page / vertical; chrome visible / hidden; autoscroll off / on. +- Жизненный цикл: open → page-turn ×N → mode switch → exit → reopen → rotation. +- Инварианты: одна и та же семантическая позиция сохраняется; `0 ≤ page < total`; + progress не скачет назад; completion только на фактическом конце; высота страницы + не зависит от chrome/autoscroll; тема меняется одним кадром; CBR ≠ RAR. +- Жесты: быстрые последовательные свайпы; long-press selection; edge-footnote tap + (0–10% и 90–100% ширины). + +## 9. Что проверено не было + +- Эмулятор/сборка: баги зафиксированы по записи пользователя; воспроизведение на + текущем HEAD не выполнялось (временно́й бюджет сессии). +- TTS/аудиокниги: в записи не демонстрировались — по аудио аномалий нет + (дорожка = голосовой комментарий; фрагменты сказочного текста на 14:47–16:10 — + фоновое чтение, не ошибка приложения). +- Визуальная верификация выполнена для ключевых моментов с учётом лимитов сервиса + анализа изображений: библиотека (бейджи, ~0:40–1:00), вертикальная лента + с авточтением (~18:00), RTF-обрезание (~24:00), DOCX page mode (~25:40), + EPUB-обрезание (~8:20), Markdown page mode (~29:20), TXT (~30:40 — момент сжатия + первой страницы кадром не пойман, зафиксирован по транскрипции). + Остальные визуальные выводы опираются на полную транскрипцию и независимый + анализ Manus. + +## 10. Артефакты анализа + +- Кадры: `scratch/record2/frames/` (119 шт., шаг 20 с), монтажи `scratch/record2/montage/`. +- Аудио/тишина: `scratch/record2/audio/full.wav`, `silence.txt`. +- Своя транскрипция: `scratch/record2/audio/transcript.txt` (faster-whisper, RU). +- Внешние материалы пользователя (Desktop): полная транскрипция, хронология + наблюдений, независимый багтрекер Manus (commit `1df14dd`) и объединённый + трёхпроходный аудит `Mr.md` (аудио + кадры + код), использованные для + кросс-проверки; расхождения с ними отмечены по тексту: + `ReaderContentArea.kt` в локальном дереве отсутствует (актуальный контейнер — + `ReaderContainerHost.kt`); тесты highlight в HEAD уже есть; модель autoscroll + уже раздельная по режимам (`ReaderAutoScrollProfiles.kt`). diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3f3b049..948782abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 2.5.0 — 2026-08-27 + +### Дизайн и ридер + +- В настройки обоих ридеров добавлены единые секции «Чтение / Стиль / Сервисы». +- В «Сервисы» добавлено управление авточтением книг и комиксов: запуск, пауза, быстрые профили и точная скорость. +- В графическом режиме восстановлена кнопка настроек с пресетами «День / Сепия / Ночь», масштабом и обрезкой полей. +- Библиотека и карточки переведены на общие токены типографики, поверхностей, отступов и скруглений. +- Добавлены дополнительные шрифты для текстового ридера. + +### Словари и релизные артефакты + +- Офлайн-словари выделены в отдельный дополнительный модуль `android/dictionaries-offline-store`. +- Релизный APK публикуется отдельно от пакетов словарей, чтобы их можно было распространять независимо. + +### Отложено + +- Исправление ошибочного 100% прогресса RTF. +- Сохранение позиции страницы при смене режима чтения. + ## 2.2.0 ### Исправления багов diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 000000000..c35306fa6 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,255 @@ +# Сводка реализации: Расширение системы геймификации Mr.Comic + +## 🎯 Цель +Расширить систему геймификации Mr.Comic, добавив систему достижений, еженедельные челленджи, улучшенную визуализацию и новые мотивирующие элементы. + +## ✅ Реализованные компоненты + +### 1. Система достижений + +#### Модели данных (`core-model`) +- **AchievementModels.kt** - Основные модели: + - `Achievement` - модель достижения + - `AchievementRequirement` - требования для получения + - `AchievementProgress` - прогресс достижения + - `UserAchievements` - состояние всех достижений + - `AchievementNotification` - уведомления + +- **AchievementDefinitions.kt** - Определения достижений: + - 30+ достижений в 5 категориях + - 5 уровней редкости (Common → Legendary) + - Секретные достижения + +#### Трекер достижений (`core-domain`) +- **AchievementTracker.kt** - Основной трекер: + - Отслеживание прогресса по всем метрикам + - Автоматическая разблокировка достижений + - Уведомления о новых достижениях + - Расчёт прогресса (0.0 - 1.0) + +#### Интеграция (`core-domain`) +- **GamificationIntegration.kt** - Интеграция с существующими системами: + - Связь с DailyReadingGoalStore + - Связь с MascotProgressCalculator + - Обновление прогресса в реальном времени + +### 2. Еженедельные челленджи + +#### Модели данных (`core-model`) +- **WeeklyChallengeModels.kt** - Модели челленджей: + - 6 типов челленджей + - Автоматическое обновление прогресса + - Награды XP за выполнение + +#### Трекер челленджей (`core-domain`) +- **WeeklyChallengeTracker.kt** - Трекер челленджей: + - Отслеживание прогресса по неделям + - Автоматическое завершение + - Сброс в начале новой недели + +### 3. UI компоненты + +#### Карточки достижений (`core-ui`) +- **AchievementCard.kt** - Карточка достижения: + - Анимированный прогресс-бар + - Иконки статуса + - Награда XP + +- **AchievementNotification.kt** - Уведомления: + - Всплывающие уведомления + - Автоматическое скрытие + - Анимации появления/исчезновения + +#### Экраны достижений (`core-ui`) +- **AchievementListScreen.kt** - Список достижений: + - Группировка по категориям + - Общая статистика + - Фильтрация по статусу + +- **AchievementDetailScreen.kt** - Детали достижения: + - Подробная информация + - Прогресс-бар + - История получения + +#### Графики чтения (`core-ui`) +- **ReadingCharts.kt** - Визуализация: + - График активности за неделю + - Тепловая карта за месяц + - Легенда и подписи + +#### Еженедельные челленджи (`core-ui`) +- **WeeklyChallengeCard.kt** - Карточка челленджа: + - Прогресс-бар + - Статус выполнения + - Награда XP + +- **WeeklyChallengesScreen.kt** - Экран челленджей: + - Список всех челленджей + - Прогресс по каждому + - Общая статистика + +#### Экран статистики (`core-ui`) +- **GamificationStatsScreen.kt** - Общая статистика: + - Общие метрики + - Ежедневные цели + - Превью достижений + - Прогресс маскота + +### 4. ViewModel (`feature-settings`) +- **GamificationViewModel.kt** - Управление состоянием: + - Загрузка данных + - Обновление прогресса + - Управление уведомлениями + +## 📊 Архитектура + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UI Layer (Compose) │ +│ • AchievementCard, AchievementNotification │ +│ • AchievementListScreen, AchievementDetailScreen │ +│ • WeeklyChallengeCard, WeeklyChallengesScreen │ +│ • GamificationStatsScreen, ReadingCharts │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Domain Layer (Business Logic) │ +│ • AchievementTracker │ +│ • WeeklyChallengeTracker │ +│ • GamificationIntegration │ +│ • MascotProgressCalculator (existing) │ +│ • DailyReadingGoalStore (existing) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Data Layer (Persistence) │ +│ • AchievementModels, AchievementDefinitions │ +│ • WeeklyChallengeModels, WeeklyChallengeDefinitions │ +│ • PreferencesKeys (existing) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 🎮 Система достижений + +### Категории достижений +1. **READING** - За чтение (100, 500, 1000, 5000, 10000 страниц) +2. **COLLECTION** - За коллекцию (1, 5, 10, 25, 50 тайтлов) +3. **STREAK** - За серии (3, 7, 14, 30, 100 дней) +4. **EXPLORATION** - За исследование (марафон, чемпион недели, время чтения) +5. **MILESTONE** - Вехи (стадии маскота, XP) + +### Редкость достижений +- **COMMON** - Обычные (10-50 XP) +- **UNCOMMON** - Необычные (50-100 XP) +- **RARE** - Редкие (100-200 XP) +- **EPIC** - Эпические (200-500 XP) +- **LEGENDARY** - Легендарные (500-1000 XP) + +## 🏆 Еженедельные челленджи + +### Типы челленджей +1. **PAGES_READ** - Прочитать X страниц +2. **TITLES_COMPLETED** - Завершить X тайтлов +3. **STREAK_DAYS** - Поддерживать серию X дней +4. **READING_TIME** - Провести X минут за чтением +5. **DAILY_GOAL** - Выполнить ежедневную цель X дней +6. **NEW_GENRE** - Прочитать тайтл нового жанра + +## 📈 Метрики и статистика + +### Отслеживаемые метрики +- Страниц прочитано +- Тайтлов завершено +- Серия дней +- Время чтения +- XP заработано +- Стадия маскота +- Ежедневные цели +- Еженедельные челленджи + +### Визуализация +- Графики активности за неделю +- Тепловые карты за месяц +- Прогресс-бары для достижений +- Анимации при повышении уровня + +## 🔧 Интеграция + +### Существующие системы +1. **DailyReadingGoalStore** - Ежедневные цели и серии +2. **MascotProgressCalculator** - XP и стадии маскота +3. **PreferencesKeys** - Хранение настроек + +### Новые компоненты +1. **AchievementTracker** - Трекер достижений +2. **WeeklyChallengeTracker** - Трекер челленджей +3. **GamificationIntegration** - Интеграция всех систем + +## 🚀 Следующие шаги + +### Phase 5: Тестирование и интеграция +- [ ] Unit тесты для AchievementTracker +- [ ] Unit тесты для WeeklyChallengeTracker +- [ ] Интеграционные тесты +- [ ] UI тесты +- [ ] Финальная интеграция в приложение + +### Возможные улучшения +1. **Персонализированные рекомендации** - На основе истории чтения +2. **Социальные функции** - Сравнение с друзьями +3. **Дополнительные достижения** - За разнообразие жанров +4. **Анимации** - Более сложные анимации при повышении уровня +5. **Звуковые эффекты** - Звуки при разблокировке достижений + +## 📁 Созданные файлы + +### core-model +- `AchievementModels.kt` +- `AchievementDefinitions.kt` +- `WeeklyChallengeModels.kt` + +### core-domain +- `AchievementTracker.kt` +- `WeeklyChallengeTracker.kt` +- `GamificationIntegration.kt` + +### core-ui +- `AchievementCard.kt` +- `AchievementNotification.kt` +- `AchievementListScreen.kt` +- `AchievementDetailScreen.kt` +- `WeeklyChallengeCard.kt` +- `WeeklyChallengesScreen.kt` +- `GamificationStatsScreen.kt` +- `ReadingCharts.kt` + +### feature-settings +- `GamificationViewModel.kt` + +### Документация +- `task_plan.md` +- `findings.md` +- `progress.md` +- `IMPLEMENTATION_SUMMARY.md` + +## ✅ Статус реализации + +| Фаза | Статус | Описание | +|------|--------|----------| +| Phase 1 | ✅ complete | Анализ и проектирование | +| Phase 2 | ✅ complete | Реализация системы достижений | +| Phase 3 | ✅ complete | Улучшение визуализации | +| Phase 4 | ✅ complete | Новые мотивирующие элементы | +| Phase 5 | 🔄 in_progress | Тестирование и интеграция | + +## 🎉 Заключение + +Система геймификации Mr.Comic значительно расширена: + +1. **Система достижений** - 30+ достижений в 5 категориях с разными уровнями редкости +2. **Еженедельные челленджи** - 6 типов челленджей с наградами XP +3. **Улучшенная визуализация** - Графики, тепловые карты, анимации +4. **Полная интеграция** - Связь с существующими системами геймификации +5. **Material 3 дизайн** - Соответствие современным стандартам Android + +Система готова к тестированию и интеграции в основное приложение. diff --git a/README.md b/README.md index 2ebe2f8d3..3eea209f1 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@

- - Release + + Release Platform Kotlin @@ -33,7 +33,7 @@ ## Содержание - [О проекте](#о-проекте) -- [Что изменилось в 2.2.0](#что-изменилось-в-220) +- [Что изменилось в 2.5.0](#что-изменилось-в-250) - [Возможности](#возможности) - [Поддерживаемые форматы](#поддерживаемые-форматы) - [Технологии](#технологии) @@ -50,6 +50,21 @@ Mr.Comic — Android-ридер для комиксов, манги, вебту Ридер построен на отдельных контейнерах для растровых страниц, вертикальных лент, текстовых страниц и вертикального текста. Это позволяет каждому формату использовать оптимальный путь рендеринга. +## Что изменилось в 2.5.0 + +Версия 2.5.0 объединяет исправления ридера, обновлённую визуальную систему и отдельный модульный пакет офлайн-словарей. + +| Направление | Главное | +|-------------|---------| +| **Ридер** | Общие секции «Чтение / Стиль / Сервисы», настройки авточтения для книг и комиксов, быстрые профили скорости и точная регулировка. | +| **Графика** | Восстановлен доступ к настройкам графического ридера: масштаб, обрезка полей и пресеты «День / Сепия / Ночь». | +| **Библиотека** | Обновлены карточки, подложки прогресса, переключатели разделов, типографика и общие радиусы интерфейса. | +| **Темы и шрифты** | Добавлены дополнительные шрифты для чтения и подключены единые типографические/поверхностные токены. | +| **Словари** | Офлайн-словари вынесены в отдельный модуль `android/dictionaries-offline-store`, который можно поставлять и обновлять независимо от APK. | +| **Проверка** | Пройдены unit-тесты `feature-reader`, компиляция модулей и полная debug-сборка APK. | + +Известные отложенные задачи: отображение 100% прогресса RTF и сохранение позиции при смене режима чтения. + ## Что изменилось в 2.2.0 Версия 2.2.0 — крупное техническое обновление ридера: исправлены критичные баги чтения, ускорены горячие пути и разложены большие классы на отдельные контроллеры. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 48fce63ef..e0e2b32b7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,33 @@ # Release Notes +## v2.5.0 — 2026-08-27 + +### Ридер + +- Добавлены единые вкладки настроек «Чтение / Стиль / Сервисы» для текстового и графического ридеров. +- В «Сервисы» добавлено авточтение: запуск/пауза, задержка постраничного режима, скорость вертикальной ленты и быстрые профили скорости. +- В графическом ридере восстановлена кнопка настроек и доступны пресеты «День / Сепия / Ночь», масштаб и обрезка полей. + +### Интерфейс + +- Обновлены типографика, радиусы, поверхности и чипы библиотеки. +- Улучшены подложки названий и индикаторов прогресса на карточках. +- Добавлены шрифты AccessibleDfA, Liberation Sans, OpenDyslexic и iA Writer Duospace. + +### Словари + +- Офлайн-словари выделены в отдельный модуль `android/dictionaries-offline-store`. +- Модуль можно распространять отдельно от основного APK как дополнительный пакет данных. + +### Проверка + +- `:feature-reader:testDebugUnitTest` — успешно. +- `:feature-reader:compileDebugKotlin` — успешно. +- `:feature-library:compileDebugKotlin` — успешно. +- `:app:assembleDebug` — успешно. + +Отложено: корректное отображение прогресса RTF и сохранение позиции страницы при смене режима чтения. + ## v2.1.0 - 2026-06-22 Mr.Comic v2.1.0 focuses on reader stability, format routing and a cleaner public repository. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 08f258960..f5f47eafa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -41,8 +41,8 @@ android { applicationId = "io.leostrange.mrcomic" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 3 - versionName = "2.2.0" + versionCode = 5 + versionName = "2.5.0" buildConfigField("String", "GIT_SHA", "\"$gitSha\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } diff --git a/android/app/src/main/assets/databases/dictionary_fr.dbpack b/android/app/src/main/assets/databases/dictionary_fr.dbpack deleted file mode 100644 index 06b6f3834..000000000 --- a/android/app/src/main/assets/databases/dictionary_fr.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd8ca0c40f360d6feb7a4734caec12f913cd074547ca80fa05f30c68608c840c -size 325654181 diff --git a/android/app/src/main/assets/databases/dictionary_it.dbpack b/android/app/src/main/assets/databases/dictionary_it.dbpack deleted file mode 100644 index 7655f1338..000000000 --- a/android/app/src/main/assets/databases/dictionary_it.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:70edbf3f6c9b61bcdbc6fc8cb7705dc78052aadc685ff69685eb122a963e9dd3 -size 38191024 diff --git a/android/app/src/main/assets/databases/dictionary_ja.dbpack b/android/app/src/main/assets/databases/dictionary_ja.dbpack deleted file mode 100644 index c8067a584..000000000 --- a/android/app/src/main/assets/databases/dictionary_ja.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:25916c6afed408f2fc3c69af2d739b7da110a1313a0dca4a2133ba6a1b49841c -size 62571638 diff --git a/android/app/src/main/assets/databases/dictionary_ko.dbpack b/android/app/src/main/assets/databases/dictionary_ko.dbpack deleted file mode 100644 index 5ad50ccc7..000000000 --- a/android/app/src/main/assets/databases/dictionary_ko.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:736d316cacacb23d7446221fc4b2a0dd1c8e47db6485a8e888a4c22c32f02db4 -size 15985102 diff --git a/android/app/src/main/assets/databases/dictionary_pl.dbpack b/android/app/src/main/assets/databases/dictionary_pl.dbpack deleted file mode 100644 index e345113e9..000000000 --- a/android/app/src/main/assets/databases/dictionary_pl.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:39556c2dcd24cc7d77ca8bd52a1e29d1d65392e0ef6a416265160ce44ee700e1 -size 44060536 diff --git a/android/app/src/main/assets/databases/dictionary_pt.dbpack b/android/app/src/main/assets/databases/dictionary_pt.dbpack deleted file mode 100644 index a09dffe54..000000000 --- a/android/app/src/main/assets/databases/dictionary_pt.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8e5dfbb0d956621108a19e0a0d3e968324d5896175b548b3ca85a1c39b85b5a6 -size 40968061 diff --git a/android/app/src/main/assets/databases/dictionary_tr.dbpack b/android/app/src/main/assets/databases/dictionary_tr.dbpack deleted file mode 100644 index ba4eb77f2..000000000 --- a/android/app/src/main/assets/databases/dictionary_tr.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f722c4d2075e3ddb59563d383f82513b47ed0c16b54bd59d07cc6cd4fd5272c9 -size 36515706 diff --git a/android/app/src/main/assets/databases/dictionary_zh.dbpack b/android/app/src/main/assets/databases/dictionary_zh.dbpack deleted file mode 100644 index b53d54121..000000000 --- a/android/app/src/main/assets/databases/dictionary_zh.dbpack +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e2ae6e58a3344bb310894a6943fb54a675d33f568a898370841d7d2fce03f037 -size 20136743 diff --git a/android/app/src/main/assets/fonts/AccessibleDfA.otf b/android/app/src/main/assets/fonts/AccessibleDfA.otf new file mode 100644 index 0000000000000000000000000000000000000000..69c02218a5ab163c634edcc34b14e8a735513f82 GIT binary patch literal 145384 zcmeFa2Y40L);GS-^hxi%ob+DK%$yXlkaC79AWgvnAqgZB5=fybSU^R z|DYmc{zIECVa!!b{K(pIHRHRjy_I&e@);Aq8&lIZo<*?~D(_0l3~8vZsmpu(>{!}= zm9~-w$_RD`Tu9eT#HTlmYo9##nimffPlaUMHLhmzc>S3HbjX!WUrKY$xcaC&GhbmW zFM%=X-0>}K?Wa6!GL=vFz}(E`oEp1$rS;gkBZANV$?ei8gRxzUE-!cJyCa)NozQ+i z?vBf?y|!s=~DSkB5r5j zHPiVPlNb};VNSa+`8{^E_=+o3dY64E-@%fklWd%JGaIIy$Hs}f**3{?rAsy&DsN*i zDc`UU#0BgkX)60d9Le61R2g07D!p*( zki7ELJK_pUZH3mGKR$mwU2jq}i-d&SnkLAvQ`J&C2CqHbx%L zHVbF7DPrKMC!~Yyd7*)AQ{Q3t$!@krj%DNJGPYihptjn>?w9J>7;4nfatK=??PMNh zHR~m9WTx~qE0PwlP&#kDxWYA+=q6<@ZL6s-GT9Gw&&z15p>4K2pSAOIqS!h)%H`I} zU4hc2>`uC`Rdmf;H0qpl4@yJXV&dN>`nt0TKe29dD%(QGZWInuUq-p!k%qd`=-MfC z?SpjP+Rkk*xVy`kBT@6njce;M*)YoM;qlp!R%9 zT}d>6ZIafp3#ILBp0pQKyox<3&Z7HR%|gUMY>2dvT~6)%wDd8JN7|@AnmhNeb=ryc zFQwy;oViKQuydr}=^CQ)cU0e48oN$AB(PaZJg8H5>0-8t)4NzRZwtB~PUF}%C70^_ zA#39GpUawf8^y94IgMnuJJh-Nzf#3RbM=y{*x8bso})SJH7e(Mr%dSx+aY{HW6z=Kr+#**G?3bs zs94HYOGi!(5DZs0ryuE>r=4q@PZuwbKlK=LNQY5>O6@HBuT;B*O;$gmzWA2P+{kX| zI^W?XgWBO0wqA;67t0Gc?Qxn%{wR4Zs}r}en;j}20VVztK5m@;bH>oWLA5%zg8F@^ zyo1J=)6X>4NY7Ntk*wce=tW8kyQAwq{}pxm_+K4&j*$-e^B?Wv z>ACoak4pOH%uK2Se@^6??6IzKeCF}4ePQpZ6J7iNC(_g7ymNea-6OThpPsd@{4Uzn z#$EcOE5D0&wRsmWPr-Qa;vITs+wk&Mk}h_1>LTWM=6Qd&FOeSlyL@>ws}eVoZkob| z((_gJcjx@C%%kU~n^T_fBpdqglq>aM3;vz+{#TqM?`3EIJLO1jc5T;rQrI8+;@f}U z#$D(BJ5)T#RyyYi5iCyliZ#)8pTkRiT|Be@LB5Z4C{=z(VldzK23Cr0oVG$#H z{lEU9c3{HDQ^%xWRz>x?k+#MEsuXD$yXvpU#VM>8mGh?PX3r@-r4HH}{(8>e@xmU~ zEY|)pR)zQ2gI(tdSFzs05T-D8^0dyxu@hvvQGdL}CK1M7Kz%ey8u;h_AiHyXS2_Q? zsj&3V?JlgQzMwp+8{Nh~|B#*PjH7>f&Og8UKS%zjT0maICj3vy`j4Fdw7`E_;QzfA zaD24?TWc+iWhpB8H7dg~dwU+4a&Gc9!xM@}()Pl3(*7 z%Xg?ekSUIT?d*4b&MZTUB0KL0>(_P6p;9YrRECm`(4TqE%>O@eziWP_>)vQSatF=P zoc2MT`Ia+z=R9YA6}mb2RoB|sU41Q-$|eR z>($B@wnN>)o>of!OleM&`+r^Zuh;!6-kE>-XUEAu7-rx|5KFYPb8i2PyMQYHn;KD`|I+b|C;~*Do^8`&!7Ew>;HH23;*4+{&U@b zTHrq|@PAJW(7cWxZHs7fVo?BN`(@`{g!4{qD$Uc(_+yjDGJj|8=JXcD7SMGY=sncw zcMeXgsJj?{V;PXo<~zKQ$y&%hYEg!>7KcCc(=~79psngo}S-PcF?

!C0KHonOXtp~cd3Rl*L5LL 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%0A_=a#T64x>QXKCd^D! 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/assets/fonts/LiberationSans-Regular.ttf b/android/app/src/main/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e6339859d0b24bee79ae3f64e0071900170224ba GIT binary patch literal 410712 zcmd?Sdt6n;{`mcwwbs5N%0)y}l#PgDqM~>~quePXrX?yUC@LB%mL&$2l@*pHnb)$i zw6wB9v)W!VDmzhm%1V<;%gTzpVP!{;S;tb@&wCbN)T!_H_dL(*`Rm!IH#75@&wS=H zvu4ejnZ340l!!Fvk0nXz=~-9I`!#x#IMY&*Mh{6FoPKb?@lbIz%Mwu+4apoa`c!qZ zAH)&0LsXYJLq=y0bUyOldU1r5ig?mSjPBl3p7~%Kx)qeqzv|k_MLm6?Pm6SMi#R)8 zbz_P5@-K${CX$*UVw6oQnttu=ledM5^hgqMZJj>3xJaVJLw#wK51l^q=4m;rKa3Wc zzD}&@#KNhQ3!2D%o3O9_>r;rJ>58VWkS-zJq43(0Idk`Z(iYoMA_*t2nR@+oGO+DH zvF@r8X)$i*tg9wRKIjY1!v?DQfzhd6@DmDet{*^0iaH?0J1atVa?>nv@mI zDlTaqbNwW-)@>D;vAF2^sYT~s7&25Og)-(K(S2vMdgS%E$CgbBN&Q)3XeOVXzF!~J z;a9hIi#+?x`3Hioc6~S*nywO7iZpsi=0`R$gVtLx>uko^ft)smo8HA=E1 zPn?ttmF~pESZ7#VDP5{HP z+IWx4Mh_C7cmwAgpVW?2$*xP)=6q3aeDfa$L4U_QRJ@Yt6xpq{jeZw+#rRCdTE*gl zNLO1K>KG##YJn6Q8{`fMHrq<9wNVNvQ$cz>;cL1)`kAmAro)xc9Y#SR%z#|Tuh*}@ z##C*`4vQtr9V1Urmj&gHccl=@b=*26<<5RGU8gDgx`pSqycqcgTUX~#)-pS+D>s31 z-6-=oa*u1VcnG^eSLByr=YH)ogqm+kkX0P`mbP4tf4c4SsY8cjbQ+zu_3weRHuVvOXffHu3++r2bt_{)|+CYZ2S{7Nu zC6oA1a1&%2$EA%f@7yRU^$evy?xJ6GzyBAE^eFpU=xfpl{Pn;RqhH`-LfR+I^!3sG z|7Y-Z39=#fwb0k20j`C%N&QRkN%!}E!T1|UfBl!7I)h*V^?yR>rmXJ&MfIVM{|@~^ z9f`>9`vdw~6w5+=O?2CdkNvNZaj5IZ=f4Zftsi6z`OPRl#%@oYo$`8ne;Lvr`W_kc zcOmw4xh3^>X=|XO4hg2cp!?nK`#i>B46@hZEyuUA!CWl&;; z^NMpar@p)YPec2D=bodUuGFWmuYKPWAC2wH8i)Em z#Dm_#^ycE8Uj3_=z}edD3w+Bb9FV(W|~ZEq6Bb#11dmr|}fp>BJ+l}F#_+4J%^Z0DNK(Z@%m zgRX~p#&2FJTew%R$L=dZ%&Q)*p}RoFxbLW&XU)~JP|I9PNO$4d*55OX!%rD&_cn}$ z|I=$kz50In!+odc8-35)ec0&!(EXtMuc3~H&~96vq*4F}RH~H;sHfpM2;zT6e!=L%6@S zu3aBG3iO<-=N0|@yacYOmua~@hdaKLv5dP6=Yiplm%HF| z17&)Cf+gU!veDNtH_=Y+hrru}cN!nqYXm*N>SuXtYmsDG`3=u>aiSk$CGbqu{g(xd zMVP7W!1(%btbM(F*bkARmTu!Ua*x@KK4VE|UQ9c5-|2gm``q=ijB)w6@0xIbY?039 zEa~j1lo)5Uv~^t0oDd=l&4bd)N|CD^cSt9V877l8Dn0O>`az~c0z?|$OM&@$;FPgE z@Uy*!V{J#j-2pekLfZZd{ARo_y&#$UU$4n7K%TzNprQ$r;azyJK0S!IURNBUe>T}P z*xMjaAa9^8_r_PGSA({}nk4WYVI<*ELep3gIHd-%rm2ubW35=)7c*4W{uy+q)T8~v zuD!mQN2u9Z&tgK*wgWYGU3!hW!EUc^vkm><4qB4#FLM5zNbQ60l1Sh?!XJ@6M|gnr zGlW{!l6VT?9ia8zgWUQwdbaK=WQ`e5dISuBiRdg~xqT^dYzBtI6)+6Bw#6Pvf6lRr z*Y)YT0*}DupmkrbD_{E<;VXa-x=tJRA{TtdR;^AyK^vYY#AfYL%0ER&{ed*XCkf-> zVRSZ<-hxs%;W}ii32A%aRYLS@n-U)ay@}&b?V5T{Afs*PBMI4?+4gmRX}dZNwZ{mt zDL)flM*0mx-LBDfoPV0~yMIq>dF^XJ-GLp1x}9GDI<-52JyvZi@~(oh#2KBn+qj0m z!VPfJqJLqm4jGerkFp!u@?5n@zvfI2{OsHsIAw+hjyZb2^}%A9Z+=PoBxO6ZXThc;NwTeEY2nx^3z!E-)5hI)U3$JDU(2Yon7D^^LqFFB ziPoWYt7MWl%Gx7o4bK|dcRh8EqiyBPS!Gs$`72d=I-Bwgc}%WydF2`UWWHl6dpiA0 zE0A^Q2hrZ^G*&h9`adO-J z(bd(b?~kq{ksT_w@1>jv36=-K6b65ep#?Q32s&uCrkpMA}->1-i4BJ1Y( z0eQBpbz#$0O5NGg&4mwkU6nFJ`$M^W+BcT=&#`zu7(dXy=j^rLSjRYVna}Y|nS%_> zQm*-P(!`t=8N?Klu}b(1$7%j6#C6LPcTR=LG}t-S6$$@tkP`LxgLoJ1er`TIC?Udy^8 zSX>REdnJ2{M9HS#`&dV%i)Vq%bXLjOhI;M37tS7B(uBEyd98Lka|3gf=KLqjr_4v^ zuF=;~_apsp(Jy-KqQ};J^E<}#<+?8d0ootom>|!xmtL%|zrF@~4A|F!abvgr_s}98 zuiG8iuZ_z(4e6T}3X7qJ) zO=sP=KsxHNL%G5A;Vp#g(Ou#^Or2TGKlPy==MBE<`+&J?F>MUD$4b3V4ZbzlYPgs5 z8i0ELat&VJKV$9v4dbIBY^du(zwBe3$GYc2|JL_&!@Tk55WN|C{OS8okD>nx>)L&b zzL&WkOI-Rs(c`x6`ba}RT?l>by|@=#Z^(F;N%)N9JMWQf#}(XL%_ZCQwuHNeNE5vm zaJlTWi0O=jiH={nM@Mtd?2}Pk-=PhC*5Gf$9+GjC##Gyd@LEEi;gV1MN+@Kk?Sl$r zd;*>8Lj70E&i_)E4&;%CjX(xaK5%tieqai5@@gXpb-vC+M}N-qT+{XfoMF~7hHFpN zq0@m&>d|&JTBhOoZHMo%Zn~Rzo;6RMu9x{4C;FLc>zZzPluCL#oSd(`NBgJ9086hk z!(}CV!555&B-xQBFF5+iI-R!Cr4!GQOzRR!cU+774(Y4qoCRjEZ%ww!xd+bi4Et7= zT5k3hc~Bv@>T-_Hu(g2wKsa{lF(c$N>l69RJj1i;thBPvd3>yC^>cd$XJ9jUw*4Rr z^nIu2A#<`Mk+;z7!})}shxGhYw`RV?`kMReYxYZDOLwl>ShHLP*?Bs@iyk-3abVwb z`Z@a!WA$qpOgI3W4QF$sbzSB)*!hw(G{&9Iqnw^Ym+<_&UEfdUTFz5P5RR1I=E%SS zbEWh&GbooKUfPmmUMJnu7`e)rPkqy*hf&3|zJ>HMXLHZb;{J}3E@lYnTfi;Z9FStKdOBI+QYwemz`8~ca{Wdiml*n0ce zSD|m?lyT}UdAN22E#SOj3Tv4woI~Xz zu48Kr@^)4}EVB2Y^Xu%fKHF+PxAYlF2=fzrIh*;c-8c%Der8nQS^JvQoug{|dQaJK z&Zqa4L!q~$m*nU%gYG3fyOyJKx1OKqx3R`P@fq*22RSD_>B~-v^#CO5K4t%uPd|j| zeMmiF#zlbu@v&eU$;>kku%`nvB=9DEHOXuS4(V?S_d|trR|5kt((mobYi_od6#96) zv6p)3OXF)vg>DdUoF@EUQn{zOA8Y4xUvj_Ju7`JEZ=;YoqE^onv%p?YGw(Ib*Y@*^ z^dH0D&#ScDK8yPw!@BbN>{ILiZK&6M%pq-=AMLYC`@*5XbUmcA(4U~k`asIFZ8&lK09o9F8yyI`u2LF zQCN3Y^QZGq{M2Vnqv<=&^I2ckeOIB+#`RgjLdP1OzrT;UhOyT;Y3?tM ze;j+%uk&?1f2oJxBk@&#C$L?Qp})J<_44??&2Kn|{a?aAUpr_08SMWS;{WgG(GBO) zjY4}5sP}E~*cS2Y%D&zOEy%g?kmmtRt|9^deJ92)M1~67@#{zgf%S_xZbF9=Aj1W7PW? z^>(FRaJ^Rt&yBnPC+GvW>jlb$qyIwSgx=r&k4&PTNt8K(zUICL0^hmr3w+mb9UA(x zp`U1DsBZTK|Lgr~sH>se--Z9%b!@2T@4`P_7kzE&34FzM`KrN>zYYHn+x=JmH~8K- zWM1SuMio&P+Gl%Vf8{&Zr#%dG{zUkb46L71FQOa-PB3O3f<5pa?1Y=(NuC|=b8iQm zQ(4nY<#}rB*OmFN@xVEVJ_~7(uSx9C;H1nTt+J67+vmli^y(UU-6!xV&u%a1v#a6wuD^q` zb+sLh{oTtofo~a8`fRp=+tD4?hz!atgfe5djC70A=Wg2O6@+^2Z=>tRW?xk**{pXU zunrqJkc;oH(C$h#rq=!*zM%|ZXg!=k2ExrEnadh?ra4S*HIA@$%ICcLTDgyP@lJ}-4Duk{oOcawmaiFkJ~Ld+}m#U6#0DboeAyh!%GNPzy|u}V*2z+ zUb_hWbJ!p=PfLn*g!%U&`GNK7qx!78p)WZzNJci+wNI^dRH{g$2kYSi^!4}8&q)Ek z6c8U^r}xQYlr6CK8SSlo%Hen}@QXv#e#djhVmtm{A^BS80_yWRT^(u~=V=@NcjS%B z{#~4h6yy1KjD5tX+i7;d?4fT3r}3#=tD&vp`awMo%Ns)a&W+Bq)|>1J_Q~eIN3En! z9wEQ827ATsE^3qe8ugJYTctZiebI=K#7Dq(;C&WV1mAO(cCo`JsaB;-WB)Y}e|B(v zcaW#AZ7A`cjGb28Ytigqeqt=OW*q6YMJ992E%osw$nCXi{TiuMwfdy=cTXO z?{svv9_L(WI_qH0TDiwA^Tfnq3F8R+*Vlc)9_=ba zpSj)!TXv|&z+2W>&hq}q6{JUj4mIhV*`yQpu4e>cSI9uF!wwK%Pbb3e$aT6cw1htO zBq8riT$dl-h>6H``pI0Oba2Dwhpt4I5;>5p88%l{1Wzv_=aw_^l) zx-XNUq0PF#+alNftoun{kA`d00lAK6fWB_JtbI+9>$L8RE9$YYKXNVm!*yZI^aXqV z(Br2L`@6=Mi1WS$=O}-(M`)08u#_WjsB<9PQ6EUY_YQ3S}_TG-(ByTToe{ZVyQg51fqIZ_}X79b;72cP;Z+K6} zI%1p0wv25b+cCCV?3J-uu@htG$37Ta9=o@_(calUqK{SfD|7b9~vJK9~B=R-yuFRzE^x|{LJ{$`1$c=@r&Xgh<_q}WBlg$SL3(G z?}*=j#6-n-V=`hg>svo5rZi?- z%sVlk#(WiXD&`ljc*AM^Mc$s?eivGQwYS83hj*EG?SW?#+TnvZKfs@YTXVa@KET{Z93yi@bfnm21IYu>1N zwdRSMhidMxSzNQAW^PS!&7hj@HSKG>HIX&VYl4sd>*)7K4i89=+-4(Rucp+|l_a^^_hkr79Hho&5wd?@Eo_MuUSMjRS`Xz-y+5A{8i zbg1p2phK=h)**GU_TZ_5-yb}3@auzL9sKm*M+f&DeEVS0!Rro=Ihb{D^udt_M;w@R zAn%LUC;ww|PSe9p-*5U})16JRXq%iI5f&DJ^*qzLm6=Hq1V4 zrJclZ-^EIMiIWZzFCC?mBuHnuNV-U(bd_$r8=fRRq^BfHFX=6Pq_3n%Ki*!sn3Lp7 zc&lW9_*j1qltGdvgC$*tuo@aFm&-62E?3BvGJ@6FNEs!gB}=lI4aZ83FpmvQD0nr{!5$&#rcZY?SBaMOLSN*(6)!CD|(5 zq(WYn?edzuF8^>Hm+PfKrpgT0zhsU)Aw_bHJ4J4ktKGS*ksfttv7*m&Pmt+ylY6*( zg!@YKdiPkF$)0mO&yiPUo?Io@xksxkcaHlCxmIrDY-Wnw$#+^0s|eM?^^L3Ab#*yHyy9LZZ>s*TT6co$H`iJB5%*Eok8-o?sOx*zFEU>i$z8Ho7RpjtCd*}sJSY#* z!WFVw9+Op^T9olV*=*xcW0kR5ZZXyvj~Vxh_=b!hmz$HFHG0&@%n?^!F?`tNLoixPQNtzI}T4Nb26LYhpsDj`1Dh+Q-I3MTCWhG;7)<$m4c79hPY* zN%ZfCgB(P0ll|U&ufHR~ zmBS*zp-%P;Cy^ko0_IJ@%hmE>W z&#>RCRDMT%Xq#-F|@*B_c+sQTlo-SqTP!~D%hj?eKM z@#)^e$#e$)4Ty_PX%icEK?ehWoh@_=okI`D#_Ddkufiu&Ncc-f=G3LVGNsLC@pbQ# z>o@XsPGv(*i)@`!+K_Xhczzt$cG&2gg?=l3XhGaydi=i0{?aLouB-Khi3{~N`=w26 z+`{lMZ@=#Nh^`l>hZfB6`W+qpPF<>T0mh^*vXDPql4ifu1wXc-f{tO~-hOeI(RK#M z4bHFs@5aI?YV^|48C~iILROC7mqw5ICf5&=!JCr0Q(|&H7i@;Ut|N2&-Q$Y<5pe_S zMiBz-*5DbVbL=8@(!u_SL4L`G+icNO zXKor|f8-!WS;xT(a|)*UWAfV+aNkYy=Cq0R`*OL$xp6sDbM>&JhdNhN9Uq#-Sjoy6 zHac$D$ZUnTgA3Q)h+&=y%7vy*Wl3GZ)2B1m1KaaRXEN>vzSw zA(RfcrF!5FO!eldHqxMt8~)DT!Bf-f^>q3VMoF0QgEAVdIdu{{gEHF0a*Nxau89WO z-uj9u?$(`}q4RVW(-hfm#^)d{tLw`sJ?OnTaZ}@R;|jfgUuKT(BHcZ9FV}a5-RE^z zWmr~D<5Z*0qMIa^Y`f>`JJX-uj>EQf+PKliS+d zqU#QqVQN^`z^;i*Z38#Osj`uqe5!2pxSUrwDD{?Q!t>5MqpGc{lYuN%jeN}}LTUMupOvzOhMx89w*4xz4uE@t4aT3x<`FM4xiiS@dX=AjW(O`8qbit%38&vWJ|DWs@iO#6h0!Z zP^FtZzBY9lrD*ta>padTsINMET+S9&G^&mLmx>1J55py@kUN9t&0ueV9w>8j3m4|+ z>YGg>xitKz{3`BJE>qm4IN}WU2gOYt=x-7?P|F8s`G7jPQ_Ed(1N|yeQM{D9WsmRr)>`n@&_Lx0p)Dwj!RVd#;rm&M}j08y%+WUEW-!-2}jS}R6fM#z>$T`b9} zKlv$y=!s9c=;;{y?;2(KHmJ(;>c#U)&a0piXO;J?`Z+Tp=2Ut@%)iqwiaD9yC1%oz zJ5CrOCq|r@bYkg=7f(2v968)B=Fq|Pn2>|YcQ8FN=G*G@m_60sRG+9eebvc*(yP;> zV*d4GAm&GPEcdijo* z*S!4V%dV(xs%Z1t&Hl}1$Y!cS^c%WPC#Uo=i0~ojoa*hro;J z>YMZv=|*X~itO1UJ4}UShxQD~W>uE0M8(8}3<#MNaz}_264E_nM98d=r6J#h1VUT` zkevuIXK}17sx(qLRE1i$DQk3>F2gEZ0iN)#%<+Cz=8qq(|N2Ib^E=D@l09yG&L*Xn zQdZAv_6GMl-wZ2U5ZOoadC+hmmn`z6Nwk|D?#MNio#HgUY(H!OH?osL;NYJ ztD?A=a>dvwuCFd?qBwRrcFAr0I^9nncy3Y!EgdNV`eXh254C~m{{Qk-3 zEc#}4LmS!WJS(Ewt7K}ure5JodGB}C#xeD+dQa|WPjd}_U*_*B5#v_l6u&+lCD$3B zne*7y+)EqRs2S`~MCHpmHJ+W=o%L2GvWuG4*dp|~ynTR5k>@Nvuw{3K1!ZNc+< zvGb73kl9?xkaO(tCbH9i?l}823eaq!02g< zownF*w}_uIh5|l#=ZnOy7HN-fam3@u@0cmliF!Iyt~2S2QlSX&C(#S!CsL*>>2B1~ zJwqgkdXlPn&5n9{Qg`wSkzNVB(wWCk<|)(1&+pgEc}C|_hSJ`BOe5PTRqT;%cysDf%P13FhAyE22HD7k?;M;3^Ts^rxn(pjk@ z+2m!TKPDKWd6hE<%7jA(z`xu?em>v@%8ieLxv*L!F9FD(KsaF|?BX?B@+YPUKZOGw z7Q#x{3Fk!e(}b_$0GpHZVHxZI@}?l0k^^P1UF51cP!0$93N{#0p$L#o4F%-W2&ee~ z`84Fy_Ve!%V7E|%Z-v+{Jj1^afbAItuv+Zf|C$8CJXi$V;iSk+FARsd;1{`e9qbj3 zG#~{EVI}MmnT1{v^%j)?dPUSb8((H4n|(~=dTd>v3G-nqAS;dl^7(fZqt=T2A zmhw+f=1Dh@|Ku`}r_g_Dhse5Upx&qRp<3jbLYO1+Y#Pvx^0gxCGXR?#w(^1tb~Z-A zPS`KZ4M*`%*GQj6I z@a+xq-|z!AE77l<3D|fu3l;&k-m)MA<^n#vy^>c6sA~s#|0M69DL}b@&WF{oSLB^w zNCo8YAbV#k?1IB0??ylx;M2|(fG<1GiM)^O{c_kZ@_`$$`#~X4*9XXUA=`y)R}rAM z3)yaDyL~VRknKkHVJKt(vJa7cSS_+A3XttVw#N_0M5>TgA*;e()k>%o`Dm`l$0=}9 z!laxzck-{k#!tH>$xehLNp`*bBgODq%lB?+oT{zLwm6(YaV z2fr=jT>$(#yPvNFcfkR^v!rZo2i7ZISk1n%7aV7&nFkwrgFh6?`9&|gcXJuPjvFq@ zfy_}U%DGmQD-Di`a-;9zH55;^s37#4%!C7?g3%AI64f*nc8O{h4Mn0t&<$D5Yb#}< zc=bVrtrHbaUie{A%?klL5!h)l7f$k87>Q~QC$T#it2{#ZdIbXBkR6aR1#&Au-79LN=5awV40}oD9|A~$$r4kUOp&;{i1p& z0=D~5zRz&T19bb~SKlO<3)I)QT2x8|WWXGtt`y4m3x-T6g(^|~y-)zO>0$xwU%Uvm z^Un=s0XhTF8GsGeM#@LNZzr4)b(tS_z+O=Ug8}`4s{vmIMFZ(Uqz9ol2>rB~Ks{;X z5AFa3fc}Kv2 zH8KJyH!>f#!x>Sdh6DMdDuMK9@1!%Q%@k4B zo)L9jhNxLNK)G3Spn})jQeimc!Aw!JdqEmd&uq%hu7YY&*OR^;d)M!T{i2FV7n3e7 z;h)pO&yrG6H(>k54nR1E{5kVsD;ySea}*%EB~jF^7OWOEH&fJY32;u-?WFI(2i9|{ zv_jN8^5&fpbr<3M0-&C|y|7=@J+yHFdJBk`(H7QX>RxOt^ozQWJl0ogQ8WyPxqxi( zT2V_@idq^4serv@DWaBRb2(-2UnlAT>^wmGR+PbMQ4i*edI;G=lzTV_s`;nCsN<3O zyqFsSTScwHztw4?9_s)nMLoVt)Ee~H=81a34b=N2x=&*FsbxU=DQrAdDQX>M*ByZ4 zfDcaxL#e1|EWpk)T=%7ZhaHkSgnH>0x|oz2y7Qq&fKP*^Q$>wH)t z>LuFv67Ag<4d}j{0r*^jjaLdpz3Kz%eD$2D*XV=o8-euelz*M_|JWt!jUA#YDOWj1 z)SC%VDeA2(K<_Qeya!HU_Wt3bK6itkS4y$>CG{ML0QA2u5%tXhQPqUsqVw%JQ3tC;u_jT6&_8rc)M2lv zBjg>a7R9ak=IWt^L;t&7xlwTI4SCQ8DQ_nUV!Y!b%4ziK2awNME$!1VDr>6 zQ9q&c6J<_Q{%7Pr7r|jszvPJePZU&%Iz!&C@Eh{8$3>l6A?kc8oEB9}TLW2QNHF-t zP#LgO3}ZM{iedJGGh$fV#c=Ep!%66>5W@|gXefpKVg#WVMENF3K>6T&I3PyT9HFyB=mp5)C>w{Yg9X&rVHq3~Bi;)+V(^-o z(Q%^~orou(lTa>3=i#su_KR^5HoH(?qEC#jZb06xRE+M}?_MoN(p)iE8yP*kkOY1) zdXkrnZZhS2%@LzF`Mt^SO@415WWrW4`eZ;EoDrihdaQ?xzI(+;$%7TJU5tJafL^~G zAisYR91!DTY+bwz(7E`q7^zWE2&=&_#wF2^2$Z{oIxaze3Gz#WVWt=ZQh~HD2@0T6 zjLWF!vLaXsRZuO)K=KBX9#{d?Gl=qoQUKjSIe?8p$kU>LdeYEiy<`mDFGf1`q;D5v z2z3m}2W)2af?aSvZLyXIPfSqBK8@5u6;TDj-q66Ufl_`Lo5yJtynTc>rjFEGJ zej2$Eurm^yqq1P97^6$X$U>Hlz3g%^#za7c7-N&fV0&P&Mlo`>0y??a&!x;b?2pTX znQ&N)@r7dKbr54h0#u7J5xt2k#F!Ke>%_<>oxc*w#h8rFWOSyGotjH~hSYHVEN1=2GUAP07dacvsR7vnnY zT}S(`!_F*xE{X!|%%<#YnT&5DMkrpN=}M#V=5dI(B>^_?^uakX?!veE9iU2#yYcUy zV8GTry&wayzaSBoiBU%SUJIhcSm+kxKEg$mT}+uJ1!63PWi!R#ti@PfEyn%$^T1Xy zR+NhIU>58Ubky>&%mJZ-^7F`l7q&kh%(9G&%{K;0V-i1A#N7#qodJ_!zs@j{sxFJjxD z0MxlDQ;f~{!86;~vR{m?`EU%*i18BjzI0lQZTPn>3o68T8J}LR6r+Oj6@_BFk_za& zx=4)I(0gr#7~7F;KPSfP2gLYC4q)qz?P64-Q;F?2k-b?i##_O#R*bjv#MmK#YzO83 zIUM$j@lGNX06ILgjdvpeKi*vlv}Gsho#gLa2H4+88{R|qo)7Tty%kUi$lvz@W#69z z>!1p#>w^xE33Gw^KiDhAF8tbsY}b6)38%%_9Ss>!0vqA57#~JJ8X)^{9qbijj|EAP z2P=T`d#d4#7*!pBwpHOn)mqpu#z)xw2pMY#<0I^TgzRH%e2k5cb6`IB;eZ&QxB>Ym z*#4vpD&UwHd!rx&ieM$|f^%Z*OMooE_C9Ry+b_na*#0yHu>0wDF+NKL{QqnRoDpL` zcJ|K&^!MZU=b?}W=zNaPpJV$AYwaPuOfi*Us3+6Ww0I4{hGSIM*ejHECTeuM*a;pzCrd4^;f$g1t?oh-PM&q-QOla z4wM1*zC9_%K`&7E!8t%#)>6hH3n+i62#_5@c37Ynpb`#?DQ-xFOelesupO$!RH5JlVTLAG9v>@VIAy*<6@e~ zSr?mT4$OtMP$i~iK?)QB`qq9pC#J&-X;27@zz@}8IwJu4PV76k!Z9&j=(@1$!mevA z>;UBMC>Rdtvi3IJ_~AjuuE@L<4?@cG~e>^FBqFUSXcZe9V0#biHVMo>0l8Bn$bWm{0TMH%db z(_%(?VL0GR706Ce*L+Y;TD=td<0bwsTMWKn0tY(*WdQlS9+fUI>IY!$N& zb+@5>n{vRP=m?;$=u)7rXlz6u7qcz$w)5e%nC-C9u3F5PP{5}c;$Grj%6Td0CEvRj zs3*1nh_{~!Yk_zizQkcWj(7(*q(BiM>!4+ni^u19A5f;FKrhIGxv&)ui`faCPUv)^ zj!tE;5h$O4&4ir)2VgtlfS8>-0RD6?g=K*3q68>``A{Wh7wYa(2>6nSe~F94?24>w zIaG?-EeVc`**z6j!VWkoW)gOiil72^ikVyn=qDc*vzG<<-3uF>_nN&)_wEJg_6Y{^ z`cO|FZ1s(T0@w)W#7xNoZ1tl|zd|wlM?fAR>wj3xi!)&b5KqOYRD4Lqze^~03Hq1J zg=Me~sP9thVJ~C)h+jtj!1-bhDiJdcoguV$2=!-Bb|~THZZU^pbJzhfhnI?ZrBBQe zUNJLs#2lF+=BOkwM`wzel>pnJO3ZBP9^)5tY?+ujkW2Y-*d0fE#$zWB8+kKfB~-wE zI3wnSDA+6JL~KsP$BD*5|oR1V=xo}`Zv*zn|6sgCl3g3rX4pE z-h$0r{9@ipJ8rEIb8Z4`7xOmsZaX99?Uis+%saf038jGkyp3YsT_9%JVKMIw1?=Cu z2uLp^y%4(#i(nn#`+d}RAGQ{gw>Te&FY!VtRExQEIAC)ry2}Kxy_|6QTruzWi}?U~ zE696rt(Xr*!%8t9#^%G=U+D$%AHj!5NI&Wma~1WhDurq>SEIjrwV00yl!*B_b**s& zx@*y0dsxgT){6P$X)&MLD(1RguwTrlXNvg@WuBoOoH?4$&Ve&xmZP)2K+FwEPzqII zK34)e;GCEnQ-SnGY;HU$=JP&S1lWAR3pub1DEDFnJqaXq10`1wnUCb@Wx6sBd__zi67UWxY0={gezO5;c2h_EdysZbs zd`X}KWI-8h1pIsHn3&th+lD{eD6@?++qOeB;KR$IkN`eFzrqc~E9S#aF<(o9GN6v_ z#J4Yl{bIiEg}Ja-%zs3~Rx#g*fFdXb^xjwteyD^hF)PulECh5bcK~H855s9O-=yrD z`2S`vNP{dWfD)JwD_|XLg&nXLj=?!G-wK5eK)<||0eMga`0*Cy-YSRffDdmS2HO3$ z8=@cqDED?D;O7qdeFr-KoCy_TzC)Tlo%zmbG2hLAm2h0looRsWoy6bs!7`xS`{=yC z8qohB6&69Yn7h#5RSxIGWN&8f#?Efa@;$W4e$3n>Kzt8&_8bti3Y%4H0sW7%U^N^Q z^JDCMO#EZYenR|{jbiS#0G++q*-O3qhQn4dKaGYG*dgX;5ik>WiMc-!7QucoKlcLi z&*_^ldcg`fF6NiSznlx`9Ke?Yz;T=mY9JxL3?WUYHNZ#5_D4@cS@z9!Ul2KZ5O}`A{Jydl$1N5By>tBhG%sJVrg= zbpYbu5&u3B<^XZ_Cgu;=_~DG0$J1aDApa5hkHp!BnE%2T_8{iJ_KJA|e@`GkaZ=2a z!(la4i}~*qK+b-{Je2_};joxL;p0!pewx7I=~#*C6-YxmRTy6HC!x51RNL3xl=4xnON=&m@k$m2`a@3LNDl~SWQylfLN@F zt)|#&dQ7ZlE8&b-Av?qh#davR!^jI;EmkUxiw;!#;1mF7`B zJ*vG&MR=6uQKvlWphxZXs2v`)*`wBZ)QSfEf(HFw9@U{ifBI>Udc~vGd(`_BK>JpFY>QONs72;6=k2>K|UwYIB9`%|>J?Bx6dDLQ$y3M0zdDJA28sz$cl z7a3+((bc2cdX!fF()Q*Jk9yIg)_Byd9);a1u&-K!hdR146}_$!7257Hay6 zs2%UTr52l~f4k$o_w-m8YrbQY){m<=U#MxgoQ9Kep zEF|1&9@H|*>4<3V2?}Z+<*-@@2e*xBZN++NNgTJ{SI2Rg?7pN==8N0KsQTf~}i%{6holOeWwvZ+Z%9^2e3y;51VM{=$kQ~O}n zby@Fyd*wM5o_k%6x+dqkoKMdCGS$^rnm5!gompF`R%&Ld*)!{6wS_Zlm#W#qEgKoQ z-WtWs)PWOKW3rICoEH z^;l=+DC?}|xc0k@cUynf+St}wEol90YvZ#X>X{zurXH$maF5om&N1Pp{ri>+;x}d;kB_7XEdoDbWwWF zoRLrOZ1SWLby-&5_}WBIiE;YkQ7O^t>1KMII%H_zv~#axF~7eKVXSuI8jSVzj+XY$ zW+A~%6PkBx)v9^Z5c;ThugD(Tazi64#)ozxw3?Ys!#y$Ea!pUgcr%``xeVZU6cROH zfDZqwI~Y$9&bam+>44;(kt$9l$M=k+@pU5At7BYyXN!o)5s-tj-VX3O(Wm(y-jG6KgPeH{1lJM!-QKKuq> zdN21yO*=Gi-6pn8^R`h&Qe=sPmk5h*d5x%^&zfbIh5uBV>a;r+Vn+p1qbIz)IZH6A2Ak{s1L>u*w;q9am#xlU^==_?4Fr z&e}G+|MCG3<`)g=mQp*tU`&3&Wp~{=;PQ8FKECRnAxnbqxGa57g^GOm(hcbkJ-u@N zjTPf|?|J5QmtPM|30)9r-JCIg`uOYb?0x09b5DN%O~HFNFYe2uQ3{#Ty!M>f%Gbm+ z49lJk^<=oaepcnG>Ul7ze)^-#9kn^e3!+wc&mmFrZNv z@PG~t@0;v2!a~Da#&tBp?s#eY3$MNS{Od2gzSW3Ov1-=`Rkew=-`D<7+wGHGYPX8P zelYgC{BGa0ngp?O(e^p0`m=o%YPjP1Fl!o}k|V>zj4o8L{e>4_(-nl)epB1)gOAlm zswMyX_@iC5$+h3=dqPR9vDr9JyPL^qUr!cVPLCtVVKuQ-YwIG*aJeJh%E1p+4a*(W ztZ7ptD2T84zi}vWoOBqDW^~W4x~Go5^31LMm@C4VG>EqBM~}s~bhLCeQJwn6_jS;D zJC(a@?a^0mdT8}cuh$;ys+t6sS>vY9ANpH{G6IruX6QZFsLr-u1^kO|L#)TSR|okz z@_=!gX4ByHhB8BpNrn;ZG+5(V&*yqRb*a9t1unzo*uryb!h~c!K_xRFC;j1J7#0^6 z+dCGoVp|w%9;nSxv&aVj4)UYvvLdLjF>Ka zEq9IDqm1rKDdBOY=Z${!QS;uhEykjc0bY)%8j* znK|{fx4*2fhhNJW{TL&rG=JG}uoxC$b{Y_-n2viSwNS}Qt$Mt6MuemKH?My7X9kX2 zagK-R*OrptiwJiHi&LUnc|wYFJub6Fajw~_{=T-|(~nCoW2h0=UJu>ap5bOgJh^AM z75D2;Kb`(j$*(`Yy!gpymOrp|%?jhq+BLO{)b;8rb&a~Z_Ws&OR1Xzid#d(>+P$^k zt7zJ|nr4MC=7OY)FT!#gMw4KNWtmQ=n;+tpwC z-xB01Uq6?#O>~R)!6NPBL!+I}&KJdpg@%QepFn0 z?rz_s^~0-v)u8MfR1ew~ihgEO5^K)haK6yE`}KEfml?g9);`=PRGm@-YTr}?7Ma`5 z4PRp3%aP5UR@-*18RD&5n-LK$ zOL8OFM-}Hzcd1C1Dt6t)^kMfc>zumo)dR!6<1Vao?Aa|`PiYatV~no-y+>I$vP#s? zdaG^ie}4GqDety^$Cs~rdeP9^2l%_2vFGn0FJ)UO8xgo(zgN-0Z zFt@7r_ghs@clJY9`}~LHnZ9=;>h4`h_BMC1@-SF>;i;?9D#U7aYTv)kDJR`KdSmY` zkFM{rx%llPFF$n8?W>-+{mvC?_cyh*YKj`Au2W^T-^OgL{kHbx_(`WfedL)3<~>#Q zqTT;9?0at>^U6iO2)E^Mh{q$rO~n)BDaj3TTKd}8JEzwM+|oVqEy!pQ7aFdZnyujd zn{&4xQNib%m`_2~B$LD#AR&cFk&KEM zK_wsp10umi1S~|FAYwvOiU=s`N)c34Km~N8Vgp1hC<-f^v?^ZTB2 z<_-bb_kBPAe;~=^-skk^Jm=|$vg9jh;RB#`zIURbGdXyvRj!c&l&L*f!qbH zPLrvkd(c{NR>4`*Q?3H?nFvgH2F_kJUDym!hgMU@L#u2yW_sk^*58%qlCu<-O9=#`Q&Ry>7eZ!Aqi-kVcy*<;6=E$Uf&rtLiP(`Vm)zjy8YdH3Dda@p;d%Ae`?>bERi z6Wva6vX8uh)Q>-F*Eeq4a^UIvSO4y!d7LKja|{u}Om|U|^g-XCLi+;5fRX_xz$kL@ zV@4cnjB^00`f#!_k@cn5UVBZs?!bYzdk;WQD2sC$by+3cEtJf5N|MC}El8HjZd*Ib z?h+^TT5&=%jxdPv#jqpO&lEJS6-?D%RZV5Pt{%GYg=DI?|H z@6yAoasI8u0U#rn=Lq#8XK*SW)fAkSQ!iB((<%H+8FJCrr8vYk|aHqYM_H8 zLove`1(2k)crOMfI0gdOFb*x{C|@fhR;|*dRjc?uJY_84k`#na?3lrX$%UWjzmRjR^@wKD!9AR=Q;aDs%CKXe~$qwWMsglC(H}J{x z>us0?`j1nfu&>!Tvw9?|Dpj0f0kx#dkkJkG{b`$A()Y54OZyb9x%1AASvw-6e)I+M zG%SBjwG5mwu3zu!sr@dT&`{U2Xa5nsr!2Vh7RMVO96E6>6d`OTbzXNBZ{zo;MDIATQq*|YaQyOAP~J-0zOZA@LY zl~{kgBk&uRX0{>(-1uY*4bG_{g8 z1n06@a!aFV|1)PCE;Z^(6@$UWLeS+l#h z%C2W)FD}q~7Y+wbD-tAS^&9ll3Ar{`nvm>e_gQ?=sG4QLNo-M- zytv&L@kPd@dMY3gstXyARs^ukF+5;N-6uGyl$wZHve^gfNJg}WIqqyLL~t@-vg`M@9b7q((D zAJCsA6ZuX&y$I42=8)lrj`r<1l`A#yH8#ZzNyDjG#-x^O^rA+yw8fh2(#WEK@gY*j zq)3shV^Weg?(EdDG(tdG()o;H5)D?Je{WK|w%x5%w!Np^ugzVn_gSq6AT6c)u#(R~ zk7N!qgqtj~NwL{^Q>2;ASVgmfd9btQ*b#Ut2MbK&EkF#0Mp*H-eYZ>byZYz$;}5pK zj)nxTTnk*;!0Gzc$=+sMHU7m(E<7>`HCj}rJbMYyEn^k(r}vj;MN{9Z`420kLy3^?+mTiH|UWmbGFmK z^qgr-r(>6euyUb)Uo2|^g!q#oK zleRl)gOggE)a0Z_Cp9=Jj`d7`8RQ7cuQEfNKS|lKqL68@w)j@ZwwreCl7HDHFKeHw z&24{D9{NkOnDbZA(Oc0+MY=ONmdpFV9B)#i3Jv-wP#36LsOK>aDeGv1Mok(uYSaKW zfxp1>-Nq~N&}}^5rqPO1;p5uLuBUAchKWJg7nt6FOWTlu(d$=sF+vI4i{lom3Fsp- z6DQABWNTKIT~<6^haJ=byu_GPQXvHjh&U(Npx{X_;WL{iHb>=#P?cVNCLLq z4M()oW+oW3aD>1<2gHMn$rOewt*IjnI(f z(XbS8JF_BLv8ZAT+g#v<{YsX1MT{22s3}I{V$?52)iH|4$RDHQF*+Edy)oLz0j9;M zF-Ai;4^6^5Mmr`AbnRbAMVyf|pt)i#87Hd&R<&?)X22}(TLSL$d-as2> z?OvjPQIdUIYBr3i%b@*cGC~alWSM}?wYowvU|&olV52Ys?BXH?|nz^S&syi z-*fzC=L@3~PDtP7lFjBctE%SITv>K9DHaJ-)kV8qwB1D;T-4&CCKoljsKG^X*U23P z#N#~{{YA&|kKtXqqs29Np!RCd(zGp~%fC3DRJV_Pyln)~rhe}TWi9$4OJhNQKL-8H zLJru9WF%m5Nfs#@vxB#=t7;g$g%2|ykD<{FkHHaIy=|&tT3U*^sfePcrVp31o-BBYO``@bXQ`G;u5PmizDd(*r3KPq3`{=7E#!=39s zinTwa{A2dq_Met9S~wayiJyRzU8TE{c@8NjRv0vCS}-Q5<=q@^U$FjSN6InLp|~7W zxTE7(vL_z(FC11lzEE)%l0$VAD%sibF{#Npl**xjIUuAtWYu!ARR#P}qlt!?sGkY@ z3|ecb@A*+7)c5$z4M|`*alW!YPNC@FobxlsNOd>4gc%$Y`ISM&a&iM3XhW{1{-ht! zzin?H@`$DbMp(U#<6dezH$TUT#>=E8Y<&YL^q+Dpoqu9LjLo-z*Kf&<>4WiY; zY!XZkATL}7gi`@LAx;>+4Qsfg0^9}UF;*I6r3&r?n@wExM1P5l6bz2 zyBRcbR|w%_aFoAVZ}>;lamNEBJFLZvgH~E$r566K!Aj-G=*S z;xKn**uViAxgP^+#9J3+sZONXAloKQoq9~Y zUH$WksQTx%X&&@J(7;m6t53Q(*~RHLt8Nv_cO30tH^E^9Xm^0N2WUfpS_0G*pvC|- z1Sk&nKm7&5!#8~T;9w1E1qn`mxyhB$VZTY0KYdEq@7lHZg`J0XuGIhIzwOv#%G|c) zufBEQO=Vo$$|ruBFZeT>#by{NG#Cdp3mg#Qwg#JAVQaLlu>-PrJZ?zKX0j`0r_E}0s{U*(8d(~p=w8A&>?mbN$rh!bqx4Ob zK8e!XQQ8ru#nA_%o1@CiC{2t~ujqj2$f!~prAU+)#fobx~T*VCO{TQ5?E=l)7RVQF2Dl$J$lHg8~HbW~KvXwe~z79GN9(V@?HhhE_wLIX>qG=~phG#|jZQDO!1qS$uP z=IHw9uILxhj;KkBk|pd`t%~32B-{|k*$w=tA|Nn4cTREMF>w1=$Zud>dV3g!3^?O= zr;Q{ypt1~hfW9&AhS^N3@D0*k2=zr4F?EIy_HnO|{i=n-M3wf}BgeztB8iS8`q($y zyB3G*etiCibK=%$0a@NunumTeZEYL08*OtpJ#!xu( zWJ$hmfd>uD+jd1$ZC1O<>4-W9I_0I#mz>`^71il?Ry+GS)k#C0is-5I^lIora6^_b12$tMOzwcdKH>fQB0Pc6;AnPGtI=NP;F+$?L$uD3L)m7 zLa_IaPA^jDxz126{tj<4)1KzftW;=aLIINtM(8QM;gB5GJ%?x!%{ip!%654|`)}nx z$&a>wESI)l-JZkd5gZYxQ5M>s>@X3*TL21U)q;sMA#6tpEA@)vPzY)x^3Jf))ixSx zqy9GPVWWhNf;NnRzPAw_w$VWw?X}T%8*Q-BDjPM~Xq=6bHmc^Jej7WuuMoTJ#GL)x5CE3m>u3?qrpXT8&D?dq3|6UVdPsmw0bcT5jYGgGjn; zoK~MICEV0$gLPWJlgr0G8Jj8nyAvFC;h0Xc1G6$R)vF;rc$k3~-{S1y%dJ>E1s?81^hdAG1-Gu6gye)QW zvQwj-8tjyW=7oKwHDz2&cG6R-dqpYLFgsU(^3_L9JP5t|QRN%x))y~Vhpk=8u9+g~ zZhg313ca*hDo*-wVFYAO3()e16x2}K4=MO836GmJCj=b|v^kK10(aB>fB(Ba+&q2m z&&%eDz24a`0Eg0PegPWu3%FmO_#mxGdj_!f-TH8P+-P4mt1v6UD-i8#czee7YpOhO zmGij&{&&mdd%{XC-F=_@@snM=U1xiakDJ zInMqVijT)F>Ul#gtMFK1PvQCE$Mb0xsDhFI|94l6U=_C{yY}u|FI&8pDVAp}do1r; zOikxohFIoV?zTK^(b$Wt@k;F}SsE?!I0BKsYBVHWWZeas65pLaUj@8j?!1P*8OMNC zW*md!gX6?|4^z`SG(~<$p4EPbe7HT%=eXlbeWJQV{}#HVK+-ClKVAf`KtC?LKNO%_ zp1N6|IFBh(33t0bEqk>i&})dzhcM17iqIr^6{xWk7Oa=}#fGO19ifMhT%URr842eMq<6vY*YsFmGw!h=&e!MNlx zmkv&u%^s-^MxCt5S+JmFWqCaIh7`2L3Fbz&yOK6k(uzuIsidY#YOJJ&N~+)wJ8fy9 zN@Ruqh&_-Dtu7_s;Ge$?td3xKSq4h0l-t&Fa+a&GY+@@jrRrrdE^M zd%=_;BR!OO>1n-N!(elhdt%eA*WNL5-Q_7&t{69XL?c@pSz3Vk$-!O>0V&kVJ zYNp#V+d{hwtnI-e9ejx3SjQ()i?s zlGE=qnaw^YYzXd9Ln_bT>|gHx!ms-MZgv}G5_{p!29y|Qlr?D5Z zrc7MkA{YGO;DqZPYhQ%E#YY~>d-DFQk&||cwHSu}AH`ZQ|B7MB?m1FcmMdg(nG%KG z09-@uFm=QmQWjW68d6GdMS_|Vl$W561RYM$?nHVOGQ^kJ_JpRC+pW1~swmx6CVy6y zuvG{Z!iPW%L?EJ4!>66@n!T}7mRn6vD(3b-&b;G+B}?vKG;8aGk>sba++*a`v*^_m zzRf+{)6S-*ulIiN#m8?kn2sVPd0QEG@%MU*nfYp1cDbdLy^pR%=1E{mMc zox!T&o<7o{n~t40@pt;{Mc0Zu=09Mf|GfD2RrOsvBq-`4)P-sl3 zIkY^qK4f-<>hN(}XjkZq(9w{&_ZWPV#XH3X&~5y=7D^5qS0752bg7SrDngARB^iQd zstnqCMuH)ia!({dLwZ(n%?Vg>=9mHz6@K8{J0cWd`zVLtJ}|NMx4)ft`@rh1g%|cc zq&(ktvGV-14_U2ws&W=wx$=~X@|Y`R z^Jn>s8d83DR#`(TD=3*k2~;wvWLKqVvV!6jbfAK^SI`RnS*oChFDhtv1r4g84HeW< zLFE6~zn}Z8clJ1P%2m(Y{N_vh-krL+n`}{^F#WE6{^d()q zRV{9vIrGxWz89V&=4#{vORP&x^%K-b*_lAdnvZ}ntfO68mb?~Rwhg8v$ucUZ5`26Z!?9DQ5%)$$=jOd>F8@O!)6)s8TBQyBD;!(x->?i|IZ3 z?gqVC+ut@;cIxHr_lq%5Uyh42l7nHVVr;e)fKBkQM)1)VmcjKJwQxINo<^`x<89{E z25ur8q)`Via*!9v0E?CR_0nJzV&aAQYe}oN|Cee$r;FgLR){mIlx~Cms#KElt@&}U z#TvJkogD+si@C!dDG*Q@@8fXgTcyCbG8$M$b?lZ4$}1yR8GTzuUzE{HW%NWDEiI$j zWi+{rdh@sTGMa?f@9@{#`0M5}8dXMv$|zb!C(7t3FPs5bRYszLG7g}Y(eX0+)TkO| zuPdW!{>oKGJy9O-UMiyvWrUK`c+~@quGx75-}2@+@_voteUI`=50;UOD2%&?{z^X0AU zW^UimzD&9Nm9w=jJqJx4dG&QSG(PhVqm1ijZhNR5CIa9g)?N7y>mHHDCi{4;R+|*D zMWQipPzq`dsi52GvPpr1G1?uY?J+vWe|E&^Ft>M#ha0#-G=p*&J5%ilBL~B_3=zlB z#F=#3*a9A<=h;`L=H1tt9!%dyW@jinY9jcn`Jvc;C3u@WuG-t34xkJ@Db%uk-ORp6D3nn(^PaCx?dM5AMc&$x5Qo9d)=Z zK26fVd24P7T(}fWCMceu0}0xmAn@Y&EG57LaZY?tf;J?mB|+tISWnQg1Rdlb&ddV| znGv2azvkC9#=&xCJi!f%Nga}I$EP0Z{A8?s$ zZq+3Yf= z3AyGu@S0~!Xf8tz@(OSaTX}yFv7HAVYgxf6QBg$U2y*-BIoFE7uwy0 z8oN+S7pm(*crH+d;|h;209(vv4Y*?HBe>lr9aI305hrYQaXEDp{=|xjZ8*iCban=3HZ@c&`Dtq9uh0F4W4<0%+ z*$^o>>(Uz=R;{^hslWcx0Ry}BIlCxv?trmu9`ibm$)G)wU%D`vaQgjrm&>ZE!9bQ~ z0d29vNaawhNsCMNvb})&nV4t^-XQWW)2kaE=YmUMgD3)vP{Vlp+NwYmlxJKmAfKHY z{f9XVYG%ImR#jc1pC$Z*{Qms!zn|ZJ#btFoc95innCoxhgB+2@Bzt=;7CU9zvtv=O z#z|bz8L&#O|Bob&8*oojIPzc`xo?b+BIOd!oWp`ey?#yM;8HMz(>Qr*+f#x{Ga4uV z9&={~UH%z;w$YWzpP+%W+F-jf*%Z6OD!WV+SmU4t4r+AJPzUvMP~1U&2dNG^?4S=E zwAVo!9JK0G7y)ptcrD%y<%PwIPkAv>?zj7FWzy`8V6-w z)cxN&lIg7IO6Q)+4Gt=IklO)Y)3gawD4Pw!&FOdMcs+Uirs;a-8t!X98 z3DU+O-4~?W4aj~R5=t#okS6{Zq_2YXL6F`E(sLYcL6D|#(Egoj1gNMHFsN=7uWn+H zh6PDf2Y@3%`iM6Pn8$*&I7m~1M2)<63Eru5P{#)x1a)8|P7Bh&AXV^g`-60XzuLg6 zPgcbi_#?abwzZPTI)eZg<}2RK_{Utk2#;? z&ow7gocB2Q{Q90wDsU1c9Jf=3xsORahn-3*v)8SF1f)#kUXeo$!(QA;yl8hIOnF z)`^Qvo$Po@zW$m1=^mP^uY40>1de_BO1hn1*89m_75BlQ&Fqn2v|( zNSNLa)1EN3hG}D%I>Qg^1Rvz^*NOrkbt-UQrv`pqpffye4-**ERbg7d9fH7^4h>U3 z&YQ-=WvpnTc!`U-(mVsnOf7nC9C9f=j-y*C*F8!^W}Um zW!PzF5wi;5Zi_A=`Cj~yR(_TBZ+G(SmR@rHRou=7o94G*{ z7FOFSZYRH;KogGJ>9CzXu+#4U0^iS0fKR~R+fGM}YB%s|TRK&n0YkNbtVO+mM!kD^ zCl>JL`|)?3U$u7X;y612Ai-hKC;1@n)OI_ewH4gn31erR_d2t%ovp8I-Hc`Gv~#&= zXRK-ZcLt1qz{0UW8U8b~aA1uMvuWURd35{hio5+`dFo=NcgYjT$ZBFyl0eYvhNiSEC8XH9Epy zH)(_yLyf|_xwH62jaC`OdTUf>xS7jBbtLpa%^FQMJk2Y3-EQt$e$D~I;k=1=3~D8b ztCbFO2lWQ-nl1_)<+>)o@6u=+*EO|pO;l2&a*a^XrJdqpmN}Huh;hm;HwZ^3^y>UR zjZ#uMS57h0c$Me*$Mnn0xBUd;CrJ!P?l5q~9z>*WB&W~~9D%YqI|3xg9+WMYXYa~B zkgd3~%kkIIY~V$`Ns?UP?3nxu(nz*=mf&PaZCebxlFY?R&JI2zu>#v*p+hzKF z)26A->a^+l{Y%!&W#hO~E>#~`&`RGxj1eZ5_oY;VS|ZT189qxKHF*`BmI>+P@}^SBN7-m=JN z2rl27PI@4$1x#yXu3?eRAts~LVnoU%7p}Z`?uZMfoj3Wn)y?zrW_F!7rPp-1v`eIG zY5DS@k#3IsEAKtKSnB8i?c`r>SgY_smcG|tpdFy2(52I!WC!DgV9vKnrli@lc9hv= zQjod^Zj1AHKB`754YAVsMm{Pp+$fEFR0plJhv%bu*h+UB@FE}8Y4F#6-zkArtCb$J z(tSLE)eN5S3JvtLl1N|`w35k6KU(R1UORKp6dL;lR+?rdRG;K7oN?|*DOm}ru8#2B zSORhtuU9}0<@E|k6`q;`wAFCdj8pPcZ4PZN z%Q(f>`i_n-m0#cwk|PyMQV|lMRC|r&K=8gUQF_!O5e#BJ17!r$CW0SB!H^tzbN~J~ zZ(ZIReIcu7QBSHlJL!JTxAPvFJ+3O*ujZ=7UdkbAr##P^HLk~&W{s>VaJ0SY$gjTM zvEsR`P3q0=fn&b>!-Tk*Xpni zPDNRm=QifgT<4hVXm+f}9|s&?01P>li_WfhB%|?qhoivl4o!s-&#V=w1=CVREWSN$ zOccr!#J92>N;hRx5SS(AgoI-xt?Oq7I9QGIah9A*P)LAgoV0TXShN;%-OjJ9RU16p zSG4y}4SVs$_x9fNR>9W5>vFuV;UP@nY&7s=+9$MXP!>ko^Dg4^LSvT}d4xBni zKRoxgn_n#5&gT9m=}Y)8P31X{&QIpYvK6VoW6zSZ&MI}4!lyKlw>=g11`y0uv^`~0 z%`CANU)}wcm1WFz1$t%Hb;eYo*S_l7lG+eD8>$VNks8AsGM6yJi@Bt>xR$HVgu#39 zW#cCNX3Cw5r;MG@*f3<=_-m#tUVg*G@#8OB{n*qgk3D|VO`GH=mrNOV?KMLh#!hUU zeg{5Jyymioi4(_9xpVp>56_ss@ezhQ)S+YTW&n4B&>jwwO%|6u5DbR>RzxjYJWi`M z9P)X+A>_qz!PUg>un$hD<$U3=WTjo}C5PJ)cWi(!x?gg+x4C7@F*mv0mVj4bZl63Q z1-x^rs@{VfQpuPQt~O#mG(w@y#?A9TRPnrGSU0vf_y-*{T)JxU&3($`gLIxgmzwpt z&*<}Mj*QPqebk#Ym2Q4hetpjBX8re6-n@E_{Brv&`3|u!GG{($;1$X5GxkJy;t0uV8&UQ)3%>y>6>tz(+|9EGAKkpPS$pFo8(3#Yu_{@tDT!u z9T{W?bsyxm%jQnL;uXAZ0d&p*UdIMrcVj2JCQ9OALnpF$)bL^7#WU%xFifFI?wFqU zD;M(&iTVF={@0ZQSIe2_f?@?@RKc2W#vA6vo=6`C(P!*aP6TjR zU5k{7tE_T=D=BVRyBH~UnXI*a6KX_?WQE@n3mC>vo{S`|l0{-Qp17ix|3ow~G#4Bm z#`{a2`Y`%X^vgMO`%Ip9&)Q)(k88T-ukZi<(}IWmi&idLdH(GC_dGFsV&mjP^ySj+ zH_YuldG5_%mlihr?;CVcMgQu&!pmn2{>^>iin*g!ZCLwa7?tElVRrqrt3 z`0c}%u4#LxenP2f($MY~_sEy8VRWaf`kFjao6T+I31rAZZY-5~G-|50G6kA@N+K9O zNEmVR@lDS^(thYs^|c-NLTTuLwbwzrUC1w3D>Ww@!tpNMi@OwC+;Gc3r)LrJ=j}_C z*X&DGmb*%uOJ!GS9)2w^U0>Q!YLd#~5$7&%DsL%YQGTr4WK~MbOUn`1?|&=hP}SI5 zh^p$u)uqGsc``vBxz8|(({B6_z9B5Am<3u@ds$KfB+ob{Lf>1&Rm$9VNo0UayDn+3 zzp8J0^>zKJm-bM@t9xkh?khLxpKjTt|7p`^D%iZ4iZ`__zH82d1E==gJpJW^Z@qH7 zaY}sJCCzupud#yr=+akSrG`DP>Cf!hO!-?j>wkJ2-N({7d3K7$AJzQi_Z=A1eD{$7=q3C>H-yJzx@LFcI^ z>|D&n<Lo-C%P0=73@nDRqji<5_t*viz1SW z+(pRfm#8W#s!AyTZ9jzAe&Akua9zd8=z#+!I+~dmaV+97U*P{`XJ`t%su1|cav_?1 z@J|G<2SdmIVPoh+-h8N;s_wdf!WEybA2WE%a}7iP*In^5HR#)wy4xm?pY78hmK!dt z*AHERxucGw+LP+7CM3~gezOo>Rg&-Q>^IBv5#4%Xs=7>j@+#oZEy|Vh4$vQ#Gp#I% z>nXfQ5}6%V0^5)hGHG>fOMw*;{BUL&#e6X8ERr2|O0m;J-m!VcjLnbFocZ_-*A5(b z?Zk@*Oi*Xt{Me@H(>FbK^Cc4|4Hz(KBHP2~VWAW1YiQR6zr{+YitMx|Im>RBRmJUb zIG;*6B$>RKTzJ1lJo`~5v$tp)w1c3sEPG7V$&`4VQ(75ICRiA(A3W3HE-c?_B*$Z! zW#mozbXvNSmgqB9wvSl35;$BcSIP5{Lo+I!o6L1)Wo1hdvtRPZa?IIIkH>Z-<@ubl zrR+baq%YF6fh%-?Cj!0EF2@xg600Gm#qiM;w7`#@j~r`da^BqQ#*VmXK)(@|h0cfO zHsAa5E#v(3l3aD|h4TmWYwFc^;Kltc*DaWR<2_48UfC77RY{7NnJMhjU%?yj89v zjvHp)eD%N^{jwG4fic&^A^RBj=gv?1Aw>UoUvB0f<-@sJ|H%RUIrV8j<>lz#@H*~< zufYwF!E+IbS{bt0Joa2!CTRHbLZ*nRAr%Sw;fv`<%nEX*9*)z&IPH$p_Bge~S+)nk zw=gC;tr$7Qm?>5o$R(tL^asNWo3k#SJDHE-NRJ5)4}zsV>1L`F zw_DDV&3C5aa)aE698%lm-7>Z+&xQadwY)5n&flFz8b&cRKm0M6lDek4u)thf#d5-Y zt$)`uzkAU=3(K<3yRPJ_tJfsxfQu+#~EcV+;J~DkGZB6^A%_~^!EF+KbEVmldoSgb8&ml zb*wEJ`)sLv2z&+eo4F`?w&6FU_))hKR~i&>4*0fa9Z6X~KWezoz!tzTr4zSc9CxSE z$#`d{b@|ZtZJVBc<_Q{g@2CmaUpeZU$yci0m*3g9^Ob%2Evx6>ao@^i@Zv+CPCMTf zi^HRm6S#~BET5Mpi#a+f%cYfTleK0+70-CS7qXmJRfr5WKFIHFq9OYB$7o2~XIt2( zO*Cj(4pL!F%hQ+D>Gxzl0$*amHrHJ&&HkI@E-~x!&ywUeTTIH1_5J{O?}DaPwl`)-u+L z0*4QGiD(79!E}Tr%hG&(qdJW5=>llPtAP>m{Cp`K&i1nOd4=AXB|lk^zjjoCOF5aF zt2|a0lPAXx#N=2UF7LooB&jhHS_xH;LBp|+GlZ_Tx-Zqiv$nP>%bd6o_|G5@fdTXX3$ta3FOv=OI9zz30W-|Izzkc2eV znHG#bnN1n^Lb@sq&pQ>7BgMalH$9VqFQDrIA4$3tJ_?07z57H7K8PBl4busujJ%c> z!_TfPnWM}Gfg!wWVL6v+lO)#^Yl2P?7|A;*#CM*3Tx)XFP4Po_Ren`hVw*eU5G z=LjLS^2FcbQcB}kS`-MupE&u+Q{vmxX zQgfw~9;x5ewa>#mOwcc5`KvGw&>LdE+(OU>Nsi|!w^we&ZRP9E)*LxJRGs6=S!q0} z16=qyBv;UinOS;EtJ5vnPQjDY%jD4)C<8npR>pCb!(|i@cp~5#o`^Q$bhBu~=>$A* z@o1~pVH8Q@qA0`GUX&@IStyf(crD&DncO=%zG-!5iDF{;*?6otWS-{Z>4Nc?thoqH z#AuM1q?oiF9j~@(7D+Zn19j7r?^}*emni$Z+zvfb(E|Nq3*oAh=EOoLx?EibIoS zfvixL$rlPCa@b_gi3Obur+hAn;*c{#vOR>19bOrUPFTk6Q%x)etu|DU9J_E-fn|+L z9+Bazut?cI=rs2{ZwsIM$~5lb3L-|MqEV2K*p7}r zw8ru*l2L-UaeA3Nxd>$#jbde@MbTWNfWSQg&(=(|5vLDDpX68!1@hJWs8PVf=m&@A zV-WC#^lGO*C3bZD(3+??S)x;)7_N#k1@tk>09O@05=GA2(b3VGmm`W9i_KyKdcl&b_pxpMOW(^CeT%M)kOVaY8;Q$ z(Q9b{a?LhgtFNaq6ZMVyhKV#*Ux$7u(nTHbXveh|;R_hUX>eh(FdWYb1iVs?#V*;4 zi}FH|0+s>BlclOi)Mr+Gad^Qpy*y6|2Lx$*Arcj~VkUeWf%DC$bSKi{idF+%?X!!e%cg5wN01AeYCUux`dzaJhMM~B`T4vIo2X;oM3^-?~TK71^B#2lGfzpQA+dY;vxTEsxm zFVF@?6OCMpJ(4k>&15oX!CNsD)_h}9HkL{eQkw~Jz)U@lWh(ESdL2s#T@n&<^E5(G z7$T8SCcVu!(>}cqsf2dX-ACb(zi}hWCG^1K@)s>{PS!t0W`xi8-+piV?ltHJc=)^E z1HK%(-|o^4$@B8OUBV@%xWgeyC0*ibWmO_ffiRiEROX07tC4u10gcJ`b zR^}z%R7HcTsIH3fXo7U%h-BWgI3I;E&cJ>lBC_Z*rnN0cNSyGaP3vq8VVDOfF;fsM zD{1v@P+k7=mM5Cz>J1lOdG!@zuN>90N3WXOZ&S|UFU3@FkMT6!k()k_#FztbVcZpOj^_I3I zugb~I?do+DRQDzdR#(?}YmhC*QtU16b8bxuA~O*Rs@Bvv3T%nQIY1wG@T|e9V9*t> z%OO|J)%LGgq zj5;=vp_zcE3h=HrBF z`*%OS`Md9rO`dhzO-=+(-}TFHW*ni<+8SP4xa|CKqsKomT>cSZpw0In4Ak@NP7w!6 zcTRuuzOCC;wf7y98?Hng_0pw?2}Oux%{2ptk4>!^w)cs3PcU-N@Ke8x#T{d9z|3&o z51W(oerM%IoPr}a@HouzxIT@?p8*ay7Ks4IqB}Z{v_^9so?qc{=Q1;>31!&%%gRKC zMskb-C-FF}F+ApN0FN0?G8rKnMS-{_9x)1VTFUS$tTB8PeVQcw1bEK#vtB83u9_PY zB?MAoyoh+EXZSe&jd7Ty3pVf*K~+7R=i75S&j%BtT;2(O23TH3bs0QTRiDE19Rln` z-hW=Vya*2j&j(?WQ=)yt?aWh?iI~gn zw0Q%5IJ<@m6eattQm->$Q|&&V%Y?`TO9Rq=F;COD*;b#hSKIsH-W9voKEw_cf(dM5 z5gfA+H72Dmn&IaFh$@nSah5P#F48Ch8ff9|tv?YfmKRz7sEpX7fZ*L#05W#N>o zhF_F8e?ilgYxPNgGwCPvf71`UpW3=>=QG?Ls=+F7R2H;~%`C$Ucxxpp zoY@BEVU2m*5nr<*XuJ<=LRZX%-uZ{zebGw52BVYtf5gf0$6i~<5z z1w6x5-Ue{>7~mPMhQlb3LpdR%0LN7U&&D9&AC&%+hUcA%JL6r>0MBq+jA4@W3*b3! zvjX52)o8XTktUc5blG1(mpvU^94^X1UX`;IyB+3x)ufsnPK$MGN<#Kw*{N96Buk1; z($>@fJVe5B`BFl$0sqlJM+anWX{BO8gelY6XPsr4PuZu$7vQEXAkgjjN>$^ zBYyerwHf0c-*GwpdEeAgk3Ti|m5`p(Z}G!R+kY3PuP(Ur!DS+Z?CNPBEjc1rjGDM_ z;jy__w!b&x;Aiu$kh>3NI;W+GS@{>owGk$9zW)Pr0Und1CghTTKrYF4o`P$qmzhDs zQHD_!hR)HNXs%H};F^GExW?N6t{oL^#A1NY-_g<5njbZaq|bLzhT)Is*CdJYv7FE3 z#FQXs{2OwHD0cF!=Kk7zJXi9)GlQb(z5(k9Dv^lQD+YtUfzMpR;^@HN`K5ehZ_A6R zKA%5e_uC5!eNokAaXLYToIwj#9F}BuVN*u%6v6V0vao;PHz92rc{LC>mX4X@_f;_# z4YERyC9SH7$Ht|j<>ZetF>;6=M^+6U824p5T26V>h>*iNiE**{5aXJ}ZUGYW!8ah` z2-91m**Uu_KVZQY?rH7SDf?Zej`I?2`4s1rbKemOh+1C)X(HAQGXma zk&0853I0ew8aFgl(2Zc7Mg45A@cOY=n4FY3E8@1e!PN+OQUAY;`sFop0TrF>e@P^E zrvC5G)W3$h7~^IA_e5g#jygTfiw1hvGh>}9u36Yu>zw34!?=% zfViHigzZ;rK8*)F@Qq88!5tk>v<73^ug|&;Z9nwr7x6MN%9ScJ{M7uQNA)SQ8kDOA=a5(sf8F~7^5=Pq|E zAzTvU1*%yT%UK`-j2VGyv>usY5Lv4P5gfT3!I20>=7d3rb>X_6+}nXE$3+SP!C5mG zG4LR#CW2uJ&2c{QK*RqfA&mY3b!lC&;J>GZd1C%O_snlAJuNc~n*+=?bEQvXhFRwli_Ct(~PV#r|`3)4Mqoe8=wP=`C1D7s%uBb zx2>+w8QWmAK(w(MNp}Sw!rRD^a|+MU2E%vWhP;OM0pEkq3Uw?yt{iDSsZ^HUPZ$Hk zcTxWu0$t;L5$ECMNSeV0G{==q`V}mXf2RI*N(10sPcwDPp4LFXZs(^5haC=B%X$UhrHroaZ0F*Laci2eiRx zcSQyGz#6Hh+!N~rFY0G&Eb1?!9|b(+<)Qw9l7h3(P(NE=QU7WQHZNX39Ppq(kSfnP zL;Y-hdHtYgHEDXrs?RIQ%QvbQ>m~Zn)>qWOhM;NU>&vPym&&^v)eHD*VJpD>1xEeW zh3et>0w*F78Dr&&)NHTGn0?eGbmlL>R^x-8 z$^2wt$gUt=p6YkN#OLy9K4KAczA(His3m(v_J(XFn^CN@vOM*!Y`F1;kTN>xh4VCw zICe8q9c>6549Ow5=Q^$IN&|`Cp2Dxn1y951R~7f}g~LD7h4KWXjN8F~!Sq0x&MfB0 zgCyMY7#U+Z<<#-*v}^V1U2?~Z4?OT+L#rR#=%&Bo>cv^Wk* zT$q#X@!(cpbHuCUTMCi|4F!z_O0uA(Kwe&e@C*XYWupn>(%P6m;2)gI4g^A|z{moG zm#qk7P}sduT%n8fKIyCEh=` z{~0ddE|ULjQJ-U!cNOygz;?0&3$>8z)p54ME>n0wU;#JGIAv${skRdV&*eiQcRvXD zSkepA2~JE{K@eG)E_4&PV{z_d!R79pp0hSU&pSR5d|lf7Bjft1jD-fc#^E_F7i}#4 z#OM>+fSXw-Xokz&{&T&F?Q=1PqHhd%Y(w1TfdI{ucxk|EY@dtzSN~wZqkc0|5}yh$ zU8!GzOHmk|<@L+!&_V1%jMk|rtY-8%4={5$?Y@M~qr49D;z`;=K9W6>2d*3pZZyyc zu&)4_ZZ$Ew!Rj;1YowAi-QdYKUL!^+^KOL^EH&=bDz57UqiS@{ry9M}DYOdE2(5~CDl=On zltI<92#|7Xbbv=kp@kM68r8^O$IlcU1*tL}1C@46`Wcjj?9nfbs!YLv@Y?yK`P`;PiFtAfZ}#BLxA_du9-g=u-1>cT9# zG>=D@PCgo@gZxoPn6`y!J%^ag^R*<6u-Gq*IM@|o8f275yz03qgYtNNfJ5MQ3lFQ! z3zI8Mcq?A*;#G^nczBpMxw{i^ddAs0;LBK`PBG`=g?MMIkl-uRhIHYE!&L+zVURd8 znXC{p24{+rLUSQ}xGohrt!C1siJ!`UoAAwkeRw*r)rsT-OH4QVH*fY6m}0iZn{XcD z&J^sMZhT!L#Pp)zcVKgVx7F=XJMlY9^vhU2EWqN-#2QAppM`+^wlggD*Ai?g1^de` zotG?MM)pQdzWm@o9rslx?UV)5v?NohA0z)t%8dwc> zM8nAq2Xo}z(Qp?|dw&YXf;8?C0#7rbL6JN`?~vbWo3(1yE<{r2+^hT7%8@G`yh7gB z{^%;!KBNYmCoEUn#mL8Ow%b)0m>o{=L&(cK2<`_K4SCNxoP>#euSx&0pOhw$PN_3JNcza>4F;Q5&Ek?;e^!_g%4 z2h0s1>*6er;RYc1&pW}ce9!diRB?WGPhU}Xj+BF|V{+7>;`6f0q*U;~JWK}3 z@(dSS!{40g_{H73JOCS-QmEg+qbe+U61yYPiwvlUNquU}#@@{%CtZ9+@w%sPJ~(Ta zy2Gs2lk1Tm|8(gV|9}BgZyfaSlXU)vuTFU8nzn^%KRS2y*tvJ`L|-a+tMkz8|ZGeJuP!nCxH4Xa;2ekJ36o;d~;+PW1~By8g=n&vp0E z0UpP?F_};pAf)iAwgFK;;}v=Rko*6dhUfL?O1Z@+y-3g>Q9t7qMg1pbeqKL(4&tZS z5=8xsw-faz=qrYMxOa;=!r;f~Xd>fp!kpUDZ4~f*CfZm}@Rkx~xcMP;Z7AIcIBM<) z$L?18(fpWpCumum`9$Vwk_}gayB)XyF{CN^1zDaksVrO_-)n~>91;ZJI!6nx z-h@|@uepG^l5jl`Gq)Nh*NjU&1St5uF^w_KH28kAtCyhLKk58dR0-j-fM267DSy3S zx&Dg29CzIHziy&(ciU(3U)!^lOHNE4Qr+cZ*AE>yI%ysaqDyF&J?#*;QdNhm`d7G= z>A23+Zm?-!uFaLe(FnZWyTGP#VZPfQh;~5=FeGR$&d!dym9E{ou)M%sV14e=#Tp8g zHl#xCfZgZx-z}yi_CvVw5AcVSw5ylEJw~%9aGEXZ zk09?lquI>N{AFu2l&+iO>0JFXmIDsB&FVHmAHk_RjC@zMhW+V!Gks<>Thy7+lZIHQ zA>?mv&CWAAou=8)Sjg{j-43r?UW^Bz@_yE9XjV@^vubpl%Uk>&Cv#V{(L;=rz5$(J z)7`hY(1+;V(ghNat<|Y0nh8_G61h1GQboz?~nn? z%h5o!x4N6r0Pl~epPf0pe(1uVOv88Sf(E)v-OG#yME}8`;U20?|8Jqk0FU#p@~ldu zX5Md{lgNo@)GOV>{Y?N@?kYEGWN@eVH=m}a+waCEq{3Ix1tI>0vL&o4@kmRg@-&sb>|26-CLHvWKt z*%%b7`y|{^giPA!wUiQNd$I$npG)BgV~4ECzA*&!8PWu@K{9;hV}rgkA1{D?V8q}| zz_1T_4m;_#*f=|mDIU&mvT-urc0YJq?=xs<2Q6yN4y0$V24^p?A9^sC1UoUjQsEzF zb!5pJNUFmL%8ZM%+4UP%tL9`^o_fgbK}I#X4oaN*J+bq5g zw@*>F{@0!Q-zg++p~7XWEG&C9?KSSAGI9^br0L0|De84P?V)Hig1l!wm))L|>&hj& zluI5Z+vD|s5kPN_yYcLMlg8zH2j!`7d{QnM8!#qu2DbNSMmD6 zgRD>MtAsNn#&L%F84n`rU&{RxfIo&^8oKY(ZN|bkk?|p73`MlH^BCZxpLd2f7#||q zSdFFPILh0|WkOwU;^ZheUW<8RI$JT0wX{omN2*DdK;wbz@lBzY(6Nvf!d38*Ya>(~ z=@IE4Q7(^61zi`Ol+Xfj=X>Zz*u3+37kx1^PqkOyTb@{ zM~rpd|0C@?;G-zlcjud#-OZM4@0+rlO%ITeLMVYOMFEu#LMR#p3y9JYM7l^75K%xn zf)oW&qM#_{*ubvXJ%7b^tVccSIVxl)_j$jW-E2_Lz5m};QCucFUw!NIKCc+Y&*VSC zvW_a(#{yYdc6(m9h=qu6R{Dw3L(9@82J&M+%dVnTSVMz@?Bv3 ze+uJ2HJ1YG%y0*n7hvJQ`3sA)XneHakQZUvi*V4GM9I)zsCRKVx0zJoBb>N|4S4S1d-qacs4*obnP)psQKv@7K0{oE$$%*U0@ zw&Q0=_tLmbmzh8!u0<{QIUEGz?moVs5V&NMFz8tIc?`)k%9JiN<7u%M>i5%`;olEm z{x&T>%D-PQfeG`E-%qg~{{4uj#_|2oO#s7Kw6CV~t9~!7C;wi^d7Kr#Hhe)X4hzWO z)i362qmG-_QvKd#!e%_r*B|4y9Bulg!QxB|oz@?+p5hP=^ngeg#>9#M6=3KR^Mj4O zZey=eyVXNBaW!>Yon#YjHg?YTqfNx;*vgYJZ%$sXTHc8je}cdRSZ*l{KrjxHsW_;~ zKpdB@+%g9tHlVc{#V+g8uu$vfAU9}j955WgjNB;UMzVzx;f7dSuR+eoNpR>grB0&vc@6NrIjw_Lw8D@Utn8fiNBkb6dmO0>s~z?pMnhKyMl`2=pqz0ckF4Q> zxzHzs31wL*GokfwA)HBbUzaEcn}Gd+%xv?#2G&-2O>5-R+}dTHG$hf~Mb`m2i@UU5 zE?*@Zyh!iHuS+y`!TL)AB$943bZr0_nPOpNtkh`rxeE%ivg91MJBWj8l}cLq3Ni~a zudXYw6j+e9&^z??b*LiBf+p4?J*u(=&oYqOG}q>-aa~4WErLDs&aXsa0V)eZ>P57^ z_Xe%qeX>odZZq$DpV_CIbCctt`$U2LxN`p9$4@1?-VfNkPC3E4Zd}jqeCiGPpFMiE zZST0O^TIXC{yFhc$B>xa=yb}4^mLS#c_Gyq zoPv|FjI3xcn~!-VBm6+4QPw4DpHk<@H&IKOQN}DqQ}d~DXD=0v{)3`?7T+oziKnsk z-!UEG(dV*tn zE!{q-j&}{haT7I@qS+ZuMA_7F(TcY?LNvU@;YzKuOl=M%eieWDzxD^(ru=qsgCLwE zk#c=}jjhOCR>p!h!R&S$KnS65h{n{e#oPB(p^z-h>>G^Ln8cE`ypO5Z5uN-h{%{w?cM?}o z{weZAvaM3eRlGX>QQ|5xVFco1*MVceCv=Quc-(IAkQ#0NfG5q9=7i9^y3XmcAUI~> zL<30~q7vO`2NB?tatb>{!%_iaVPPJE;4N~+C%$H)^7B8G|5?9z7^{Arbz|d|dzBMP z&FaTmzqgbXGtdUHj>?C9A3v_lR1PQy!Cy40X69mSEo3xsNc!IT{y3TaVufi&V>IN+ zk?cA4g8TqvKmeRyuC!=c)B-#uin?Sz&Wkx!a9=T>{4q+c@O5W zuSMG(bn}(`-zbgD_9ZL4XY)|yx!2!T&fL!?U=ps~%WiGgt$ny>m&e+^FP~S=D(98B z@*K;hwOfqAnqq)hfG_ zT6JB=P2bKHU!$73REdl-JF2g{@CX&xiNXNLi4B@8Mzy-0!(Wmy;&~j)rHm#Ks_(ExbYCA9@au2)s8BvKZ2A+E5*4^c@ z9DiB-N`DaT*(&aztxS1(*3$LMmv3A$6L>#R`VPNgSXY*|jhccP=Vyz%2m?Rx3GYmOG zl>V%WM(%VVHw~s6HFQTK?!ulH4P$S9f86k&f4cCc(u0j{{7t-n;r1QNm#v>Xr&jr@ zZs^0q{;?OqITLQ-7x8}uly`2I-ue5@D8!&YGVwN3?~|^ngSp`SZ&Mk-#Tf1XjRxJ>Xy}9RhENaL(_7tDvypz~(g?vt+x? z7R{A&3knVXkUvyk7xmli{-}i3Z@4Z{2NCLv-7K6`!XCbEsrnVW%=cKAqY{MWI&gQ& z%A3R7K`;8|G9A2R^=-uz4-z&UixVHfw>Dev1@wveDUPrP0Vo1?jg#5 z+}A&S%c{fvY9{Z0eeZ$J3ua8awrSmr#q(D&d!HwFf@kM>oXxwi-%jjz7R7OFHe$PX zG3)_DS8F@egqdW@*s`gYBjlB;($6E zKn4%HB$r7jiqDhyF6>WWrZfVl=zUD{RAsV3$Mo(;UMG$oc?OS={eZUD$TnRm&EgKdWoKXv4;NGC~|ONjY}>f0UE^kdo-bB3l!BRd&z3S$Q8R zhkq+C4eq6ESAtXK9AcNBV?)`@*;xtAiaTe*UjM@PItwf$Iq2}3?nRqs$z?ZNP*ss8 zXNOIObT4{rA^MqtGpp`6RqiA&H&yYdRsp;NP)cx8MU+VbtOa|ObJ>yiBQBVFN2s`8 z{QENu?R(F}zptxZoK=~VQ`x4lu#GZw^{uVPjoTnEZ}drDUzoA8q3f(QOy6O5&z`$F zR&_!Eg4ccOZ->mM8sc0hLV1>el%11x* zsNraH$0b;ddP7CKB)1tgBg04XS1-IZb-vbLZp^Po8g8C->x9SNcAsfrmu$Q(rODhY zPd=kOv3b_4&ElbWMZ+r?FMMRJ&zBdq*bt(iTqZbQ3v5qHAFeDYh6v_o7FxwTlr9&9 zbc+ccp6a1dB{<3Svs##x&N*oSoFD4ti{F6zCF*$gfd?A)?Gv}(DQ=68zB69W&oJdy zYIS$PXY7T-7n4guhdBvs5>BUp_MaXv$PDZ**`eNOSs>?7r3K=?60}USy^q&mw-fM> zq)+Za8wQx71LLn8J+WbBw{GPf=8AhhiSHHh_saO^^0?I-ZM&W8l(FL9;+q>kQ`ZK# zl@BncA+ZwCs*})3!+^?5uLq$7P&s;SHi$|GR5ufK1L{DojE24*+%ZrV@xtO}vBSIO z(gLW3y6OOFJW-BONg1RR;2qh{QhPQ&%x?4Lj?|Qm_h^$JRYw_LhVkL{v)wKC!jevV zK|OhBFDPK~-}mD7Ns}6Gy6K|5fDI@Sw$cR*PQ++(*zJ0h&xvM}pEv zLFG#(yrV*`qsF3Rb_s3DcN>Dkz|@xWm?^Qi7F77E#TU1+XO6#CzyWy1k5v4ZpxH=o}mhyebH zFA9kuKlu8{nJ+72o+=wN?3Rg7kG|x(Y`x>vGe_zl0)=Os^d)~j5 zb(fA@xNhx`wF8Fm@ix5nt>lNC@{Asdm0GQot@cMl=c&awhnC65HEdaWNgOV@_ z=zeh9vx4E$(C9rE z-dVHWywkdcuh&xLO092NMGQP@=$w{r3YySV$ZJYAPV+!erT`%*=#bd8|5Yb6E(?W7v;*o(;tBSq{Sk_X-?X8SNid$Nhm;qi zB$!Hlh-E|_rJT@$8wu6&<2RkSRJvrDT(;9Vt8ZVXPJ+IV;Y%h^yuR*n)81(3fBK6>%=ckvHX9po#SBmNd2r&_Q00amk!kan3ut_K4( zIs~btSoWft9VMzkd%6oTKxPEt;a|l7lQI-zO%^Niz;`zDz>@Vi{#%>BwX53||O_6$$5F(`DxI&D-wluROHraQ0Ib@m~RqwU%FX|C3MexSY+HzyG?d zCn}Y3?4I|sWGjXANXZD*%j=XZkio14>t@!1)j@zzKX8-LIRx)DtfM~R& zbDMjvtaq=!d-L4`C-==MDD7Uc_w{V$`8_z+&@U5UvhJXGl|+hH6uJZd{k!gMoRC~X z*JT@=0obG#fB`eS1L`s|Y?941sLmFWat72%tl7715eeWs&}?O^q>@!!fV5zt?>_OW zJ8tjPv0ddQUFY9v>l_+Kb zSgYtRCN#Fr4wif}6Tor&r(Fo+-FvhzuW4sZ=|#9&`G;e>vs=%}Em{()m|WmXAbVju zpc96{ViD~?P?}64(DF8T*kG4S5oF0opQ?;;n^I*L!6A;n&BfKG_0rAq7Z*bE7d2{D z<&Kq`X)Vx)u{G>$(n=AmM4FuCv&z=&uxeZTQFes&FxW&aM!3JOwbqPr~4qy>$uPn~}4 z1!=5uQ()TFd&ciP9aQE$_}d~HAFu|x3}Eu}gn_`!R?;d!<-_|HXauIz4tl zj4N*_yWf4|zIMNeeGVV{{=E;rf3iXB^Gmz?UVl&7?Ew-q{|p;E)x7!K)yii_zfzR1 zjiaq5xNZ}c6}vE0zT?~c7ol<(b(E;LAE8V{WQn5|>? z?OS8oVmbG|vP>EEDvg6M3UQ3X4gcS3w^`DqbQ@S69r$;;Q-HmL)F(H@VBsb$FtTeB zja&^3@_{_xE=>)O|dZzBq))h)ztoh_EfqXY@tTSWgT`Ji$SA9@JuKef0o4<=|=BK2-B1(# z%d!iDe|VC{*#&xO7nh+uV|fmVq6f!{I4|iJT99u(57IZv4z^k`DHEh+7q+tP$|$J~JFuPfgU+Bt&-9(z@Z|GTI7EBk))4#n$K-d27(sQhs6 z1CRGo{`KwK?|y}2hw)yD@jlMS3+ox>qcWU4li7?GS#l`D6YvHQO=b0UCb!$_m8?(^ zR@Hq1BCkWj^h|Ch_ei2Su;~zbP!p6SNCdP2pxu?a_WVU@9P{vG7MQVfkn+uIN)tTJ zJf>F+()q>@*)QVwFW*~nW&*SIdi(*_mANmlicI#+j?D_}U5s@U@Kd>RC)oOkm7plt z;0=gYSj(ap-cvvxP-lg;5el*X=t$%=BaPF-)BBTkh5Mt1@G)EhVv{GO#`-Z}>6iHB zY~H}l#|JDMuwsMrQD4)$cfL3MJwVW&Ey+Xkn@;jr5u{lG6TwLTstuC@;IGs_eAC&mELP^Sp8;#*|%bC`; z#p2=+B+r750@O6fBo{nx44n>&`D&&pG7{BtcA@g~o6FxFH{_mu4^DaJ6dM~~1uZ!3 z(Z~BOKeTLg<;tbZ+3ng*`>$9wvfs^pONs|{ysYyIR=n~S<#_svTLw<(Rg%-DYgzAs zXVf_j$Gx|}{?8Tq#tJjsvIB=C1T2pO7N|E@$gQt)2||`N3;EJ4r^#wX_SD+a70|Fe zEePT>cq#VQ#{7{FbK3>W)f)_du!srRHGb^rRex4)Qur}ACb6?4W0 z#g7|DJ9Y41@^Q{X zLXTK3s8sz9mqD+Go0ys5b5Q|9D(;FVzzMZ|e zd9(6HqdLAe@K3JBnF%1`l9wh}O_E24Y9A{&B`BBf0W-JWfsla1Yt-psh@c~^YNC+} z_!F^#Ay!7H5FLZ^a5sf)Hg&+_e&xeG4@nlKTf3J&dgG}_-pDzazF_3%Y{(mI>B^PL zk(~#Q@7Qk{HJ#Qi7vtKX`wZSSq5yt#0b{vgN4Ur=mlWrt1U(9O!*4B!+N>VjH!A`` zDsw3EE&M2_?ly*`HsXPNXdVp27!0B|T0bw8mwDKT$iOQWUDvBTbovdgC;I&HxxH?@ zbocy4w;z5&I&$OS9<7}@#pT@xR&UtD8>06g*0#1d+f}k<*_=geL|^tHzx>63w zeNYP*6xzo!d?uX^JFXW(8D=Yb76BaOvAUoc?5N&OiLzkK37itnC&l1& z^XhSQ5#`W5%8SbP4SP*;t38vRJA1rISiR=-my^UvyObZ_9jm;p`+VFbqwD|u6&v`< zTk)SSfAoE<=OHZWSj3V<4LjE+q@i(%16=PqUAlnCx4|Xg_#?>#nipgh z?N~0mnC=t_ho?sKRKG0MJ5&8LyowYiUPvdOerZtgDQx9Iwn~{ozYZ$X*h*!ZtY^ zj4vz;s_~Xlqeo*?hU&u{i?4p(NB)d|j`)-0Ow@$p`9ADz_4%gnjHoHa_)t?w-$Ub5 zzXuTI0Hcf zGu*Tc&u7zm8i8H~55Y2g(DQLT4#cYAXwy%zHvC7jdHHRVP40&(TrnFk3qVrm1D<-) zcFv|>V+JhtT#Sc z#a5$70q@80Cu19uGPX#Bk1gArt*ycz#@2Z8*nqj-i?J19Zrk!-%NPG|LGNFz&$kzkL0l$${r^3Np($g~)*)mLMgC$8e_V&|!leJ*7$E2A{Q8g= zaKZxU8Uy|^U~w577Dp)Pgo<*aBE|+VlFh3}w{?`pw2V@!ahlXTmu67GO#muMjD{Um zy!mXqYH7GuM#zT!c;Uj=O7uy!UQq1*4>l2B2<=UEUHKM(ytR_~o zrbe!jIEylgfvQqErGN77VIsv1IBzBu%Al)*-siul55z_91-8FU(;YCaygXkTTJq#)IUM;Wsj$EZ zioE(dkSM^5?k6yt47wty13BI8hLP>}yXAD?n9?;vl{(464kR===X-_qb+N0h`jX@< zR6%EZqMl}`O9Iu@jD(S{V7mz8GJk$5%H#$l&B0(f^gde-SadPo%)SdS4GLe17|%2ihLF5H(dck z4l7}0F0I&{2fD=EN8ZeO%sG4PxS4(!G22D4;?Y~4f9j=|rtWJarpXWL=dH|sZ$Phw zgO|^}W!1uM%Fq?_gO?8&HgaLrdpC^x7m%B2=f{oCT4!jtzwxPsTenF+&)7er;e{6- zdLHNbVm`76?-xMYmh+M2WaShJ{=^x<^B41xElco?b2>8cjepUQ${42F7=$_D`Wz`o zZZpKN2dF9Gv|A_y+0bC~=u{#TQoD4|fR~XagA;eWulh5>X~CWPGq3#dXGWtQ>Rn!6 z3O#K%h+9NtE%=y+5WW)M1zk*^vnl*@{ye`^^n8K(JlzxahFVuEmElI031y`T<*ttH zO8EVm{9e)b;pgc4=;w%AiE4Z#l)p-?kd8`}ld3<*V==T2#J|bcA%f>=t;vvJS%$3K z7F>-z5EnO-eisMx?^pTd==sb{m^Oew=A|bqG+{TbMXsZeo@c+~dDuZ~)o-_2@w9!Y z-K$S_yh!YeX*{6BshPQr31kDGgs&VBM#qABqffAT zolY=N8{v$4;U@R@vU6UlnEnKf2zt-*F7t{uFFN3Oy)Kk!yO5QtuY>kVI90q^Fc(U^ zswbHjRqR)LoKo!Z?!oMqY;TiMiPk-#wGcSJ?*Pl2co@EfGyp&ZbVxG%ophgg z_z&KmV4x9rcyf<-ASAksc7jvh_=&RUtrJIUk5Byg`)AhNb@j$8ce3by>HhbXPRjkg zlwS?SyM~Sa{r3kRxn}=0jng}_*6a=}v%0@6I8m}VKQQj2%Z2R%zsMsEc9*`T-3YA% ztP<@R`~mD)cY*XCekL5o;R3%%LqldbJ*Ce8sRr^D3|(MDgb;gbBkYZhCw?fO%dbam zPFjYLlb&7#kH(1ZX2!D8va&U0qP;9uHmPiT+3RIGTNyM=QIXxAeP`Vy`+TH@?D_Lb zwv{|wa-u{oiJ>~HzJ!(N4gPEt0Qu5fCaD%709c)3KE59P;{XRmM`$Y69aZs4Ik2JV z`bP*+N+hY}WfY`Nk{Oi~T1;DYFv~@evWOCvnm}V-34I)h>!ct_LheI|PC}nGk51a! zsg0QV%#QfnozIK8Z9A_TJ9gE-jP|N0MB8Jb%<+BOcj()zow4H;zoK*Bi@HhGqcbk_ zU{jSl!fizOv8X7PXjcXH9?ByS!)5^3uzs%UMVJW4?oYzqtLtIgq&Bg8l~X zrXl>^jA6qO2g#SS`_}gD*H=2xmkE6*^_}1M$G&o3Nxbw@d%oN|*!v1__3|bNZ#6eT z(A=9UEYW1L?^XMP?l8A6jM>uXbSEDZ(Pc#R90gGkTA5f?ML^H1wgsIBZd(X5#E0=b z$-WxMzM9@Owa$F)X+8(u_l4I_-lxe3a4PcW^B`mMdgm3UHS<2>`#IM)KW_;0^SM5% zJs-vQN3V+J8#HG~m96SoBN?Ad&x>z}*Rb&d?;SzHRU!(8qYVLXs%b`dfv+up57w4` zj-A7M_|MhQOvDaHn{I39&`xzxRhNZ+PJLf)@_lE;hw(nlU!+z(88SkWD&ui8FR?sT z_avTI-*Z+(=7QE2FA-~wHof0aQ<-psX??M(vSOh56l(LCrp`yiS;rfg`)Je72DgK8 zwb?u_nomkk1k8v0PClQ!Jem&}8lP^+$+qy-ZvHv-eKa5SeP>ZyKOW;_co~(~#T$G! z^U_!`FzNy$2tq&B<6!M7t9BxrLQ(v z(0!XuO>$gKs7OZtX*y@nt^DWSQ|BWxn~e&i@pDiA@#g?@6U6=onveRvv#gc6KMZff zkJ>;InQ}gx&g1!Re1FvUoMk}Z(tH@+!ce)|@_98Tz~@u0?a%Y-e9+r1pUzv9sF9=L zB<~GP3j3if=0VRb$bb`W@S{zbi} z)c4T-sP8!|;@t85!CS-}Q_e;G2C)CS8~A*1pZNWmkeE+S4$TMSx)$ToakY>fSJMfs z+u!*94AJHjOYBc}Hti2+aGz+%2uLk|j?OpUNApqNcUGkJ!+pTZ=!_n3$O)?NQqLPK zIkAE7kNTdoY>l=*cnix;%l4^n(9W9{=b-!3UYpOO>Uotr^bE3JANBc95 z;v9TF!?gK)lbBC_KFtUFdKmkvhEpuD}tazf+p(2_d^&-vBoO6Afj z%^aoYF5))9b9buGSp37|FAqV-n zh%s!oWoGIH;Cuu$dk>qD%|?C^89J0d0%VS^t`((f3WEv*OUY|XfxPf+HnX#;Tmk8o z0==%PRf{wcuW&GAG3ocl)hMjEsyOeE(wj|ZUGH2rbxW6xLw?%3Q802d4bE=ZUK`mYh>2^)yVVQg08BI^v9ti9ZyZFFz`0Y4rTe_ z6O&>^kp|`O%)`>_uf6uVjDw+Z_s%+TY)W}-|7*%SBQ}){yLv{?p3^gL=(?`vjk|8T z_R0~FYi76K``FfbMcZ&*=w7FCEJxTqI4>!_UgIDAwpCNeg=yS*?YJ)EQ0XNA3Tr;Zt6hvW(dtFbSrpBBJBmR3ToDO_z4L2!XNS3@WB z%}Uz*s9Gk%8>ayKGl}b`@Cc2#B^y0HLvJVAGm0Gm>5noYromx=s?e(tW&{63sw&@o z>no?&s@c2BMX^EbS~GuTui2M9(QnJmvnIqpn7L%*^5yH7&XS(*&@sPDxM%0e+JiR? zXKv;f+YP^F7CUvpwavHWmO+!dJbirnzWNW?q|csx>*IG%pJ^OY)-}`J|Edn~bBKfK z7Wg>@&`Vdv%8|@+$x@cj*T$YJNrfi6siIQvij+i3>g$52e2WAlL9lDIt*1ayL46X% zk_;9hhfiUn=DWus2fWTits*j@_Z2~Rb8=yrS68X&aZjRy2OaQc6n9vh`{8e^=H9hK z`Q|@y<%j*ZFI(`%-nv<%Wd|s6PE_%(<2BP0YDc=pQQz=IGPqEXibQ z>&VN>D)C{1s)AP7QD_eam78OT2yTRo+!`vKJ0$;QmIS2WVIo4wCMHW%3yH}B761U0 zPs3GHzLJTT%vnC5ckdOm=S^C;L23HxAIgS>6X(xf(YyD6Wph?Muxb7J`+BdC#xI>S zU~Y7BkAsuu9SqCaXI8)c%vYIDgW?9+RVU2h3fvXwR}OJ9cg!x&m<*+_kPv zjMsa*P?emm7nsfl-VhNjvOR*{MaAqfm-8avNNw=ti$Z;E%c?a@4Hq^AG{b15t2CTI zHH{C5x7~h^V-w1|8&9s5uiU)i!UK!u_UV&DHQ{^=ZJW%}S>&Q!@T9YBCZ^NF7|@xJ zwxu!jhi~tpA>e8ylh7zr=P0-Y6y(oG&{n*MfHd*(?8M!7xHh?a_KB}tCgrbK*7(7s zsXedEXB-%WZ;9>=?LN5QkBlU^w;8q_NLAR8*gWxLH(Ne-lHT12Rwz zZy-kYVMBS+*w^A+#9fQN2GFyGJhpNxdqwsA@Hu)I;PYPE=dcqnCZ3bxF>w4pVo8Ig zFy5iYz{y}k3_L7{HKQ;k2F~q59s?hU@1s1E>FfYS|8b8-wDN zBcO0Z0;feB{Nnf5Nf|Up;(RP1bFn}us7Ttrct4F zR$g1)ul(xriRHR-&S%NP;1mUKo;Tl%a&<4ncG_NKWx=dN|B|F3%=bdIgK01DPS~}Z z*d_QP+B@FA!q5CkLzq8*@w|rddGYu@y+9Hps5nVZ3`Tu&UX&YC=e3DluEqChO_5iS z=4G+ktZiAMJ*zA$mL+BJm$!)T6W*_g$07k+#yD57Yc>8Y&eMbDsL2MLe4Hxh(IC8Y zq_%%As`FAgj{>$pvXK%_nAo&Wxl%j>-Q^H6sgDw#vRhzP==IswED2@=>N_PM)J*7$ znQlT@`*{`6N5CJT2&tNmerYQ7A_e3~RXVIok4{N7Gw&LVEru zS+gEN9hGhx?43f$)azm$(or-~8VKYT7G-4><{RazcDaQCDU_2_j-*pgcFwjODP78O zfLa5v36I@k3$+5uDuk*))X&h+6EGB*f)^`RQmfn)_$xx!i>9YjlUJ%r?eIsE*B+hb zEKjOcwmmst@!+2nYsGK&FP@c9sq>QaZ#vTSt@ti*>Qt{aD%Lh=4eN}co)d9E^;Wqc z-<@eDCIhos?{tE`z~l7l4d5+`p`uwBPzO9Fk01tLrMYuc0-2Z8j7xJ7m#eA*K`Vl4 zUo_7a+n9ZO)y&hUdvuz&tmV`?hI`ah)=Eb>HC<+MJNMO#ed850ox#I9KyNg?stjeF zA+s&G*H#likGgcTO+rnsq~%PsQw8L#T6yQ=IBM2;=1i3x{f>>xHBxk(Ye^V{dba11 zw%3?gyEcJJr#(BnFx^7dS2!Sb>KOKfV0eYRUKm?mwBr^souKiOR0}L=dSQXo6G4ZB z{s+HWT3zZf|AXC?N+MGb^+a3PUFsoGfyq*N3%e^xEA#7EaaXh1CI6NnmuYErrP9ni z^YFo>)%AITXhvNR;6I*|uYfJuM(7AO+^Q;PQ3y4&rGAq!2UrxR)Tt&L)@HWdACfJy zEnsuw>bUK;_LQ|nQ5X)Zyk=1D6NOgfaaLiyjH)FEkX zvHZs9pG!1TlPlNX{_07#YR;A_`A}+Q(y|RpZjBc&U%zbT%q5$aisAUnPo?ct>ezSJ zUOxops8?(^Z0!C+sdY-HpZn?T%P)WY$+PbxOeV-p_vi-dz5)N37xyOv@}pYv+0zUz zzYU7Z=CXsG!ej=en_dPk6Vw!efYk~Zy3c4peVf7H0-h8_tpc?wu6Q1zk4#XsrX<;y zT6Dm}!z4TK4?yV=DZ--FQC8t|RT#wzR~r=G>d~pCg1Msd>K)2t1{wD7G9@$+ml zwi&kO4Vc?3%qaYzMH?`Q-h=0%vkx>OGdLObYZ@GoIeReMByK~yDogd4tjlVo; zMS}U~*qRYL#ok~8x>IaDV)iwx%~M42({}OmL)X>x?c7+~g;W3Z*rdCba{>K(lXN&= zT?_CNNZ&yQ&{|v@>yTzlw^%&hEHNW9Qxrn>2zu-qO|%;JungCprjF9ZfY%lQi-8Ww zMja$)nhsb6ZPuw%ZaH7<4NI$ych+M`XpP4tEdmd#J* zDVItw9lBQBZ#s^NGGvT6|3A)iZ7f?(OG{^Zv(ap{+R&wiDzD*Jie^Bc%}x?p!pFBp5EZ>ICrXlZmH)l~gPajqls6ST~DszeS-<(>ky(S zt3<}T!T>lWZihoTaa=jElYORSNZY&ajCEHswrv9i$j|XYwj=(F<`lWev@xZQn~eZqMt{ceCmtt?-ogKNHR9F}YLQtoA)m zwFB=@1ozz=0E?$^ck7EA4iG! zg>RIv(v;gtKJa=vsXuH;H~FfF2?;Xl8~|MQG8arFCLmCho(>Zv-Od>s)FVp~vx~>W zU2v4BP(>(tta^;*n$`E;H19;l6Tx@yf91pb*Rw-wW-ngwto7alZ$7j7#eC&?*eJ;6 z%irjzo-Y#^nsKqJG=DTpr*oR4UN{@fX0{a;q(waeUYFt(1dlBWUixSh*@Yl#{hVSB zSn6BWr)a83yW7dri>syn!^4ue@TE zFlb7%xGmvGhS^Kh5a^-;;I7?=h@4AL)8Y(Uq*%4aWr-LM$tZ4X0K7}TCjc+Oflx<> zC{I4>EE!c>MqpeOO+O)7k58YDYPtrhu$wMEa{O%LPtQt4;xI6b;Mc~vD5gW9-J1BX zue~sJ{;AVA7e@G2eK6iMoY8E+5Ct6!B}5EN#)&3YW@4o#^f_ZXDb0yZBu){cp&q4X zgZV;3N>p%O4Hgx9JulXtQEnYPSh?+tZo&C^(z)M%Zw%x7Nx&(D(ZkUR3FNtmOZHa}Wf*E1UdEhraPEtRWwFaVUT;AtA2}Cud2@pC+ z4%OFdVW%V~OQI!&bV^czJ1}1_ud6{>bb-WN6fdu6Li#F=TJ`31w={46Y!`Np_*fq?;vb=1aQn^%F?^o8i zfmUo1e1HZOv3AafH!go_EDJARKj14>Hc@{q;xF}+4nv1(KjK(kF93En6FhalfW1Xj zD_y2aEIarCQft*TEM2oh$AvoIR`}$wL{V9vz|cXPak@y%6IX`qLNU|7*egqxd~`~= zulq6Q+^cVxshpRJ*i)P1dpG>^m2cUU>}d;EFaH+~CimGM!af3L1skBi9*{jwtJRNa zpA2eeuV+x57es;%J4*i0-OXh)pr*;7xpR~;;inMWq{a(0kJE`NKt|qXikrtdiVEO6 zu+9@FY7QR=avB=Maf{>kYL1`SZ!vr09}hi5>qnd#L-l&-8NU!kMkFT$Hc}x1L7JCq z@mhV78}_Ulz8=Jm)ruN1s|rJe_SZZ-0(vCsD6n$mcH%6kmBXD`bF+`?3Pw#nR5BA? zRr~6S(vE!>Eq;8z@`SRM_)MPJes6aClW%5B{l~wQUzvI8fbQ0v*7bLt9>MNlLx{?x zPx}k<_0oZ{%Dc+X%D)tZ@o4?UxVB9BoFCghehCXQ5wLQH|G1M^yXV;X|6d1eVpEAEL&RkGM_>pXJ7W^Zo?;NSs6 z`|A9%^2=^pK)%;3c-(Cl+&F>(N+q@%dTrL-x=!CUP8iVLveRA?JV5~HjXsCoGxdC>>}@A=`$E)1Ti=vphhI6dRF|TB=c+PLDpCz zP;&{m6CDsR%#h|~N&O|SRMslJPwd?*u2+d7*8B#Zh)(R4^}EI1@grjId&QlcCnCN> z`Yyo};l|>le+T@+OJY%vDG>14b!a_h7eYZRNI0x^KaU)H-~ggkiN@sQ?2tUmS`HA= zFIsYqV5_RKAW?q9X{aSS->YuDRlh$?E}3*)ZeDK9tKP?suz#$5e#-Krc`L3n?KUZ| ztyN&y(^=rLKE8f#s{Rt89$ZehS?8eARfhzFLxP=+f;9nD0D2g<x zS@`|O`&Bc$sshAM5b(?uk?*?uXXU-)%KJa9UH|6YV)@-~#upt^mdcyfDz#MZ%TBFD zv(NZw29Teq_AiHx8H5Z6yB$N{8Nc1<&;^}lPtcPa4cL5OrSSROlEaNcLAYCPcbXRs z*x^w%i+~R5!a1l?Uk$5pQI$GQ$qM2f&Y)1sB!WkZw)*^tKN z&%HR&TJRCgg&{u!yeA|@nJ_fgrYM}2=C;~wZ6p+@NNvkA{R5CSEkgJ#QdA^3fR1!P zkU6{pZXOPX8V%tJm25*I>f@F)tu-B0^&Ftk8mH4nnV5u1(Fo)WRTNkAAuQs{K?$tN z&yg>_bN{X{zW;gB%-J`Yo@%4q_VTO6oq~}bJ;#pF>tj!h8gpOWbMqEmdbQm5p?eQJ zBFmj_pE7uuL;3r$N7$SF2N)*V$4#1fS>;cZA(93o?h%3V!G)< zW~SYzx9fAGE|1m5TVjK42A#0sC?q+NczO+?ucS2O$GT;(3%5@tJ59|jUX)Gz>n7Kw zjk_;ya4iaRu_shdppDh1r_tKof@euKk^hlPdOSG zxmLQNEjli5!Z#LwS+Hu{6zdb2@9#PH;}4%b_=)Fn+k*P#OT?T{pB_Kly8a<>3jd(| zQ~Bh{&6Wu_yrIgzK25u%=P(}Pe+N#}FBxBykOxj8Ob_GVbT zmO`P(EjV;mqZI*KtKA4o*XR`kI7XTz)kF@JPto|ODQDf`B26Ip*{jD;1l(iRE--%ml?O93s-ug(E?;a$!dQ%sBoZhc;! z07;Wx5@_WEO^lB@^){2O5J6?T2~682uaw{;;X8u~sPz6$MoYCv#Qm zNu7lBHQikG#8%ymfv@9 z%Y*9uNF66E7OK0VTiaZDrN@G6wO7f?D^)|O)ixMs0=7ibr}+Z^N$u5$|7Go!x{kWf zu#RD5>aGUf!(fPH3k3z{pevXy6&2gzB=~`RKtZ@(3Y)2RSI9JGLSSa{7InN(EOodz z)I0z~Z6jJUef?T2!_xxS&V(!n-Ti%q^_as>>!Geei{+`cWLI zSJOYG8JJ_9FeTR2BbgmK9VC$1oE;Xz`2~{O=Z5qYvhCUR*^=>LH?z54bARlX%u==+ zPJFfm9)0BMqTVzsY2SoFL9z+eP~@>sR+Ewmk0dc;HJAe4Z)iEd+Ti zJVcq_YyPaipEz`ovqtz3qO){?_BC&a%kFuyEJMKc_I;u5DbM32vZcG=mVWtq7K#1GuRFF z222vsE5~b&j~$q#!GT~FtmUjMxGhW7b-H14Vc*@rzZ?tJ9*-)GHdk=48R97-{&6mAH1KAIIG1xVv)Jq=nU36H&Mvd;c2N#Dhr9;8<%R%i^_XWEdje zNI_wi1r=|QkUBrqU238jT0J|i^OJegKec)ghn&fVoUV~ zio(ER#>8MYgCCNmb)P$>c~5t&dip<~J@qEG^sSQ@{w=oJc4|a&Pv3YMq2J7HD`{6J zuHMbUbcWM_XDEQb;z6u8ETMUV%acw~6Cb$#K@sdRL5?GHF`!OQWJNsFo1ocDq&G># zQ2Sst)~GopMC(@dVZ(+E3$?C7XBSCF8hbBVr}Y+^4JL<#Zhsqku>knx!Le3_2uL8t zrz-_{LA#u4ZXvP&*4B$WtJiKv?$z!QaaHuB<2ffqs*+FYNG=Lgi=11Gq*72Uo6z^P zh`tJLKYFzWNK#6rU>7ERt#s0g1z9?k3BGXb%qvs(wh`B^4(C^wcZhX8+-uRG<#T7O z9&&rHY^6P~6l9$a-Y|}(u@Ld#jvLL|-nCvbKj7?pX?)i0C$2qx?xVkrJ;w1n$iMx7 zzKnW-@Jx(2`nms+!F}tDG|^_Y2GIZ^GmBGhg9Tluk%#IaDw5$0ZXT;$hLV~J87mx} z2&eu-5|PhS?X4t5KuK!)kRIciAIWik)n$B{# zLH5d5*_19PlnL|Z+&F%YFYnhYE-%Q< zE4WwM6_0=%X4FU1Z<;^N;9ZCO za%FC$Ra%FRmHu2=&O&i%X(`ldsVxgvG|QU~#vTXQz%W?#Tyv$WLV!~Sjt~*fM}bu$ zK1fauRzNz%fP0dd1Ke=Zvo@w?JF>-EI9TEdlc&fMbf@?1#l$Ptw4V~Gt&O(!Evgtu zGwa`Wk*{^MwoA^G_G_*fJ-w*f>!~iB{*I@(;tF3`S*d@Nbh>`z#PShURmvMz-8EqR z%$eh_TsexBRaK2BpE$C9=*{ady>$Ie0KS9AU6}~}`6m#e;lI@rClEtkAyO zwW@x3t-2;d`M3&D+m-7wi9!Z4+)=AZE-DPeV+b=*pXsq63S$XzR^EhQ#t~>SLI@L* z$Et^kQ4)xQs4L-s{$aEp*4-%a!75)?4C&Z;y1Ibckmbvk*V3394=V53_juMxk&8yo z&oL?u*u(I5%wUeKjv@A+W-%J=j&ut(yfOg7)}M0MqR|6-OX6~Aju^va#jetDU0edf zsmI?yDtY{q8>UQYm^>MwVfKbnx>(6zpG!3q6=sNk{J>to-R2l0!K5=;fiX&x1|f`~ zsw(Zk&?1oOU~#rO5V;O@ounQ8RM3F(O4ok{5y zvmp&>!!(d`N>GR7;Uq%c7lBOkz!Zhx39%eS*$c1555D@UIPkTVD_2U<6)U*Rq=njo zH40J`+Zqg-QP=NwXBfCg?sB11k-;EwmmF>Uv~Z-F-9fU0d~*<~6StnWTon|m)_bBj zhdz8x##D6$kF+~}gA*NEDBd`@y1^dK4(b;UuaCOLm(t2gZ@5h-&Wd-_mzGVLqKnVu zdk3&O>a3oGx$YNw#qzvq4vQq&P%4oo1wi>=aUp{$JHTmXgDYZjNTBK=M-Mt5^x8aH zN~6>SCWgp2lksgNLD{YoqZ<>c;s$MnIDB7&ZtUU(*BKi2Dfb&HI^8VR#rKGHi*6h< zo81!+zgJU%f-&0TJ5bY%p0kNPHl^#V6xhOUXufhYj*R3$a*sQ*4%OAAm$X*CY(R+K zw$sK6^?mSti{O*@!}n!VWH3{w^BMKX#o4n3Ap;zhVTe=&NRT29=>XG&x(>mD0G%LH z_W~^coMeti1bGH3#b-%w2|+HPj#P-gYU1)31JAWP7Bz^!D65}-Z{C+{pZdDsk?PMz zuj}{pmVs-@^8WuGtDaIfd_tM`oFaC8k@*f@yRVi_=Sxj3Iiv_m=B zepcu(7%V1JVYXCU;&)^erG;%75D*z&j*9fACtYz!2J*8K3XP7CwnIE}ppx>ZFo7ls zLDk3t{#<(K%1Kl2IIK~EcD#4O?9~}k`)!kV-Sg;`lV&U;deAL14{aBxHXcwpL8lJC z=Ej@qAAN~L@0OVl?+~Z*{al7TP8Q}sS)3^PT9{Z)Mj()50z0VF;*F*SZC->!yj~kF zn$2s*CMVHpsWYGnaOu`OewBC}-4>fQ1NSWwLw}_Do}2$_(C0J7VK)x@2j! zs>|CVCkv^>!%kd#@LNo31ffikV#nOj)3i%cZtEt~T4TZgd?!e9mtQhdJV@&uzmxuZ zXnXulX;8}@;X-_ua~er+EZA&2}!c zX&xyopLB+}&`uq8sy0lHd{ru!>|-25OQS9AH6t;)NuwTlnN*^RnYBRA-0z?v;@o2o z9p3 zRR}>MXp|TfLwrRI&N+d-sE%YY7mUV88TSVn%C)CLo&*z1k@qFDE+Hxu$K)9b_P`%=s`ZRiQP ztb*)m&^)U|l|519kktR{%v>B9MFB7pafHN_rcau3%eY0~?|I<+MdNOnGHD1JKi&4? z(FOZ6L;DsSd5PUB_Njm7$wSB1e?D>I=j)Fhdh(fiktv6j55804^Jd;Uhl&3L-)Ubu zOSogyFV}{dWCEkm+HP?hZ8j7~_z-lrNZDCdY>L%ugmN^3`$G?qo!-unSz;+oA+ReK z$vWBy{DV26c-CRh!wK^uT&i<`N1?z0tS2g&;PtUjpMH(4-1XKo%D4}1m@;`>{P4Bo zCyW7x{5~IZfvEELa<)jB>juGoTmh%;bBS-AjLXpYZ?vKHWB{|t`ly9XjDMN|Z4d|Y}OQ&ars*LC3u?m?n zNLqtnx9QO20DxkCvXb5g0yLz#lXD$;e10b!qd7N;2j@RYSb#hC#8=?^!r0FsWC#B~ z=nK6at<7mhd=*KU(Hhd#Awz-Zm0?vn*mXD0tjW#mH2?TWEF61yC%*BzC+A<-M{*W= zNG`(}NynJbyTfcUTJ&h2B_K~@(lel#jp;VH)o8a3xCt^&HZ$XzNs~k=Mm*OAWHVv} zU~UE69}xr4qOfRyMan5<#=14ISC77T&vG{A1Et|KbACAOM!DmzyB6#ZD7EYeJFl!d zHgW{*Wy^7Oi$#Fz6(#uig2!Vvn0;OW!UeveS+v-=xuJ=9^7?sVAfYiKN-!iWNd^8^ zV~N17K}VqlM#RCoon}{jpln%w&-+J|@&;y|F!e*_dvOsvqSOW+oPXCY+~a;DMjlhJ zN?896uzd(uOcA`+=;l`$vRW(Aw^yH=+on=#S6ziUQB*$}72sMYp+sHekWg&{b$IAR2H1PPHS_-(JN)JA2PWRyp*SbE z1bQ}e=z#x%8t$_3flk|2TXyOUxWe;b=Ybbg>f#KfV)}H4a8y>AXw=(@*#2GTU{G4xvAJH743mlq0NSHWepqG zTz+}CwylQlywBghvO`U^GW4M%Qu?CnLVKM14mN&%-OyJ4?Jm3*I4a<}FUQ)15Q`rW zD@Fhno=|#PdO^rlDCPvxWrR#}VSAf_V}`u5<7&%rj%0N__UGx?krS$(LX0lCOk8S| zQm&ZSM?)4R!%|?F^ZKB5>1dMIAs)GO@PHG&XZK#QAl8HBsyjMiR(wX+(k{LGcIenn zxpLUGPj#L=c*at{T*9tV_jh&uzUA%PUEQy3SKOoQrY3MiP(6OvSPqDttY$YlTzT|f zUD|*;oz7}Cg9_a$0xAJh2-D`Psb&3W^uP;h;2Ipdb`|BE1J4kpu3W0*%dQ)h(oRJ; z$N%rfx2OP3()F#i@^hB#^Wr95L7;OK`=q;NuLlJ_=xSm%qOA$NUE9l+y+R zc%~J{aYdo^F`-hpirU;B%0&OaU8Kg7!9Fk zLSaEpS|A7;R>$nYpj*nYxse%kd(o6qHI%Spr755;O=qeSgpaBj4V~hiQ;q_iwKP<8 z7GoXRuMHC>r21Gl-pJN1yjL+6kEE`KJaiUsZq_jy(8|WnPj2;4KRLPs!eGp!9p+IA zM0Zhc$dOhEPLwz$U`}puz#^~=>4S7XdT1cBj5#E{pac$)79EO(JfB2s7gQyPFkPI|7vFsF?sJb# znunGFZJ(;@D0Wi5QC@iS3vqpX%-VJA$Lf~R-P%AcG0SUF#Fq%f@flUPyuMlnj9nCg{sMzUeIoh0%HDW5 z7<5Gsz0L|AeHe^irGGnhqC_&}A3g-9v(NP_N8Kkrj3K-j-H`oeyp8zwft>tD;yh;o z>SWCGC*5bTGSHvI^nX};55Op@HGXu?lb+%z#X6%N+Eo9Dz)3+MmDITH?{ukfVfC=v&*45S|`2VhG40taF}Tt3|IsQRG&7d z8`7$RDl9PKIk_W}x26U%N*g#5)1j!^x!8s5rVP&6UY08D%PA?!Xw&3MC^wpSKs*TL zr1dd);w+5h9U!{UFx*+LhcU#0YL*A{EDkDV9-EwOaCuP*)r~x#Qu%PE`%MLEeb=&)wF(c;)s-=G;DW)}Wrv?~IIof45P&C0X^c_dItc=8yo3 zRz0}8C&Y`Q$6!w|Aojs8Nx&Y6HwUahrgT`q&kK+z34FP(TX#a!Nn1^&a)d9btzC_B zSzNn1O`57LJa%^aC-;1!`F6)VwBoME_dn5{-4$vjH`%Q{=)d;n%JT~j9keZ-@#fae zmC&rxdXGn5{!Xw#1rWP1HrTAL(G%xKlr{YOm4GqeNKA}FmAjnWIBzU8VJt1#CS&A4 zi4aBZR1`WR>OyXGx?Y@n1n8l0RZ>X}AYoZG=ySveLf-?_Gx8w5GNYh` zC^W=>>ASh>K3z~bc>BXIeH3$KV|TVZ)CMzM`pQ?WTC@pm-aGV#tsQ#I)OxR2-fnis zI~T2Q+iug|wQTV!OgT;q9bGu7aRD5P>1P<#t7rXT8J3oIFu@b7492@;h7ztaM&c79V?dM>_Md>1c>kz}v+m^y)CR={`6X5PN0NwC$LJCRufFp~ z)MS1CG8zQ*{9|3(x!<%a+C@H$gP*;B%q^B;J^Fwrd>g!If>0VvaKI(U;PEDS6O%j$ zAHrdYlTZ`TXe8e>J^-wTzByHvbYk3VErqTj*Zqb>SDz}+#(P(+xhE2*`pwT`GL}Sl zW;}9M8~gr;JTMjh0C0NMbB!SOf^wNA17?z($q5O9*Cdz%DTxV*30Bz(K>}4|kwJXTFYnF}8fVEHg_!;fk$I;TUAG2oLYSoMlJ^fnkva!!^bGCm#(T(E9Vrvni$j)+7WVY%Vc#6OX9Q_kcX?oGh|JmN){mU;Rf z10>TJ9i`?lls)D$VVcqgtEFAMc(tW6ilwDkhAYB{#We@xZxWqifN) z`=5lo+NsyTkv-U}+L5w;-ESp06@?D);URrE`S75EhLCJ9q^4S9or$rDb+eMqHZ!;z zrx{a#n1^iEWSk&W<*&IlQGKZp)rIi@2YrwDi3ZlG;dX}NK00I6s3am$xFvPck1}AN=M|G54KQ5y!IugNMAa9v#s^ge4CgM+0?Ps!KtaH&{YP2T_@Bqzz}^#*LKhZUaQ3qAo^&r*pHeuH4Y|FM3E9R<(vnmjX4(i<;NNq?IPCJ?r!6{`($& zrEj(B(uqsx&%_xc(s+raae00kSIGAQgxHAc9;N)w0b zz%cHLq0n8s*c&*Uq}(qOT*A&r1G=I`e=8}>>%C}57xHK5 z@mwqrDfxSK58J`H>?onEV!M0-7o08`;nZYBezOw z9&3IR8^Jnhdn>iqn1@+z<|po$vvH%2SGr&>r}4 zv`C6?5Q9m>Tc&~6ovkANMs+%Xb&=|)DcLxAjcrLP0yC=WDvM0PX+ znfd|5t1fHQj*HS4gSd0pPoiPAx@H5ui~loZO1!2mJ@nO_Gb^>VeYd9FGjZAT2OjUv zmWE#W=U6IQvEAAO{;+U#>9l^4-xiThZ^)`dfLATA0xqi(EA6=cg_IV$KO(vU7VAOJG6K1{pt_Xk_Qs{2bN;?{Eb$lz}MK5y6tW zK8q#Io0H>K9nLf+IGDxYfC8yOQeHeX(eXmIUPIdxbUAInHnv!<`iUycG4=QpRa^vD z^Mb7ySdDRL0isq0io&j1h(aC#5moYg3=)E}Y4U7$M4{ieoG-wKRW^u)naw{ej2a?x zV)atn`8DhjVzFIH3JvcJ7fbAM3hed^u*dmAGhuG99Tkf=8#7gZASI=!e*LC(MW5ej zY~CUf2sIE$RO|YhA}lQ@r*YSERC%$u5T6U3w#}l-PzgZhX;_1(W7bjia+PwP%Zt(d zCmcina{!?DG_G;HLG^r7yc*cmn*!FPOS@*we{A;Zc|2QNvyPqG_pXxf)>LFpe7QHX z4%oGVhpyeSu5WA4h!M-@if@qyJ2b9O+oDw;R*8LxVVg2>==Z&scF}{_`iyK(d_fX& z;ohgb4UB?ly@Eg>BMsik35jmA*^!u&suVUV0Bca-tkF=FYv$#KDQBB}TV&XZI)NJ$2-B0T7Pj|v~ zr($&_d#{EMzue@7@x6DQh&nz=snB}A@Hlx!jkw+cx$I^~oco6oEn>pmkW2o;8EAmo zqV?)SA(HHh^O>`S>C%VI}718>mL56L_*qMpWPkqp`5tZy<6Wt-J&P?ru0EA?tA{0 zW)o>%s6Ppv={J~{3A7TLAFBd=sRg*s1Q%BYp>M$SaC9_4xmA;IxPez7qy@l@a0M=Q zccoMjYAt?qy}!7Z{1=J7OFOQADb?Z6^b1U)ypuL?G3|p{HosF;+=loBCnCvFY&OPd z5hXK&VLB$ppn4r%8#L$8bGM{FNFjyqM5=P|3?>9Gz!+&(XpajM82vBR`l2jdvu^3( zxyv@r*|c=_v6^2bvzQk8LVPXsme`p7{Z&-^JFU-fFkK&FtfWVWcP`3ODt3r*g2`@D zT@GuHa;7-&pTTU0FH#T6*P&x~&g_yCYz~_r=kFZ$vY?B+j=X*W%Jq~O2&8z8PGbtBHbG8;>n+GLlAFm;OGFec5eid7$xa)T z!I?NRQIh-{sYI^ZD7mj^ntY2?^cei))4H@TZ<5`0@wT11zxUp?U3=|#hpEdZH_d#s z-sz9D3%c9WAeH9t?wcWpY9`(I*1Vg8s zx$L6&yaAPqq+EuxfIk+wW`SNS7)&!66XD_M@)@N-ipPJU+#@6>OD5O%lI?uCAO(?pBUv9+;L}0Ai`Gp@DS`e<^C`3ZSD({yVIOCw+^Oq~WUiZtuQTNZ& z-k5#hfprI^ovcVZ=AJoV)Ku|WXyn4B+F$T>_?2CPj_Lw55Lka?X_9?N7;5&p?XqO@ zI?a~^6P^x7Rjyo##2Q3!8O(;LfdtG8dtymUVGK7aO77KnTKyL1(pB%wnv00n2No|( zvAY+}WS7{FS@ZKdguWO53jNi+w3*l|^vALmw~2sfoV)NJ!Xfc#jKd`~4EpSVY^yuQ z3?EhdCFuGw3>habTG_png5dSJ^P!Z0WkkBz5{5BgAlQ{@4bw^+56scdEZ)>6@3BSN zg;om=nFh;!r-}d3`u%Gq`!=LJSHW?>Xz2|W;5B|h`G6DpNN;WaP|3UPkG@q4&4BYu;QtUNx_6&Zy-FH3Y>XkI&qg>WYcSVX%4TAy zkxS5u)WP2KWSVk1&F9OV-*SY+@e%otL@XdtUW?9bm1?O*4nMG^$=PGd`;5NJJKwW@ z(j!~%Spvj2%zpp!)k~LJMo&Gm@5y7<|6m-27{_Wp4sb~-2(1-t5<*MkV%T|T9T3Wd zowNf3N5`QL;70ox(~A>ui4K;ah!)z#+AO(e>m!rbdFFfX8r`RSOm;H6_#f-BC-)wm zI@+>y>FVY8+qFkHY>pHjlFQ`nh_^&|3ShPRdn#YAk-|@Md-(wPrTCy#5K%ADBr*dO zm@<(tyYg)4KlXC&CsHIwx_@ z3nUKTo|r;$!wgLOKjw!o=0(a8?_o%c7UN|remk{D-uqXQ7Q$pO z7i}zC6~-f?b72)I`*QPK(2UQejrOBBIy?^TU3+ON_>dPJ6`#XsnuGQu&0z*^dMmt; z&w<7xRCi4?*2gmzh6aPLDL zFT(Ta*68NoMt&3Bfu%jFbPY7B#{*aswX(v<5Du1dW05&tt%8w z+QvDP=R80D!UcVfwfevbmXONQjH6@Z`*?!PVYIM1$V0ad?Yd|}5A1e!--Qd~`8qu& zJb}nZ$9uOEKhN2)ZC7gz#FE=0Iz!tGj=$%El0^VO_TF5tWx`hv_{2&`XHh+u+xOZK_%Onl{~q&vNP=om!m2w-9a zC!3wC%CDH2)0|>Paw4-7l)S6Tol>6EQtBctkRFu|OFsi?5z^p1C5E4a+RaexCd;y`y=Y*3UljvkiW>%+IFz*)Tur?q{w2sBOlO?8xnBqMzOVtDk-CXCL_45kK4M zXOH{YeSTKqXH%j#wDq$_ewOZMv3@4|*%jXYG;hB<+I|Utzz9FEG^=i2S3poCh@zv|XPUIB;Xll;U*_$}hd>DV=Z#|zlk7APc^ zjDP(6{g~ae^DQYQ#peFj{BA9Cdv8LTy@jheSOq{V;d6(o@ z&V%+?h4i3mu)zBVE~X|#9NOD9n2fdfwS^ObwMcZab z62y@DjcAD^)fcxZ?{XX&Yq6R{AQ0rxmoZ$cBFbhXu}!En3skFf(=bqz&x;Xdx=ON2 zkmSWEZtMHkUwyA#kX-H5oBVTrhAED|0;n5M)>CF}Sx51}^td!H`UE@$WV>d>)A$K^4p zg$Z$u#Gc~4p}#~!Xkyudy0`Z4e{0VbANzuBhL;4$MILj93@d!ebrG>aGgbnW_af)Q zt57HK%81$Qq8xkeBHHJ&FU6J6DP%#fl4>*rJV>)82=U12T(@4p=97F$=gSj(KS~y` z(GtY(>UaviJyBks!x+bxdf{{miOZ_a#~xv;k=9%kS8iHUvDrUf9oA=0YV(=5_b*&~ z*X%VhcX&7U=u+IO>#RPF#KYt7>^&~FdFk}@`YD0L;@e8cOz1n@F{ZG%Zh<>CwYVqo z*fMzkhvf^<5j8|y9R6@EA`iF?;$prQTR>1VBsL9@g$v*o!3KVKxZed z&xN zW6=K({jlCEq$g_j!`B1uCs+@ZpQ80Btm!(T&qd2;Wh++5SJ7-3*0F;$3F{aaG()iE z_u&YgjIKh%{D{Lv2hG2N4VET_%=EnIeZ(`_!7G6;0KWrY5#n)cuOZB`ynMIVL0dI# z8k@@Q(q>4Lw0V4A1+A~RAv_xu;qMZJtJ|=*qL3tiEUq#nVrzKs3NJuFy-O9kR9=FL9BUCuiUt#=- zg6aY54j_avsx$(rRiBuj>jv0=YO4sMngUvvh1#QHMwsM5tfxUV8D&;MYf>10QXe&X zhWf^b^ssZgKQm>>kSW@u%)fp6P4~(IBuU(CAPJ+O`d-xdfZUcrcRtfydvyDD=BIn# z(t5M6P(jx~Itm1Kfgdp;i(`eZ3aA0mir47#W)!inwr|(IGw9vics~#+6B(pXkw5;O zn(M82m7DNefti53Rvq^h~G&Hf57=PKtz;J^GX#?v%bbP$M8?wSJKR`U1!ed z)_wYrruFMLYX&3W#cp>@?ArB?JG#~{DXCw-w3Oz$3blakVr#%`%sv^8Lbc^RGqRXL zcH5zSd1@DPve+c;D-uP4+rPzqNBMnLf?Cw?kYzh6w3sWfY_vnf$m%+-hWUCosk}3B zICZAoQnSmdj>YhI==as?enE~<y-9oh+dGtt~TxbK^rgb z3(o}C3))FMo*TO03J)|zG)~~>|2MQ>8-Ek+KgjN5^Ep3G7!lc3pusSj%%pS^k)t_} zD)5kRUv+XAu;&o@y$4@=?KL@c>eTi1r%qu{?w03@0rW%o2j(Qg$4sdpi3PHcPKg2e zaMOY7RzUl45!;}2`;c|TzT9vxiol8nqY@FVtM7%c5@e>k*@i_L>d$C9Kcqdyy@0zw zcpILZ9JEL9*8->u_@hq_tiB&u2#I0v?6HfO2SLqUse>Et2cjX-dw~)idX4*ID5(sd z?|}raz4xKEQ-6LkZ1pOh&*=%YHt5o1miY)ENz!Ij^`T=c8U->gt0Bh<_7yU5u&&5= z%aF9bujGu*Lqus+_`mUSkO*4cN5gy{?)Rqp)`(+5rt-Iz(;S)rhfWqY1J5-Mx@G786WAj~G}$$~1|30&P$eNg1Y6dF^-$y)ChCw=tM zq!-!ty}z*p2TS-(V4>>hFAyfC;0>B z=DH(mC8>?DbU6fcMXAgk)fS;$=vhOW*wgSN=!>;dV5_H5RMPK^vps-jB+j;=N>x(AjQXAO^^I zr#=yS|4I3^1NhU&=TnY{-!kUFjuQx?^3EjFY128=6_aejj#uX7mBF7h??ldIQkhW# zcijd_#0>g^7VK?)_+pFe8a+&I&s63*Ej8DEto0kwy(zEgWoa?<2c<> z=`>e(IYiS6#QXyWf9oa$u57&tg98_93$X8tA~J`gvIeT8ji{C`v<3epUSPkgVaHL4 zB$E~Vs1%UOuSzL7?r(IG;p1|J(1hbWf@OXe-Qy~364gVB;c~Ru7Qu^-O5!}wjsO#D ziI&v5c=F^g^c%EFcIypua_=`)K#vrq00VJfO^U+OwSA{P1x3LtR)Q0$2t^SAcY9d6 zVFcjLCAjxFRErdWy^{q6c{f6zgw3o4fk~C}o+u2mT@k_goga5aGUlLPTQj^{d)_H|4Y&0$_$ zw6Ve#u7ACuJ*V-;UA0uNXx~yBEIc0Gi&LH&jDgJIL~Ms>WJd-YCmI(RVZX^67=Lom zKro{Ojzw@rDk^+@;_tst)CNzTJVl?|AoOuG+J_>Fa7-|YM&pseY$8KwB!INK4;1>M z(CjqcN2e*1r{c^kmtSYI3@b24+a5_*a1u4?I`HL1f7lKm?)-}Ms{%25T*60*2q#Hx1x_d z;is`yUgD!gPEk1kfS%I&03!k?7VhDCSgnt)h-Km1a9?HgJ|NKrbO^Ds@h1jyWr>f9 zMuL&3xBDuhhGM+LtY8!Z7l|z`HAKQI=uw*PQ~c?zVQ3`vv=#dR_%jo zU8{Cn`-qi>hjV-{32Vv$K~Wl1O2Ucu5gjHLcG2^MO?aNV&%_7GPZQyZk{U?@cSrI` zZ9-n3PgEp~;wBobr~uC4vB>?>0uwV;)VRMo$I|YZ?FxDoYTs?reqHC@sv;2q`H+Oe z*TdTJ(4O#}s?l5+x4y*3o41W^j(E*mLcgLgN6Xl)RyDp zEY4$sJKzTaOrFkK(#Zwig44@p-KC|h5P#MB96zq})!M__S~fg94_;qMLA%RhiIX5e zIE~DNyAfGJ_@AF8p7AxasF228T>XYA#og9+(14*08kDpe(5j?CgP{WkP0=o87A+iq zYnzVk+I4K(Y5YQr??sHSUARB?C@JVv3}=b`Hd1_oXat$TlZb)kWEpsr+X_dXI5+xB zm}(8f~_ zKhrv$(WbEU@O0oE;0zEsi&JK(fW}AgKiD>HN|a>Y*pC&AA5fSH$7|5Q0>0Vae&HX3sW#$ey zjo_WtHSrGTeYKYt5G;kB)y9ZDwM)F6D6KBX@h1Ef;eE9+I`1n2kM#i_+stu60^~x2 zssxmLMXDhiadfVLY;e-Su^VBy&$#x5#`KWz3(Q*~%-_Awt$6G>ExI^N9BIk_&6Iz+ zVa7~*jJerhU_hvyc5!+>v8?9Ddb*hcJqt&jW+ON0UnM!)cR zkoj-W#%`;{`8BgFCJNVw50@U67F{1URC)w$zYTxEQk3s_f55s{zfGL&C$!GOfbem^p&z<2!D(kZ?1$~5oztOr2I>rXEkozPsDp?0L=8(P zjj!g}zd@{|8SBy{n^jAiBwYt$zlE2>r!ZE+z8pCyZXFJ97T$&%hn8#6o6mSDo6wZ47#Rrfr4fFc5Y?FD&_1RT1DnFI0e0!y(IyC;8sRCm%g% zh|P{#hwjO~rB$npyh8KHq!)5Kv~88%-#N-TMn0I5kNncgy-}924xeJ6q&7+nOxyS z>U4vJs$--sJ ze$lDE_Bc^~7B0fQ|Kd6!b)Uj{M4Sy!Lf|JO*M|LzYs1QQWf;>o%6GC+%2h;&`+`Wx z=C+wD{P3LOD_#JdA)ZBA0B$EFLm06s&|e@6oiVFh-K60$+*H`2OY@=4v;Evy*sE!` z=0laZnAtEF=8rEP@0q=BX2S_3*xyIxVQi97#CfCm)_Os=*R9x1LQ=9l9{UZp+AgJ}z&zTT-@RpS_fBn-iwjI+T_ZK* zu&h){0$E@{%Lf*xW~G)CEF1^@3Hx2wp*$)dWs?khF`kB?w)ULl2AQdTzgzKQP&f~y zGA$sxV678TYYfugj_e;t)a##yDj&UJl+1L)1%k()0b8!{i#Q#?BoY!O>8k}zoDpa_ ztHfJ~Le(4o+_2%#KlfBr?AbAWI>#lDIr|8^z-uFkP7pb5kY@!v1x{7`$;tds0jZZj z#%Vfz*RHu*pUZ33T&DZtLNHp17jcgD^XL_*es_JuRy7ye37v#)Lb)(h7$Zy;W(f<0 zyM%j%jl#q5i`gbT7wosC^HWbfVU%vUCBIQzLdPfy>#`0?qJY@6#&o0dB> zxMWG&71m7y)~y@z;D~$f8NIga*0~jpnsw+qY4gk#n;u-dwW4C{+6OnSn7MgU-ww?h z*-diCM{j$Aj8lbg6adwuBun)lkr(bl9eMd5pTWZFHvkKB(T-RW$g_nR9>g-i`#UT0 z9us-@Mc!HD-FU-y1`!ebG#4FLR^qsv;H)`c{&sDm816|eDPM9F2(-T84YkMY~ zlNXIwLM*O(Aut}&zQBlc5 zjT)7Tfk^96xTK`$TQt<(L$0-K{ER-YPn|G@<0vu*#4Mn6>SZ4Hdwg@OA|% z&@D-;$dIJWj98Kg?>!;U6b5j=R-6q(1+wG8i1H3c9^7Ro;>9{3synFd#7g?J51~I> z1}x+vOekAX^%)+`4roJh{wY^FlxVehcoJ=EslJjc4a=geyw-&)(ZH{0t%c9vHC@fG zNUQ>a2GF?%105EUg=^je(`E%~ zR^ri-d$?7OuVvIIhkHcrd)Nh^=g%Q-)M7SSZMAOA14^Vv>PEdZZe3)wBjW42Z7|ZC z_M~oUq;YJ;{tS%lkHJ`hb>_nWd8x(J4u~?fHNBQ`zwn2=4Svfbv5ux)62!ZcfCcV} z1$7|2vi@kCmDRX3Gpn&;(BB*5nXRy1hr+L66Ol&@UKkc+)8<<9tyTGOIAK)W3RQn@*iu54y*{%-rMc!cG~p>n)gHZ&t>wMXM3vp&bTJ zZWn$9`U0wq2P)!JpzC!d+3QJ?58HgQFz+OPaT66$Xs~+RAPpY1>z|Id_jvRrInz5r}3@9lY?OaFUmsg+dV9P z5ANxK*uiDtx3SiaN-I2harj``$KohrYCtNaNVxP8W!Vp*)1gy8EFHu;{`e#7G^kix z#ilNrKAj99=tDlNEHf1FK4fs1=tI{Xv;WqIp`f}G7J5=iQtB%tPu7yxl=}afXDq--v?tMm-*sOpPAwPe#B|J7H zBR!(a#(wxPy%J?<{mNwG2*IvT4 zGJb80Mv^=6lE>ig)13SN5Cp)}$p8nWhyot`6R2jhS+i#G*rMSGH&Ljc7tv2g-p>?D z_Qd=C4A<(Be~mIT(l^si;##KsJD2%T3=}^0&L0)CSv+5ErobHX?}vX7j<^aPQKSHx z?mnqkNZa#L>)zJ6jt6Eu_~c`orrhtDAA8rZ-qLvO!w$EaCM-RE?9j=X<1Kfu;Lm>x z&uT(3V9IEXn-;(ieT*=GG|Vj)SjuWrW27Jjd{L|ycYAw>fI+%BxC^xrjJsJp73)8#jknZT#=yRj^C; zVP}Pd${NgXM!19?gJ*BYv&;FK&(ZeNHOzS*t|4Fvum-t?$rht5nv_dWyXH}rdQMOf zWyyG$rNrKRZxw`zozPtWnX589DdxPD@rf8qAy4o5|=MROe^0Q(w=ksFVn|-5i z1b5t%uHJF#to*Ep*^{*E7>95t@ZN6t8jwE)cciKSF{<*PS9CFLH*Q+t(SA#UksSV5 zS8&hi@Iv@#8CkJDAE5B)>2$*a}q%kjb+UvV{vfZiU z@~f#VJwrQ38zI4eYNxo;&?>^~H$lAy1f*E(b~sR>B(c$8Tnn|IU^!x7PD4wB2rgnE zw842NBVL|SzdsNbvcyW16pZx#f3-WfSNmiGYrR3B^c)CVf(agvqGaU7v957D9A#rQtj>KJwQ^Xh$u_88bGQ6{~ zXiPZB4zc;-LzqQ4=#=0dE-LH;<-GDcTq4Mk!8c=WS$Aa^&?>1TgUArCcog`29SBzh z&9_f1Ype8BL~6Nb_^2|vx@W*(dOxzKPk;VB=NsQw{H$El!vuC(VC1D- zlrs>^+Y&2Gef2zWT(d|S8 z#GVr5sxH|;-skrqP`!Acxx%NBJ`>PqFzB&bbF5+`>k=zSAqC zD5{z<&Sfji_gOx+W(*t+U%P4Hx$I0ZY*yLEx+;rP%hbN=0rf5Qd(~i4N2@c{6{`GZ zoZ46wpTT-3PD&3;qu70daM%AZ^`?jgfsFtYzrtc+c zAN({!eL($AH7@EyzxeQ#Zc>_M;iu*J>EEhCm$T6N7POzC?pNPLci*X2Bfr=OZMH=& z{adxz3~H<@8q{KSy1GGqO8r(fEbgtofp^KEW~$3@=?zt(kG&%;_NjlWrXbx`td7F9 zWs&>-R4timdo{SKH!knPT^~_H16qixqGqV=)b;9<>R#0_nZ6aNz0{HFow)Y8dO`g| zwa}$@{NhqHdtLpIU$!b}vzLEWTFGj<+E(qUPE#w@W9oVJchxN8%Q{sWCaF_Zai)Oe zc+g|oR9nzC#5T!RVKW>EKNYmvY_d!a&pQRN8puEdc*)kuaFC~zY>|Jp-)j9vaDy}R zJi(3BALUtqlQ`TI2kkp@7|WDcx|+T=9iY=97@MA+nwkN&f3WbVAf@1Chj11^J~;|T zjNlL)qL^%Gh5p6?iVRYd`vu`Y!9z}eivMSS*e`#O=WprnLZ1*R;N+jr`sJ-o|0(}j zw2&wI7x+bLy~8g}Si`SMdHg5bg0OP@78e-brd&DZ+3_;>oHo&Fjt-14bXY8|OuVTF z!e=VgSjkLusHjNbudI5BBP(5fA-eWBS31sLh=?5Ee0+Zzxh*d8I9}R6hVM+yQx<^^ z!KN<3x6Wu#L^umb61izd$vH^5mpp{!yinuA%A(EJ65uX{KM}eqL5ar~_+ua%HPry? zjKu-dyeO$oRb@_`LP-%FvIB9yZVN~T5-$@CE2NB&sCTOqGA`{P>99AoBm=1kvq1lt z*ydBFT}xxKM|&Z;P&B9HXwNGPuh~|ua!P|bK4uF{9D99h)todwr@H(*a0&7y(ws3m zea`b}|;ck#}pwve4=jqZiBxx{0K6OvDuzU;4Lm58{ zF!{iNv_XZ~7#;}6m3)*Y16u+vw@~SPT$|gor#Abz*zye~ox6JVIu_t1ue~V`xVBI_e|^8QaPzhOn>UL~A9@In#QF>cP5M~bfPF9E>jBs&IDl;4 zGKgfgl5CXCCc(@EbA@@Wc?*2oTB4`>BH&_OG zoG!=VU_@9wiu0i7>V5!)MQ~9A7%@sVH(rbn{agC(`W5!J7AsA7Tx`RxJ+@hEGdFZ) zGsXfakSqC~K*GWI`j~$`{=FIBlRLwIfL~b&c`^kY%lu$&z@F$a7}8xv#IPCdC{tdU zeLh>O36^BceL(skoYFEnnnZ=ZN><4=pe`DXD?4_;=*efzZk z{K6)NVvig=ctpHdNo#?%QF8cNkl~O)n=TlVu}?|Ja#iZ_sYyw8fs|K1)X|Ys+NLZ? zS(hSjO{q*dmU2Gj_Y`GF3TvCvGer!f72z>)+Q3IvVL%_9d2%o-e1BLP=6lH=P|bq-iS zpj6f^8A%_-Ze6K{zzrY17N#)D^az_Q%VYgNez)6#-$Q%$9K8I=XO~~94DI=SLATSN zu>Q&hZI|}haqYROiedA)!7S;(_nP+oe(jqf=QbOZ$*kAgER%J?RIq1o?iCvuc0>LH zzH=GObv{F+R1k(fSynP(?qeo{AaWYXNhEHPbeK2n1`f0dD%%Tg(x%#N2D?pmxlsqz zac{X9=6{5t#z|$8WRu{Gf*_;mZWQofJ>9Imn-#lRx|_wine1l2x*0p?W~bfkn49f% zvn_76&dnyd*$6iax>+OM)Puw@ZZ_t3H#;9~TIpt6-E6I!Rk+zSHyh$+UEQp$n+4nq ztz|bu<8yBIL9|~qUdLOG;G+n-Sw8Pt;4P2wu9olyJ$XOr(SAzxUbea!e%8l@7LEAL zGTj_(OrtvDW|h%S^xr*v82IS}H#^8ji@U`k{K0u{=5%x7pod9TM?6+XM%IXltp1rk zRX5?EYP19R53Y*Lr1Rk}9Yi_r-id}X>L&vf6(w$YVuh%bE13_8p>rqMZnpDe=qkBf zi)En$VwqGL8Y(_Ii+jBRUa0QhNP`iw{%~EQV5xu!5AuU#ArX33d{(wW<)VEKgf6si z5BX4Ofk!!Xei+F&`y#1I+|D}=k@n|1EB0k9;SF=5$r`T@Z zUgu3q-zE5UF>rZ={#D3I76b%ma6vFKCaTS$8CqcuGx#%_2w&nEsj#u5M0YG_4-*fB zf2mBYW8i9vD0@h}8J|CNJ@G=O&?{IU{8&tkO-N0xn_%$88e+5RBFhj|v#BvQS&j9g zs1}Oom{lt0T9wHX`Gr~nLsg%DqJP*U{tCu`EYrw=s@GemEH9!l)=j?3S$d{QWWff? zmO8cEG3(^fPv4!tr@rVbc)6gbNokt5KQ2uP9h!%g7n1{u&L>9kn z){JRmwUfiPXeVxOz9yA5{POsB$KL|bs$RG$C(^>@#%+kX9O@X2ng9(?|Z1(RppasNWL z{*7-2P3fzBtUQNyPlfs3i3y^hizgpixwRe{`kEs_yEnefW^}uq3COyFQ^GYMmr|GO~}$iLuE-EE3|yMskjWMpQE} zdYYs{QqfajYP3`%pd?vDF`G0he&a47#zq=a)T8H~Ll3W9yIu=F#G1_-(?rwGeXWHq zey1H3zhEEt*|@k*@bczoUa6GROxnwHAKh{LH`?#a@ZGD-*Om2{u|!`>ewG3InF6wN zrYE`rc9(=Q1r|B0uCs2Jx}pWJpDNh_CCE1IOaZ5#l7cMGfDxYuWg7u1Mogqev~6`F zcsdL~RGbKM>Wyh-)Ks{aL=)QRYIia(dG+#??5#fAnvUuE*^<3S*H0Zg?_W<|n^!Sl zQ~x2G-`@COw{cU-%SH?x_SE`2)}g-Jis6eMeX;M#ULE`QTAtOrQ=g&wyn1VoN<*+0 z=|caYH?d9~)tn;4o8vQ4=a>Lq_6P^USAr=qv1QAJ2L$o6c=k;Ei}A6ZoYFlpJwH7;A+xB{ z$kuF`I8>GwFMv;K8i#w1N4ZY=wjgW^E}UV%B_X~dfvrhk(B-O90s*`mdEosCF0a{( zvN>MW?xghQk#uWuNZ?8WCHN_wN9VJsQNNwa*D`M;qpnLT)eG0AZ+Cg67EjDVQp~<` z@2ZNmbH-yAl{M{ZwHO0_WAeC7PPOYGgB&uZCgmMEbw zl!a?oFEWT}$~^H4ZLxL_o5!Y!m$fz5eGH{3v>&ze@~7G#+64yjRfid(JF9?K%)-mT z#b9b7g;-?>yl1U!t(A4LvX+R>wz9E5TNy4rYGsqGY>1ViS2wv zM1_zGh!u|Yy_p=S%*hBWZzpxW{!FXcb%8tYhO8rDj#lJb%n_3K71ace z*w5LemUar5wa6xu;*>!=NYHhGwo#(p2p3Ki0MZ>Z@@Qz{Ii!5;KIFw`pTDnlVDGTY z6A%^EB=nnT*YZLS;JFR}$Yq?zSfOn&%js~#uNi=0a>yu$5=Tn(EzqVTX{cMU#5%kd zKrY-*qiO3RbhSzX0D5d7Me|Vl5Q9vaTCH?o8IAY#yeAz1>gx} ziQ%G(+v6*fENYV1kHp|cWauTFM;TBMkPhhNN2)fMrUHBGIWr@ak@h1hqxhsj2$hkW zEgji?CX#RN+c$sF+%N2>p|SENM>1CMR3sWJ8>+t+S_744}{esn5?j*)it_PH_wUy3Q%$ce+k(Ouue*COA7e`#V=UH#rR@(NF(!eh!n1ll_+`)*8``!x6hW zSswDkl2+bmRh&l3a}8r56n1(tk}95;AiWCgiB;BtKJt4F$&f3RjL^R@f!BzYcAwg_ zU7P(1D`A#~yBbNIU(lLwkqDC^-UKNq%6S)s_64MvQ#K5K;i-Fy6P?tmU7a4 z5edl|m;*@R>{Nt?fP_R&6De!pAf;2ok|Ja?OVz!4nJ@Z&BaP|(%b5)?tX-&e6lb4$ z^|m#4|MUkDnkj>tb3#+U?;=iU*swjnr;675Cf~ja=7h!b;wr|q>(|J(IOUc5|B>uO zoB9+glTi+p&)k3g>(eOXt|p+gzSM)ETr%c92BI-lt)b+rQOk}!OM-BwB8aQ zH?EPKaWT75@7p|+mx&*gUC?s&zQazv-8I&^(&kzm>Tzp3(?V4H6PAGIZ7O#GZxZk)&p-O?TJ37d(|s4*Bg&VxkJ@%>))FDs zm;Yeid$r%5eD^19hW1*!&L7bECOF&-+FnPvJ?IL!EOMMBEm~x z9d^aG+Gzsb)>B7G`)h$LK{Jhu%dZ%hS7;M-JE2k&AbXzkv7dcxh>!XBh(Lv$K8(+O zL%~UcQ?k59Hy6XHS#^>e+4QoL+QskA9^ZTCvis+?&(+ILey)A=!8dx@$y2{num-(& z*-4zEUN}de$=%`qP)Fz;Y+zRrZ!gO+N<8#rW+@H0ktHFW8&dS!d_L3%@u`6r18TWT zhFGd6OPR}gkq~;#izxrA=(o;3V35Q=po}|Y8XuQ+e7rafKT>u4tOb)l3;lchuYa6< z-8;A9{s$gYINUF?GlfX#zP`q%N0Hxwdg(K&S5Cst~eG3S&>uTr>@_8ZIUeB5B zy~lN3%}fUo;^K2EN5?i?!6)9XLvG90y7oMbE&^bo@}vk@JuS1v``;sbA`I6-o{E-@ES@?YHgP zAHVKhdtmzvan%drve4XLJ{GlcY`yr=ui}!>?Ctl8^6UC}>jBzv3HUf$n2i#v<~oKt z3CLhl*Whr}O^|YOk=H3dmtDzaxdh=KYm&bWT@;I0hoeDplz;}eTIq!v}RV9$4^_qH-vNTno2j&d% z7Shxyqozz=GTXQ4m64|o&40L#T<78WhfaO{s&B^fiBm>Re~KM_`1zMkz4&wDO7CN# z2f~8(-NL)?UI>gMTx7w(%J@r%-#PgF!*E4{jrAme?=NKzbW&W8C5+u7zP z90-3}=|IYsTB=ykUycj-eO#9%j1}PJpx;TVERh7M-z($eBAP5+i5sQcq&FkFO*Udy z5BHHC&xgBw?S1k39YQUg8R`9Womr`XaZ`JSr{G_#zjk}Rms~G5C(?g7(mn_6b4qd= zBsi+{c#sFDLLN*5Z-r89h-4TPY?KJck_M=K;j07dM?s+hyP6*loDqQcPJT*0*a$S4glLL-Ru%QwJ3V`r)#u+E7%tj=+lnP-+3lw65dEh5Ac=eDi-SJjew&^*o>qyc4 z2L*}vS^*$f(@Z7%mEj-#aOtC`z8L@KUxOYj-?iq6St}PVTzPlJqI=lh0i8Q9tT%Pd z7CC$I`e~g8o;`8$>kX_KOJ{S~#OBkwO<5Xx;Usx8>ah>sxjS);E+acAWas?g*qiW*YD1C=PFrQdELsh1O*I;a^rkK zVF;7bgrmBusO}-nBO20BQn&>8J&Itet0B9KGI4Ino7W9@TJqn8ensv&bmIykz=2KZ#er)b2gPYz+qvOl#I~Rq^n? z1p@~bwC?aQYr0Ds`rP!5mD(rTId)t-K4&pN05}#JU4vO!g^c74f<9EWwM%y7SdO*0 z@Md=lFyKXvB_uIb%?4+acnz-oG(vxkR6u6neZ}5vY15wZgEG<^2DB-MLSKKxo?|^r zW-6=d7p{n_pOhbZwn~rf=zR-dKM|I=rOF94OIpn4a_B4wlh(wzo5hh^7U`||7|AXo zVbCt4xel9!7W_meCX!_Y=V~*~l~byDA|;_KXkiZa)CsO330!4;)#fofoj6TC6c@F@ z^5pz&;ADVohHy3zL!uP)FAB<$tt3X6V_SyfS)^tF^~Sngs^UUD zwk%f*22-97@yzVo<0sjMtsk7!249>sd-kNz{o|(HITk*PPx)EQAM9cywpN?$(f*@_ zG)s3xF#}^Y* z*C6GA#0x48q#9cf^>xjVdCaj=icdhn71Ej#5K_@ET_3E!M|P2ujObKiz`^6x)yA<1 z=0I_AD1)<7qbf~ZL(yH?G(Nwy2BIx493%ZTZCZ=`>pL1Yn>Gyyce>nU<-qSp*2J`9 zzZ`b`ts}>$U+K{4m#J?be*R%LMBDQ4^BfMTPVl303(T@}Fh?{yn4BHs%S=r4r9w5; zATJAw%dFVsG*m21a0_CL(`rDaQKv{6GEUD+OK;qy$R43cncG$4oQXysgBypJ@)NjJ zXuykz#x#dl2B~PJX`xTGe_x)F)VNOTPIG4$rz>KB*(c7LkzH^l*e0i_r19{fMRlZ< z>tC_1Y|M9#jWG}Iy;Hl~L6j0UX!jic&W>P*Pi&9b+jbk_SpU>LEVecFa46_M?DW`< z1fd`puUY|+06;5BARA)_mm@u!e{eaf`N6GH*T~ky8aQdLbO8)CIa_#DL2)sa6w0_# zSRjapk(A*Oo5DI$kyP!F_5<6XUA^+F_8(^b>3608+8&B$6~KPo**vO}s=ci(NY+Nm zlWLbs<*?QpdjRzB>!g-oNVfL}tCxoF&=TUwi0bX5aYC z{x*z~_5eVK(7Zz46ZLANwtXEVZ1I*5Ryn5g(Uv?UIYheaV7tMxu zf9UZf-_tos9r^glKk8m(A@qn%;8%<^YgQC|fIz?u@cv=QRe%l0l1e7B=yR+QT~ZQ2 zIm#f2Or=CMiibi4TS-q6*<(hu?lt(|u7yP;`tRN>EBft+BMfV-^7Pn@U-@GM7K zGPB(#f*Xe;s|b|@iaT2ls=T;Uu0#wNc#Hx^1a9BpmmQ7QdrU!d2TAt!v zdgc7uq(IP9KP%7#m->w>NOp_g$(`3}&DV{X;N0SQeAk^UX5^sF>qlAVv5U|5>QWyU zJKS9{Lwmaq`(B?@0rtv*y)px9#GwK-av`EZ66i5<&Y~)*$%LHFK!BvJBABn*AWVa2 z1weipaK@>!Mf>s9??b3s{p$DPMe%w_7Vl{&9t%B3>()RkSN4HMCc}THj>~2=qF9}5 z6_h{<;>!0=o$p-?{JkH3w%*esS$yW_q^Qu@D@gnDCU=*Dw!zl_PWu zW~Zknvj2y(_W+El=-!9#+pxlE!ZC%$b=pr#$C5#mKf9jm5UG z+^*CU%qh|1)+lIf$Z^^1jSV*VcyePy28Y|F`pwWirk0Mi*o=HB)z&j9oJ<5MD&A$+ z3c(_z1zQr$q*GyP>5p?So4NF<#y58~yQHt~S}zx`nSJ$LQEBXt$~{k=GjrB*&HQG| z`Z>Gqlpa6!@pB(uwECWS!&#$8o&N;8M)o&bZoQWN1@l>m`2=xxONGI)qWnmJwL|Y< zqp@AYCYO~5y`fTlp3R2VZnjXU2zh941~z26u{8G3bN{E=)U#C3oliy+FhfjU$FoQi(z+MxpR|vS##Ba&nDlv zaobH(8pltX(zxp-@pbyA9d`ki3hrW@rzYGdSwdYbV6xe=tQvG03kLiEg!*MYfI)g} zd8Zk6C-w{PrIb2X&ZM)#^WoepQ9BE3d1@ENy4NRe?>lnVtnGJ?{kGq}eIM`sOZnmb z*GF7gEgx*Zb<^f}ZJzk@v1>)oa3+8A)3Jhl+B3^}Zsk4Lz`bJmPF;o|1OkX+2eV=C zH?T~bH^&Rt3h9$=@>wMCC8@o z+x(%h2S`4r$78btr97@72R&!&52!|1IUf-nHGbF-9}ib`12Y?mP0cyg)mDdqsY3I} z@Mc!__yhCLu6**5kyo|?rg6e5p|;ATD8%#)TezvJk(^PvlKN|o|p5HuRfDschm5N;*$63;pK zMzG{QZ#BF)R9}W_+IM3Yy@tvy2?GOrGBR%SGUhKRiHg*MMZR6Lxs!X@OZ3^4IYBI z@jEX0iR`AF1w!lG%|54p)SKU7RIl0dZn(Z-V(sk9pZ{KslVN`H3z%O!!jjqp9&f$|esw@D zE4Almh#n8P6rf~;cI`wFI!7dz`(I`U{jSBJu{P=i!`Om!3x(RwuTFmEsB%>KQh6ag zJ7xREc;Lx$ru{GM3S(+^A3U|_>m?=iAMR^4t0Nar4sW~d+1)LZx@vx~koKnzy5=3s zB^MkAqhb}NC>jVw^71SZtKVvowEuroRd9x2D1(86)IwC%(&DYO+!%}WP^3KmDi!;nQV=ckajkXnN~f1tn#`t<;EL9GF)AgKfZd} zn8qunJn+Ehbf(~&;Y`iNz3magLjPEyBM{Ju`ja0o8D3rg7KLW@kxxCWyn zQQz1pKv{xyrrDOUfSQ@#U2CG@7T2A&c$2$b}ql{ z!cX62vmbv4VKy%_GMHkw==4J#y7R@imd(3jnn}Od_XLdXI zw7OiVoCpR)hu-Wlk88;AIPk~llZ>Z;tkXvzJ!G6|-u4b20wQHdf7RHN&}!*!*J|r+ zwN16_t*c$EV_8!L>mXg(qkB$=;C0O&>HF(~9df$&VD@9^hbI)C_*Kf$ya%?1ikRPS zGKm_a#v!?!W(G(F{1v;=MhYjDA4!fw6h_^$^fp_xbg$K^`gY3|C zAx_~#%{?y&NL|;k5WDk&Eag+|Jtmxib-Ncak34i?MSw*PyL1cU8w%Wf%IMlY8{}$LtMmqj2sKKKg$ymmC#0YR{!6s-Y#A4x)$5J4G?y0Oa6A^;U zOmCLOq%pw_HhDC8@bXeEfBAl^Px~pb#>rW5wRe)PqIdxUG1Z{9?AY@zMD0*Z0tE)aDIdvvlv(y>g^z{Wa4!-@Nj+tt;4hzcT$j zLzUkUhWSDH;nu|)`{s=TWY5rb@*U5yRcjs}`N~^wynYBV#Z;}WRzR&SV*alp9?@0v zGIHkwIaW4ZTOqIKYc&M>wj=Cem(V8`1)Y@L4vdX}tHeS^umwm0)VAt$q~vT2*Aya% zsen|fk_L+g%ZOW&blQp-KWj*x<7V>UqrHfsJnY*qe*_;K(<;B+cq^NwT#rPQ1r+i! zuA+iv58b{{dElh+^om7+c(MU@BO`L_*1&eOX)qp*#sw!A0eO!Jqe9H-G~-Eyra^@l z4iSYO&N{glP+VdpJOodUU<9*nS%&1S_H!ou@RjoIGB$GIhV9Fg_Yty5D8E3KTk&YM z=4um{9r)1YAeZHS{{2Mg*sFM*3d~;Yd%T`%X#mqhTtKhU5M!O$j20&rD=JjLrY}(g zHGxVj2&Y551-yYoYP2N1umgfYPyk`)YKLZ&(rdBOb=z^ao5lZ>-%G3!Cm%1=%uhTZ zjyy)p1+cX-rr)uCZlNZYMbET49CmaX_PS9GWp#Maw9ux_qE(~+)XP@2V^!T4jv(Jo zK?pRx1k#jZAN5RftyiAZKE(VVwYPiAK4O;b=#}-@hCas^Xg*I&TCw7;Ys9t34~TEg zxC~=}&5AJ)#)0t4Lt{lDx7%j4S^;py^lef>p%(2>08aMVfn&DBP=aoiaE6jPo4ou? zV*h~A1LfUT5vtl7=Y}8-K`O&i8Df#4h1Nbj)Pb4}$7j#}C&0rsOYXgQiTKPZm2>sTb|3#a?H5J+3-Vos0>$&Q*(k`z zzneK6ntV#HJsr0?m`P1ZQAu`kk15(+c!v5OH3x7;tk~{}dBl)sh-aQ>r{}0gBX}x2 zjh>aB{hnVu+UG+a*r@2=j3faP#oxv4R>9bkBw)OblkbnR6F+DNV^8KHPteY-&tZW; zE^1`-QeK1*Ww6owvVU9yN-=^VDd;ph#x>MinbinOpqh6Arj43R`3R|q2(Ab9hCosP z>kwr2FjlCJ`(#~tLQ5vEh%l$(;mePY`0RaU)7VwLrgdp3A2DS9$}!5%J@+XAc`+hu zn=*3uKKYJvRmUDz*A!eh?Za8KbL|PU{N)A;wQ>2N&Qbdd>O!H4l%DZZ!#Y>|6)c93$qzugK-V^bYu-VzodnZub}uifdVD2)3GBuTO5my z2oW}|7a?u(wOZ8|Qpi^Fzpb3`h|3c*oXqsj>d*UZI8S*U?GIkOhfQWZK4oQn9<6*| zK9>0Mwl9@m)~p=3KmIW5_bD67uHCu4%hkXOLO$o>uKf?!L12o&_b1mD!e0Ns)!yKD zgy4ftvZ9StV17kiMwZn-t~q#A8ZhW%am~ z*L4cGqBT7`b?Q`%LW*mDV4D4#H!JJew9OmX9eY2MKUG#LGnBj68rSK%-^z;6%CJY` z!8i6_HD3{z^10xQ>K?#coS4m!STU#*^)|oXi~>CX11BGN{s*}NDw-}ljGpTQU#NSra&^jE&>!QIJ@+XGQr#EUFqEfq`ah+ z9Wo|?stS|U$cr>lV|ePe_Ll`Tav?aq9Gj9q=nNnC8-Xi|#%~ zNA{7#&&+rm+bh=3o>HZrQ)N?UWp!dZKdXX#2kAd*zRJ%kLb3d;x^Y(BQMXSpr_ZYT zdX1mI9yvj-FGt|@(3dV3UJt#tD{gl&eH(pQ2OXWG>x5^##ov>~z^f#9V-^M)41ff5 zUPQps@4%g`Ow{-xKNIYmLX8N(7ZL#?5(9P%3jtvad@-yXgQTMxPqd{R*C0A6U&TFe zaHFU~NiSk*wjyIl|t9 z?@acAM;M1rvQDRL(cx8IUkXwcB`~@VU~~iYuwbY_ajWElql5NndMfUg5z9+$#iXwT zwbKTe-gYaZ`n&Zel;7cJNxCx;KK1Y*y`l6?pOxf#rfbgm_q^uZt|eWJ`Pd-aUYL&+ z^BE8;M(+!Q!y?!uFeh3JI*r$7^OgJHWLhv83-+K#M^QyX2HwgDncjUvLoJH6<;}MO0=i=)*j{h%IMkkn-T}vI%T5R;XF_lM^Jd4BX@@m z_({3%!sPvdJDIORA;!#jUcL{;91&K=hGu6ue13m0pp^`Ed&H5O8$qvd$T|{`qIu;} zCZf#JAVe#ovFN6#j9WsREomiN7#YvT2IpMc4x0$Xu+1j>fZU4&X=SQ;Z*dfiUdk^` zezt&h#43$N)ttJIlKKKWD0TGk8Ll<%P)q?IQ1>I!i>kUYUi?Ef+=$URBGRzvv70EE7Ka!>fhA=?D3OcIjDTOpn@enQATD=DQFTGWttNsC0*nxcAN6rmPv); zq{P~yX?L?qWx`XD=KqQhm@^Xq%`ot!{~%A-tVO(Weym5XHOHTkAxqAj979x!78d2A z(h|`Do3-95h9s+12>7Y>jo%*#U?~G=4>+zNBv;7O<$1Dv7* z8*?TvOKeyUKTg?sNwvdm2`&IlZeBs16Dl9Bc?qkbI5{aJHN3^Ub)%3AAv1=U{Xy1_zmV2cO}6@vlAs8;Z?l-opD7+Qw22ku`?1yx$r zNTjjk9goGqAJjJBlUoNn>H_Wg&E<>Mry+g~9`sh=86Cung%h_b22#uvg*j#EMK-RG>SZE}dR)F}uueR0G4B zv5F>#=xA&x2dBH}GCP65Cq+*c%XGk;tttTdh%sC)YS(Et*VfbRBwR_S**J`B0(m{g z=0?sT=yEfM-r<(K9>L2TlH2J(BHJNpO&|tHg_K|gv_9(C?x(r;BhnFN>`C{$cw*1# zu66Q%R2}sX`~@Za-_!msaS7n{_dzFTYvzMtWVnL7M9gP1un_$}#L#G%4nl!VhMk7} z20d7lQoldx10o%U_qXx+@ICJ5mRfzR8NZ+Snsr5P-)J_OBqO|6>?w~0z;7!&K^a(D zX!n#kN&AZQ05u&;-80CflR=FXEi?cxp=DN)4d}L{Z0Oj&)xCO8NId%idz#IACDAnU zl55=gp_QEr#_f^zH2)&mk3Y3(HZ`1=ge$-kuu+rZ3AiNIMdb;IwD1Ig7ND_sfAQgB zslJ$jAHXOD^9*_aJ1qdzTI*VP%mDvmX^~O*-#7s_f)k*ysW8b25Nry-tszbTjRl+l zqxU>yG5r7F1fVF7??%|h?dfOk*QFX~C?1P8!OefB&%ZU`C9soPHOQ7mcoKs%; z=>?h>b$lSou$B|UfX;yzA|QYVlb6+sV;~Kk=F&`NztmhP&(BG}+`hZdCm-y)Zt>bb z_w8fNx&B8V7l$RDxq5P?_yd1Dr z38P~bW}6MGU_#SISccn(WLA(rp%G<1Z^9HH( zjeYP`zTD89nQ}{TR$&`zr(j%0=mj5qbq9=P5LMZIrVLrgfKtsMK(gOt?W?l^TBVaU z9*;(HyJZ;;7dHu$3r5NW0C%dgpqe~KKEq(!KF zN=zx(>@9XIgeUZfMNQ!Pu)`H}yQa}@kGY4qC5x0Arj#Bagvf_RN~7hFk+vY=wH{gM zbc15C^oX9jYJmddn)cYmY77o`-BqzO9d=31^!bdk-42v?b}$pp+_=mYnIZ~2O;Tp2 zRcA$33PC{Zy&;a3j!llEj$aU*cQ~vrbk?y*5{>}E0BEg}JOCIgPA5!kHL-wiIaOj? zbCrrMcm|G0HRMQ0;*k)c4)KrtJn_YKACx}Yhn0QGda%iND=)pPjCy=Sudi-?o0(jR z8RBw}azZ|~D(@@z+LZU3npklb8;zQWS~lj{-#6{RhYC)k(I6On zer9sQim*BBor9gzoX}uSfOmCRMI91Xq^Mw+wJZ*4Ej1j?CxZ_Jx`&y@VUT&%G^a3g zA@+_#nyYPOEAM)Yi9d_CHYxp-Re^hNWv?im@7N-a3%qm3zPFFf75gO~lYW?clc=AM zyB@K1`7&OIb_O5&mH571^9gipE;O7gAlO1$E$lLc*7A1di4L_+Dgd9(EFoiMHai1C z*3PV7vT!`IoTwads_1H34~i>vY&o{7iX-j9EnqcUASE*jRG`vLjCy2b-?}mBx`k^u zuDx;XUCVy|6sUvd3LY(u5w&Cru?lWunz}q zV{Ffy0X28kj(_LvYnDO(5N6|R#F1@a`40cNCZGMZKgldKeS<5=3JaIDRs(hHlSKG02bbbVGmX;)MxulAMO2Bkg zGavO;1e*^#T&fdLf`W#mJ0P|{cyPv)`SU+~TIm`8;fCq6RvdkA-joSf$(@!hICqXm z=eTat>U&XB*?;7@7Y=97DEs@KJ!}A$3%UYx6n9{bNzOEQFFK8gWDe2`2#;f29BPI^ zz-x{;#2w1&xZG($b3eR=ueV#7C@S0X7!(1 zCN5khJ%$paDK{s+QT2={mc5e-;`UW*=yiqoU4J~}NbhN2Aa?k`ccG0KKS8>8q{QXUnBD2zhx_flP7 zk;&0F=>TK*+H5(t!M1VW$K+o?<#SKi22U6QO?kplnJHHoRe!24469MCmTlz_lU&FS zCKHnTC~1EfEuB;Tu;{8r4khIdi^DU!yOi$^vhyP6zW3ys9(_BP#j`8g_jvZn_s)&5 z^A0NCxw>b@*~ezRViSi1m$F_zUvh39`#{OJSC3(xmAy-YiJk0My_pCNm{5th=UC(b z^Mo5>!_3)Ps0f!OTUM4KSIUhd2>?lCgB0C_&27#xizmp21LGEA^o^eB9@!~*JbnV@ z`0avrNJAs{ilv`r7J_4pC=cl>Oj|W3s0LYns4Q23%Zr`&vzP%9e%6E^@Q)Rmnp7iwW62@)R|s$lEjDm#y8;gH{i`K@Pu@< znnXSM#xA#qyT@iI;c|~?l*l>8YKisYU{Rt^CqxNuG%9+`9^CZE9jbo48p6lr!S6$C zKy@?n`S88S{pGfs?}pQCHM!Giw-#he2s zPl)Gu=x17sSzH{u%DWj^| zWt7zQtc$c0UxoS@puD!>Ov`-3RJ>l#dL2KI)QQRbVeeQzdFanPYrpD(=-SIY75;|HY2l6McD zO?Bi!c_8xm!&5q+b87QEKlT?Cn6!kCrE@G-0`as?(j%I|>J1E)vIY?&K;*##=1A?( zbODVTl7TK(_Y=Vy`PCPWNEawuQIlSO>>PGqB%{|zf z9&BL`HnRu2pa&b#gSGF$K1}^k{Yk6`3MqQ9P!E>Vg9$xav6-dl7*zL6_0e!bxHX8= zkxtGe_a#&a4oR|5zAi#Xh8J~seboh6dmX2k0UaA+E}%Z(Ru}IwVP!$JmR{TKiTG!O zwVG`jP1)LMdmdp^rmrp4Xtqh3vXwI)+kNcqDOX%KTbi}_s>$-yNmq`&q)wbYf7Vnn z7lkZeJvn-~(!jP%={fOi=3*IQ?b+jRW%np!HcV_lk;rGC9(ev8|C=wr`tb*c#yqv& z_hQp4e2sk&H1slLeh$Cam&5*;z|RG097Lo9^g3qL83750mXtI=C!y-V16oQ|uaPm_ z)?4Kk2TTnT32ioP^^fDL?`eJs_EJCEZDd{G0oYHo@$3!w(t+4$5X}no%#O|hPVk%o zbOnnk2Rx*b-h&Q&6mLugFIw5&w4r$|5y6f@vd0~EVR=}YblVDjAE#OLL4c3YgS$7xgNFFi(Dk?TceqLyTB6V8uy z#u%(tiyA|LVzz(iU~va)ga;{#gtfsgsiOmAjU>9jEiF)v%zA~IpDhbyp%ga93vp{0{sH=JUl}i zc9@&YubCw>S_YfJ9*f8myeq0u(OHHz=tyF8wX||^x#Uv$Y#Jh&s@&k9=tfOAWsR3G z$@0n@?;TJsQXXf`hpxMF=AxIx>8m&1wpn^?fl{m3#+*NPG~~(s3r!WqoQiK$z+?t& z((UtM;rtm!7lvtUGQMVnxte1f3~dSjPUq3v+&Y~pIY^3d@C59sZg{KVRksX|Vd!GM zI>5L(I;l%s9Uy6Ub$p72lr7{+`uyGG7%z$Sr4iaN4!38n2WfgTDF%C{c~*EfdUkkT z!;cJz?8YJmeg9$bdW5rD#H+g9mnQEY)zDKHKP}i+BNsdHQICv&L^A#Our^HNz3GOCa`{;i8%bPzkUQYh=?= zM7N(E#>K_xFy&_D_GK)?OBVe!+g#ggHduSMSTNMrW&?N!s4GIg(L$8d;X-T#Jo~M8 zPEzl3rAs^8rJ_P87E%V4OSEJuU|fFPZu_DO<}WuZ=d(NXOKx~0^hDsu17H35=Qj_3 z!8)5KPkeaaf~W3#&boFBaLm5|_x4$w=8bhi%x4?s?EuOHEYMEDZ_%1jg9VZb)V|ul zD`2w|GXfeJny}dosKoL}sDVwPM3W(qay@XtZZi+Z0ok~kG%D{ z;gfj$NSJN@@!hAcT(*4fwTo8G0fWL9%6Ck6j7$ZX4W9t0_YIZ&eefp8A~9wM_7>R+ zbY{WmDo7Htp$IcNG#VxvLHi&fl`^!!#)HLfrq^4CHt21pq_L1bnymsHNfBcwk({Yn zYCf@5z>&;=pb22unTbB-`Ip#P|9$T@wln_SbyrTBlK|y#Eqk|`Y1ZBX)q9BjItH;I z3=wxc_ail50KWu3(gCYRAZ6&^E#~B)0upqUufzj6W@kD>)$$&23MAiiiSQr12kfqr zmWYdeIGyD+$i<bViCUj4W5Kb9obZLMRenUW#Ilcb=;8!P82!&@ErMFwWX3Z|RuS>7HwLZn^Tx zfmPjl*Ya8udG~_Vs~4>4dY$o-^LM=3{QCME3^!`~P85ITH7AK}BqNGD93;6)UvRlz zA-o3}aSRh&8|}!GF-%wo=@ablOkrbe44ikL-{y2mlEW_rvTOlZk~TP*sS~=wrm^9e z95^^!2-!~^>z z07XqMYTL8j=4iK$6><-WvQ%nNkmz|RQ43j8{+8HN#P;9(`N!7}d=h*j^u~>g^=yN3 zzLOa*SY+Sr`NGX|Uem|~K zzs-tbd#hKM)uQoFxm2Mr&!wQ(Nzvodrmc z$4gyt*lwgZkz;_vg{Wy3*iSq@4S+TdgWYVlQ1&GlXbghJ8I!K05MK}|6o%O_X162a zvBTbEf6Xr0>^b(qXu%=b?Scga149wzvZ^+qdekVWfp|umWryghQuE~+i|j&R2G#H$ zk9xrxONaTS`@i5bouCW?dEc%_)k~XHDj5~3Cq`abb1!aLK$k$T5|3XXW*{4PD_LdNPKJY?kl%>v`M+I(r*l&oNXKHhKKSgTPQ(G;MS? z_vI+oLcSbuS6RYicn`%|d?03F8ODqpLyMLtjPXQCwaEv>%48kP9&|X;A(z5ZtU7XmGL~e__p1`ZH;Xy&bZInf-mWygr)+)sRb|_|O4CDx<6QmPz?pORe5W)o{Oyh)3Q-UaflRV$Zm znzm${a`$7*wCeiDjt(DFA$PiJ`4;8eZ`o4iN_Zes*+cA$=Bc-_{ z9?z>Lv#J0+FKa?Q(dF#cWNP(JWv0f3#Oge@l*Qz2@4mbGJDyzi zCFb!IAs?rFA2?uXoT!8HVw^5l%Q!P(W$7_$C^9=Da5QErq9mtQ-MA^Y=+sd{s2C&Q zbtzqj4a9*<9#$G>#mYO(@yZ+TKd*FB-ejNteQf5U|Hz%#&C0YJl`pq$ykn=dqxtZw zV<*rae+v1%81{G=7_9zMI4nRZ11c}0RNVkPR*oR$Mo@a}kNJn7x7Q~BVgFG-aLI(i z@&bhgibxc2p~IakBms+SFPE5v|0y|^cFf^7CF_1kGebwW2%R4SZbha%(Q3;1xsoI} zm?PlJAs3;SLjrnsKGk%{!_EV~j_3eDE6T(l)wY zMDF+&R>=k__jI{-Zu3{SeT`a+yn)Z6tvahoTu1X>3OoKv*g0Y0=GfWB49uL*I>#ww zWZ)cz{L}sO_+&LS`$qole*8cOdPhQ_4QU8?b?`}SrfH_Rrq@iszQSd*lNhj^su(~t zc9Iw{dG%&aN&*+MTECK12kK$rcNN$0r?$yiE+TyY&a zQIOXfhMzpyh_m5L<0BaNbykKJ5%~q;h$mh@AlAPx);@dq$sM3tt6s?-UK|LB?!*t2 z%BRoruYrP9XeHXvYc$5Bh0aEd4`Z^@nBWo$=oe*0Z7veP@Pw`KsVu1MfJ222Y@})a zWlV@6Vl;p(Vq`88lbW|Jo4I-V^*h+stv%MQVbeh_hrbB_?qjQzsa&Tt0h^r%nYfV6 z9~LV{=Np$xu%YJCWb&JJX1gE%r^G}9L$Zyfq=iKGR92J}76kXc@|wibGQ(aj6yH;2MsgE9iSOIWZZXz#8D!^z z5sey{^w`m%&8v>x=jUTb7}SIFi7S$Z4k1=y=m<{#zlRPQ__R!2NUNCG;y4R`zLnjV z6z$3%#Glr!A(4tb;M*ld%q~?{u>Yt+7SV%T(4v*>=E7Za}I3YcklD(#1XPIDt+YU!~)hcaiydwPyGE#eDf1yG>PTn49%Fw z?u)+y;!K}koRheQ0ktVoTvEdz=S=wi<6;#a21u{VrN<AhRW}S3khjcOe#EMj-q5BJK zkS&CTNwGSU))><1Ap5W^I|CZOKSRofj>vEtQ7_`M2c;nBHQ<#98V$-SXwzpnO{Cpv z=d@jq)W%OPL~2Ent3s<2#sp5YICWtM*5;`u@e^7OrB|yp+M=wGHq-Lp5G=$(EIi#J z70*fBBr3E-nBP+DG0mLpC}#FtCme=UoL^B#6Bjf*|f>y=)^4qcS@r8ls6I6lWx z>(#sMO5V1)L>UT(o@;S;H6kA~UHiQ-LRb|W7;t-wyc#JV@weLk-D3tcx@ngm>o1QS z)w3(`Mm>>H?b)+$kw(^l9HnaT;J$#EckkP`!?=dNIUY}DB<6KRY_>=QXD$K@iCZ23 z8&_xAU#ThwiW(}Psy4`A<$Kqm-r*^mB)!_)xTfQGGJYB;QrLSaoCmuD_tb63X;9RI zKBA!&)kFhpN<_QhfQaN;kt+eY6yDZNmUgtpSl2y$>*E{lIVae+c6ezIN5JZ_oOMT> zAlDVcF1vBlty4O0?lkHAb4$lgS$Ae|)Z}Y>F1>rihPzfz9$g<{t6Q+x*Kf&W!J5X4lo{)>qp+oX+@tD{7It6ky3>Y&PUpSJ}xt{ zZLYrdAB4|y_rUO+R{Ej7i*8srsbfn%Kkuz4+Y0`i=}RfSKkUIrrVlP&*J;eKL8a$h z042{3wEv-Ytf58bhcj19NP+;yEgF*P$|=j6nUj}u&BTGh zNbnXe=bq5DFcokccZp@Aw-#7j1U=H`K0xbGG{?{>%VKHMCadkGK>CK%r~~981=xQo zh=XuE_y@N@jj>)lf8C5R)i*TVP%&i6kS97k{7jSb`7t!ds-5)M4(g6|&yqgU8O=|= zyaU{ho&QiiM|&*PmxFgs2mRuwaXB5hy1XV-ZI}R(%=F0)u+W*o(SSS5)P`F&t>=^D zYQrg;93cazta##)jk^`)uWhqdjVWCC=<>*j(cqQ6W0yE1vGUKaRbJVlk30ur*GIqvSdWN5c*1`NKm?uzpr%CS?r7dC(P*VpY!YD)Tjtu)J*9s4P(FgGt6 z?)WPM!iaLf9MdoeF_;WguMUb8=UTM5jA2I>6=%Xa&m*J^M_%S-=1F=z`kd(P5>R2O zK>R;WnP!}X9EPi$EEjr;(z1NY(JHk$kq*QS*`gdg_S28Q5eHx5%3fVHH!Z*QMnk{a z_M-=#$@1&Fnij9Pewm|NebwOpgOee8pd~po-#3w0pWXRHC|nfI3GaSr{if`2VHkVM zfV*oHd@;9>kNyjys0ClK#{^WcN!BtEEymzYIKj!7+)NzrP|du)dd4uGR2)>Y9!Na> zB3oS8E~7Im9q?TLJ1a&EJg>SgfVz;-#VZFrAe9`dD_YvCx)13yNK^h5evngrrWw9~ z%gvdQ?0BY)asa136ZP74E<7^no`@H|t<2j0lcmF%5A@i-e8$l3Bg#5t;Vz36KL5RF zeEFP(_3Z~xJeAK+H^7-sePGc?`*m>&o2W)mLm?_BMg*{1JeVnR=GG#bhh zw3?#gNGQ*r7qjs{FF8;~mOH`w^JQ*{94LwO@Qe zc}SVX76R5foXz35RV^z8;Pnr1njT~&&G(v?X#XyeYnpG6t~h?+JGd#th|m<>Sv@ zdF8VY-hDISYcsFUULaep_QZ|anVR3BYb(G&m>)G4=9UFAP&t!pma8fQx%S*~4JA2+ zUYFffR%j?h!4H@Y$Wh=oDLw-}MPH_suR0~95utwRz{ot%1~&{A2MG3DXhNv|14XAq z^iiRoYsFf27Ax?LXkR_jhj}#IseHW8JF2F3q)+)k+4js2FL}=DP}ksj{p+s}yU*^> z@oe|uFU1$I^7H*AXjko5u2o(=s5I@fmv*Qt&0xz|^^eM-U`3ZM6SyQ3+M406t7l$zwQEETzWP=gzohMwgTSd) z8aA|7RaKY4gNO9*GNj88Pknud#)f)NU>s<-tXOQT%ZODry88E*q%s`SRG1Yo?Bo{4 zXJX(1J1B8aB4|MZNUnd%TB4vUmihEi)-s4dJ=C=-lAiOZ0QQNUK>w!IMbs^l&8nls zUWS$(HIW)k-$BcUp-SKqfb8E=ez|j#^4r_WOLwtLSZw&Rf&F`4RP!BrG+4iw`(^j; zU(Wr4Sx7k>_ zqdTWed6*6Og!pCyku@P7MQ4Gier8m!UZVgu1G@)n=L@mDH67~9rcB@bXmPA#r8K^{ zOZ!ga``mW~@k_H#GX(so0%*9KK-^P1o*nY<{kz}`#CzM5itBzzFQm2-A- zRsO0qp-LaSk;D&(Qw?}&I5jfJ-jK(_iR{pZj3 zE?D%$=RZ$>f9?9~SFSqi{>st^&R(@Lp{P=nJf{({nES?ax6J?VZg8r7I(#@K1VI#c z%X4qM`+BF+k}hw^Gg7jf*IJ@HC&+UG+HY|eA*+u3#aEgkxWkb1sINyEWsJz^V>Mw< zo16!apQ8>qAGI?4J>q1n3FSOOA>=&5LfC8MJxG(amlKtdn#s7gd_wzJmdWT79Dwi| zr3}9g9kHoOiBTm9xQ**y;kOg*_%xCL(oJ(4tK7Wmb_O@^lgekxA%vP@NqUF1-=OK5 z?We+a%1E{$$gV=U-w3vl^E)W7p&gi_ykbe@&Jsf5^hVY5n&@B?#YnC zg{_bx_$ETr)R1UEys0ZUQTQ2B31A)Umsj6l!Z-JQ7 zDA`Ef-LuCDva3#DC|L@=I)h=4s5;{g~_k z_SIl80nkswFDDB%qADY;TB{5O$SN-z&>+^-gnQ)AoH*g)_GcBJ*Zcg7rxD6CU+!3rE5Z|TQxndvY<$C@S&?kb%X`L7s-7`m z72bBz`@l`r`h9Taxegp3h|U_+pm}-KnNvDV8oP(TFY%_Pvsebb;}YzTYAy3zFjC5^ zt1`KGPIIWmveO(_O}Dnk5HO?WsIpD^Q1ck(;X*WydMb3rtuk1c7^~?G#>Z|wLw);3 z2UgjPb>{Z%9UTibLa94LEH2iS_wPBdu48FNd3i=@$GU+%`|Gp1ck!Yr59A7W8j6em zr)DM~ZJu!@v8TVFFR=m$Rd^!y>MD;b{Z-4)PWP2`n%70sKNFj0%(&?N_Z|FW^G*Dd zSjs;gTf{#dQGa$b|CGq!pQPv3u7zo>X!$4cC;sW!SNxNl-}>L0kMi&S;2&vi^v#o$ z`O-$s*QdxZ8gmRD*ell4S>Y^*W_z-X4zxTc`Ou$gYD!I8&_^*GzVHqTPF8C@)gEp? zfc(9tI!dSAlOOR!z`w-cC=}>GT#TSt9ef8LqCN~|Yjp*6#HdJ2pgvu%%)kXb`tuKo zOJ<*OZqZpoZLS)GmokqUfE}Ir<$qKo1-d5RQ8$L zv1UqV{*ECVp_|(;+THC+>$p>#(hWdVX zma@UL+1%7*-VA0OwbuW5yTlXhHnrYgbBx7actP3E$G=>8QC_F{1^!(Cy$(u&G|?ge zpx0@=9*@&52u`gtJLu2!%tPU@gnWlbZ;F|8=mX$Ew?{;H1!7W$2iUP5GpI_XuTw)$ zO@nkiry@uWp4VPP)8kjGFdlm5!JDq)l^ASi%M9Qr?(F%7bYb&FN+GKg*VHYyQ)R{# zt>yqFFaL~K5am$^#R`${)gs~RWOlRt|DNtmNuP!et;ajYdm=3f-$*LqD{pRk?6H#s%Z_nRFUtksN}}WJ6a1;90Z|Ct`i*S%p7{G$Zi~A_a~d zOPj3RccIJkq;VoIT}?EIsf=#~G~~P@r-Ut!&zi-|9J*3Jo0X}k9F6OVCl(}*5^_ae zx#0L@6%5PkXEl1r(h6CQiB)(rkY)qxzs+Yv(pN^MLm*2qd!-Dk6(yV?R{~*beZzFvWP9XcHm``CwRrleG(sP=6`Z%4|cX7K7VGvOj-+8s7)mPRaUCzr@2P(6sgs=ax+ zJ4lSgsfi>-jU!3_t3$VAz+{%DAp7V8E2@29RVO&vxI>*xaP0UC#*V(~*eH!0*FMA$ zXRo+({=oxJ&0aL=s`{nt&+2f+h2tWC;(x#FlCv+YeNuln*x|KXYURoat_#kEU;puE z&z#+~{_R7>bI+ML9{Y&u=b5xe7eL%25_4z(EY+f(LI++Y9aeyWGk}MHREg8cBTRV& zlo#>D_9o4Ic6;K*;{nY){3`q|!sr;WCv^g}I)1N~Y0)r%x&?@8dYU|@v3%6+9J4`*txil4SF7X4nc{Vm7TA0_ zl1^tfY6#R}wP|@w9Z7e+#R5#G#iplF72ljmjw$u2v?JA~NO&9_A^ZWeC{F}6){fjTe&&R@c%CwR=4GospEG9T z_g61lVHwf?som2zj|wU0qo0j3GjjPS>RJcD*)RxlFF^m%SXQnRG7bfu`3S*CXfafv z@*@=lrJ}-Il!ZotlmQbG^{upltTSu~BTf)@<`5rJ0ck42BB=%1sza)FkD`UZ&Y*Rn zuSjL7s*AqG*H|RjrG#ic9TyT6Nc@j{NNc9YEB_Zpq{MonkxH>5(V5kOLxgH*xg3PB zKZrh^W`hl+3x23*yUm8`08Ip9KrGYXL9SWSdQe&AM_W6yr@&T_i+qwN8U&DDW1_5c zS`Mw$Hdozk1<^_vsvN8+pae5jQW~dL!g66qms6eVztzIJ2Hg`?3tj)U8dj;M3Lu() zPs(p<)(SPiRdy*2_Z6fNO#RWkNhJ;)b=C(Im5g(ft(!FzvtL1p%N=~v36kW;|@B|)g%O=I^7sUO*TS9 zbD5l894?3hwZE#_&cFQUGGOBJCt++KJt)@Mcjw-A|5OsZenoiJl^2arnsd9>5AGw+ zx%QvxgRj}@n>1?DrD3kqz^fzQ1>N8S!llqjHMh{pD7eKv@3Z_ovY#_98CV5sPqZO!m`L{vqIPWQ@k$n)xA1%UVFi|aJ$*9A?m+UX4iLe)GCVZ)p@6Ik zqQxeXACofp)KWQ&nr~tZnD#JjW`5l!-7c~j(wjMXsX8LYd2v>u96pswVV zPN7ebcVpZ|zzZCKtUcs;wc)EXy9CMWvvR6BkZ}nXmlFk&$nqg7&Mh92wTiWGs|Cq2 zz))$BCs83f!bRe|(+F3oZ=)MhT!-?PsxB|Yei)2<-6Qmg<n&UlTsi}kQ!g>BbJimX(SjfWFMX?J>~s2+8{+eA?<+dh_h@9WM>n4#v&H8 z5ryI+Dr{|Ll$&aS1=VUnvjI{no719~QTv32P8wEFtF$vyMy`5vQiT=xj;tm5yo46K zv&Wj2#81pJ;0h`%_ea${q z-fW=}xUqStI%mGtqxE|+&oC+hOES&6aM%sh1QaKl07h~RAZyZb8d6Le6kh-V15FR` zD=Gbdb8l#^q3+e$tF8>^qiXBxpkEP}hCkM%}neJvz{e2XVCqL*Z~x2X1j{r4E#9I5!udk6aLHnu1{uH4r37 z4oQ!sm|DJ5oHTxG!55?S)IT{pkh1hD_h?Rac9dLHbSJ)rQ*-Od-j1}Zc@Tz;|3sl1 z@aG>P$5SHIp}M5nW3ZK%2W#?#yn-q@GicCv=vY;dDa&Fg)DASKA*Tfa7>le$C4<%_ z7J(@kNf4R~|I2MTvg5ERT23R)BuXY3x+w{uZNi*5U;|yB!C+s9oD4D9(=F?_wP!5iFl}nqR zyJxHNUcYmg=gvFdA~{5W<8dOdln*Y4##s9TAsq2nSW8Q@+y+BdMObcME&C&|{l#oF zz{t+ln#~|pG26AcgtT4}RQ{sMW0e#HFS0p}B(OVMq>(HfRM9Jxivp-e&CPfa`KhW) zSKT5xE<1ZqI(yjAiC_Kx&U5|x#X>cgTz%*2sx$hmy5g!8H&mQav9GSIynStXSxuxa z8U;`noJ_1VYp(l;mtOur8uanyyFUA5m-6=-seoO1#nKyQUi@_X_Q;HYD;_v9^lyNV zcSWpMG%rtYu=)YLb^8r+Q6adHEK)QY4ucOaEW0yNS%$VbppqB#c704gM88tMNq<;> z6s^?&Z8DotdW2gJTRK%>QC;40SW`c1*%8-Z7Fj$^K4h2w}r4h5$N=LHW3j|Rb+We+y; zZ};N|D8Uefd|f_VoP0zw!eJEBggq8yARP{iox*%=3Qkfd9m(lSQ>7=QtyC#s#^m8p zlT~W#pk_={X=s#^rbNR$+^_lM+h2|*^OY;+Exo>RzHPU2)781H9U2w_=EuQuc=mGN zr|%t}IN3b^p{!YhR96OTUxxcG4jpWw{4UcGIA4M-#yK+?)@UFck#ZuYm?e$rcC89$v4uGdRANZdA2<0TUwdLx_{bM7=q*CRE%SObpL3fLyfBY9? zHw&Sd)o3=uw6t2Z;E{no&IW4&P68q0l7nBNa6D} zyTx57>l;nyE;VMmP#eoMQtS}CxK0NOJgDm}%D6=E8;Q}c4tv&}}3k_vXa%Wcon3YeUg z*MFHf_MD$&x~I6q3JI#uuEw)dvrRtz0*1oemPzBgXX45;xPj91zCrnv<#4%T-)VYa zy=;VqLoXPEtk=jgC;$MGhkt4o&31CB`S7QY}Cx_^?-h!+P^^ z5s|{bjf-+7phHpHRHx&6|KqrrqI~!ao3Gsb0?w%UMdfBze8D|s>`CPev7KmD&SCc^ z{!m7yY{E*!BRg_=b&G{TaH&Dnqeip{8oNU;TM&;oS)9}+NC0q(RFnpVLQ) zZedPsM2Crv6KteP`4+_uF5eyC^i@2F-5%ddB(duGCHsI0`B`s`s7Z~Rp zgVtg7!bbFJC7+-8gHiA4_IeQo_aer^4+_^(WSOQX%^wUA6Y>F*CSoHB0I0TjYyRM6 z@mo$}5KcP=Oz5S&Df@u=^i!{DY!kOcl-7cjnc;*@3|ayYTKu3nJ0p+aRkDP*eSFd= zD253u2v#@_?Qreq|ron_3CI>;FZ0s#LODRIrn?ah>!fB<*YlB6(X^uZ8EfArzL*>kVH^1glh;+I^_o_S;sF)d#? zSz>P~mBw7+TV~BgM&&oYZm3Vt`dn;NtwFN2v`0cU9QTsRPJ~e!-PEVhdkegJnceh z%u8KR1n@gKoOGfr7Ti<<@gDQ?K9W^R-;j4f)L7IXq+0C_BKzhodiWSfsEzlh+k$B3 zD>82if=3}^Mfoq(=!4UtKYqY_oN8CY?#e~PU~H_?>&VZrXmz@RP{`ttic18vk{s8N zZv*$M9sWp*mWvNyLg%x2}q;83{Pp0nwmexBV6t5Bph)@#l z&H!@5xB?Ips6s&(&Zo2)6JEXj?T$N#DK8!d+INGy=M~pGws>b3WPr&w{1R)wMUxo! zk#fQ;z3){16lZ*)99wi%?=9QeNLIOIZl}(jtEPiy_N<4Mr(Vgc7gs5NefKRhCFd(A zU~3^sirw2TTNlg|1fSV$vzdcZepIk{5g^V(5Y1u8%nVcJREtgoD4#c^2{~G(+IpVJ z%?s_8x$EqfdWGJng^Xfjf4rr+q`czFy*M7ZutGQ(_%6+(J=N zz-}@Z>rlRgpk-;9;K+hvDneT&6KV^yyq27td=#+3?ui*KS^ze%hL-diI;hC0Wp@9t zko-U#tr)|q1{^YQ?^1Vd^&O(2E3#QRY*pc&3NJ-=38&!~WlFqe6Gz+AR{J#^Am zk8QWWwb&4P|2KGY2&bLtk{d6AIMhfI^t7w|}WFrQP-*9|q5^0QDyo4oc@ z`bw1@g=(oQdh5JzP5V5g*?Cwz6O3w)yLYrgU43>Frq- zt9kuN<;WeYS7x6(n2AR}ILhlHQ@Mz{s(=f|9GQ^H|3+})OL+PabMd>~9?Zofc%pe> z1pARM^m~vaM1a?GN&;BDN89jOx1A2Z+LK+^C<}~wDs?_;Zo(_b88hBkp3DQ^e9P2l z=gf(5f62r#cYQaTr-Ju9HusTpl~Akb31F<&@|GWG zD?4v&)yYc*SdktA4i%Ux;?wfcxO`sGBfHEl5AeAzd1!;nX0XB}GvLR=8$=i;WS8(( z-mQj3S`#2I5HOP5JQa!;Grj` zcB@tku2+8s$ER6ru$_!bMy3U|i;t++5R-sgc1Q5<+)62Fjd+W($ zOcyxe&kia-beuS55EI~mEl)KPJ3bjLs~@}#C=TLq8O=Fde8NERykbnL@i~kcey~3o z-MDYuSr$ED92O(cQbr!(O~yJQHtP1-$`WWZL=^Oq8%7uy7@AZ&TEP0bCQrKd%4Ep* z$n%f9k~?qInY$ik_azz94mE3zD1R@y6=UQ0KI}0Y#b9k#5o|;ntwrmw8(@uq)s4s$ zwN4Q%7vu=>wM<)?sgus(ptPLnaa2q4^c&VEW%T%y$^>>>OZb3!C&FubqLF$*YHRDe~BraG|7V3Kh-;45uReOe6O zHR}{>A1Y3|@WxinLe|DNu`wsL3*r6Bunx1~0}|(_hkOp^Hkvdr1@$KM_3=rF_(_rh z?Dhso#YE%}D(?L><7mbENqeFV_vbbDOg@SI6TQ2#*qKQNP`($m2lyR;ezY0L+lDn7 zk)hJeCq?pd(V@~HdHs^b3yf1t1F;auBngn@WS*)xuo}tmo68f&ra!0j#IMvht+|I$B4_>n(C-;U) z%`s`e|6Q+h`rhUdfPa>EUXopSk4GY^KYrF;)Z7I*`jKObT7mI&*i)3saGTKA(>@$< z39HQ{qUr{1bvdOn`6$3naja)rcdAhbLWG^vrB&Txr|Hy60MFUTkf2*@YjS0E*HOn- z+{?({GI0089v{r_btDfp!?eGH4+?fE?)4~x7en{7NR3NHWTFFdx-S*pPdtH_X}-ei z&!E?fw=q;#5KmxbC8`YCKYrq^c>7LLR>~4+^^9HVXXG;C{NOx+e!m7hfnKfGtv*F9 z9!x)nxZe14giC~9@gCG1N@9lT9+_=&^u+V<+BT51)c&vaY4-2x^8{GJ}DyLpE5 zHY3A?@9`0RT4}z}+kCzo`1pB4Rc3qqgc1jlYTG`oDmNm{xo>jLh+=UW5zV0()CMCh z%!qi#SmYMAXl_C7ZYX~yf4@A)t`(7YO8vVQ@}Pgm7;@Resks#tkUT&?_gg#~3GygZ-Eu|3R`dGJk`k)>EPDLJ ziFi?gYQVKTPqpFjJc-+I!X<1H-iLJ)84`c=KGyb4coy#|$DegDIX7g6)%TQ_)3Zul zr6q{1r#T_<9?rc_ynfO*$$1ghfIp{QJ9xXC$PH(;?m$=19&dg})+=?d{yg^wwej3tOBStO#g=clSGm;m(XIuDFQ+v}-MVZd zK4)=6Y9FHihX=j*_KKlT{a_473cjtw$(J$~X?+?9Mt%bKgtp|#-8!5;g&<$bgk zc;5-UPkmU+`*^Q4dLHsf>YhG|_wh5Hn@f8-qzh&1dAnZqO&BAuA?JLK!;fQ(Zdk5JEtwMS7SdZtAXFy zs$Y;yw>Vi98uzF*_2}Mnx=Q-5L3-TmvN0Xn8YeQOuQHV8(^Sqg>)$^s-~E4-y$4`a zRrWvr?t5=~@0m=>OePIN5+IGvAc!bMI)o-5z4t0zqzR}XO$0;~L`1-Vs4IvCSHM-* zx~r~=fU97`U3V4c<^MVNy_pPA_xJr12+1V#?z!ilc2AY3|M2O@R{?Wft-MP}BbfcT z5BQKZDd^LKK1UBI1cZ?gAVq~dGC^;skpCT+Q;OqJ7az%D9#lo5!){$UyyD-o)(}5x zShHo_TAO%_i^g?hxQR|CeM!M!gF~<386SUWOw6oCrdfHobVr= zoxDvuyI&LUp`tojqTa8mXNPt8ye3YQcs^g}e!Pd%B&{Q|oa40@YI4HPlyTMsUlsK_ z=JG+&*3#sq$f8lA$q3PMBwO&OhzRw4x@^v4@6r3fV{e@;%r;_VHtKom_TT;yu~fw2p6#FH7A?@;&N0)X#Si-}_F1oPT_s zoE*Yg+N89Ix|`&Cc9EZzpU+3y`AkW?Cq13kNwcQ;4JrM6fdlUIjcaJpNr4&RoCG*{}`UfZW2mjX*wj=qiL&TF`JO{fU!bGBW_6AXD_^y| zwdu|C#SI(OvH-=1PS+nfceyuWA27_7ejMLHVMweoU$AT9-U!BDydF-{0I zQ4GpKbS4h^oXDzjhGYYB)+q2vpwIdqeZOx+V=FJl$R7ucF z`Ks*JCT$kp!RrX9q(IrK0ek;Ez>30FU1x4D+!D*zr}-Ri9U^%FwBYl=L=apVAwOCx zr}<>37qbme+wKH1&k=ZJo2P4PHf!D@chc$l!MP57w=CEQNZU2d z8~6lLK~tl2P3ChNC;^QZ0k1A}?JHzvJ%*fGt!U0{G8#q6C3)OVogQ~t8>F+dH}=85bRXiJfNf#jH1vVvUeNsNF|Hqh)xUW?2SSj zk5`66U6xIH6Byita3@Y$@eHuW%`_O z3zV1Gz9pw8ig~{-`o}rtd$AGeSJ)@g_xw(H3x{POdXiGy)dK%EvN^LN)?g4V_9B8i z;w~)6MY?RRCmYSnAb6}kpCceSAd4KD@PSi+H~mQCA!|;HJo%J9LDEtvvJHxyF@cZ! z-g4oL-E!(?n#D@GsEKT-y#Jn#k_rjXP2& z%Fr$=EArI;)Oj+ekDh-?`Sp$#e_r?IDYkC@o~EK$Gj-v@Dc8ErS+RBX>dh-}~&tKf{nm?>U@J4r((RAP>J`&}z+=atjJ1 zqepI7lwmX&4MVH*@=zb{cf0+5gvI@`kTbLzHQJh_r)r_K87M*Z@`&EDW}uvG6w?DtVlSVrE|%-dFiwh6zA!^k!Tc+zoKsB z_j}>b^wt%~K+QN$tKKxBE`tzf!dZs9_y1rbdM1v8{Ct19?V%&TJ&ygGw$^NBGsn%V zsCO1=dms2-&Hmu$0N>vSIEQTF5|7sw45H~PGPO-!IULC_oA|sKa0Yynesu8iJ0+bC z^D1;Hwp8LQxM)hHD%wFFhmnHEzjq!OOm)ZXN0&YNT)i`3o@JB>!t%zAsDA*Nu){z< zZty}o$f+F4o7VXPQf4@eoCG2j0P8;B_Z8qbNcr++z21NU<`~*TqYqHi2(LJAJ#95g zXq}BY^a&}W`Nvg~&~no?S|$mT;-*hQt={|k70q8iJ%LUpB%&ENAd#Ov=D_He0FP;e zM+RT9PV^v;$`e2zK!yq*bjY;?q%=nWDWxzvHGxi`p{#>6Cpn5WGYM*m`|j(|1{DMq zm2l$!vT)AP%O_v^I_bwxVCkicU%otuVj2JmdUQ1IX}iepCp1j>`7i>q8+<;w9Oe@I zdm1>kh z#1}CNPiW#tr_vZ@R=Cje85xQ*7jjgc_=b;Jv+JLBse8H=_R}oL z&ph0NN)7sSy%{y4IZ?maoGC@6f_!~2JqW7;jm?2?5F?y*IVdkgojm97|J$w-A`1Je zP}8LoZQ3b=I?=j`rmwJ1PrkqDuJLQ%d5m=~*=?ONVDO!r9^Jcl^2j$Tb{3!ZKDlYa z2AgPFe&>=$6vf*SQ$hRnSv6`{?@@aiE#W0!e>xF! zaZ0{)$W@&V^(Z>rlXPTSkp6mAyUJ;d1)h>%YHPvOHY}Z?kSBR#s&I*|eu5TG@ zbkqFbi~jzi+`rkr%ehepGnwMQG!L-_{C^d(Olt@wQ*y|I{@@{mr$P=F`hU9oyux0q zH>eMpE^*zZ*kyg;DQ7BQwT?CFP}SnMa&w9Dk$t5eYwf`eVDR9_n~eRVk_nR zd(26ce&Df(-u!zLViWLLjh>*pFyc5{h))*g^fDVA}^^uN216ZyvSX5_?+uUaG|m^q9%kNeV@dvo^Q@ zohuqVRW084pnsCu`XVR4D&=cK%GX6DVUz*qgi9_azLSkPv*`0f`s;m@e`rslFa&!N zv_0j-JeU-6<8~arlp?P!x`o5)xmZT(`OkO-Tuxq&xsR z=>|I4RC^FPHqK{}E$oy<8N*i4-=;}pq=rHk3pPo`c4Y+qIrxX0>C$I$QMn z0KMg~#5g|QBper1ZNVSCux}r{T6Zx+Asn;0G6W0!krqV60@}F@R?8wu z8c8HmQN*E^Zg8WTQu(XYLS$;!w>=}TdHTej?Mi!86jp`#H$B=k?N#2WiN1M$Ud-Pq ze@?re6}L4n<=-@z*{)~ht!aHO{x&706k8gI%eBJ zUAIAFChmNI?-M?$MeL^9uLPZ7Jj`TZ1$d7ZRFxUnP4^u+a^Ew`qMDi-L9A`Sad?%U zFZg066qv=(T-`-IzNk?U8gd07t;k;0r5x3!{X0g^sudDv)N}3babpKrwc{bU!O`7V zCk<|t%F(Dy74A0)ZW_`?qWa*rQPXwZ?%y?WLUs%^u=4Pm!22C`qNI2;q4u zDoov@EGm=^Cily$+QU=W1PxrO@~c#9jY}OJwUnU_E!yE5_^Z6FT;+g;)dLsaHMnJQ z|DOFb=T;RIwrG`~i$N%v_`bO1palyDRxg~}A~(NPi^BY7`kvJb7YwLgnAX2{|I${4 z&6*b!G;dbesubU*(d!GV3!Amb$7{srg03aIqV2&KGY4tPho`%VnFw-#eFJO%2Ikfa ztNSlrIG}lxzPc$8{or87tB)@`adXE}?K_SrC~KTQYSe%M zmO+Ee0|qu}Qqj0^1?uu@J1KjF^|haZ_U%U{L1e&~Dis2-7|SZwE6;r#RbC(W7Qr$E z^uPN?KJjoq*?nJ4&3!d0o8b4x;rEVcvIetQokY(%Y4oh|1HfD3RlPVIFl@*4 zH-liUi802|GwMRnw77r2#j`<_Et)kb1Oc}$ zAz~#OB4TY`0Q$lD+F66A|BkhRZ@{x#e+*}1IV`L4mvmeT%5|7}R>Oa{6MlESeg%Hl zb(F|X5;grrn@%33eg!{Ek{>F*vDd!{M`)`sE%xiWLw+v9Yi}#>(`zL2bn}G|v47J2 z7{y2I;{flFz$1~GqTy3m{lkUT#32Lk0?QCDsw_h<(sosL!LtwHjJnr;$DbXDSy4dE zV!mBywt&PXT45@dNL99}3a~rrTGFoxy*oToiZ4s*)UFP-ZHrj3_IyYxbQ>&)dCNkf z=0b$UBD*4Dq!^Y=frD1#PGnk=3&~XZ{YR!tUdrZEp;X>Ym@;)XO>sC{TpSG-vs)8C zOWF%V0kdWU@&6N6&iATaRJR{Vxr+Tbu^$@P970S8=ZuD;K^6<{3W`A+NRlIs3mP4g z4^kmd#T^DK-O>!;l$H`$8fd#GPK+g%)T|2FEy*=W=Vimjbn!CtfRM>znY%K@OilYu zB@>e?cr@0Hpf}qet-Wg!yJg(&`im=9rcInU?Z|J|XP}WipplzEBgVs=EN-N_BpE1I zwetsEwZ?m1sqMh&%3edpDnDTkUdo}Rg7`{;jQ*L>)5^az`wcc0(VA+<;n|DuY?WrC zbOxa$F{Lc@TiQ)sER|YbDaldS!NUBDl9>rmhO0l|i_#w0Vf_d#a;i1nmeH}LaA?GGIHQ6UtwA!i{J zQn^iI>!$Ep5~wLh5%`0zze}*jj>O`-5?>6}h~-udm`*CTN@y?QRH3_M=QJacu=<^i z&|}0CDpwZcJ#PR{RUDtj!!R58dvHOjnxyRc>@&#Iwdl&%Z#?K%o&0=0(U;jJ~8=`*Pn}r=N#ovn>-wg*pz%3f=mVgOWB*TF>PK;* zQB1fzq>Ka48u@$vC(j;F@GKLyLD`&@&qA_Q#w>cf!|t*=98THL&dg#Q?B@89m4EHf zwg5*HJlXU?>G~eTvRX3uYS8G9BGlX3Z``?L#jV{UY;OE5@rUDQs`s~8wlDtOe!efE z0#F^kNrlJQJNq*r7LfdA++ReGTV-6Aa=jfG>!tfSLB7 zO|XboMp?&Jh+wZZLHe`cnO4$ z5gh?fRI+1?MYO7oR8=CPTQZY}jqT99{NS;vy}LH`M_!Dkm2{f0rfGSb<}2j0**D+& zMO@GR>uWV@LCvwd?-|rO|GkYZ_T$VN01s7w$sja(7CnqmgKmTs)#4cg5+T&x%N!KG z`@|7Z@+^0jMx`Ck#XHFMt=KIQjVNHJ@?<=Z^lwGXjyhbI8FSImS{8%3Pw>52@IW0{ z8%&lQeTFBKRYB?pk1k_q6q_FfTEl)98*%)&WO?qvXO$B6KJY7LUjI^j=lB*vFP6j} z4&u`&zNYUPQPxS%c;Ij$EDA%%D9q2oX!f!fn1B4Bow;_4n8jkrggafR?sVpje zwc)sO<~aKSICxHc=h(JupUG#5#F`Ke_Q3ypR{l&#M;@HbWz}0T%@H-cA+MvIDiOTc z6CI@>nD~kU%8WRL)rkmINMQanXj=7xtA&XbuwdZ@a|+^y+8>T@*!$|C{^Lg7(QVbL zhdV3Zd7)P=;=A#(=MP+EX>Xrucc}HO_!%ULP%atHM(n7ajsF;8Upmx|9;dy&sY=$A z!-*sDfe8i5B55~pWf7v!;?@t@VbH&rL=j`Hd7NIAY2<>aqZ4WOb3o84&V$M#x#zKM zDutlOJNWJuJkx~?0J9B^evE?PvD-AUX$Ls)_V6_%I&{^osfkTGZAASmFoC-@@1P5s zbV;wo&nc%^`;*6>J)ykF+KT03@A!fJ@4Z*>{=56*2WTH1Yu}YV)>AELRm|$NnK6t` zw%Oc*vz>{x0&!KMyMTeOX-*tH#<3(8C1G7vnTw7kAONHuU{Z;oJ9Uz|*MFlpjCLszdurzI^>3?e=*3D zIVI$unN1p6=^z>01GJ^zqLq%6=!hm;^XB&8C=qIx&5d-sZN-vZ8#?S4-Mdz|++Th6 zS)5@R`1viY<-|n8b}>h~*$lFiywtfU+ZIqIvyHWU#)ZDT>UTOUpNuXM{h8RQ$G8R^4+yVa?j&il*jjr2eB^PzVcM8>leB} z&Km4GqcMQA<1`(7Sg~W(I-Lt`rZj>uVO5vl<^*}fCU#QiTm&jCU)<)xmGJnzC4fgX2|o$muSFX=Tiq^BM| z5B;z)&V}5!%|0-X9^fwf=@;-aA3>aL9ZTryA)v4D>E|&*q zBf3$7&CEK8|7ToqqN!A?G77gYRC-OCIxqmaJwQwY0%-%%Q;k4bzTRCtd;RhywxDZA zYv6>}UNvekYr9{L#D9$}S^n9__ufA=Pw5Ue2OrG`opb^`5(PSAPLIXNL=kgKjXrc$ zv|1pITY_a<t^DWpp1fi9M)Q#pij1#c%6jj&P2%S8Qu-c}AP=im8a z=KACoOtk@2F_^Rg_^L#l zkv~vxr2V;aV~`U+>lR^undph^b2tTN2pZGU0)At_$eeO^R?u*q-E<5wQoln8oWS|} zA^6)tLirN}Npd13a!*FLYKYPK!^HH^5X&m`iWKc=0Zum>UG&_Xn5K$TuP?6Hu?3uZb}ZS;^OYD2cGN-LzdGzo|7#v7#wa*|Xt zi5moH@Tdw|%zt9~6XIsbw&M>zr+k46kHyuodk*YX2(@?>2{V&1)0RtFg?r?i$a5z>b zZ68kEP-JCn=&Du!#P81sz2bdn%0TZkz*=<6NMAtP9tr2lOhz5kt53if7E)5gwRBd= zCwUT2`*78&ujq;K<#;Obfe-uo60uza><)BZF(8rDWR|c;9cJEBTLh!N6oZ3}W~6sJ z7^NUlqYC77ptTh5B}dmWUIa*FiDB5<2d68Y1Qm&N;1(Mig#8P?|NCxkcKO1?3B`0WY=MR z3}Iaq^s-x{oLa3Ql+XSK)M)i=UBIK|@PTfM^+ct7u`9>Yve54rTH<0s^Kd$m2;cLFn|c5txU>A-lzgY-`b73{jU$~ed61newaLW;Z)n7(m#Fe*E?rjP@>YI zR~E1AGGg$kZGGcr^siX7K`*v({Ii+omCe zSUS9CzhTu|`n~$(!%sd;a#y0WmmA>hy^u`#l*b;mAWu2nY|r-lz07S#!mH1M1_Ws8 zY=n;%CENnK93#cR?Gv+j6Mqd&JR^l#Snz~q>OaKEUZs1YeKK!gA`>zU$YZCze|$s( zC_&9qgri9PA~=&h{rm@=_pXT)pFX2BoryX-``l5JrYcvzpF3*QTyZ%2XvcjcI~G59K0SN(g$zKQAoX`co>Q!B7X%9lXA7}094QLv%if!n1+ zPNmKvgQBT7Z{kFgQq?JN^BSR6G#3D5NQ}6MiGdHWNn+u1PrZ8lg=e1?i!s6L5;*DK zXO3R~^epi&{_wRdu(G>j5~9y%VIGqebL4~RUN|I?o1vl6B$`kTVDgDbDMV|r1OQZP zp3(_~z)6k}0HN_K1^|oZsc=#Bt!d>70!jsMg){=d{rDq+H-(*v|3o0NOShgms=N$7 zIt!Txd`LTi4-3`k$^uPNfRbZ2tKJ0e*5i<20N@Tta4`%HPOvvmZsgE$e;fIIG+zK= zD#6my&g=gaYvV`7-1sNrhy?$VtmSo%$P>yHhR2#(v;1(TnsR)8KMJg<6-+*OGRtbQ z0!K)pPxJXOV+C_DG06reBts#hMRgrZVsX-jLUX1uXPt}%Bl1KsgyK0x{c+Q9c4qHu z#edW&{6p`8C4bZ#e9Hlx^(ti&YYjY-4nU_kL=$xIN|+1|5e@N$wjaF7{TiCs*TEy_ zp^UoL2y16W8smA^o@-^eM?LkfZl2 zU)u>1Ql>ONGe8?ehIm5sG!DCRXzhG zyfZ#~_UxJ$UVtNL=g6HS*>z>g&Ye4z#aI&iT?ZI`!kS*#6M0k}Zg-&zqeUXwto03yprB-aJck7TA5cP1e&wK5X7odA*o9`u3mL`)b%@+;ZOaxD*W1Mst8ZO z!|Nk#LZ?_G3(AZ@|2nJA?l|Ol$?>*BI^_7lAx?2DcI@J>=tQ$svN`ltfTcFdW`n0r zBmF=isbDde1iJ@jA)iE~&L29!XAePI98xYzBeDL*+y}C0))1YGRwD_S1&=4bZw(oC zi04lF0VDdg3Qgo)(tlwU_=WtK+bh|Pk})lyclE;D6HBjZ_}jr7+&)Z7urNm7$PgIi zxjLTyr;1l`63bwIpf^71_NRF&3s0d)_2_{DcdI-7yfn(oT{=hasqHOclzb_uslxYDK43=+sHTR>Z= z=B9dC)y$AUWuz%uF<_Q%fgTi5p;IH=NCecQtQ&+;B@NUXWWnxm&%2BHzPqOUsC+eZ zp>iGe#;MgicW+QW*mK|YU(|M4Vtd{$iwVy@OQfWHt$cO(FszJ%kKca(1NPM0%1d>9 zvrrQc+nb!`dBV-H2)r>EV-~ih3qr_XGvpV@&dgX^nmHYr-_ahF`OS49U3b#o#Aa?Z zpt*3zWJ3cb{K10YDL6bOw2bzIf_E*&wP~f)b?w}{x4!+-ska}QGk4ALQG;HJoS!gl z#-xc;QQvqxP4=H!`^wvYJ-PN3ublbpl0_?-A>N)@S1nt5?@F8<6Z(o-(B<9G2Q(kC zVq{+!tZ+P|KQ-CmWF}JDXTrcwID$d?Q70i=8cKZhB8}%3vxv#~=s zYvwq)#WNeX*PvtNypcnP+)3+Vafx~oS#g8G++a!2!Sb<~v7QS2FVccTkR;0wOVCncE}h> zov;Ln_(*Ql-Q6J9Rno&35fGh|ykUU^NRp>U>HgM7zbO08vXIxhpb)IyJhSz?s{$$_KBs#&D4i;=%E=%8%@b zua$be*ihG~`R!8%A3hf^WwwTcs+Vr6(eKACT9n#6I`_z9Tn64O^w;g@>t{iJcZ%g` z5SY>Hs}vP zRlYusKfebsYxAgBOkm@j+SXRfYvq3-_SPyEa;2H9CLw}mW!AimNJhh=25AVN8)S6 zyZTOsyE$g{@Jd*SEX z_|L&2*r#fjw}6iCiFspQwULNRmIA1@Mkf(um3zG|fjWus+sp5m<6sSvBdJUb3~+uy z$xFfxr_#E*P9plG@jZ9dw#KY7{+0N(vUlH6)$ATIVEph^tihTU%FEJ|n_+GP&UUqz zr6TldFAzq=Dln7^v2kj4GPdu+Uo%i+X~XWkpgdl*M{cYq3+cYr^#kR z6^xBE3wL4CXF#XU=mzj`SJbC4&{sl{s8@DM^{h&kp^-hKUhEB`v*n!8!+YVbCJE!@0SbdY$V>0U&mv-h}8P#5bc_;+pM=Z?u~aSL$tP;;EXR3{~l1YL2qD zol<5%tweFZna%n~1{CMq5*&R0Z7p7T?)a-MZo7YQ@Rpq70g?XAr}ytSuSHc=^SR!t zw$lsBbE3Ek!8`G@`CAYt8N+Cr_TUHU9cDEA6^rWp6M|=jdjo) zbm%{Tdo~&I35g2ia^Xeo2H@gZcKud}!y9Q=?3Q>Q*DD60eXK}CmI8xEY?tYf=_Ql2 z%k;TP9BR7HMBmGhnr6v>4tThKsa>UtxWt^AG|jOp2~7NxUawI~vGBFi)Ot9vwx&DX zdL7bjv6qGLi&f%>LF!Lt)WP#<5nO1hqeJ}{8nmLe-(HF`D+?yatFsKt(1r1aW~`dX z!ZpREHUOq@EffeC9-nEvgG*ep`P)TL9ocjEu_I62b!9uNoN!_`yHh#$&>?YAe94o$ zSh@1>+!N!JGXM!Np?;407d+tADiK`iL2ERxZ;EfR?>?V2#dpXDA7a?o*f*5FrngBh zpx@;%;09-a34r$W{2+*aIDQB{s^v&fM(US?xTG<^tpoXOAHZv;k*ju{eIxDWvmWip zDF|7tvS2VcK?KnD7pvP6s?b^ck|6(6yEv6%Tzj2+7^KTohouRn(fBG~a!*wHPkw-{ z+|HINrIzf)tnd5?gRP@{CY`^YC5_A(q&&ge^@ROlVc+N~u{IsV_*hGe!R|$0L^MUV z+4yu3yA<$y0fotWvD)EfAQr3SHF-^Frft+2FIMX=0V>%RLXUccs?xIQzSjFJ{I23@A-|kX0t2pP`(YrW~9h z%^Y2&JcoNi1#pL%*RTnFJVwyKlb$YP`Vl%=3R&3}nt}?zJ%Ac>Qgu2vhdBLWHQ|z< zr5dh+i2y)(5v_I&AZUSD?rV#L=_Z!N58YzJ2;kp8ndHVSo9{ z8&&_j^y9Sgl6?0j>HJT(&rfqLW20`rN%@O%>437kKWI^^{RA)%1I+5ZtbeSL*`Nzk zpHk)w>2w~e(;Ch7qtzpT4MQh6MQ7k*wez21_Qh)P(hXqOa~cxNT-kFCLgP9CI5`b@ zX$2TqmjGOI&gBjv;5+_lbr;&m5b^j|0DfF0--ukycK$T(ra2uZ-o1&P-AmAmKH3TP zAPP;fhe_B2%p&p)I&>HVA$Yuc`^9SgC7lCku0t?4232f=bHOZ4WhDMVeP-OZ)UKb(^%z<>sji$joByC ztGbUt&!|zg2?%g{(DPN0vaA$l=InQ==`eOM-rB?dm@;tQ~EjuUA#SMLW;AT`d-1RpBt~X z^=#9=+q1007WN4#e5iQKjCiN}_Niw@v^kLDp$&T7n++zy1M&j|i)^vlM1$GD zXe>gV@aC)i&i%;5=gCsY+Qh_z%007ylUMdAquE=)%ijkp%Yc}4c1h_T2Lm#pn0+H| z$GSQp8}G}gR`1zINo6qq60BIbD)oMck4HC$P zhV#BllG!cU?3WCJ4cNxycqqEM9u{~U2}Vphhq8%E`2_(O24zeg=HZg=kmN}1h-+Cc zY0*)k)|6q&x^@V#Zl0Ix8y&wz+|#wgj`&z`ch9tOkzwM&1kESt$qHYt4~bo}fWckp z|7|xg*_?ttP|=N~WMb<2d|-k2kYdv<+Qt5>UxNKr?v78uj$i9NNL*8Y9h=Z7=0&a) zDyU89mtuG5ta^6IL~2aU*t~HSLLwiO4%cKAD_M+O!$%hD6g#0bKGVKiYI!|N+&G}q z^)rx-Md07B!M{{50omx*Tal`i7BoRBn!b|2!swBgVA&MeNKOhq+DS+)i;}*s{o=D) z<@{$t?S0QYwDZW3haY)b`usb1F5FkYXIAB>@1B2?`15U|M^PwepNb#rYG8Zi3Ae>^ zJq9`8_h*tZ5k=%NzW{#6bRqo$E;u&HZwp_jwyCg`(#@Sq%mjjQ%-js)x`e^zj_9zV49Z`T-mSKhgHFVZ{CKeF~u z$`as<)|`el$$sb&Yrw3S*hSQb>>iVB@&~LKO^U_c&@ygBt933iAT&!%gMr%)saH5c z92q*?E+fmC2QncDDBBH}R`H$xD4(xouRnfh{siVY_C5w!l-)4}Y`#qyC7s`L&&16> zrH}Z^Q_41~!DB*0z}OKm8id@Kn~8c%PsimMiBSZLsA418jFMOrOH$uS&Ox=8=`$45uz{j?C`Po)-Jhg z@yfLyu}|K3TAUN#uzbz^E5#A<2W#HpYcTeW{9ml$g`dvrbz4lb&u_nEaRY3R=@K)N zP(k`hLZ|>@5+iyfK4VM{jRZo6E#zbTxM#25W6#*TNBL#rnY7Hu@7bH@`tt_Jl3CrC z4xbWV4c0z9btd%a=5>9gt~kq<*qcHA34Wqd*s?8(F_158EG$IN35O&#ZW7jK>Kk4_ zM->6XXS404cJv=Y$lhhSQ0=;snRcN%GgA<9!5V;1jby6^VqG9xWe{LTbE99z77TC4KAAgi&O%9Qct^JuZLNQ~dx3Jid2`+EmM&fU-gZZ9VyiZy zbbF7s?Qws&0=;)!+xO^LUW2gFnC`VZiZSBGi+wjRT&NB=zzBg(4H{&+_(reTkJr8w zbK6~=5(xO3@BB(aF43Ngl1!H3Cg&o73yqC5RkaS*>wq;3Mzj<<)FtI7^gzO;rC2?qhGEDj~xV(XA zeuX+6gJ&7&#nR~H-uEtL_TE#X%rH}yXX=$zJqC{%GGz3iUgF5x+P-|6t!sC4S;v)1 z!;#Gz_F&Q@t2RBn`QCep=OoCOPvosQpF*B{u{l;!VJx&oTb8G#v6jWz9#6|csa5O3 ziVM|+g?W|+0mx)$G@>1tGm=F|m4l7#`#uuucR}2Q+4lh(& z=;+8tlbU{^Q88puX^|G~pfEWJ%{mHB-pEii3eSz|ph4nk{qzf*Fij-n*%vCQ*JWQI zL3s3#?n7PU^R`U>+bvb=?wmGl`;(7$>Dlvk^OCS~^~v@xQ}m61@#+W$!f!o>w_P_94f{DCP0X8tsG1QAa5_NjaJ@888bECgv( zkJd?D?_LMW7+C%1KficJ8Sfg#DL}<3!RdyD(=HbApoLH{XmwlDLvGK7YPa64cbFXh z3)POVO#si76ll2HC+#zLOYkR6prR*XAAm3S^z2rJzUTfAxjDnDgyzS8E&xq z6(JL9ryeR{RwPU^@)F;OQq{WPdMDA=MI91~NGv)9@RTV+H|xCQfl+-r4P86s%dAPR zC2iZk_j%XYs@w@#%h@OM#&sTT5)DH;4w!t*KC)H$yrFAj?Pp{Ugp&ff71&QT_7kQ3 zSTfVS7&ao=)6-26DUzF)X~iTJ!3xcZ+LA1Z=F@cYTD{hQ5O7_n4qOrR_4dPYpK!8Z zGYMwo8Wf)r`$@RGQQ@tM-sIV6ZhzKs`tqy^xvOGbKYy=%+gNAAq^vKe-aEWwpHUmw zC;bOz&uAZ8J9J)otBS_uLJkWbM`KkGBY{tUXp84skkBhfFe(${1tGi7Piw1ETlQw6K??0q( z&!N>5PK@c}p7f9T8(%qj?&NJZ=MIv5tK!c+@`(7vy*uySx$~ZfN#+a@-a?M$MQ9xZ zxA1swjmc=ZEaGk`lIhH6nh-T5cvDNT`Ec&sxvbONxzfXNop_CCUV0HQzzuXr6GGN|3*8V=_eH80`!v=L# zew6+3>1-Mr0Cc5w2g80W2JPhu@atomgegzR%*YT-FcQ-u7pl{)_-N)Ll%D&F<+6}z zB73q-yAnXXC)Aq?0+^heC3>o7Dkuy1R52eY<2;Yo)H862=4Rcx4VouS*NOQfyYwFB z729v0GIDOS+q(?}kQc`HpEq6UDE5DJXtZ1Vj-Bscv`&%f)!DO@jvQvt)&anrB{Yj= z`CTr=3y~`)Wrsty3)P`30k`+E`=6o#XQ0s#az1Z>6mWXAu1BgJRS#gFc;IA%hN0r_ zH}&f`dOm^KXkxGVli2!q%rh)w#EJ?1k^pvZw`g5L|C|F%Cfsds4|Q@M9Y&=BBEuCC z;*vC^W*F5O{G=DTHB?U%)@*x{-FD!i#RJDp7&>Isq&wi?p7F{XY|U$@cdUQlp-o#h zfXA5733B5RtQkO#eh777ZUMqMEl48S<8geggZnNamsM*;&45YBWKAU3MTy9K=DAv8 zq~GwB{)N6xleg}Eblc3W_W73nH$5kfe|Y5@^Q2klPM$iuY?86dtpr0$X^cqw{%!t2 zQX~9f{(;{ocA*_EroBpIXlt}ZzBjTt&q-tO+ydO4bm+Az*z8uF7!ysvB_$H2r#a8v zkl4VH$rCIpD_~6khl}fQ2b?`b=QIGca}KsF*)*7DlkEn{2XSh!Y2<`WW4&zpmaLk@ zktN7IRV@H{Sg!!yUXXPuE5p!4joRxS<<%!z$`8vZ^v4MV!evsA6}wif!mKAh+krl{rP} z#q0;y0Tv&;fB#@5yQ&eG32P4o{`W(+`B2>qDz=$1Z^j$&+gwK9Wvj~tf#4#7=8;Nh zloZsE7c6;&4MgceMmE3eePX70@(fvQIH~viN%BP{V^W`a)7kU!eUC2f(qYj$b{*>$ zi2*4rkHWf`|B5VFyB%5ry`TL7pK!6-hjJkJfKi4RLI`C2uQp!|}BoULm@r#Q!v54gUqT#oZ zTnvxtH+2LTjPfNVg9IbT*U)IMHXS>+??yzSuvZ=Y_f=E z9W#oTxi6Ovieg#3Scx0}xO$*uVEz5dznAWAZ1+C96z{&KIQEve7b7Y@WcH~P1b-Kh zf81|!o2-(QnJG0W3ETQ@k7qp*n7`Ltsn+4XAA9VwVs?6p6#plr)0AT2~@9 z=1U$A)nYASD2F{-)~l#hd7G;6&10$;FI}=YuY7ncr#!oLpk-0dvVxZ79c~2;e;RGt zvt5IJizkg3J$v@3;$b(>?^lwW*|TZX*J$w2A`f_dD0uxF=u|ImgZ*PAS#C5p2fODu zogv_-LBs4UvSJa}Z?<$~T zcFFTm4{cj{@8*%y<3Hw4ZFT2}S(E#9?b&(w z0Li#+$4%@0DhvCzEtz?^t#94+Q2wOBqb85=4DZ{a`}jGaO%eNf2xn^)8pb?`1Bj6M zW{4FNYH7sF0+b!Ef3J&H63X*%;vu8~xtLw~*U~d*mnz?$V8!CE@zvt2xK4QwPvx=} zcrwFU>xjIlkWI>PXXw*lj%BAw;fNFp`7$#snJ_^!zwtr-`jWbgTw$;`$q?c82*N%% zYOYxdxc{ZBsgzR?fN)Hv!4<$=CU&{y_8BWC40-+OvdRw!waY!c>7l2$@BD~el`6aK z?$vPL)H!!cWAztDjGJ@w?iY?fy5e;6?|V}oh={IyC#0_O&(L{caA-lAU^BVh7>X|I z943sWmU1FK+}nLuY}PATxS`-A^<31UdT+|zOIlVApEOdMbLt(Fg4xmfH%{#Oz(2H5`DN2u zJ=@AM&py3s;|XP?ZY$1B7EX$%kfTE~Ez{#ftk>=J2FxOw^m+tYbX~T>Yh=CR6Tc&8 z9T^G4F1R2Qm2qssTgPXFsunuZQp?aEo{j!;F>5rVv?Rmr3gnbF@6>!~&7xnKcL`>j z7Kh+JQ9c5g4agZ&S9^JOPmIArXXmq z4Xqs~J}pj1Wzrl$r@mJEQ|%maH-8N^K=c~UVz@F)yezHb{M9d3%=rr=9O^NcBRe}= zwA68!k@J_D3amQ$62$~Ja2K5n2r2LX%wCsO_L{ftp%rVljG7kzF?Uw;;Ui~G?$@RF zt-}XwBJO%ox=h^lbj)|(we*6?gGX}ix}__)3$nlVC-et-M4Sc>M{dl47WsC;Xmkn= z88aQw?xhSOjZ04gjA#bsYp87`0jf7dE30fv_vYB7l08e5|1|M0wUvs~l-yD699!AP z@rG*-k9dLPsVKC<8lQ_aCpn57!~*1$r|HaDSuk(%a=m0M!steUH^1bese|YeLC)OX zNh6U;5l1x>h+v=({6&2?dDMK|%R)&t{y>!cOC?ObbE}u0R%B%V?9XE+CGQsW`Iq8m{*_zX@Dbp*O+&!dOAD?67cPyJsvdyqI!FS3qsjIjco`4)w ztLCQ*VOjRrbWYB6xlwc?2n2AkhVll>AvYZsFRT>;muqRB_6=c?A}BcY^*N8rjDRXh zxgnM0y|lC+EbL;a7)n~e$$g$c1{UY8}*5Fz0ZoH4o4XvifMH6m9`L!o9gIRoc~KnD)>iwKcOw^JTu%7(+(;jX;fv0>MKY{ZW+M< z;HgkFe4BE;6Z3$jZ;*9bI zTf~EX$E6Gg7Hc1%oE*dniiPIbZ&jYIyxba0OS2-)(V87D5rnW+YSGf+k7oEY++lnK z3v@+dQIqb~Mb4mL_K8(U$l@Luun_)ATdi^&c|;)#&HH zeD&?~@1K1G^i289e+NDq3C)C2vGO$3o0sK>Ba!rckGB|&;Pa&x%?o|bKyGFr(;LA@ zIt(NfWl3fcvC|~YW$W{Ik=QGF&%eu0zt(epn`mjP(!IMajAmuRGUcf zJE+hfkPPv^CuS5J95a9=#uL1KBO*{~i%KYi1~Dvo$Jq2Fysc^ce8bz>2vBm>(U^14{@Q!5-Ba4k|NIZ@0t^WHONx zs*WdjT^I8ty<7X=oSqcHHtRPYthtbY}z7EP7~5% zRQ%sKXdcai35Ho|F-SY;ddN)0^#i92PhLa zzJ4!D>pNw}GIpSaO|h{b(t7q^?)^l~mrmsM{+xT)?41GS29pEs;C?v)ycP&$!kAb^u^>dV3kuPZ$Z0bc7%R%tGEg_i!i^CAFLc@(Iipc$ zBb#i<$bfXoaC#jyaQS~=59rY>0~K8fRFz1CD`<@0AS;O_WJ>sfB2tEWsd>Ali{`he zX;i#)-mujC?`}&c4~zG{WB=1_d2TrCYA!4(wxi1Ts-ixefRRL~an`d2 z5~;HE`rb;p$tcf=!OFjsPtU(R;N;f#&%8#q4MTS?L0kzsXh;@9maAby3o`0*)1*d4 zE_*}CTo5*g^||=)HT3z?F=EweN8NyG@1|^?Eb^3nH)&j!DjBdpzq-AIY)CLc^pSJpV-kMS7$IPc{CYtybm6IuZ$V0>< zjF~zSAwAs#gE>Dl*YC)6L_GMAgFe~Rs~V}@zXN=dw{hDoC!YO1I8%k)x4Cq6Z z@=n!rTwQ00*$I*~_@!fCy?y56$j-ohi&rh`J$iAUdx|n>24m zQP&CWI1dB^U@2)9%)c<+s20$uJLy2dRylPRfXAZ zUtw0+-bSrdIy+MPgVa zg&Xr5-43x~9k-Fimj34NX{r=eMLHW8sBje|jFuEfS{9BLu>}=HY79thSUvcoe3bwj zEV#R3&Y%CRD-%`H)3p%G+U9mJrv01wbwq*^t5|mR>M~`e$QjHaKGm(Qo$pU*f zAT?7yxUC`I8GE`wIl5*Qn{A_Ag<0!0tCR;ljUh9z8BtgP*%$_WX5q9t$MW+fXEtW< zq-QuYN{Vf$BlPq679La)g3m%~?Ev_SiX!WVNHkA$jUdrae<2BR$sy{4wM@|kUN;C@ zje5D6>GWt8HJOtb2ix=TU$c(7=Z{c+lEz+r{e?H)oc=_KY<^PzOs8eNRxg~jzR%sA zjv;K3~$E>~bqWGrr5i5*S?~AU$VJYY#3>+d}>}c39Xn_yXkZVP&%rp(w_g0!y7gbqXWwpn^sy}u?SG=#%D>MIx5{tO zs?n-7D?ZK5%&2VNXU4XX>pq?{`(t+T@MC*xjy<-AH$XHlo9g! zJWgj`UOxISHOfa$e3@Pr#e7dlt|+$&rlzH(;NMbz9{T0Er9g%+fK3OSaHr73b7KtC zBQZM?MGjmhgSSCIr&al^4({gTo9a{x_ReXE{=?b8*8)_)O8EzxmMKqYa1lS+uq0a;SZ7hw8z%HZFN& zL2*m*uf*{27uZbkqOx!Zt5teCp8gBfO--9ze(1sYQuuP8$hR*~3w2QC0CED@Q#f~v z&@`52VIU^iXhha8Z_$8p0%*=aO&Ji~QoZ41CSgysFDM_rL3kzckv_3z+O(R9NV|SV zDP68)unYWNtUVvKBnXWlikSsE8;csBEJOnv(Jiqd#*jKa4WhbGD2ruyz_OuG)RQH| z8Z>YQd?pYWZ?@sH`7rd$=eMCo*yhwFl)6TBJho0vE=$v$Z#Hep6N(dH5~%n`(5lvM zGGge9(gj%!uSAg%nKDB+YeLdqdEqr{x2^$YH5oQF6U254?lQzbGp@OB-3p7CX__yN z{a3WI|i;FHhpCK;85yWma0GxZng6D!i8&ev#_Lm~7Q zvAVOf($(G~Mp@ggLv%p;I{bPh{bHqdp`gI&%*b#@^!k7s zqCOM#jhQ|#dIEVvyyLQDgjmE-YMd?EbtLvQvqm+o@r~x1r)^@R^&JuXWr}51K&s&c z|EKezMw>Ugpzj}f9N4K}-%3oNo&6hMgOG`Nw5{N4h@kUKqcGBmBqy^Q?a24a$%b1Y z+u=tsq#yGH$zrpM@U;2rpCI3&x=q#C289xNlDtmY$L~53dMjU5-dx&t!5ufa4z6Dy zh#k9J8)r+;ruq!{dFUR5{Zb;d5@yF@x(0=)7b^|BE3N72?gmnu)_IOX6q|-KJ%PeP z_=?JKJk9{_bpc-^K;Nj5$%GTDu(5m$GBAM-%Lb$RN%M);ofbKFp`Nj;6g!Phi#Dd? z2NO~KyA+^D>Qt1}Gn`4h-h^klAcrBs_b+LRieip!m|Rp`I14?2T52W`vx#L#W@|Q( zXq2DSvjfVv4NCr9X9{gFZ(cJ>X~yj#*7D$(vCPOqXole$J1lFXp>luxISfzz$cVJD zq)`O^WWs9DE~X{Hw+-$@^|oZQSPTZE)n=2NMrT^k181s-5*6%t-ReNoQlA9m&>UCV zx!SH1epDjh8v|`>*G>{I}{(V7#^1$6SHSFVem0Q_>shlO1e{Y=4zMH5l z(w)0LOtdMbN>hz0NtQ6dq4Z-90#*k)qoqNyS}b~pR1R9 zXH+IhGL56CV$q12e#%$Hv0CfK*}8Ko^;>9-l}bPTa@>b2ggaxE#o=&KPFh8U;4&Jm zs9DL+k*n}x_v&I_F6`jkkm?O_rTI)g81gTnB@YDs zj^oHzk2$p8#o+rtsMQ{>Nw)FiDf+{r?=wkOPgH-a`$nHcni<+H)*x4wv$Djn-)3{! z!}h|091uZH$e(Uc2a403NDueP=p>S8YM{A3xDczO0WP`7=m!ZZf*4t;@2Bn+@ zqkdcEKOjjKzh7^k*hxaqzz)h2nnDJbuM0d6nbw+V)QD?d% z5OTsF?EHUd8j25Sr={r{;%6>&VFp0iXvWU}aNyjfT&7vytz(r{+~7%?K{vW>lvmb# z%(YGX9#d@G7t+4de5{$!C*|XjDFKW^zyzotZietcrv~II2*{Y?tk5RbzJ= z9C9G#ptzPvIVV!3^Z{c|J*Ik|nz{p;ykN);?iqD1IB~4hCdDnIj81Ya6PjaA+cC)D za`JQJ<>vY!SK)URin4^P$_jsxH4=g1&x<&%)CJIb!)&=aUk7)-(mB8$1L%} z9l1u*VtEOKrVNu$1vAQ&`Xk!&rfT4}pvmH5BlM)fVDb3Bl)*`mIqsSOPV)PBsPc3hu$U7o3P+jUl0EbB&K2y=&MrQIjU8l9di@$L2}>3 z$B8d!K*iHnN%?D5UH^LuFrv^;>5IMs-OyL05_V6!d_zfz&xhg2>2`;sTuPUkRpkbH zROk948R%UR(l_o=olnI+ZSBlqpKM3GpBKBJ7l%`TeF}2W=#wlTTr{SVJA~Ui357;n z3Djzz@E>my0q(1-_2J{8U0%9-^H01&0ycVG#ShX<{y)~<1U|~@`X7Go^UO2*WM(p% zWU@{slRY5`SwXfzWK#n{*&>8Zltq!vg+(N|Ri%Opbwf}@D_RxQL87&aOIsCOYwOQi zt92^^6|1d#o_W9L-shPuf%fg^9}R)Yx#ygF?m6e4d+xpGX0z=(?|uK9ua~V_eSzcY zI(Fs3*ShjT7gr9btJ3~)x6M3y-&tqh(fQl;SB#mR_0TQ5qGr>OD_5LxmY0?O`cbWJ zhvkb~?c1!Za<${tOBej?tZmR#xncgK>2r8&KGpOcWKx1?aCW3I*BK6nLMYKJRiXG~ zLw%tq*Ol$f#v(WyPdXvbDx=Jo*IP?ZT{@7gaOm*_daNrvccICbWVirC-xECySHz<| zac>4`M@)ZBDSM>@^d3%uIJB zFouO#Mb3!SnY~^o9=0uzN=tJJa>~nS&%YFoskAgJFArmu=gC6BtE_A{R*`smNOVbr zVEXMI`k{xby7poa5vuSLe{DI5KDEHV@_;?D!Oyb92EX2!g*(3d@yE~8xAwK$MK2yb z1y#`UP`bM)v9Zsj@#xQ4=uf@0GBN}&VV2?sJ8q4*-IXY7fVXW+m4OX47@L~>eqOk( zllr`Ii|AW_cb?x}Lhn+eada*o$QTxC+=}S`vD;(`uoFCcs|(@m6Fq*bZ56V306PH; zZ^}$l=JCESj1KrJvYUl>K7++3?!G3*Wu}!!M70yzyLJj%>pD3*UU_ zqu(q(E92IOXjcNYa{o5=5nW?mpqTMS$Zag-7J}4@3na;J#j{M9fJ?mK)a%W4)E5I1HiD84a>TqOx zT`cShmzNbt#l@)OTbv!hrbWP?<)+-0-AzU1^c0q%s&PLnA_YqM#7|CPtPO!;$0fi!AKo4!Y!f?paezi|LZDwk$GwN=WXy zGH>9i+E3dy-FoM(n^5hQ{C)&?6K=@?%niyyaM+(&We1)Fd-<5WJS zxP?^t!W2h(^>gO&9c;>(?0@Rysg6rCZoTBT`*v(yadXyX+1JjSs+_O=as27lC0D)t z;xn(VI^TZXH6*7d_Mp5Ov;!)HE*+L}jRbnedIyhhAlv19h= zjrRnu@namwHBcV(I! zJ|8L{n}fLJjrhoOSi(Vh;|3)UVi&{h5AH$AD^P#A9GZ}Wn;F@e zg*bGbaw~83wg-3ZoE15B>cK4|&fVNR;*A@Yt$m=fYsm$(=gnI>pS{2LpL#owe}$>?i@oJrK{@ z!f3@-E+50B{*q6EBc;#}kzPtcK~b>-!)6rZgOimf-FW$>H#k;1Z#u2LeZt6LBicue zzR17Eb=}fc*W7Z(bU8ZzqD$7;w~rn%?3DQ<+geB8;8?tR#oTob8S}UepoR;(OVzMv zH9CTV-OOT=eJWKBgVAaLccgxR7J0 z%ab=#`$}h{m)O8LcKyC7_A-}nzUJU2%R{)+kSz_41a0PQx6g;`0676C#3M~~&UP+! zV&ll-JLIt(rX(x$xGR3jfCmzg3FX*ROL8Fl*e17BEuDPHinU`WO+R<-TC;LZ`zh;g zpnEtMPF%N;J^$lj=2?cL#4>6m9cFdl!GWm^)%n~uln-#yvy&dL({V=_m+h=AU`y>PeGujr1^EJe>{Jem`BiMcKA` zHEdu$YB{}+G2ykGs9urE1a^TE?iqOIqow>~FRFRNP`#uWPPt`{kNv=XQ3UB|q6ac3$4%pUrPS)l*efQD>SDsnfhP zO?sBi2hUEb9V6N8E;Yk(*s6ZoX~lhDD{)0^QCq4m%B2R+{OC@tJdd3T_zBf3~`@tag&}>iPUCG5h%Z>1$V{UNr7~;7#XB*CL2s z?^8Ks5|-K{6;N|~CMzkxtv@@~1DQUjFFS0_JK~0n-0tjb%#PWgJLsm7?%B{6aT$;- zIi83WzLqBkFzV;XVvk@fg3?KmA~ic37L?%%x{5=A!cRK`|4`+O zLpFG4+fnkzTgs1to-!SmO)>nnOU}T}?b{bQFSNfY4=0xN5f?YNIbYD? z2Th;A#!wHW*a3V`#Y72_yons@!BK5LvBx8^fhMkzURr1E*BTdQ;*EZ2k1I9zX0_3xw9n$^sMcT?2UcuEL&AE2Ny7ltRwd*!eNpA4j1|R$q_dVHC0ixZS98;Ff?aoCE zh%*-^rLf3y*oKQfIGAKc@gMWitXzi0K?eJrTyWxaiLu+AumflqSF$p?apx(62uJ!pdTGDB39;Zn@LugxYjbgxAym;2YG`V#tNOIF zu2jwSl$YX&aw+^$>E|9yC0SV>ENzehO<}I4!~#_H?$qqAtEk z#BC%iJIM44kMTu5T>MtgylIbzhOJsKv+;&&*KGD(;J^LMiLE0$R!?i*x_RZqGmA@W zN`50phYh~CbU?7UpmpM)g-fQNow2B?wX!iYJJdRP`1u!AFFVJ&!ukfIgQ6_98*{^r z7@r)R4=eB>FJZFO#Rv*I`M;Tu4<4jhRQNi!S;$vG6;b*GpWXB3R%U4LTJ){Gy|-Q@+7)-D}4j`^l9&>lD$p8QERR(o>R zsS@05xM|DUU;S$B7WV$8yV!eL;R`RYkF~P9HbEb!0ryVe&XTGkKAYK_k%4N4 z{%o)1hyo!Q*SQUukU&V^u*!(X_XsX<--gLMciepQRclY5wx_DBJ5-~r`~AB&-^2c( zRV`S^hIBoD`nZ?4?WNwcN21bUv#ScqqtKMv6u&46B6xsW!A(D0;WgjL9Qr+b)2F>E z`zP+%_kI9$;D0FQpdH}fh8m%H7DckjSg_jh-jE5t$#jI_odpPqVlj*?<>ZscmjXS# zYJJE*ls&I?;xO@WoFhIwM;<(HPVevaH6!L7-Ybh3GrUz44Q*T~Gd8LkX>q1kON4*4^Ebcq{9uux&ocO=)7P329Tb~nw* z*zts2@<^X2(sbR?)0x6eV_}mlp&vW_5_Oo(1o@ z6dz_QxdB_p*KmP2Nr|!JwUia#vUFWP=yKE41XeFjkFVlRFT)*}CYd6>)+j!9h z>(60$T8QTGq3k31akI+nd2n15g< z=9WF$_q`*Zd*!)Tl=Gf?`Q@j;r?$uRJnJx5Aq29aK4^Z#;jrQMwIX#REW#T}{OmAF zSs}gR!|K=P$14YZ72maQJu|y?XYZW*(Ej8y?Wxf2o*ula;N|}!D=d+)Ai{~_S;Oro!X?-Qar;-CD-%6RDR-Ung6D3E=eQeHIkB6#4_#cjQU`@Sk=+H&QWyOW? zuBC+)joO5WCo98_mjmpcOt(MNWAkP;goXzSeJ-on>cj?!&uPPE2$tIL7uo}+k6D-c zN1PSNe1c_W+PkqxM5j;@ha4L&J)pvEIn|7%7gvWc)>SfS&YT62C0DOMd2sVtU5iGA z)w9Y5lwGxC@Yu?RN`dotrngzG`ZmVEw@;P3Wo1*M(hiWSIQrYhYVqRI1MH-u>cTDE z-J;#wXm=8CmmP62)22?h5>)CHML`JmYF7=KhRWJ#G1+_2^OUcWfGSo8Ip3Mgd!8!#?~*dwbZ? zBkIT{Yu0R8f-Wbwhn`};m6^Ixg#*sCp3X@_vG^ePD!@QaTh7Htjt*nB-+VZXcwe$* z%^D=ASZt{_O*%jJ3Tnf8_NvfwxC*>cmUj@^q|o_2e(lq^I<`rfY?5=@29{BY8lB^xlrm}})KLFlE5^&IYM3dYN1wy+fyby!NIj}CgJ%+ifDW|Q8X@7t4LAl_J z2MEh<@dNLj=05l!<=#x#+c`tskXer19m#8v|1LiSI_`)~k#kTn+GHuK@#5ek&(qQV z`%1hSS1z5R!w$wTypJhzTTkrmXxl&~UqA!zE~KswoBs*eG7h_yZD(at;0dLqL@if} zdtwKo#U&`50PGTd6E9bad+um6T5od`@Y|zuMTNJVpa`$_vgt7Am*cfHDOtqtql7Y= zKq>G67H7^4e6}X}E5JJU$z}Gk3cH|9ba!b_C*zdAmb(ScP)}@MG-R-;<9s?9Ct%w+ ztX$p``z%@>M@x7YY7^Ae<}W=JwxEp7CQms|RYJzn8vZtL#y&|^)$-zI)}{Atnl?{) zMg7E60ftG9u*l8nQ+*O-v8dJdN{#|s&c6MY2AUnNj-c}&$_PR0ir<0dJ_LoV%! zeHtw-H)rx8=CF`+GG53IuSF7Uc2DezXtu$sj`!(gynw9&ERCJHERK|q-QX0Qhh%z- z9ykL%vFoFOLcRMs&cP`-56OiBwxTEYU9_S$mDg!0c#p|X3)uXg*rU;We=6Q-DR_^` znF6-DCw3%SU7CUycA;5npp%s(f7{0)*P^TAJW`b1X_23bS!?%eEv42K=E2i2hn%5&r>;zV z+*5IWrH_x^i_nR1ekHex@d@|DV$pEDfmZl}2^gOa*dBO=obO;8*kW$82z4ZyBk_i5 zIl~UOpUH;z;(Sjq1=P>bPK?hT6vXj%)uJ6A4Jce;*y&Abw+mx(Be!AbH~PDkkqr|a zmkV?pWvC-*OM-{S%0oFk%Il)9pwuSf0_<|nGRo20RHn@^9p=?&Pnn`#awqde)yvE_}DR5PwdNR z968sWP4GouaNovp!x!m#me&*eS2T{8!wSA1Lq7%H5_UcCCfT94C-#r1S7)BjQQU?E z|DU2Ary(t7C2X;j7jet1xLAf26;%}JJrIqc8#iJei1)!`me%Bf6h1#+n~>m_KL5A) ze3!N&0go~JQhrDD+1(TSI_l2R1*-RXMFIzh#ZhF~3oDQc@TvhHDbR}T#W|D9xg2Y` z+`nS^0w?4ci@I`|B0vfDQoxSqay(tYN*{5YGHUr1stKImK^NP(FXK3EQ&Crmu76vFx{20 zJLB~XB|~Obt8ghTNxn=SVg3?0>WD{Mki84qWH&^a!F)U^dWV_Dq*wxgpCI6+t+4RG z699+WK?uvE$t{GAXfLZHxXc8NCWX#hxF03(eQCOP9byfI%S_;JlJAYf{yq(s=D{Sq znA@HZu&6fwd=yV?2!r7~T!+{s2`^v~Ll6&u{U8mNVxxM_Z$0m0li~y{bOEoe*qCbQ z0^XH=nGNT>P84u#jlijrE~t2bA+8HN-m2&R12_@MG+|Ft_>l_u5yTC4yQ;?xu{VJC z-{u{VG4K*BTPt8=+t>txwOYZw5yw59%q#k}Nbq3oiSz9O%pLOuPK?*TqArUe0>XI*bi6zX=P~J10qf|A{Scn5-6s|mL6-|m1vuxB1TXNDJUD)W*YOV+K=_rK0jc=4m(4QQ3&B60 zi~v0O2sMrwGQpjvm(5{5_j0@#>n{Xc4U1zWIPk0cd{jHYcx+|=0KeRpz%HCrEQ)8f zT?WTokHNFin3dSY7CW3a8`eL5L0U{4aTh1A7DgJB)-v=qF@Y1292EF|;rbRQl{q!@VUii|fY z!RGqr5zVukP2w*T@zkJ6h$&Wk+$gkljWhJH*+05ogy$iu;MPh5S%a zN?*b7nT{r^e)u>fYv3bqpCXsi@L`qoakMlX=Yr3VV*L8@a50I zdhif<2s|Oyi3q}DQC~L7phJS>ow*K)``YBg&ZIVo`;qz}pC?yWBMvMdR9@wbw?IDy z4f0`}27JQrxlfS&w6*|^e?^hgh_^_hL4J(WK%131jmk=*5h$-rr=c$7eiA%#zw^AH zk(UP=Sn%zM2J+NCen-BB`}rxNjdZu5!D!T&Kf{m9fkw}%AMiU1xv%6j;CIdiyjV~E zK7pR|4}VEDlfXa64ZkDSle7lWuuj$yb6Ad-&!fQW!TOC|!^UGQ1pcguQw~rcphK!2 z?gI||TnHMs^LQLIXig;>UbDOrp$LseAb?&VKOK;qMlW=FPbc?7{#r4Me&iBwxrUfH z>}2ifQ3D$z?EnKJF9(j^l+~ zHVZhE7BVmr9Ou{KkK;Qc*1Qz2x3$r#w_$NZtKgU`KiRC{`Tyd$kFY;!FNhewxfzPd z2DA-mN^+IdM$n;kz2N((yc7KJ^+Zz>(P`<4eIIQZl1z&CgUjcOIIW}XM}rn$aIr?E zaZoxfl94e6(50;`%F4Jdjf%^*-?1Q>8DqiQF&YzHb`;O_KPHHNO;Jt{xh_8DYxVfV z;Q$?MlsNQN0lWuf5k!rrYV*73(=-8(ogx>-2Lb3fDb=^p?0_Z979;Ny7ARaKC!_nqLjBmY{( z`#p%u>N6gXcpsCQFw`8?LvrGC6VBl)NARw8xinper!X5VV=E0281W6gKYxVBH;@DLc)Ro$k_Y0OvqgL(uWz}l zzES#N>6fLZQqC3P zH`HD!EGnaZ<_qrlXbT3H3pzH$+wB%J=wNKn&oG+{9x-RIOxRPVnpq@Ug)xaa1M6$$ zd-XSf@8o*2U3vuY*?U0z#ewKjn%o7QAsVh4hb40drrev!j_^9m%YfB?XCNkDAXW)nSn|7Kt@HLF!ZN>cl4#9go7+-Im*C8k8dNE@tcc^~= zd>6-mjdY)om&XJ2=)@0EFG`Cg+mAF7^XN`K#)5{JM`54HYs8`smrj7o_DtCF9ekWQ ze%SIgz%O8b1gwsl!;Zv!nuHhg=?cI?ckm2U94Y#EruK9)PRyk=KMViR8+E4O+yvO9 zd@XQ7?gT45u8|1l^c{#P2AQvcET)kxq{XqnBBtPDTEHg?e5*^e*L!ls(H7R>0;zqwRg^eRf0lGv^j>uh_Ky=WBn?X19&3o3;IxpMIxQvhl18 zCrP$tyiGDT!OtY^J;Tp5qmNi?W%k6rie_es&1ZZB*lEz~q`hb8>PrH)u_tyg+Sshi zmBX4~$7k@EqLj<^fbuqAVP`Vs>twtzm#?E1b}7c~oA|gLP@1&^7za5L!N6f}``Kt) zeBku;aT*PKq5LptKtB{&gbhXK7Ws_bbDGkr52PRFa)XY&Odf5t88(bkFvCw7-el~d z>uu8M6mdGxUxNofihLI;Z3Pu zq;uT=qF;#bu95yK+VTAu(x=u|QV^-FQHe(+{q!v)Q~bsIMmzJ0oTucK^xHSe(Fhq< zCHgjSAoZ=8^t?GmjQx#Q$^SsB`hTPqPS6tDJy}XtL4W!&@SccFBxMu|DS&>^Nt3Ul#UVi)W*X0#34QrY zj3M;p4(w-fJ>$NL@X%1o%@T&uU#4J2I2aus5f+$?2f3o;{c`9j<{+9E5h8tsy`N#_pa1Il}V!(5Y}kI(}S_q%9jwvL#1;XW5W zB%qRCL=2Ak>40(u{37XnRTcVCY%ea=DWFB12CY*ndB4Mq;$$9Ig9b`E8@uu~$@poV zBJgjQz7+5>&Z5X+YkX&3hfnJReC{6s&*x;yEFh*nH#bA{A!SaU%6%@Uk?eEJ%gL@P zmRA&rUc}p^(<#Dj@_5WZbg)-&OVr~_CS~~CKoYGapGyf%MaC1w_eYDu$>gYAI=!I$ zoYAi^lKfb@R+ajbiJ=Y6CHZFZxuRbcGU8+5W9XESA~#KJN+Hw_onrY7qhGbPL`Pp6 zCXdFQo^0|)dF!#BYy3f5tw<*$P*n-%n#T6nK&QZuY zq}e$EK{?qL$fg3^2ZJ{E!Jq3kMS5<1T;^v@gg#;SN0u!$ID^6WqvZp9Uu9Ng0zX>A z>@GWwa+kRSNTLTkf#N_zAQDgqV>2bt5ttKL7PvdGC-7y!O24F!=%4%PgW@>E4fv-8 zdknL>=v*yV7|7L*!0gv%ho{^o^4JI1*V>1;Q`-u<2-NKoryC6175E?!3z#P2ha3zr z2_(BYjSmCn8{*{DJg&d(W9kuWoVz0viJ?6OKW5z;uh`q=oH!hMkzROK~}hX8*V! zNb8Heh|}TmhM*JdiQN_rmL!uRd6j`yVG^x!PK&oIDQfwY?ALL^LeXGH4 zri_DtuSF}Wt??67LjSSmQYa^d{37%pdtv8*cSA=(>W|TqGIRV?m!YF6bcz`D*Pv6` z6FWOvS@R$0jFKKmqJs^~A<=Moe{?9m68+Kb4Rl&s&>y(w9nso`{^*!tbD}>9o8vnU zkim7)ikg1uU~bpt7G?*)8`iem`C;-$I{vsn0y~?c6;(QJF&@w~_E= z<<}8QUBF>!kM3XCqf5@! zB5zH;PUP?TMjiHS}r_Kz7t(R-uCVKv8C zwHS2N0teg4K!jDc?JcsZ=dLDD*KC?D@4ex?rNjk;(EC3<3e zqp0!{&qDGxA`eM>^@y#!W@*$SK5~468&mZC(LjDm9&$k3CIpQpV+7 z74FY1H(~#1KKZ6v_$H{+JJH%oGv9U-dAfA?gVJk&hmWP>R`NhPv^TKUXrSAAdYc`b z2F1)gI!D&(K(tY(V#LhpZ61+c0v#SV;KC~ASfrx%McWdsC{}@d)7#y{0>mGk5fa24 zja7m@PKl3UdYj$64UeI8T8_3vqeKjFjs<(Ky4-e3Z{dal=Wq0A9&1qkrD0f{Rx*E< zl6bp4Xg5@}8#Cqv?XY@AevtMzurBwSEjPi9SsueFRA~h7clz%S-kkdTYMiUPOg-qS zkV;uC$FKu>VS8M`V~9Y(arA|uT+M%ixkrcLtQ-f=?mjRhh#&K3N&M`@K|7*_JcG_p zUzk08V8&qIaWU851*GrL->Wg+WFJsqR%@=Uw420)AoM*Q{-AU-;K}~@dBK6sfwe{h zzK&0YH(PehpN|tuGJ`VVkDl27L32fegg&LWc?4~s88Rbn!dVMy)s|>Qn!)ujwVlOs zkMwh)y9PP6vFgOMESBB84bKDU)YP^_BcZ!)=+f`FE>yy*xYS|=V8QYjb})2(vi5;F z*as$nCQ}6-nPzcF=L;G`Y%0aaurJIb{{_rFIt*ur>r`JnyZgW_)~4e;J^3;?eXa{1 z>AHZZoZ)?`e3!n#Ds&+o{-E>^T^H!q1}n`h4H^x&E~LZn;69180V^5&(GxoqEz@Zj zek{FB+>goJ;mZv1-=S!AqLuC^(%ap`S`A$g#AcKg2<8%cm)>SKCNXF$)&mBu`szd@ zp$ohX_eVlzJ8`~!sAw~25cMkT2sgwD_1|U>s%^KADzSPL(t-WtVZW&r$g94yJkR z^;~z)qyk?Fr}P@ONT!Qctl2gHx#L>Poh~UQEo)S*vEbx_`glU zzvEir$N2s`;{R8oew{*GpZc_U1Z}t<%g&}zMqvL+G%wMLG?)7^1-Yk5Nwn^Hm(Res zRYlGGLBCV5X4ptN{BFQ=t<~uS^AZg>5%lNhpwVeL6}B$qwG;j4yz%3bberl>)K?s* zWAN6e-5#SIPvai99cn#D@+n;JYSZ->Q?HnV;WN`<;`$qhfge-e7I@P1*JkL8)ECC^ znJ0oV^p}$|d}bOgLx1Biq?^jyTz_lR^tZy`rym$Yf8#KE?ghSASpCX)uOX$z=(cKwAs+0G{iHKD@*Af#Liaoiq3%nxA7Ff!5cYDewmo7jutB zsVrJZeiSXndp3qg<8n!-Vd#U5(r$1xP@+FXLpl{hAJW^z^+9HMBo3{Rb^AlKJkd(m zhxB&$Ad1$#njkhL6cFsi{U^_HB+FnoZ^J#CPHRYcqLI)CPKVFOLT)=DH@-fQnK*BY zJnNBYur^LBnZKlV@pT1G+afa{Gqn4xpLXzB<|%xM&1py)k@E942;*@Ni!pw@gX|HZH?+M-2aW$0ZD}wX=zb)<%??h3;yBvEX03EOrB$b5=uLW? zM|c}PU6a3M+185Irlbc7zX(jWQ)NT)#AMe+xr|RuK?WY~)L$d|rU9QYX@ms8pYmM+* zX)tlkN`fKXx|Q@snUUhRz;IkA`ob9cmkQIQ<4N{g>3C9M3>}T*iG2=!nn*{%kHE7N zQzWfv#v1(e1G6U`M$bc7;_LkVdOlFDrNjz02)d(H{pU&IZIr=Gg?(U7?4B zHbk*%W&&_ym_PA(`xGz-jQM$+#8MO z13eX(w~=@AS`>>+8G;k!1=HFc#5vPMyZ?|MOl!;M@i@)@kS9!Q%jffWTlg{T6rM0& znAZLtOnXUupEz%r)^>MFTlniZ?SAry)RxcJXVV^@Whdl3G?niY=MmG|@9EqA#Q8*C z>qgHfdde*~ZG+`ulqnmZl2=TFJ9r$pYP=DclwSl!hMi4xcR z#7Ij1v=1zge)@$CXw#DORb)F~aIET3ZaXP?t3I%Zg$V0^1&g&@3T#q-wGUpzM*YLa z-X~aYJ3Qw_wvz_S7pAn1?gw_y@nCUYep({-C+SiQ%OzF}IUpN$yfr{tJH7_^AKLMC zK)l_5SP!JpVxJ*^kg+Mjsc0KXewH>6n59MFdaC9Mr`Z!iTmVL^Ri$%c~F5eXYIeF?l2 zzbE{8Us&CSjsu&tW&mE@hD^vn_JcPSR=1(Pu(8j}M8p-?|Gk5sBSrj0b>rx;$#0_74QXo#zW+)0s`WM-=?)>SJ9sV?tB4n) zRk&Y~l6w(xBE^*yzm!|{U&cA&QKGS8>9fi);!8TOB53JxCC;m0FYWVa;{ctEh}n63 zWzeWV{Eby7^*e=1|LY>rC+vgkecFzE5|3ZuLuBL;UW_&jHM+#}kwlBmJn;U|JxajG zX(8@CPCH~$6X&IICWW7uruaH>PFfs})@!{8c&b5F%ExCTTPSK!$(gvoDEnlean%E< zQpiidLk0pKYa`5g6rp7$*4e2rSPNa31VK1pvq^VLAV0)KbS}D}t{_L}4$CR&YtvQi z8N9z`mx?1U+5oiKoDMwk+>JV;sOZR_2SlyhOq5lms;-tWif*G0?K8mv>j%_LQxCRJ zyP&1%^zePa#j;S|@n5kGl9OtnI&nAJm7zGgt$4O8?^WY<9lX0obv^Z>yG`sie;^}N zTfXVapF%n2Wsci!>W?S8-9gVz{|`LHJ~uroKcW5xb;fZgt5W4R@)3m!=w?llP8QWn(v+Ioo{*+ zWk`n)K6lYD{Jdn#DoK&NrWfR`=Kc7aMWQzh@;qjh$*SzlFYp%N`43lqR=3A$#VQ}A zE}KwpQ?KTlTK*R$+Ue0ZDiZ69bA(#JI!7{lc4zNr_kCM_`k0XgO%2vFU0o9@PahM> zpW!~!Sv}75Vowjl|Mrr~l0nTIR^Z3^vuEL6KJ-Gws$^dxj^b;3(s>^LVO=Sf>tvYu zeb@MiqqrSYTfI$RoMP>N9Gpp60G%J~-*OuSPJVXf6X+?$%*fO;s_tT>4UGL;@L<5> z9OQNh@NrtlZ-;E7x!P>O*@H?}OD$kk(?H1|g@d8Hge#(6JVnI)9d>p&Zzrd>Q3l5@ z!mlu6!6}LTAHsV&$6J$vHyMWTq`>rp2eq5@`Z>tmb00)wSa%A4tS+f62qy<3*;&@2=kKG=w&!-@AjbsG+&1Tj6BtXr~t z!HVB}roAD|+4K-z=*&o%v?SNx=kiWKlDJ2SMx=A;&6+S zpvP|d>^CbW8{Q*B`^d?P69nbhG1FT4Y4c~;4-4ZJ>534ZwF-wz%kYSK87#LnRET;i zE~uS-6nu=E9Qd;i>Ql5q|1L%wf}b@XGLCaZA!|8*kBOFiR3|UnT6k z{)`c|Q#!^LwKiI3dd}XWGyDb09XAi2d-mX9S+Hf$br;koSV!D@3-$sV_&iKGEwg;b zlROt>w?mV0b>;t}E?*);1^CzDH2=ltU;)4JP7aT^*UITS`8&8Sngaha;J=|7n3OZa z?$P2qg&vnYER#EHMAq)CXR}O^td6WXSy zh~cpVmx)(I!#);f9bP(Ub{Ds?*h`@E?_@e#Nh1fxKKg%52N^T$ozna(`oDb>kND}% z0QPeK5cLI(?hC%|0er1_zX5N#UYQ>T~-_n6Om0Q1j zN9-S{TL^z=GT~gIr6=}d)S_4Fi{EE*qRl{F&rP)17<(6$F=$;*g=-mVmKoL8#JpV$ z_+8u=)W&_m6yYx{hQEN%Nr5qDZWPHUkAkmM8*hmCO$<-hd6*{?PRpsPW+zPC*^kNT z7Uvfh9?r_n3D>bRXC^S4I5&S#yx8XD3@TCd1>HZi4V}P4_mPn?9zS#24l;p2y|3 zOL<(1q5lPbp&xV}fohSwAI%LWGP8Vs5N#-i7Hu|4ABZ-_$HjGms*^Q@@7=BB{kRGE`5Ayx#N4Rv)f+7>Hi}qLY?}^$5S2n`&sV0K zA+$$wG+I?^G)Ou(u5mvh>0FtYa zDjF@xlB0Iw>@nSgL=J|>^*9SnI>65!!z2F`b!HhyZ*|^88;b3D8`QNrU#~Aqg&~>4 zj92sGr{0+6>w0v~@eoR5-o~h0{?!o}QSa z>5@NYgEwF-+>c=LZjzsS_~FAB?SJey4?k>r9eru$};a z8TTC)9l!RA$M#<|loRN5YTvtDPhlN!0B<0CV74Ik@WD@;Eea%6pZ6*cmEjW*$oTwh z%ge8i23&EO_LmUkEAmMc--=uZ-!Gw9RVn6jV(%=ypr zF~u6moI0jHde6f0_2sVvEQ=|H*kVPSqgbQv<7?Cs)J3wX3K-o^a2U3 zQWKNHU~z@M~ZQnhM5Q@@lZWkwS0fb8~{u)U_Ml@mNKN0 zh&Mxas8-eHvRPbsY{8;Ht?}+384JA>i?(4eo@c4RqYF8f97{#@Loa`F1gzy@zi$ZuM~UbyyJ z&Ntz*O7iIfmn`Ag&i{Y7tU%B|F08EoD_rF81YdDn==0+UQntdcn%Na3gV^7+38w2} z4+j7s-)ha%E+OhA}3<& z&t3Z z&+b%uZkl`ZsB71~z{es5ml>Da@hrLB3S3s@0eq+7!fR2Ma4QlwO>A6w<=R=(Uod^S zXmjM6OMlDx!qYb6q!+*!bUls>cr4aCXIL#3-k7H=AP&1J+E`gj@oIM(Q+ ziOE(5zHFukX4vFAJ8f~kxbX;M6;Uc1IepgJ%5rtXZ!f(ja^nI-_Of)E(j(92xO~#7 zk#anPYgb8VHm}`g_h(tWcJH0=Hg>oDff**ZvfIz@^0TfkJg&swDZ*>L@rS0kt&+Wx zWyJ*>ivNw>T3Wqk*7V-53{lVi#VGX2ka!bhw>n7NTgOvoAhXg|oa`IwPx$~Zq<3oGQ^u|R~pCp=qk=)ZKev(aMbd&iB*CzARH2dbU;Nu1{?68?}7^gAn2Fyi&e+79V z%imGsmUQ8|*u_-uN^(eTk)Xqdki}xg^OP>9-SJ$f#r}M!YQj(T`A&MowI13ZS2T)5 zcp##L6}3fGnv^oXd~Np1Z0&9~r{J9eHdi_H<0jLJ{Cf-8D(#xWdpW&pVk?!;LC-JE zjSO{Kt*Y69V&X^~`jDISDkcwneNJ|!@3~HQ=JPXLPW*H|Kf`UYKG$ioJU>Hb_$fa> z!-N@>c##6oe4V8nu*22Ii@$LtGT16pd}aPJU)YBaeC(gvZET@uw}&m#Zq3VM3$@$u z5A9aAC{H;QXEtAZLVFVb(yn2v3h&LwDI+FLVmHc{Do;aJ#bkp{*JjKzN`oRRZW}60 z)_1+yg}1x$+7|RzJpD$`l4r}8?!0*Uj(e6}c%S^>@?F1Nx$>?ZWQPjF4IlGi={@*Y z3vkv&vK>~73H>)&6{kyOHmhwbI%=i(kIaM~mlN;2Qs+X*I~w2S%IAODxBsWTFTBUU zFs@?mGQEj8+>Uqyj~3OV4b7h+fAyK{FQ&_Bf|^KtFs&W)QEh{6{yPJWU7MS1hbBGX6gF-{}i z&Ovjjom$#NQv?L)U!t);WFXKkVjB%46E%c($@DoMhz-Zw@q`^csj1^p(4Mo7iTMk* z5lH?5c1%I$pQueBj(~>dP4VS1TmFQEeLMR{JA1jE{komq z-pYdgzrXErne#F2LPemi@ho!!#TR=2bBIK&VR;b>>Q?d;ulc7OwHZD-dT zFf$A?`J4=pfJ`!y!R@S^(-;IApYdNm!MWJj9yx<`wzDzqtg4+QH~)1zd$^skEu7Q& z?d-I6HiARA+u0|ai9PL1zPp`mZfDEd+3a=}X=lN9Cbcu`tS<328$Xtdui5;^iujiS zl7L79KzWPAmyQH4Ba%~~r&WzVxjA}aJ`_pNpFAX_XFurxZo6gkZ8vWD#f_|P)WpeS#!ozLyj;}hBi7sJBXxsw-(%6I zo_=g^RJ--6RhL|SGKIY+~*_M>=b1U>%=^)(7q0o6;rbcTZUzbwV0Sz^I4NJ zCGz5SWsPj<{hsUq?d08jUig;Q1eR~vRM>+p%1m=itP2Zh#8>wM<`00WuzVYX%fKA+ z?R=U;zU6g0@V2*fy*vpgEl?35l5J+psw#Gb5yxh@@k-lLWjz^aJ%8hm+!rP=yzAZM zw=qB4rR`xI_iK-`N!PO_BiPEI_H*sVLfAQ~f?hA5Vc=A(3_j6rGZRj8y?Lp5z1f67 zJQ-)W#fI$|;AFel8QPv-0%aj$-3x=-^&`+vqHqsQ|mk$WcZtZieD#T_(@c`(m>iFrnq+{tz(N_VOA4gNH$>!NfyL2FJOSyXl)gioa)KH zJ$CH3lP|mMGH{~qegFNvdzUX?M!XoZKuzh0Lo(YGm&@cx%v1^4CQCLD7p!(?Lax%K zvP-d}2#_7G`AJwix8NOAu;NE_40ijfb)VZxJ_*~|Q2Dn#J(J{c?_bYg zvA_WBwHgPPk958)!;S|@h&!b65=$8GfQFRT#%#nY#iF93+_h-$_+>??@4du(>Q{H} zx>ozTYGvsyGZ!jzW}U>M)3sRh_5Zy2Ip?bR6#K6QzPHrTSc&2;fv>d~h>FWS2zhcW zc&HkVw8Wg1-Pn3`1k1kh4(;Q+e|h^WElWxk{gJJhec!xu@0q3SKJWT(w;W`@_~@73 zv$fFlIa7A6ItR9YitLha0-sb?n+P;}7j(JMUs=kQ_FG_D7(d1=pSAbq3sBlzfv?T>_Qu5psr;W*7Z!z+6u z{@iiih{rds-aEo8=X-H)k>PH7J-SZ3~NSTsrck^H*yWP|DK9u`%*T^0ip2lD=|z z6nmRnrB)Bb;K^o>u;y)dXa{a#YFFnQGrQz#wOI%L#CE*-X0P{N*17#o?H&q{nKU6b zNxn>82fdea@ZhzjrH!NxJ&SlEYc%vM;aE>(#+qvZY5{Z*w3A?Z0%p{H?)tv@vKv?IBiVCI)52k&Skd% z{rETUGh|oR*Vb31*}AQdsWWK-_jl0SDP^%>b6d_zHW_KF@s@IYR%8em*;d! z`KB!IoX#vgDnWFuhb4H=nSyJHK8Y__`Q!=D!u<-iJ+ThOLQVfJm%YuISSjboZwz>R z+qFNxU7oE?VOMIGvKM==`RbVV`JHc+njYHv=+iIuFt&H2_Q&hACpWU!5tA|Xd#2ua z1-gVeU}0=C-ZHhqPAgIOdsSp;*k-ksU>Dt0SR@shGQHlMfGJSlP*Rv5DXXnh@Vp8O z=I5gbalWrA%buC(ve_f{n%SLhi`|5vrKv{B3wT~_r08WZe^(oif!8Ax$n(6`sIM&0 zFa8`j?;rlasUWqjTyF75Wo=ECY`krYKP=@HtkjodHq|goxUIaxqGaV@YntKX>>SoG z?260T>8v5NsbtEy>P>@h8@o3+Li>REZn|@t_S-j{d7FyP98?h`6c@v&^rfadQ=oiYM@9$8aDb)VLN(;?D zzEhRS$9*C8K31bPbFFkqWOQ{^l`}UNFAVz%lyE_~Zh!}}u-QDGV7a@Rxs>Ya@^Wc* zXSvCo8_b=J5%=WF4f&D$W%=Ft`}5z(H|OUEQMS1W$x|9{@oapco)myq<4EpNHY6b- z@pyEJ^Mh7X&9ZY#%SwkX zyO?Ec=7Ra7_VP~b0&=gkED}Mk#g^f;D0UCt#`amAF6X39HRxiJi&+&H zi!c*(!pNb!kwcfQ3N|?0ZV1P%*eKZN+RwYboNLWN2w{2B%nBuGq<5PUQ!~5{;|A?>ma{?md@CQflfZ8W#%+LfTV(2h(qbtb zEHC#st%01JYN;Ak2uxNFEVn!ClRCq@9ZYg~9K{aB5ed58L3cfvr9eL` zD}QAtYD_xRK$b{~>r3ZBm z{X(!4Y8j5>4Coc*u@QffQDYb}5f?JO*L0RA)YxX~@b0;xcTDHhr||XKi#NYi@`!)w z*cHpzZQ4Tmp4rYmex#7K-Lm73y1Q5BZut3z%U^hT^^(Dn(^js#4*#-T@>JCxvU?_7 z6B~rL>>{xFvm-4yr<`fCx$L<$IP8^|pC1mFloa5uU427QMMr0Tq{uTMGgryBXR95Z z*`5q{O<1z~r4g?+HHksY85|gq_gb>+LT`EG&ws+ak()N6Bj|9E%%9RmKYdLW>Muo2 z1Yl7xx(Zvzu9&)P<<*ayAH=F$R!+L*{Ixd}ez1LQb>Y3gcx>vj0j0Yh``b&3W$Ct6 zd+zSNUODqOcsXY9#Fb~wJC8m4t@_x3-g^0wZL3_`!@b*jwIS%QO3}9Z2y{0erx(Lg zxl|?H6ge$FC#R~iqBtunmz5Q&rKQ1;;`i7yGO)Dt_&wD%Wj;5Gilg3ofin;eI`Evr z?9K|TI?Mfms>*;=iH9Z?+3U|VWA!?sX#}l!>QS8$XF~aK)7bN|<~l;d&n;VIT>0v# zsYxg}e$pb}2ch{wBshN<`y)_w-2UbY-x8*m-qlbAPKEhcQ$_CPzy8R+erOZEKGWXS zMm)HA^JZnH6w{V!OWB+oUsp=DJ@C^TOD|ErVw<()^u6`(TeTYYNBZ9Scly#U)BdGY zJp-#oEjsH0$cENR719K@5qlCik2C7zlPe`hapusXl9K$~ocg-DaYKjZI23bpb7iI3 z+%a(+stZTn@nw%|w&IU*%}60Nc68R)WM`FibY^9{;Q>aB>F69hiYe|`Um|72a$yd5IO+gZt;YWQz&L;O1hC3Y-{35!p|KesN)N$BFqgf&2_Az zPEK|uSit2J$;wmOj~`yXm(~1(nV!&k7AIG?ma!Qb`AUO z6<>XIg*I=(_>6h0He9eOZ`etTg4My|{2|kZFS}&Xh2DndDdla2c|*HkiipAR)`88! z^Ge7Yc`VXYbrODIk$m_A^>Dd`R^a{KI%%NPEOkdl)-?`PvWxH_wK*p{JIiDCw6@e# zgcVg>k9&4!WofbMY0SwQSm(*Yf3BkZ9L0tcm1LbHTCz^VJawyt znDHfSHEyDOG%y&n3y!>7*n*7%?Ah?|mZk~_UxB6i_%~~63#s(2TPF`|iDyZ(p+V_vf>H%l0j0`_B7)?-id6*S7!k<;M|iJ08^w4c{q` zx?${3-)tJo9;ok9zS{cPR;?N@l;InHHO!c8{^`rl=u2C#y~XOZw@j`VX#E()zDSPg z05b7Gyuz5{^QdNzIp39$Q5?w64|%b3|z5Hd-O@R zsfSHoy1e1Z8PEO7bYSu4uV~->bo*UnfBe97;KvsE-QG9H?)-&(PwyEA4lhJ3xBQK_st8RR?%8N=vf>f!YDJb@dh29Jf2mBe|@!Y|nFKjw|*stHRs6;=63+DggABZWKmfAG!HdXLBShuU3aCu7^ zPil+VOTla`-y1%Es`k+@em-?kn6Xu>E}J>iw92Bb@0ho=Yv$5ur?V#*biY zpL>49=+*~kE?6+Lw`2aG+_~T0e$@@P-uSa?d%MXFFb8{3&t)p&lN#y4$fDwk3R6|E zv^2xxaAevuasvUylwqnJP~!1=CUsWf-T`71Ita1ex!HM_Q*}lv>c>e=kF(pU*qn$} zyg~15uR=dIdv|+HZv2oPiq|Xo{RJyKbNv>Jw6asNm`b=Enq*$SU-g^Gfye&;-~an2nVC25=&r7=uBxuCswS1Ckq_zG*L-j68{k{I?iJKn z8c`NR91R~~h|Vu6cB7k9xtvz)g)c|3H=>!!VoWin7@{KG_)28_WaZMZXA?ho@>(GA zXse~Nyua0&$Hdq++jCQk4=CIL3*dn5%99AUs zFZY`iC+L9NBbuahN6bl@mIXxv1r1OO#YiXl3%3$4sa*_`)=0uPY18?cyz8fd)BL`` z?|84j1>Q*8aekBBmX8P=qxb&60)EfDz!$V(76Z@1o_c`KEe-Oul4xZ=EX)#aw_9YR z(Tvr7OSFtV9bHl2TsBxu6?2rX3#0xZ;zL$b0wweZDhTGYavhHM=N& zh4s~*rT01g3y1N_9m<#5L&qLI@)+LQ1m=5U&(TA8v)3Mc5V}xnZHDnX!vfgGJ0Z{G zNU2=f=^H*SA=^D_P+EI0`gAySrtZVdS!tsZOcj%|`g;3L^1BC(N>h+?bIc^a>Xe<` zD*VaLWM^!i*VopYo$Xa(Cr4w>FTY_AH6gHVihUJ_hb2toSz zm1d*mXfw&QEOan}bBbLw>xqWLEXyM5BSR&P*y31L3}^SkqKsnDrNNlU;3mM8<||SR zW-M-u_U0Ew6c%6tNme=<)yD6hy7$&m9WoP7#^symrSDy}_HbQ6uPL{6%Usic%!JCJ znWGYtJKr&G)wey)vi!^;tMdj9-t*sKEu1)M;Zo)P{?A|eR;ymN>;fx);Xf?9Zbf$M_U+q( zZ(xxMzNseg$rNzOLB8x%ryA?Z;fmBM-tCErS0zJkJ6BGWC&!Z%os?1GPeNo;gvnmv z$EvvK2Yup2st3tCqBo&}&~zkHcERvRXfGs$6&7S!xiO8!v7k0#)YW)s6EG2)zB=jN zb9ZUk@ZQ~g5z9Y)V)bE`dGPq74^^$Y|4=t?p0BWV@j#{Kd2M{){=vRgn_PQSyA*qS zu$N$Ych&Ml?OoQ3eS2TkuKVW~_UYMSu(oQ=S7@IiboK5SL$Oja?9$oN3E$LMhCC8d z43;#q($eCSk}Q(bX0v2wB$~|;VMuS=CMUyG;ZIk-w=tVNk=YYszlUdexG&Qko|oav zD9e!Z>^^&$UCzY}HQX7V9FAFC1}r%C4B6sxWtaP1vL!Sp#dHR0Jk&P4ASpnz6T~!B zeA6r^#5zJUrBS*B2P_Z?g&_@e+Ni!`W|u+&2C76BxJwbsFHVUHPsvP6QNEGKuJr!- zkT&@|l=v-t=+igaFY%v~13rFU(O!IRa9|nqE%%e+ z6dfNKDaZL@;#~%Nx!+=e3riZdl#*e^xug$Q@fnK&3%&_`RX82#+=&{l|6|euvklhI zqJ`}tD;z@=GBhRJpup3%FkNzy-NL}9!H`gN`)f=cP*Zq(_1sJS{>~n$)INIZcI^%A z_=OwqeE2ngjg_*+jxAfP4;?6)d8@r{n`v+_ZNC;f^>*up3of>v_4rA<>{4!XXxOfY zY*%=xqXn;xgZ*l*ud~dPttKUzMMaQvZDgb~99}YZTevMP-5C*M#702n{&)pzhC*|U zHAN;vjzq$_33OG0MK9L7baH0Q2rBJ83gl^~}rMxwa z=FFR!%ZvuL=EAjeY^8R7+@Fs;_VOcvRR>-@eC+G-+H>**ZU1QQ(AIw2J37vsB0tXF z*D`m0x>KuU57K+*r=POLCwmQ#zl~%H@~R=8^IfS>TI1{4uCUN-Nr)`)Pml$=Mme0AvzxX z%g?bdTJ>7CdBdsiS6r~sWQ7a+8z`OWWX|8zKGI%beOU7CZ10_?a?Y)1 z#=9RJrhRt)c5VFUFTM6rxSOp7wz{!pQTN;(sXg1}wsn@eZP6@j@X_b37v#Nv)!vL# zrnxopaN5z}RI3crz~{4RmjyMgFbzWr_@>DphMaiQQW2zH6wMP!>cK*sU*sxi4U1WR zk(~DE(nFVy9C`bR`HyIEKQ0;*BAY1U%{u_ARr%c=~ zrWQw59!ZFN_MYe8QX=P1)_N=mH2kC~YiaJL?UmnROAPxlYqM+18Q2Q=b?=5B3|G{< zu_i%&Kn1WOm%?S&4ue4oLNrf|i*FTekPIirIk8-CPc^hM6ctL?GDuCy$Vja5=VY82 zmy=%uy8(P1Z4qX;{K)1r<7Bu1$<#Mu=3D}$Rj?p%#O&#etpkk zYw5^Gfg3Kl8ouSL)Sa}8pESUinI0SKu$dF0qdDYHR;wYcm?b9Nfxo15&eM6iA=zcb zntJY~5?xP%{DCnYP9yr%zy@`XKHvI@s>;q6qfPw3ObF zOJ~nn?HxL#aNUYqZhPb|-+dimZz`WR_?1B3sAVs_b;)~#||v$$vn z9M$9$hcgM$R?dv{bhwSs^cy1z{}X5$UINE$orwybf=1Clyo#6`%B`2dxPh<@Vb35X zFIjHhi%@x$%s`%D{~!2!rUP=((@!0K=EPAp?%@%)OdT`2eA-ye$yX?cwE8jXnz~{8 z=gSY)uI4A6fBoXQ7cOecx3Al@MxYfu()= zYwyt`7ulc}USeezUe-=rc!H%Ic|!Z}al8*7X6c~K=lODD4EoiDQ9Hz!6BTW@Sgcq= zb-Aq3a(qIhC&u#!zrzYon&{-{vgoR4C7KzH;kFpEc2cLk0lg?cWUy~OZ-D|7?qD#O zWMMxj^r_2T>_VT?bP644*lV`_`PPGQg{wMu-O>N>nYvbYH1NClay~0MIq*7r=*`IR zz**TCxRm`Od#L^=zAG@D@0v4*WZ|vggJ;lZX&6lzi7^Iij5P`o!g5q>iky;>p6Ice z3~{lr(^_M(2Nl>m)ES}25I?6^lQbteCzN17n~e1pZY+VRC#8_43Hl@beO~d&g9ddT8~F#8$!Mt`cpua7v2wdoiqVuL`FyEKVPT3V%9fnMY)VS9JvRxl z>QcPP8E%LKTW672lP<}hFhQ^c_{)k0kX}lX84@WK&=rU&cm$S5$o?lL=&tZ~m?jy+ z`K*06Hr)Ng_6L5rqyBgK2S=_yHLSGagKge}qfRbL-O7v)9fd>lJ(_k%d$6d_^tIW$ zZ|8ThsKV}xv~Otan;Kp-PFBB$G_qp-vrt;;>)9#}k=fBvQSDL{c+DA2CIf;qtf`X2 zTwK&HjyAoh@wd*f8*2PEGdy_UO`d^28A%~LHGa4$%VyYTUKLgd=PSZMLrRrUGSNr6 zS`y^()Wl#*1&c7VG&7S^FeCBe+p9B)#*Iyz**x|BOJp#58Wz-@yjK}i|F~Z}Kj9wM z=PuS=Tejjq>hPLFJ5MC6YISaQWuHNpmiQijWXqxGs+bd##;5mOvud7C{rabm4XO2u zAob+i>bLPfYa0Uz%hunvEapC6kAki)Z@2M17Ok8*$GNz;OKO{>l;rM{K%W&*Ov{x{ z@Z%`*#TjKp^{80Fu)s^X%x^;cf~=JJSk7Q7svO3)y2GsFlI$*fY@E|w=1-2K zK<_J&pF~2hcLw9WU&INc8ESsCL%26yHYkWSGFU8fncqU}S*Uf;7_i!kmBnU=CgXw_ z^`>pzYS_ZvoE;nK>X;8Yc2yh(|;|4#}#TIagI%n1kC*6lZ)XwDv6wsi+-6 z%$Zq`+g@nLQG89uZl$ZYY*}5}t)u)AYu$70&VK3rcCPJ7ex-~#8(!u&l+}9Kh;l>^ zLcg*~W?m(-ndD>QLR>+N6qGF#0&g^yh_P`nd#9;1d4E$q^=Utc+ln+PfZMKyIZQU$ z{!4IYVg=eAMWHfqFH=`HFORKms_7=U5#I)9K9dayk+2uG9^VGAn?apXgxo1cLJAxm zyga9=tOHGz$R9PwwOi`qi<7JdZZ;d-w(zL1Fl-v`i3vj#T@=V1=^KD{!9fJiq{AS` z$Jb4L(5zj}dxL9Dl{f2`M&Mes(~U9jaUfvHDpCECC&uOui;8I3QExOhI|v%lV)Gtr zhKFW-*H~loJ`C_C=p<}k*-$Fz1pdrYH($KVX2TBbN(>CYijolTjEjhg2IGd$ssVu) z1V`@2myk`PSvL?!_=i-1jl%V7P&wHrTJrB2A!d{5}ORTlev_{gF1XYKREK*?+0ctG;C zL&KNyS@n0&xw9p|&+9fNIAI1)H%X>8Iqt~V$TGhzd8prk&EIol=fqx*#j@l1SZ5AG*2#6p4V$NQ=G{rwd=Vw^-*W#YNuy2$_Vh0s0(G zl_TV{rQ6?K|H!e`Z>}1#Az4w@t|?s6E`8qImoL8^KD+nOJ4TNkHu%nR`N%%)PVLL> zuU8*p-5#AXV$myawMyIGP5UZPH)g?lt#18-J03vRRt~8wDW5`bQz4Ph_-<#C#lpi7 zmFILAR34^a^;C(lBx6YyK_d|sNnY(_xlZPA!rPZ!ce3+NhCrVCoNTU>Vf|!_la+|8 zo%G9>LzgR@%;#j~;@Wlb*u&y(JXR*2l$?l4(_g`r(D@?7KrESxGf-Txt{@n3qvj>n z#@j>Av8a^d47hw{!9u{erK(wGvdJ(=j`=A<0$I5c?MJ@+`|iTxk3CC`~z|0ra{ zU$w#NF|^%CUdOOi85N_+tnzX{c8;Tn*G3gw%m>5C{+1H1{7_$WUU^9yY}oqK9HaL? zmq3mvYY)oGLs?d-w=W%j+-AwdEe7D%W|tKdT<+(>ml<1ZLXk|igDiBbiKhe$xMV(Q zR8z#DagjSc296o)2YdL6z)qz{{Rv(gI4mE~2G2&&Q=6H)0!sZ)yHNKnyh17E|A1Z< z?X&1Hr^o&5Bnd4tH^Hoil5UYd=M|W$VBTE;!iK**VsA77`*Z9ZTZL>6vXthF1h1i> z)xE;uhBKcdQ;$*=Wf3A=uogV`GCTL-htzIw!#|YC@Y6R*kNR#kGRa^t33qXm*`o5N z{0@~_RFxS|8rV}7cAtgKwXiY^%d@a87B)v5I4$gkg$=Q=TnlqpSjX=z?23gwZDBaL zY@x@th{yDUIiXtxt%MfTg?W{J=xgY?Uh+Dp?N@bk8}Vw4H1Z9@N=#u2ydL;%Gqo>k zSlB< z225Q-VZd}j72{nW?&^dS8(v@E>*$!xPd>BpVpaF!lkR>(JK1MR2k(+W!)MRdyxt{) zN6eT$y3bNR_|QizcEgsppFaAY_T}2`?+#zJwyJ9Dyjk08tE#ronS1Xl>T9+OYr7T7 zYZ&hy?7W09FoTh!yK@m-q2r216FEq+T@#g=bsdz~>K>Sg=b{@l*D%}y-rhWwaW^uo8+u6ujQv{&{Yco9MA_kX`({XceS66fc> z`0N5vAz*9Rrw!&i)X$}8_MopJ3>Iah(c`u{%lwknXN3pH^RT1h{>{zq?-!~s4!&F-R@_*h{=jh$d&tA? z5f9AvFjnqiJ`cBvTM~36(F3_cEVR5apsPi^R@2xCvR8fF;O3xPO6as$ z)nS?$Q@@3T0X~(|re{U^q-4QrxBEG5+MC(QVrI818>k8|>Qd`C57YSWU!^ zxFz%JdK^1V@|2Y+ZR8&?zS>By`s|6O>aT^x1RCAa)DiX6Uv+1~BhP$q!D#!idh9ost#Nb~?jwZX! zYC-sHnfZ#DQw;3{GHkpFV@%M_R}%f%80pje#R=$_{qx4wkNcxI^2q13b1;`a$Wdl;MctZb$nHtVRe&5Qp7H zfSSz;Q_w+AE6kykD07t6iekgUzI5DQ#+dF0N>M?gOT^Neke{^TptCSiJawT*B9z{h z^5^vl`dfZ}$BsaU9XnVhU|~{a!$(SAbrG~&2~v{6T+k#~ZZMM)Z@sC-mF)E~fsgp4 z+wZ&mAKH~MY@^m`3{MO^x_Im2A6dZ|K4!<29V|op0AE*jkmKl%E5y?{qF5y*UAZh` zoBH{(RjwE%3zxXnW<*ns>JUS@VT%D_ygpvWt1!KXcbZbAUNVif&Zqag@#}u zqxd})P3w+k9g@e^$3ea?xx_Z_(GF{ecYnKet6qQimi2Q3vKS(c4=Hy<{jyJ?>VVxL zSi0YWE-|PC2YB>v)n82731a<3{bNzTK<~G^*%&rvkGAMiU{k36hfsg9vIcslS!&~p zHGW@mA^M;$@r;virhu9@{slJBSYU{Lhd<8cI zY6BaFaRWP|i8(Fksaoj^{YRDJd^VKFp#y@>8A@)XiS~KL!rh%H;mK;=ZJRcNWDZXa z?2-RyI0AkY3*CvCm^(V$stZIhqXg3f!Na2zV>`H-Ek?W15XDbzKfJ1BfHx!2?#xVa zrkf3cJ;qV5{8*CLs=#8(&dN%gL4ClO8rZ{6p^iwZgGZVoqo@vou35@YNZm#=bv~vt zr0f^+)a?h?cOIOdo)T+GN^&N+4M+ZS_~n0f&CM!wDy=iTtEv2kA9;p)ir1j?Jf~!l z+T#r4C;Y6QG(hsXLL4D_%A%g<8G64k_FiB+pJ6P;GwzcdadBsup?aLGA=4BYlNdD` zOGE6vty_(yKMoM>;P(W!i+WTk^CSpG!ZS7FxKY(4JdO$_st}fIJRU3pgD5Tdpoi{lky~v=18P4~wgus#cB2id zlpZRldYl4%2BlO}DWS$RHVE+Wd$#fi>fbY#*4?&M$DN!g%E&yaasv;-BG>*FEZ%Sn ze}G_7w$;LlKy)N*! zry2?-2zFzLd_)(h=cy+_MRm7PF9c~%z?gf=h{;nEka8{I;Wx_!m>^3+_a*s)no#@v=Xe=agxn~#+x9yQSwQ@O0*&t*~!gNr#ldQ5N1)nfLqj|gRX2t zH9Gi+J^hi*Q%}h=g)D046P{JDaF|pD#aH{)K&SyJKu!+qzZT z736#D8fg5FXamW(Rwq@(gaVD#<8;DMYQ`C5O(FWsC+Hi;3k^AXqbN3AAA06f7=A(e z0-R>4%PCARB@59Ph=C)ysaaEq7^?kBAT$y~;OfMt$Jz^W!w8_i(5MtNC(qn^b!$DA zzrm{jx1bTT)ZwJTY%!lcCP=CYY5_%W1zMU~OVA!A4P) z#MPSNO|*zS?Fe`BhxMm`KE4o@qiMBgl{wV9Am0%<>{N~H7ZzfAKq2P(7L#Tm9#-u) z)N!gAOehfS@#ZMhM;GFMgG3dW0iML>p^dez<&T53EB_I^%`A2L8UB2hagqgCGyHt4 zwZx##?+Aw$`9ngW&h>Pn107S~ma3)^Qw8k>+o02)N$?faz*QRx2JIm~u-!zw zrP^<*6OB0qQZVR4m4sVUbDCEOdPKDtxAK7cbPQegdEjniY3Qyw2XphYkat3q( z+T)fWFDKeF*8Xp4PspFZ3@|l8d-BJ%tp)Az48e1V_9UYPJOM4!>0ANrF^fs5HE_;! z`WD&&@%fgtN6t5dJ|OUqXv*h0`O)YF!x#Kg)X^M&=$J;@Gc(pmdv%JTHdI@1H2u=p zrTS8UKcc<*ba}v5&A2siH~SoIQ~T5jhT0@wty1?IpbyE;g+RdQ1+%bfzeRt|2e6nK z=$#c}gF@~GWv?JZ@QNUh5^~Kdm7LV8Bi3Xv7;S`Dh$7@`uxgU&W=#Pk8Dxh#i)y4v z!P*5?f-#Y#2+DfF;bD8y`J#{+W-RKJ)gV2S!|cRBMmI{#iGBkF$Soaia|i;p)d{}u zGlTJ4c&(WUehd`+?nHueOr^AN!o%TBdvQ-O-zdA_~{D+WzGga&8CP6O98VM_ixVsDe zfG8UW;I5=tn@@qjNp@`2+b?txnicmr4R>_AB8Uw<7J0!iHwF~wY;&Cfxw8f87vxR z=b`$7Y~v=r(Y!t(x772x=ntxjL48Z$T(pt+heH|;>F*@z4>25u+$$<~f-!>1kzpa+ z5r+2G_#JhWVM-htlz=rziSsY3rU-l_GXP9pZJ7|ua;TIjkMTfloWx#y6Eoi^KSPE#YX zt4>|NQ?N~-ort~0Zx5(K{F1P>)HMlPBDE&8>qkw3T_<>IxnOjGyU|>tcr8IaZ!AUg zl{p}IF!)B=1KwTISfAbMa75V=`fR{XA1>lEc2dkDBfw{DFk1np(|)sB=hO-Dq~Fd! z`RH;X!Gb-}jOPeLNsuaotOpdK^L&~a2^OS(VPFKsXpsHu{7C4FLf>)0OU|u5RWdskaC&?h<#0auxeHNrr(Ope~0h~$@Ep=i@ zKwJgYQfCH*gf4(F;S_U2g?LwR?rTM)rCXX^!yOLQ<^gy{pKV+ecJPR_THPiO>4v1_ zhK0qbQPEL=&}a4Y=sGE)))DGpn#t6f}U8O)v-`cT+7ad%EyIHSk?gkJgSWe(sL!{wYks1fn+!Se=G)tNzEs&N-E2P!ZI_XYnGYodyr3a+F*t7E>?LUgdz-h=C zE#7LgV{)GUZ|BMLJao_h_I`?L3_W}EvB&?HXO`p#8;k|#_w7^z;KdiqmPn)Jy zOrNezo<3c%<9m|+zT9*-4u9e7uarU01xv2K&~#k)&!+MSil&#EI+u20skS7;yMf0Fj`B;$7#66}rtKlc4m=_%ypJ&oLE z=aKRBHR*Nfvh+6g$o>HN_CJ^YBK=kRUizo>Z{#UzU`Wr5L=_I^Vs1p^B*D8mi{-!} z(aVZiXE>DiWPRBHaxf%%D*lBx(boUp=ZT)0*FnFj-mG7E6TLRSH+1a&g?s7wU%Q`Z zH}q`t?_WDl_@T0>p5OW=+NXO#_aI9AUlIQ}sJnaGG>|?0>u0A;s~`J+xQp#;UebkW z)8xlmR)}h*{j5qIu127K@t(SW{_L?|fK-1J<)M;a_@r~eQ6@%9i?P;hsD;K_^Hbuu z`FN}lqfh_P$D3d6^=IsH~qxzxJd#hmcZlRF&vco zNrR-Jh;j5w6QoJfEz%5Wwlq&#BrTIxN^7JI(kAI1X{)qT+ATdKJt93OJs};Jo{>%= z_u&iDOVaP8i_#_OE$JQUn)H$Msr04vHTKy2yYw&VCn>Cv6_X8qV8HWWe%^W{SD)1SSAQ5>OYj%MG;01^HRZ>EQRG~VdOJz34( z#+$CIxK`ZkoQmTVyp0GZ$-uWu4=SO+67jzI`Tu&oS=|H&yZLvp?hu@z@{K{b2p%`; zrZ>S5x*n>x@prIXSMXbXr#5G1(`(jD{Lw4)Eq>smc4yP6cW2FdcjnA@@E<%E_-1DC z{`w=cSjnt+X0fCCgW`0vvReLbEQm*=$XR%f{`p_2*v1NQIBTXh3Rm6B?CK%gQ# z6xhyEC((ae1xuMM{&fNme!kQwlpvQTLOK~{hOWP;4~wP&`C{nG%b|mc3hjDm$Tg0) zrc;f#87>9OVyVFch(f%m3L#rxq+fcmFHwP_FufqUQXvWu4@-D4=sSy)v5KAxlp(Du zE&l3zc43tl8rcf@dVPvAqCN%dtp=<&UREwjWOq;W+2GWLBvtV05*}TbS~#lEqb_)K z8I+542rg|;U&_k5%SwCHIh(EKGvpO02bnZwt|}aB&$Q?qs+e?BK0{MjJzI@66DYFH zo-tx4k!bkVU_IAtYA_(z6w-IY)eWK-MF5O>jVtQ9)+z%Iy|jUdp+bRL8Jn!_)7y_v z>;phq4I*l&{St*khqm~@gDEEIKe!%%FBrokhJnhHfF=t5JxGsD3+KVxY3&{kWo#cG z!$&Dk)(yk`TN}PKj8%@|ekvQ;N3jojW7#NRtH8>!z`np<>I=3H_YYLIHpbb$6AEdfZsqQnSGi$c)2k(!Q3PTZv9@f!qbLn#q8oR_PQ2^q!^P~ z1;5PS3bf~if!6~UcsJDZxO6~ir?iF^o%|&#gXLDfkYLQxgs@;J-+m1osc#uX>u+6 z+c9nxizCd4tqZZ#9*lLtXd}(X{z+Q#CPuOail3OB{J(H=RgTeyIn=eIyD)_B)XU@8KJ?R(?iaxWO0(0Nv5Zmf8Z+8G8A=y|%s?%=gGM zLdEk!)!=b!BH|&*$BUy<~$4* z8iYc&&8`&}0i^zuuHQvNIvwi`Bp_7kHngq5eJ_r3AoW^Uoqd913{{psw zPmvd4yvInbe34;Nq){@)#fJMV_gQ$7g<0IleSQhPV$Crjd~j*-lEYqfV;%~4-0&$6 zOzJha*Y;bc_3b}!YM(w+C-)yz&R5R5r~CZg)3)~SJFU;useSt1G6VgjvS%O*mqB(~ zK`rT09x|ZJ^_AE<#UvHwVABH01vjfMom<7Hwo5H+KfOKkw`YCZvzYd*y|;rSwLEoB zs%+}uGl!L?c1Z1j$b0NzftU&io(X!qZqCFB&P!hM)g){pbT^1y1e^%#CiycXqR*Hr zWfe#%$&#@^%EGt?S-JBm0dr$w6Kqq`c+B8egxn@b%3)Zh?WzxGdl+OR$KT|!+MDma z|G;jR|K2s07Z}$rIk|1y{wJDHbi&{NpdKrNf8 z?fmH)%V&FbUwcn`6LI5gRC2q}!{S>zziCt2*|Wfz!rlVTb_<+Q{BK*S($_KF7SSrv z6X5~6q;|O}QI0rA>$cO|GJjh(pe^f)BuqJ(ls&QBF~^~phGdp!az~~k6B+Jv-=H)L z6M&~ToA4A$4cdsNmRM4Q@tEXamC@{2ASO#7X7^2q2}q&lG{eP{gobVdw{9QU5*N#L ztc9Lvr+s+#Y#Qyt#95z)1%@!CGa^ZfrB%M3g-)x{!n3oZW7=h=CZwgsSmcf!aOB&Nn>r^dvjX3H!-{JQ&lH+N?l=0HW%Rb zxDk9v-qfSVlrCr6o*8AhdNq8&nf5j5{ks+RSXTLbPR(HF)vLC$>Q*n@HZN)OOD}KQ zTruSprSmmIR@<7kXXY*Jwz2{@#b!HA*RF;4t8ROya%H!wa+EO0-u2au8(*0;>8_2h zf<%C)_u&GWsm?$|O79S-B&r1?gyIFxPZ=zOmi!dh_9;6Z683;e;@d4`gYcfiIpl|o zMD7R!Z|E?-b{u_`|tFJsN@4@^%|WSnHV=3Nkv$Ee7klXI*pHy zXKeVWc%^M&dB;)9x^*4Vsl1?VjxuD`>;v{geXtXhq0b@fz~uwh4@|a)#|~eG3>jui z$GqIQ*n-@=P9~GlxX_=_tS!nKaEL6lC74L><|ep=G-zxi{}4buIe+o+=5VGpwSf1a z%2z!$YWVUdfcVyC!2VL*VR{6SE>Fag2+a3UDcCzZ#0Z5N^WW;;(z0%5&}#bx=KQSg zp;E2#6x!WJ>VS6ZeN*J6exaDAC}LAdEYcxzFd22nNU>lr zlHLc!1L%hwBGQzx_5Wgd!wNw{9nlvq;`S4N0hQR?^ zG4xK)D~OGq*m+n^R_oLe6;`%S|D{bbSB|*FVkzzg9`ubgNQqX~ zLnK84+5(XZMaGh{NWTE3FeO~RxK6vlTvc|h9lND`dVzPG_L+Rw3)M7$ield`mYSP9~(XPh<~kr%I5XT z<%%aZuUg-?qU!v^hbEdcGA13{8g{;Fyno}cB@oC=>H-F+{k3bnq*}W)(9)s2>>6&3&btOF*QNiKCd+^?j{$COlpFa6z97|H-gHkhtX|1 zxdUhf=q1W-rB8j0>CPqPg}mbl|DZK}woT|(nHg?9FmK`h-XomRSu=X|dvIZg#pPYq z_On;dPm3#Bx^rOe(!~Q;4O*~|dAqmD$Xs^+{#m0Yw~|eR2QFOrSo>i!R_$I|7)9-w z+VC}OMa#f%<9#-Y`8LWiv34i6Fu)F6p%^>FfwjYWN09MR{xRLT8#6CHIeOgFV+Ry% zzGd{5%+!0wOxiML&fRO=qiVigQMsnatWEQmE?Km3@~Cs0PQD}hwnxKX|rtzV2SRG!OoI;+zhRmMoVskWX$B5Sw#{zmNN75UfP?(GoIZsav_tI*H&r| zZ0e)U*wL4%=cjIBeiV=Pydov=Lg@ES{BQ|b7EuvR=E03g^AL;Q~Ny>ch48c;{W zFzF0(_;58;B#{P)BRoU-zGy|ijvbc|&dUzGziC0oPTe{WP~^64`B~O$@VpU4xkKkf z@TYeM?pU<9ua{(CJ9xekB5roeqXGw^;>eeA`Z+Tv#QY14Kt z+C_7&{Duzlo5-0*>$cN<#h#=Hg8_R7#YS@;@51i467~%;rzE?b(FqYYdt9uNWHOsg zNlI*--4>A$?X<~}Ml#{OLb>drwl%&4wV*R})m2y6gx*G)J_!cO*a+q*-rn#GbX#$h zF}zsC1^Lb2S7_B0zsIYB4Ww`FJ*}F(;D?^xb-%Wkb@M;$XaAy)zS<}I*?8@B9BWp8 z75mqIjkbUw`-YOMu0brs5?^0?I8vLrB=`%$`3D(IWH<7^8IkiV0!GaWq+RzRC|H7* z6qkKghcU^RYaC)MH-2x#UOR{qhjxy#T2d3n@ST# z%0@+^2tp|6u8|UTm~z>j7yA)lPXhjX_Qi%B8(4$J@THx3P>hp!G~gKe#3fbuijj-Y z6$G3b0lfy0mkG$rT0kBI8~~doG`pmvBZ6*TZ3(1Mo=I)i;cN*cJc2I0||$`pHri~!c&Ps@s1%V&+qJRgrl*oL)Z zD^lY5MTeF!z@Ewy+Kf><&&*Kki zo!O-a9^?H2{f7>8?hD_s(Gd2|xrg434?H4&yJAh?KldQ&sZ1NAEC5}$hJBz2n!t8n zS#esscAlt+0x!d+-I0+A9%NRQp&PcBJ#yDB-t4UGGJio{r@WzlV_H_HPR`aomKB{G zH`JdT-4Qz^l=-7v&T>1;wQs?8NOmWZlh6hbm;&owP}F3MGz>XpsMWhhG(2cE(5V>2 z4BkV>KtUYRWMQi&uPakz+>1!bM=pICiS(QBl9d()7XjJTjdx{6HPVfZl^Arf;<9t6 zKl;&Bl{aYFZPw@8vGtWhYwmq(es<^BlwN%+#~BR0Pp`99-h)l3HLyOp$|Vf-5N_o&;|IuM1fQ8ZnY3GgsOj zEOq7j!lkdAxHnMmElKa?pD?}X66<@{UD_DFg#bf8Y^!S*l zsA!wfXfa2d6B6A>#w5kZ%NCo>IMi>8lEaZ=D3qNHaj~ybAU!4YZc1Y*NC-&?Nrj@i z@h28V#P(Gb)MjK|I~`p4^y7;T-kDu7a`o=&YK*QmGp9WDX5d8NOSX$YIe$(8Wcyn6 z4Q*A|wNTZ9IO;BNG|bmZGCJ&bv)K~naL5KlVF=v-q9nV~ZV0m?>!V^YuyVhl1)hj) zw**t52IC%!ROA;7?@$t{wJKWE0kTrfAfG~-0!OhF<=`{*Ll95; zz5EU0Iycc~AX}7C4{okIB?GTvBpF6lo&fL7I{{6s5Xdag)VXvG*4%GBef$MC&n}lXu@gJSjONe#BO1fY7=}7JD4HcVK^8aolq9n z9MPWDwz2uF|I7RDPYQhc&Ef@r{=4>XX6-wU-N8n(VQl4~4z;T{Op}jSYFD*?YJb-# z;RhodzVbQpOGRN$hmAXs_<|dv!`!xr;eLk$*|-^ZFzWk=j^NJZHz|5rG(H7x;jU(1%f`EI3Cz}M<`g*EKTrj>guyJU6h zCF+O1VtNL+NtcR!iIJ|vFtLqhS2# zbbXsZ49_$f^D=``*5N9Oz*YoEhFwHyvW?H|n>?>azx*zdTJ;w{INO>d3V-}UoAg?s zZKkK>$J77nkZkVgI-7Q{djIxWQ#$spTPLqB{9XQ>7wXp_pXHjvCvIbkJgt8F)tzZS z^yItOEGO6-u*zZhGjNxRIcE~ajhq&jj-8^?JW0d-@g8TmO_q&ePLsnJC3G?!aELOD zTKoZgVtAAeFbFa70lM4-E(Z?^;ZwQ)t>Xp8+!I%{w@#ckr>0$b>4kPNk$1Rzjvvu~ zdEv~&lAJb0;r#4h)7}X52adB+j@&0l29jR6s|RZsGre7(k(F(Wy7>SvC(DWalhPYh zn1+lXIkDz=qa!iVXhy6pvi+)Rv?OPw%MtMrkl4{!8i{e(QjizO$%z1Tipe{7K{8a^?x4*Nnu!djwxb4W!gHm%~eiVEa zbUO+B!YF0=NJmB%IkQP{Pa43)BuH6rk};r@pkz@d=^`a0PxSKc1#Vh{ABgg>tu%&# z8N0;}^)n=*av1a>(6T(z>@n_H5B3)=rdsKw6|K`;C;~4ojYNLp_pu%xh4sFUzQkxL z9!}RPM7ErqiESQdi@@w{t zT%j!3zWoIr9r%Xypp){y*`2}XB2OEQ%wUkKV5f<_ln>nxdx)Fi*s0=acBi&|G@Grh z+oM)$+efk4YqrCKL;FrCXA!~You`aOLlyW~F7|JrXGt7TE^ZbaBdTDCkadko~N~_bZ=T$#;Y&0oV4$e&23V7m#SbaH)rW{^Z zT9z^Xug5++vU8g@nk8tTpF4+kKPO#etH2C`{#%H}#^fCSTa+FIJK71AE{Hu!L`8i; zyDZDIE?*8zynI>oDe{ZR@raLY@&QA_4N(S>w_XOifOs!u#sZl{Rq``|QhtWNyJLrz zh{PaxAvXy3vjpG{X(CU-j2R{3k-HoooC;B#_W%jb~I z<5!>4Zee@Hb24SJlNY1DC`g(F#GJG-Ad9cd&KQr5jg3j*35iMZ4o}Q*I0J z0^}GT6C3w4NTYuSY2)Y;KmyKRh4E+b-T8Cq>iem0)E1y0kR$X-q{*GCydIIMLR)eg zl^>WzI|bUVQ4g{HhR@+M;Hoi@dk{r}mjHoCwSvmYkdj&GrKw&~A?sg#|NYem#tt4d z&hYuxV^7^XXhKDQQFa%(QhD8}mz{+v9Blol<9memrr~<)hU-cX)NN2B1S~z!S>KA^ zI&7HlBQFYF2W8=Y40>d`q2Hht{4N7tUlzY%n#1ot4R6aI7}nrAvP$DOTsUT**?VL&CHHH<96oC@~0rEc~Bg-H|H<0akE3dxhkw z-XZ!9JtPHy_7o+n-plTMj$y0iv+NkzRDj+mJsM&}A7x^g z>wURSz}Z84w8i-wJa`^Bral$-6WqLG;ItHWnpLE+yfl^u&;)ES#w%;YeZV^1$KFN- zmTK(U#GFC+ktaxQEAHX?J<>&KAeA*Fo#m!8Y!#rF6{4F=q8m{b<$!5fR*04|iIxOh zYs5XgslJA@=zo1|ps{d){Q=yBNdLlRCM}1Z#6t8?tvXBcu#2TWYX9XpY@AlbQUMiDPSt_fi}JSsrg`FgWbk}jsT$`=2q@w_ zcAFM$E7kraV9f;rSb79=;+u%4seLO@N2z$m9fV^9awvtJ=KB*^UII(Noq}#dJS9^F zPZ4E@@ZY>m-~pm6od^6NpTXGupN+Q=kIGcRqXa%k5E2!lM}qhOpJP)6j{uz#?_g*# z+AYSEAqV)zn8NvglZ?fAK@Z|Q=z-2NtkZ??C(i5m`!@$i24BSe%C`pEhqk}ZP398| z5}zt8-05^Fj74CjRtfKJW}QF)X4Vl8DEQ>82=gRwNiOC}B$R>@HaMq)RzYSZFDr6k z_hGozu(a>?ogKwqzCYf5&TZ*3Wg46DjgrfUY5fD+?`Hh^2eXddKUGU3cKI}=)^j^OB<634I3jFY7U;q?7D&b?V|B zQ`Toz_AQniixd&DG&QzeyBtSWR!&%EW?EcM&XQKikvMbP^yz)Oj2O|iFN?FKj~{7G z>ytNm@`U1r(QW$1rH@Q2SlFh`!h*Ds>2ZBcISwo%$2d(&d{&Yx2a{xIQ?Jr~1wyTA zTEwGn7kU;Q=zro1Iw$^cg-C^N2>pqJkSSi=6S|^*H{M9#0{mOR-zcFVHF3`5{h%`x+ z*ICcNOW28~Coie2tXoS*$|~)J>ya_URR|F4d)AzE(L4&7EeaJs5Ir*vFTW%@n`jY0;uI=2m?fxCx=K050j2=6Ff?rv6 zy|S{heigzHKd7uNsl2Yl0stJa4_MFoRoJ4c=k?FamqZNc)w7&^Ha76u+S~tr>oPg# zm0nZBt+QBKkB*x5MeqLF`>dxn_R_U?E?#`+=0&4}7zoF9yl1 zu~KWZQZjuc+Cct8tY;?6GUiAV;OEd{<_9NA8VR&G$x7R_TGY1vn3rD~Ju5eFRJ#W; z{=QV6<}a%^K)o40BwWO1g4|-{5@yHuVkIAYrBNEmeDuFq&O>h|%U)V_@^Uspd(ir* z?fiM$qgLf<=6mfm?L`_6OuAi(XT4#oq0D(P1_{|$O;(G^xXoWCSIB(7{4~~H0eQ(A z&GXR<1s2Rg0NNXXP8{>H>K?r)w{q;`D08jy92;OrMw!LFR0H-?Fqm3pQ0V9JuitX3_aQT{AdoAHJ@Mn}$s@Ad_|C@4L z@EM9yORrtl{-D-IewJ`e$Dssx?l)*?Z-FntY=YuT$qy~^!=|T@u(wK0f*(i;D`(I@ zu=ESCg0^FkFc`Q*z-gK>4Q2EDXMDiFn*PljhOo%Zn0}>J~pXSAW|6qWy|pcG#2bxpwRzWS4wW#1?Gqhs@W4TrW}9Q?appql80MsqMhd zX(+(hnuvF?7fXTPW{()$H>tSDJkFXow4}|5!MzhJ!^ekLsXq{t+Eqq0@RBIAX)uCSdK2*zluZkp8%{+m z3ZFf0+^q0LQPpbgU2_V%bS;>FC+QJ0m1lT_`UUo>=qdDy|Mt67d4}B(_e0>~`QQAF zxv5*d*zkK}4i+?ETZ0W;_&CD`NTy2|iQzg4J`|N!*o|HHW|#I!8awlEL&weSoo85+ zl{ijTEG_Q03uD*<>7r)-AC(oD{F`NAKJYbg zSfjpxR3G~YCqMiB5w0&$|BUN<1kNP4t{- z%{W&QJa@Z(uI{he#rlW!bM+qv@4Jlq)&=i-F?io0+&4COAB5m9;XE)~yQrn;_h=LK zd$jeya|it#uq~d`{(y6AlUGTmf{oMbp55T)YNvw;K2^ymKPN=Xdcfjzb zFKrV~CCzJNwUSv!Ohwk-RZp-S&_CG}t&s6iriZN!lt0>RFGV_O#*{fE5pw5~PMDvY zGXX`AQH|Jg26VHH2LneBvOR$hkl*eE@#k8aw^O-?MY+1(JJJ_#!)5`Bf!Xb|5H`A= zu^TY|Jd`!91@He_Pp|W^K<88+juNF z!sZM(AQ;mS7_CFo?K7=z?e_$ZF~@e)@~#jT0+!2w#fvtY;LYfi#`%&=ve99c@#ma{ z8Mo26USg1Y{AuZm^a(K02#&-)k+6{Qu*C8pM9QSOr1y}R4}p`P3xEh3AeHi>03!Id zKmvwPv}^B`m*3j^z@0i+mg7flNSmQUb~BEK`{Jw+-N+m};*%oU`8EMJV`fWI4m%GiJ%fr$*T08>V2K*Q(d6A8=%?x{@5Dn0t(YohO; zKXc~BYOK9nP+w*xqO3S*V(D&EvNO+#T_~JR7HLIvs|OJ|bNFgbLQ&zqn8Z>rI#AU- zY<&mCbe>QmP_CS+=N=dGnB410gdrlAD%;^5F*3bvThlme&cKr5;loq&atz~b*#iv6 zR%^R5GtycOD}^s@X2#I|nCXLh_k$o$3w<-o7hzq=N#f&CrnxMlJ}5&kD3vohfg zekWq$fwV=ZH)@Y+m)I>Vd!zgi8^2L|mu3G_nUVq>_J3aHecGp@O#Ti}->CIw&;5T; z<{jE!*&LRo!+(7b-ua%y+8X0O)%A&OIe+F0L9 zejcbqd4qzuG=W!y(-hfa=E&QM5`(BjCpN_|3rw0>F`PdLk!Q520#edxLG5 zr2UB%piJ02eyM+MF0|30E=o;7QWwkzsn{LbmdQx%Qm|2eoXwe}ona`jIjppX8$lVz zAXt$f6*;c3dM{cpfNfbWiWQ{>63h9zjl4g9V`5-0pWnPZGnGelr1v=oAvJG&i}H|A zyqLLkIMMv!?Bs_VzvYjy4YRcATCO~U%I(vLQ)tC{9|50=(HQC@VtJv}I%<5gG9TCG zj%81Nx>0_FEuX5*3_OVV%3zs%uBxwxi?)mP@c&8M`P_}##Ifikkb707_8x0R<-s5# z`sn{vzgKI+W}$=7@5=)3@-GGluJe>;Wtu4(z6qa5FJLsPEhtKuqr5kbP(fEZW<>>) zSUaFWdxn28AaD{XMv@8cA&s<^=?f3lL6l``HuQr_17glWE@w@oby!=vkX16%M!uKt zD-9gr*fT`by<|42?lnHi1!lIM#K@#Myy zZ;nX4?^_HiB+my+fnWnRFK7j;*ccl84+l0VtqSYE;!{uPc=%ZzG+zIAb?7~IQtP>b zEnxA$i}s9CnqR+?Meun7*N`wl-qnm2{^#_yQ=77ktz)*0Y^bc|*N>4812?&i_(rS; zHocI{7O(;XbzMu=+Ki~qXr-&!@jq>3eHFR5KJs1VLL&}Z(gN|o-=>AaMq22)0<`c? zWxU>LqP?^(O0;j1Qhg4Rq-N{}B^xoA$j1iYHF>H1jQJO>M4}(?m8|L>U#exk{6i`7?x}r#tBA_TBh>dLCf9B368;YO$eed^s56#)x*?Z49bLPyPGwlo+ zK(45y%c^E*Lp%O?kQR_vE{2=y*Wcrt;EaP~5eF_HWyGi?oxK}4?spXHl5ZaE zB}l`}ywiUp>IuaBnFtWVcUGNYH-kMr(YdI#``=VJF>*0a|wl*#Dp3Nn5sy?7S>XhM%?eb~DlN76$o zNdkSTvg&6BQ|MoQ^!ZNz%v2r!VV_(bejTiwSI(;R0$=p5Cl8;hWCts&dhi}ltku=l zH*Z@i!**vKH;DMll#h9(CP^*zxz%rbb0=aCKvtzXyJ{zSbdq;B9l`O|wt-H`k)mG( zvpx%G3jB2hTw`hLv;|P{^;J0&5l0cN?Pc%V z{X6{ehBY~p^r)nh$%8rG4=Bf1YxmLa(LPaIF6$rZL9cS~Jgib}cJ46C%BZ@JVfzJ9 zl4|-}sRvxcTmgW$(!IN#7(7q_nuskd0rN>WpATlm zof~_B4<_+*&F1$0@8b9MKmLALK*w_(F7U(7!|$+<$qw$Cin}xUwubX;ed5J^F;~Yw z*Gpf_=R*qL$LEXj6*tEl6NqbiWt4fQ62hVaPhmcysV!Y32uxWFl^s|T2-sqxAl z@Sbug9Ao9)@gN}FGC!H~_6NJq=e{oGmGMJCyaxTyKJ5Leh`h+XvLW7S9INA&0dKCq zPM}?@CT7(BqhEHSh*VT!ze2bEycYe-FMR#K<=??4tC~K59;{S1S8d7l;ir&QpugO zS|7NM-;Tv5Y--0)z*mL*tS&g@HOO0pv>9SNAhhIXZ#J1iF0W+wc`Hd50`K~gxc~W> z;2}&eXJgn-T9a9o-VfP*{yPp$N->KMoYj`qLv-8E-Q z{C)4#2?&rpqf%{M6-t(j_p&S@1FR)VU+RD4vlWlWw#nQ|R#EjXxputwH#+wem&fSy z={UCepZkVB8(lIMPDZavnvbsknkn9$#6!89YRmI4#|Za&Z=Hnb?H%B;k^FJq_u;W| z{(bV7^BdF|(;_S`ABg!Zv#&Q66I;B`Yde9?t)%aduXM&sT#tg+X$SKf)KhYWxPwnbi7fpLlx|;aqxVC0lo{JN>G!x3QY$ z@R?XsUp_l3d~B!P!B?w64&1VL@biEL1)mo`s0d@O36-qlx$T@pAj>^=2^v0sjkoQ; zN5iKLpwWX|RY`lmNPpG0>KN@yF(bw0Q(YzX`(OENzA@?BUQif=fj=4TeS&_|w`vth zVUzsiFMT$w?qa^`Z~1J4i^$SHD>=85rO$@CJl|&%F59Jbj27SM1s-&kKyy%)_PLj= zyT9wRwc^$k>iUy+;T*VCJJsLMWqR1}LVmDvQhq)?sP%RJ(Hz<#?~C(r6>a}YHk;Hx zw+Z;YuONaLucHfS_|2jHBO0H3*Ucl3Vdz1#$*C$D$Ns1~LL2+hxDNZ+{zWypmx;Rw{{ub5r6&QEI|I=K2X4QW>&2LPAMJ%`ud_=d;`^+rFxa^0? zxlgMmbf<3<3@&(iefTV#H4*da8rHin<-PgYWub4Pe037Kg^` z^rxzoAW`LJVv`Y3y41)V{XOPb;RjI6X`k=4)lc$8Cq2)bf5yA39Ns)8LGGwppTT;c zy9*a!J2I;4x8YJ~zTWb;{k9FW$cjq#cGdFktp9SSK5^1V`WQk#u%!mKIbKpfm`^`; zdG8tvV~JJQvEFR&z1d_qEfw4gtce2Se zIS*Ki``_vvsdcwkS35$%G3_|-!E7?yyNO+QzMQW}Zdk{cN633jzDsZ3H!ks;dpByl z`G@z87c!^)exE{SS8Z44JGg7-3Z*~)_w~Y=0ucT@y;Jz})R_-@q((2WZvg!U#-l2( zt@aytzyE$zs~npKmd*ixnip!U-r`ZD7`i!E!eD z)Jj1jLzI*?atjVT%1ZAKD&M(QJFg;a1!OAb;<->U$9A4?Ra^hubCdqOm?>5W8pyVD z(PYk)AJnBnwzcD4pPl=Mew!xOy9I$ja&;vee(re?_?7MYz>n`*JNggt`#dUvzZsb> zd^X_s{(;?Ej?eaAm!S@g9;6REW{eU*V>hNwZx?cc=OOw!gU`WgaJ-zo?HBXs#Y~%+ zZSclMHQ;_s!$huEk0b>7DpV6$iM%I{goIV(mRcy2eljPqT)pP+V5jj#OI-BD1p z@_(Kce#`sXeDVnw2=)}jYJ+RntD&*Ab#xL999l8*A+QROx{o-6$B4)JU`17(}xK{*qguNor0Etl6zF+;+ z`ebLkIdjPCWGH;HbIm+zlH-#>h;kg@N%r%V_%IXG>vd2&+f>ifF2DLe_Se2>~2d#Of%?^+siua2Wze?_6T1pZp^rT3u)pgRgdeN5)OVheLIB=p|)*E;dK5k8WtBGic#;oGa>jM#tkK3GO7$tZYWRXM%sZn593c75o-*0F8g<3YS&^v7knTry0 zT>a120bW;5pM6Ok5Lm?w&NtrvL&^PI-}4YUEq6LDjCk^R9lW*c=FZ$P^c_b4VYNK+ zfNz~yS?H?JfvWiv=z&UFQuV-ix`|!|KCzuv>Z)(|#D~vPE_cS~eBYQ;?{zTxoK`t)F0eY2cN}d9Ut4K& z?(0hRa=9j0i?@T^OXKqaVPP)7>^Cjw=U^Z3E<>QG(n^|EwYrj?rn+DGYxw2+=l>?Z zPPf9{b{m`LNqg@Xm1G>GihA-Us_&7F*h?57&Z_Wns8xYTu?S%4@xTqjp*$R>XeIy1 z#xd<;cE*UmG1{VaIrzu7HSu^)+AeW^fuA*Ln;bzk^N@K42PuqVjSRx^h?5#aFe`CJ z0>=z+K$D-;5GNye!7KQw2*mrr!2;)4#EbkKgFnv8k{dDokbVZ^WUE-gVTcMGhhXyl zrA&d+Is1XrCgL286|vqA7eW3-|}MTBN#t_lCba+ z=OgYtaO4_oU7I=wBuH-HwJx03sXe!0Ao}yn*8?NWCQZWlsgnkc-Mz!Yf=#+lJE2u; z?0txm=Xa{#u(!rS47ZVN3^FDf6^6dW*l(OP*k*%FG>Q$%qbJjo23c;bFerZrQJN6W zil3grzY_bi4l`zW5WPI612mnVndLE~c%&=O)FsMhbXbgk7difccw(cq z17s9}?qRV95hPsvQkV*i5-UV3vP^z_2j^LE{+T7<{0?z1UL1?lwr}Xy-01x>j5KfN z{T}-8y7o2cE6%mlQ<}e_SXuy0HX(-BtGL7vBsU!+JilM+1Sd zT5y5!mAVd(drv)1yK_Z0I`E@C-o*=fJL9pC$%}|R6`;U=2N27GH(&Uv! zqka80EE^#Y+R`P;H$3iz@u>108LxCoZlkj1u`#g&W7(Qm5)xYx`$+8ju_t4-SlTWW zztMI|yS91*9#$tILFteH1(}h+W8Yi~?_+*=yT)`K*p;p6N z7pXHt$TZZT0ypO3-IT>7_DCF-#ilh8JV(I_RV&3U4A^i|YZ zxF62P{zo|d`F!brgwvnT75_a><@)O5%qz-1OZKnZWdFLMrl#7*v>(`>t!Ym}+E=uH zr2YHtPqx?ElZFk2#zrU+jrDrxYpm!8&cls<;2Tj_OBB%Ht%1Hnd>R3Grn)Yh)ooa`Ot`XBC=0&Ih5ltfam$4B`A|8k^x+3p^ z2(}}FoQWV2Y&kAyY#tU9Rur}{Yi!PoiAvN(10|3OLM4?BGbZ$~~m@Tu}tNLyu^@>%sep}MOh=Dc1`zC-QMx@+s{ zt>?8?16ns}&A*Ioy`=R6t&Oh8d!RMj(VCn=5JQYI(T*ZwB8nmwMl6q56R|nsM1&cP zj2;maB9`H6b%eo_{P$%Tk*ESKSmR?eV@`2VhCe)hGd&YZeJ@3(>0cUltPt)bkV zJWnl(UKqVBdQG(210TzyS#WfZ=z+*vjy!!!G&vbfqFM76F)fN(ENrp7#hMnITbyWN z_GqyTUsy2mdx(6k1tk+AG3X14bVtTUCP!*KksLWFa$;n0 z$$G4w)`nW6tw7D-rd#QNn`sj;Zszy%WNLRQEqTOt%N4W?IFx^)JubGG~qYmZA1WkdD(t+uzdV)uYR7M)OAbI+eUBqhFG$GGrw2Q}@vz4VX3 z7jC$FY0$j^O;d)BRbR_`8nKiX{dwK`QEVdT2X|a>*jQ#FGvY7J(C`q2{=U-t$BnKvuFOYo+VQ!+M9t?QQSYZ=a}GYb@S&>^cBa97La1x7 z;L-j5{M)uG{+!9npWlkjKdP@2>k`&D!@qShJ*J z(M{JaM(n3i%6#Q%%%A8=hh*GINGoF0FC)v*Uyy_lhK4}f!n<^>Eml?PYuO2QAbD;pCC_pGdt4I1mtd8J$QTXKp zBFs))>}80^C9d&=^6Exg=QO%a?7UAAKt>;c2m!8qR%TV)%fCuMXd)u(VbBBkM zd*-KRT%D0T^omZMJ0&z5HNALp`piy)(_0q`UG;8(Y-7P+D`i4*mlh$xA;E`6hwz)i zLPDCd;0SJ$STKu-Xxg+P7;Orsn=q0j*Ghe4HAQ z4KTg~N#vog<9Wm}Sr8++Z z1>+-&lez?+L(U8w*A_e0xiocM4g$XvXo@uhok< zN185QK8&39wx2;(B2C*IAFV;mcc8Rf~zu81ZL8Z>DFNjBk=vmP*j`CAs&=zVM3hxL`-Pwmk=l7<3WNET#{TFxUz9!y4xm-cyi9|9Y(7LnC>=& zEJ@sxOT5)_cZ3bRvfs!)gL({hmp3C--jdK(!+KnEH<>&Cj*Ssr6NdIm9nvm}+*AHa z(^iYQ48K(JP`}gA%33PDUJr{rG&)qwI=4gt!oa5$bZB&7plo{XL!#*oWC&+jr}6Qt zXdHHv>bAp12G`qg!J+?s=;ZPTuf6-Br|(>!xVY)~0b?6Y>^<|g+cE~H_U@CBK7bU> zTDJPNk3YSyald)zjI2TKa$iXftSASL3dPaj5f{WH+|A`L5NSMxO}%h51K?;EuVj9#gpE2nnBM!@7?v{-K5o9MQ9uh z@^IQlW5}bUlAheIq1B>Ii#CTwx8OR}q6H!;H9a&M0k1EI3_3XSxS%VlsXEA{rWNq3 z&=R7ZJCVg@EVwC~p)H8JzYr{y%P9=)gTy|xY}vyT2lX9z&w_>b5AHWO>xr9gOi#bD z{@}iS20b+3D$+BrsCa7cDS6)ClD4Dl&>fMF9 zZI8O;x5#HCw{P0;(C7wM(_|~SH*VsxtdK*a>(y&+#ULantN?MLZ*7U9iJC2utr~OP zzM8Ess#bKah!u!X_C8eFaO5{o>1 zv4>yIaq)GlU<3i?S0lH&)Y8?v_dKw5-IfRRyt=Db#i@ZymmzRA^?nSVl7x&KC~3*< zmMbe@G3)7r7$VA|yYLP3D=^I0osm96oOBoZ%9Y^}y>WA8%q9djD}l61>L>9E7NOh{ z8bQr!BWSidu;-f5i=M1MJ#^ZzVY#8x8}d+dx4XW*zAP=VbL#9x7vV!&p-~Nd(1{fP zLflreHOQK1RajdreT%iPy!48%1p?$C+|GAM$tk2LHshc9Z}DlMgZlfj{55mYX@bw+a6NFHG>pnX5>jcJ)fhfdDk-JoHEZYfD_d^B&C z+_gEx&HE0-^9C$}6AwV#afIK6ULl(2Uv?UL8^2D2^74xg; zPWX9tpteFb_Phi!xg_4jQ+AOGHa-+!Jh)Wp^5E;rFU$QGOxVOutDHYg0FA9cH zfQGzZzcNY}LC>%j>_fgolh)UwLNfefQn`Ea@XdM?;|lZc=O zk&%`^?oFPWCh0@(*C3tx^e@7YOutaOHAVS45fOKnAmVPjLKfwttDgYfR@ku;-aI6N zS78Kt{=h)10`}S`HK(UX<%zoU7v4n|^!+)pE>VFsOkI6`1rp^`y%X8z^!aZfP6O_8 zLfaQtF$rtO7vRImof|i&>z%e-wxb2=yzV;+b}-g`N5o+^K26`eQSa1Z$7Ot94dxH0 z%R6esJsTly)Kl*$2wr`LJf|R%>jl5-{C6?;Q|F!!ym68&V*HMs^Yf3AdszqM55oNw zxU->t8w+^x{4sj3z;k~7PxN}3kNaEvp8t_zGbQl&?gjDPsnZkNU;pEME{g7L<6TJd zs((T~H$qLvC|wcJ!%mLqXtzyD>e0l)u%tzc9#^()du4F&m5g19_KLWt{QnSsF&m%s zfKf<_jnn*`v_!wO;g`P5FCFfeHZaupwuyGPjE-*Ujy~*5lyr2r>e#WByCe1ZvZCGY z*q9bAuh>vH(H&izi5FLhmt$j3I7s;SSUemZO^Up?DXOSG)znB=#r{r}*;bR2_$v!6Bv9Y{V4iiui z)jJM+Oa>nYWYI{8*EikTXLo%H+2xlsAD39 z$T@2~azD0!{55-@6p~)k$jyEX#>7CF8w$P0O9~1~UcD5LRP4cMrYC?ytap(3XxcW2 zg@^Gum@BD88a8U!C<-?b@~beSX+r#!G?64=UaL_!h7)XJTzbFAJ~vG1)~ZiY|G~xS z&AlZgW_Xz0wq0EEyp^RTE9dv@v+&u8xDv1Nk*S%3M?6Zt9DZBrh$fMPOKuhi3@H?HlSw(!l;;+Gd^wIE$)T@!g7c8}v9=10P@Pnq2us3?|& z%iERK)yn`At9Ju-p~DCMwN9JyD4gkYh+xJmUE@ zk&_`{Zvp#(dZIE!q=$?22$3Eo(qjb9aezsnk_O1BB!LP~H^VbYpu!=ilBA>w*jqf) z4-}JtnLp1lCn+3fk|=K%U|WXscOiJD3$XEYGm(xGur;3S0&E=i7CA!%94_D}0mq@$ zbQ2QjCM4Dk*wT>0<<~Iq zsPr)oWA<{m;C8s+b2xJP;lz9}-oj)W;281i81d^kkw1>-v#zXf^-jPc0*(+lBLy5) z{UP!*MLMT?H_}A{&H+qjBdgzIDJ)fa4iNGL>?L5|>ZM2z5a}TzJxai_)ej?oqR7vx zUWs&ebp~Lb$eAv3@Y?rY#QHnHCiZsSc zk%nhDsTL9v&h*b5YIWW5B%UV>I%VA#$20t1Hw1mqIyi(lVk{qgPxfI|dCZ6ZCs`UqgQ zfKvp_6>yq>#RAR{uw1~|0?rd~fq)f&0|aja1aAWbZv%uh1_<5;2;PPW-iCm;ootBU zZ3upS4{6TZFksjX80`H6FqA`4zr#fR4ihyyoRy$9hqIZ0BgFF~gbYUr8IFKfH%ERH zhmhxLK&~Mp1cnjdZ8y?fLq>o@o<>Un94p{>@zw+ZCsl)c$b{p`5$UNSomV{#Ib3H( zutJe8f(%En8RFM+0cTYYLe6ZFo+EypE1tYs@H1avTOfX2i1bKNijkt!Bhf-Lki*M9 zlDAO6C=NwgMvAhG6lEESvMfbDFUv@jg{Qf;k3?A>2ISg4l5uSxiBbS3O2PBF&W{xQ zk7QiuN1_xQa-AQ^xXzCR=Nw+G%mL&&KMHg|1mra}O4QUC=*+8Z3@~#zMZjs*Um-n1 zq{{`IE#N!>7YJAZI2L%e0`eA=DaxKH+H$5SU8X2qrYK#eC|#x~U8X2qrYK#eC|#x~ zU8X2qrYKjYC|9N^SEeXerYKjYC|9N^S0>6eiDim%O<@J#c?vY@AmB^^=TvWGxhUN% zz=_a`T;S*Foa*NQr$C;$pvCj2L7usw$kX`(7KvwOi04a0x?H4Ziu7!eo+sb}0V@Ef z2_EuA8_Wl-BP<_OIOJ_GA5=KxZ7?5Y;jm0V-V*asA`W>=%twhhS0G&sY#RZy1)L&au7Je?&JeInz;Xd+ z3%CGqhA7<(A>kQ9!ZXnOZDb|Dc@QvLz+3@m2v{ayxq!0;oF`xfV5z`XDzKFbY%>L( znF7yD$n7AT3Au5|vCV|6ION!7qKq7JY%?LHgKW0IHd|nuEwIfNyv-5iog>OS2RJvf zIl##wFXnaJJ&oW(w1A1Y~p8>){1Zc;h|ZpJeo zuq`5In@DfR^B=Gs)lUKLtX>VcOXTbozkaJULe6QCJ|o`xMdWxnHc(m0*0K-q>smo& zE$|;hdYgdTtDiu62lQ$!F!TH`#FM*3{%+7(%l3-={Q@2r@C=?=2b`+`KNRVY1>7KV zHsP6dpu&IMCg66IbRDSh9M1nbPpFBl{4g-VV5{dKKVN z)cA+&gvdXGI{HZP`4RgW<@#9E(Z@muJ{EQKG24zOKL(zTfV&0cHU2U9JOX$^yv3!o z0VU;-*Y5`L?gq9UbT>fqJcrls2Jpahcx`?vXniW4|5QBxso?fgJh_T(63=g92S95R zI|KNc$oUKyjv>u?_>6rgATP^j>}LU;Cw~*qc#*SN=-y_bCz~19lg)z9&ESfszd&7V z7F=xxS3KvifLzlyi+bM*3p<}}g>B`q8GhZWLGrr+zOdIfi$PSRg`F};9;wvz7_hg8u`41ZiR%O0z4t0mp_l1UCp+M5^WPD z+9qVU4YYVZr?pMc+6F2-hwJ<{;9m;3OQd&0w%bIT+9qVUjU7RqZev_x+o9FX05QS> zECAi@&}u+XTn+f4fFDDus6+ld=xztxj1p}}S@<(thTBmJo^w#V`yHrnXU9bP2LX?Z zUw;(o6Da9+c2cCdR&R$Ma(G6-vm*Zw0nZ6oC15p&kOrO-Qq(2a!IGHn@ZRDAGR($Xo6Xl$29HCE(8@=d4KoD&X$|{vlvBhbSZ7 zLK%;-oub}%idxtyINym{UW)u3cyg!E;ho@o71GDS$rqxGUx?Cu0o~&{yo_I{7 z%l!g!-p;-hI`bv;p(FcRXvNoPaUI!iU|Y?03rn#_P~Ri8ZV&W)HQOU}U=KK1h4g2@ zvq#|B16n-2z1jxk?Q0Ksn+V8z@;$(b9ASg@in8nlwj*q>z_wSEYcJkfg&bZBd(jqF z10I3a?G@$P3#lGsdsW^K?-QE24|twJnz!zK!0-eh?}he>7Pn7GX`kR^pJ)a9fRjJN z^>ClS_ANV#=f4$J>|4l$r+)*s1JLBHfc*IbsP|)lM+7`7e&rYr00V!L_n!xZJP)v6 zMEWc^KL9)&{wbiB=Yw;!4PZV7ctAjIcMb~t2L=9vf}exna4Yh8c@K*EIw+_d6nq{O zvu8c6LaV;t;6tG%xQV;OFTR0{+aQc=s^yY-fiV z??1m29Dav)w_@@LkAnJ-g3l9zs}rJ(Cq(`UA)gaM+9w1*Cxk^f zAuyc6^IO>|@#HByxe96S1)UO4o)S-SqL= zGeV=z02^uuebF&MuDxf3w9hcErDp`szX&P)A|&&Rc=s1k#$QAke-WkoMLc;{&^;@j zKPxDn6_PnCcs?uepT)b!*jYj4ta$fV@#L@K$zR2jzltaS5cz+IoIgZKtMKkbR)x1_ zF|XjqD{9{>-t`LJyh65K!J${k-z#{l7SC6UnynU3RtpJKizlnalhvFb6}z{qZ9vFV zP2nvF5MDGOYziRkK43xhM8NqRa_eIpG7eRvmsBtt35YpQ<1qTS-NyIqwd#q0s|AD? zjWnz@VFw5f#Qo&qOrDM9S4OuI;){*_DzC{4}zG99Qyo4?6)Jte`qL#Bh2 zNUIw2z65vRD#}avbJEzbOiY=kN)YKR(@crSkzVH+70)~>(;CXMRi<_9LiIyMWjaWSw>BziN}e(m ze`WZatK=w?6*vB}@I4EuY^6vkR^})r;;CHZx|Mdwy&Rt&+!qpuKey5gPZa@6KHhiZ zF3%!7Uo0TUlZRBH5{+M6; z74ANT*+AS6d`<%05<%19*{ux4w?YLG%=+a`%5ku{hh`NbgY!65DMy*J@QyMhXKHzV z77{6VIvc+gLW&%l8#}7p{|S3aX?9NG=0wIy<$SYE&0A9K_-FSyfkz*+n zY&-gw2kE&n3my&mbG;b^jQsaPKu(Rz)~yV?2*rV*I0g844b(o#zm?$KEd0VT7a^T1 z^4!>4UXI^52b0BnK7B0(W&c{wD|KhN%Sy5)=M-d>Om`PesjEQllANh|rDZuK(5k#b z_weYU(e6Q6WjTdq?ySPe?qM}A4xBP2FFQwMX6KZYW#QqXvRqXCwDOX?(#d(*Tw_Z? z`$9#!c*9a^l*doy6-bUtY^IPye<45qy;QVCWoTK>%p74^tQ@iP}I zZ>(-m=RzsKH{SeaL0nv@xgyP<<(&ja$2*Tg`Q{{a6uik?nlP^tM`1V(UKxd;FjKIX zCz$2bxKd0JuvE}31PwRdbDAPg@#@SI%H_`mSHfI@%cqzHc#W`WRF-$lIe3>-7>O>p-$gMx z((-RMTm|w4zq|xK+`JBX33#5J5-DC4wuPfyv_B9=T62G*(e*YHK(aNMua!qx{~7(<5G9tD+Z6eE*)M9 zkc;NiGpiI$5uII1NnSzG74BKNdD*${Sy`p-$vLHYQw#Cqq&e=o4Z)3HvkK9E3yWsL za?H%R0?m3#Nls~QUg1=CDf)ePX--Mr6p6xJmYY@PM#oT)Q&y6fot2+I2L`F27_Ux( z1)7ytmdmMU!p&y*JB&&(+mPZ#FoOfKd9)MT{v ze7pq0`9($3c~Pbmm4Lm;Ww{;w_?=QzScX@N+*y++qs$OgQFeI&@4;Y5%Y4jbWtSA; zx8nS)GGHm-Jr#Eg%Ag}%u(%upP2m4Uqeki)Lar6uEr^!-5rY@Y%8I+h#LSvCD>_SB zz-*Z9XfVZp{g-HY5`pI(W zC?~Wa(hf%3oyZ*kH1<2_HbsDr7frGelKhm&LtKne4T%Sxk5 z^YWvMN~XpP%)oCr{K5ZKzpLEsr0POfH+;tl19+1vd~F>&N=)p!b73b>Aa08f!akP} z>~X7)T^wObIQH5%!k+BL*frb)XGEGQQP_Rr#vZ+vN-OMYxJ+q-NOkStSGHF=z~poU zt1-gA^k8Q~Je-$KN+RN$Cqc=&Vhz2!awYZ&CBsbhgio13Dy1(9)?XQb zA`Vgp!}}NtPhmKOJyIEk74$L6SY@1&iTlnbC=-<|LY2pr>y+yiUdp4&4azOb66GP~ zS>*}Bl$(^Vm1~tdl%JK;%2MSX+;hE7*{hsV9#)=He!=;`v&v)2i^@jjV|e_NpdPnE zAwGplY*0Q?HY=Ny&y>%VW9XK)M?8qA6i*UJCq%XHOp*%z!EfLseaKZ9!M%bg`u%Vb;{Y-cH*60^7}%j? znDVD`PC29;Cc_cEb|mi89F3^zW63y@Nyd{2WFjtCn}is1lW}M96f%|Mk~}g^d526V z`J@0N#P5_NxJ{`TF}_N0mqi&VCo>U_b2gbn=8}2jYBHZJz%^>+!}KC+*DOAe5O1DJHZA;tX5}fw51HFQFq|r2n#?m>Zxrv6J0`Yrc3E9 zbQ!&s-bQbychEcOUG#2x551S(NAJhgyARTb=)<@({1N&neT+U%pP*0Dr|8r48Tu@J zjy_LcpfA#waHIIk^cDIleT}YEb}8S|*XbMdP5Ksno35ho(AD%^`W{_F-=`nYwR9a_ zuk1#gqCLufRk z5f$+px`*zi`{;i9Ej@tPfQRT|`W-z&kJ4lGd-?-CPJg67(G&C}Jw<;;6ty$-7kZZd zN`Irj(?95+^c=0CURsS1u!J8^z-8P#IGusJ6)fh$#q@!!9t*-5xDXb~>azwcjD@p? ztPzV~jTwTa;5=M27R8z~H*3LKvR14$yNn?S1#8DHXYE-Bb_MImqFD@!WpT{IY!=TF zSSOaqI@FO~B!bEH;T{v&k$6`wXUHzd# z%h;{#Hg-F^gWbvQVs|SKuzT3O>^^osdw@O29%2u(>2hf zdyYNNUSKb>m)HvSGJA!+%3fnD+3V~L_9lCaz0Fp!ci3w7E_;uyVeeyQcrDgXu_B6f zMyxKfjqDSw{B6Q|-RD?e+k*8itW{w}WGB`lzGS=DS6G4A&A#EQ1(=)Pk9qh5nAgVK zHD;ENU~c#r`<~DBV*d6gc7mP6Z0paM7sbpa<`sWszp>xhAM8(d4l{*bR;?;H;Yo4q zLdCUkIxY`2RZDfL0cxOHPYuF>!Voo7t*q zy;ALg6Cf#SPc>CdQ`6N9wU^pk?W10$_Er0-{nY{LK%5U5tPW9!s>9Ub>IikDI!Ya_ zj#0;|dop>^%ixRdaHVy zdb@gudZ&7qdbfIydart)dcXRB`k?xd`Y?{jKB7LVKBhjdKA}FTKBYdbKBGRXKBqpf zzM#ISzND^DUshjHUsYdISE{e8Z>VpoZ>evqtJHVY)#|(Id+HkXef0x%t-4NKuYRb0 zq<*Y!P&cZdsGq8v)X&t<)y+5=vsK-uZdZ4xJJm1LFV$V@SL)a5ZuJ{=kGfagr|wt3 zRS&2K)kErG^*i;5dQ?58ey{$3{nkIKKdC2hq~?_RvwB)RqyD0vRex1~Q-4?gQ2$iV zsa2|1t=1GA2BWxjK-Dyax-~RYvox0$pap96v>+{53(-Qg`dR}mObgc1ArBrJ60KA#)5^7(+AM9hHbj>Cv>Xz=(1N1<>o*twJ>mhomUSDsZ zhw0&ZL%oq6p*Pke^(J~#y_p`RH`m>I3%#Y@N^h-Srnk}C>h1K)_4axP{R+LK9<9gd zv3i{D(QQ3mPtZH*iF#)}N$;X})w}84^(*xrda|CP_taDMG(BC<(0l2<^*;JldSAVt z-d`V}57YW}G<>rd!U>QCuU>(A)V>d)!V>o4dp>M!Xl^q2Kl^jG!Q^p*PS`WyP2`dj+j z`YQb$eYO6s{+_-@e_#JVU#qXv*XtkZAL$?K8}yC(C;F%QCjB%0bA7YEMc=A#)3@t8 z^qu+_`j`4H{VV-zeYgINzDM7y@6-3|-|7eSg9s0LSpQBxq94_d>EG)==*RUR^`G<; z`bqti{!9#!1&+5PGzv;j0f9QYe=kzMwt5+Kej%-qd#Z+mC@R`%xGh@HQE`M z8|{q_#uY|KBie{DVvRV%W7tN#kzjN(5{=GAlF`NJYIHNY8&?`V5W6$Q=xL-HX-2w{ zVe~S38-0wcjJ`%cqrWl07>L-DgN-4^P-B=e+!$euG)5VtjWNbpW1Nv`j5j9WNPdY0NTa8*_}g#ysO{W4^J# zSctfmi;QcGYmMuS>x~G9EUT8;=-|8jl%|8&4Qd8c!Kd8_yWe8qXQe8!s3y8ZRO4=*z|{#;e9_ z#!BOL;|=3Y<1OQDW0moavD$doc+Xg4yl;G9tTomd>x~bMkBpCv4aP>}6XR23lku7H zxv?2RIJX+xjP1q_W2f@oHl`w*4$TjPLn5G!nljqi*j#!=&# z@xAebaoqUP_{lhdn4PDLpN-ST8RHk@tnsVyoAJBxhw-Oz&ZsiHMzyJ!#H7lr%4;Sw zRZ~;mP~KG5o4T@6d0qL?G?Z(UwaQ}UdDFyEbC(%l2AcJhkIW!5*bGtLQ`VTFW_@L~ z*}x1l!_9_fBQwHmY(|<*%%)~DGs}jT&X=b{aVfHe6n|;iy zl$*`IWnK{YKR_;{pGAElk<`i?PnQP{m)6D5+zFA-vnnh-@Im0Y5OU*K~ z+?;97GH07}%(><~^J;Uxxxid#R+x*-Ys_oS>&)xT8_dOKrFo-ylexsa*<5PgVlFdp zHE%O-H}5d-H19I+Ht#X-HSaU;HyJ=9lI!^DFafbGP}8xyRgV z?lbqB-Ez5FQ0al<@&kC}Ftq?2Js&6&0!mMzsq1DKW zuo_#DRuikK)y#^rnp&rWL;xjYh7nuZ{1)mwkoX~ zt(&YR*3H&Z>lSO7b*puob-Q(kb*FWgb+>hob+2`wb-(q1^`P~T^)P}qJYqd+J!U;_ zJz+g*J!L&@J!3s>z+w?4E!vOcypSR1WRtWT{?)@Rn|)@B^?-fC^Lwp%-_oz@rDm)0)p zE9+}(xAl#+$J%S{!};%TtpnCU>yUNW`p!CH9kq^G-&;Re$E_c&pAb^wq;<;r**a~V zv3{}6TEAMqS-)Fgwv|>h8MI)x(u+lo#g3#-_x|&om>Y zfX_-78d=V#o-(NfbKZKE_%Kq6rWO_EOgFNe&w$kIyb?@oPsz`j9gtm{cBM`(D$B~o z)M}Y4yC$ipWoH4kKH2$51D&igBV7{2S|BH^r#l35#D^=r=51F_P0~o0Y~(nfdb)!# zM|=eIs%0c#YHixps|JB4Dw;Pf;Hp}@0n=*JfqnfL37lT%%T&I| zk&lIpyllN+R(5$=j-D?*0{i(tm+$vQ@8<|8Uwo+jP zl5~?K-6TmjNzzS{bdx0ABuO_((oM?H2Idx(V6@7i!$XQJbBd&$B59{c+9{HDilm() zX{SiqDUx=Iq@5yZr%2i<>Dq8Xv%HpOPg&lcl6Fr?yQiexQ_}7!Y4?=0drI0pCGDP) zc27yWr=;Ce((WnC-BZdtRmwY6(odE2QziXWNk3K6PnGmjCH+)MKULCCmGo03{ZvUm zRnkwD^iw7MG)X^A(od80(gNk2`}Pm}c1B>gl=KTXn4ll0Rh{WM8GP0~-3^wT8$ zbV)y5(odK4(fCYKO@P7C2sVSyd!jF)g+CSbdhCb<%rLKfu;FbrMXVJs5UJ$ z-xKR_gZ*3byTgqq*5Sqz>u}?Vb-3}wI^1|-9d10a4mX}yM;e}3M;e}3M;e}3M;e}3 zM;e}3M;e}3M;ZwF;n0nf^y4J`I7vTF(vOq$<0SpK#K56`9dux+-xrBdauO#wiIbef zNlxMa?l76D3pD5`kO8SYCexjtGDCtX2z#}~YPiINLv!vfy((f$kcb4=!OZuH9{mznp zXGy=aq~BT6?=0zemh?MI`qC5dNKe3%BGUyf@$DN_E@EAU9Kz$3i^kMs&W(kt*tufQX{0*~|xJkl%h zNUy*ny#kN)3Op&Y{3()tPg(!cGw?{yz#}~akMs;Y(lhW#&%h%+1CR6!Jkm4pNYB6{ zJp+&Q3_Q{^@JP?VBRvC;^b9=GGw?{yz#}~akMs;Y(lhX+b`F`6S5jJ5hCyC7HfrUR z<~V|vE`dk71Rm)Uc%)0i@g8a7J<`N`q>1-P6Yr5G-Xl%CN1Aw#H1Qs3;yu#D zd!&i?NE7doCf*}WyhoaNk2LWfY2rQ7#CxQP_hd*#&X5f$LpG!g*^n}1L&}hfl#yXg zEyx)6%xv(ze^uw%gLS z+tRk%(ze^uw%gLS+tRk%(zYWqor71}c3iq7zsstSw%xWReOn4aT6P4ulg~>bNXu?Z z%Wg}{ZcEErKwXzV=e=M&;jJDfz|N_sWx2R$)}epraBLW=Hc-=v@}6W?U_@S zrS;0fl$R?lC%-I<&n8VbBuRA)eq|u34a-GJ8L!+ z_XsBFpgcAtw@4eBH?<&(4a+JwBtvXaZXQd;-=NYwhc7`XkfZeR6)d0ieRpNmh$Aq^ zuUsa0afHN6B~oGK&hNfgMW!a+S0~|dZ7MIKHklXEkhs*j_<`{su@4lbqICJ9 zbVfnBV3HP=)7g1OkyAugl3S!>n{z={oF+b48Hy?Cvtkqxrjmgjt%!?1kfZgn6?`6> zffA=vck)N^#U)-ANvB7UCe)TD)RrdHmL}AeCe)TD)RrdHmL}AeqiS1@s%<%{w&keW zmZNH0j;d{Ga&2jHZE13CX>x68a&2jHZE13CX>x68a&2jHZE13CX>x68a&2jHZE13C zX>x68a&2jHZE13CX>x68a&2jHZE13CX>x68a&2jHZE13CX>x68a&2jHZE13CX>x68 za_tPqcZ-+f=y*Afj*oSG*LXROj+f);cxi0orLm2d#x`D#qvPc`I$n;WpCns;=8*vY9p6%$jUwO*XS8n^}|1tjT89WHW2B znKjwWnrvoGHnS$1S(DAI$!6AMGi$P$HQCIXY-UZKJ)1mxHrc|OY++5duqInrlP#>t z7S?17YqEtk*}|G^VNJHMCRt z7S?17YqEtk*}|G^VNJHMCRDO`bcO zJa;yE?rieh+2pyi$#Z9u=gub2olTxQn>=?mdG2iT+}Y&0v&nO3ljqJR&z()4JDWUr zHhJ!B^4!_vxwFZ0XOrj7CeNKso;#a7cQ$$MZ1UXMDO`bcOJa;yE z?rieh+2pyi$#Z9u=gub2olTxQn>=?mdG2iT+}Y&0v&nO3ljqJR&z()4JDWUrHhJ!B z^4!_vxwFZu>L#zMo4l%S@~XPYQ)rW?&?ZlzO`bxVJcTxS3T?9eH`)H1Z2wKR|0dgi zlkLCB_TOatZ#E7zrp}x-tF~rfdSqe`z+?}=WDme(55Qy(z+?}=WDme(55Qy(z+?}= zWDme(55Qy(z+?}=WDme(55Qy(z+?}=WDme(55Qy(z+?}=WDme(55Qy(z+?}=WDme( z55Qy(z+?}=WDme(55Qy(z+?}=WDme(55Qy(z+?}go;`qi_5kYH1E^;Ypq@Q|diDV7 z*#oF&51^hsfO_@->e&ORXAhvBJ%D=l0P5KTsAmtLo;`qi_5kYH1E^;Ypq?#yJzMm8 zw&?Y2(d&8SsOOQRo=1*)9y#iHUrd-=aHkH zM~-?PIqG@jsOOQRo=1*)9y#iHUrd-=aHlS zb;~)gX~u%79!#mG&YU;n%WpMHv<2y=OrpMxeGRSioK3Ul&U)L~ieDhka8gDm<#DAi zzl8|_nX&p|ue;u!KW%>NJlFK8U&gN&o%n&`j4wOioci|EnU1+rXU;&$c{AS55Wj20 zG6W_HB681rYbNOP-!|sA;Mam*5+tlo%$N&A@*UogDJy#$!p(6^ZJsex$Hc|?dvL^#xNGI2TX->a00BrYw3Z!dKs#?w&bo&eZ0wOr5DCI3el`CcQc1O&vjA zpEG}sj^LVy>tAVd2qqa+uw{nUGQ&e?>pV=C4f#BD{k7>MT-IZKHfv5Yu`HV)B*|led7F)UwX?(bN%w! zLYnKB&lb{Lzx0-o=K7_#T-zwUWyIWm=`AD8?U&v%(%gROEhEkCm)r+bC@wVs5{*c}R2nrOiW{+b?Y%(%gP&^N{BDOPhx@w_n;k zq`CdlTdr-C-ZEltzx0-o=Jrc(8EJ06^p=t4_DgRWX>PytmXYT6OK%xzZol-Fk>>VG zZ@IQnddrBp{nA@Tn%ghEWu&?N(pyHF+b_Lkq`CdlC#!9gK3Q#w?M#a8Op5JHitS8_ z?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8 zOp5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JH zitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_ z?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8 zOp5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JH zitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_ z?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8Op5JHitS8_?M#a8 zOp5JH>Q&42d3qD-S}3E>)7wzjLW+#frx)Cfg=tK<*|jOQGby$+DYi2ywlgWVGby$+ zDYi2ywlgWVGby$+DYi2ywlgWVGby$+DYi2ywlgWVGby$+DYi2ywlgWVGby$+sn=4* z>zE_Z+e~9>8uPr~_1@MqwF?ct-n~bZ)DWE7n_$a9YP07cLe!R{i*eM(r;BK>jov%k zc@ZIE-nCTh#oMYPu@ ztGDGMLd3=^-HwPYQ<~=ay)LGnSzV~{d+F=OtS;=cUD#V`%!3`aymP>VZMKXCKij+M zZ}leFa!6g+a!6g+a&$3{+H!Rf?X^i=*m6l-*m$KSkJvJ$X`(fA<)(MhnlwsO5=v4D z^%J#A2tVcTN=i;txhz8{_Pc{=x;E^0x225FrRm1}H0Hrx;-k`)UKj_y3&;t{O8V*QqmPEB~jBdV9&ZuO4k>Nt?R248DB`}GQOI|JYVQ$ z|J5$k^#x+>`YJ^<23zd2S?se}?6X~mS{b6M~mS{b6M|0svQ(5d&S?p6;>{D6nQ(5d&S?p6;>{D6nQ(5d&S?p6;>{D6nQ(5d&S?p6;>{D6n zQ(5d&S?p6;>{D6nQ(5d&S?p6;>{D6nQ(5d&S?p6;>{D6nQ(5d&S?p6;JXg1Pu5R&M z-Qu~r#dCFw=js;wP!{`87W+^Z`%o79P!{`87W+^Z`%o79P!{`87W+^Z`%o79P!{`8 z7W+^Z`%o79P!{`87W+^Z`%o79P!{`87SGi!o~v6tSGRbsZt+~*;<>uTb9Ia7>K4z{ zEuO1eJXg1Pu5R&M-Qu~r#dCFw=js;E)h(W@TRc~{c&={oT;1Ziy2W#Ki|6VV&($rS zqgy;jw|I_j@f_XaIl9Gjbc^Tc7SGWwo}*hlN4I#6Zt)!5;yJp-b99U6=oZh-EuNcO zJU6#^Zf^11+>&pyYAyLD3$a`qu;iO8q~+RxCEsM#TJlX6V!1Y8$v0U@%e4SYzPUnL zt_4_f=L*ttEx?lRt&rw=ml>#8e+*Y9C@+<3UXIBBnJS#8fY0TH`@X^&+M<9>i2H zV$v_-biJfs@j+l=&k=LI(q1Cn;G@s$;TIeDWb3qM-`n%Cl}-F?bS^%<$7Ps@v~6`Y z(zYSyI;Cwxn(M5yTyM^rBYxXB4_j5|Fqc(9N>_yb2QJIJ5@4~qZ)_}t`_X?#`ZY6sd7*Q z`85|_qP;z%*)bizD0GN#WO4@X6>>B~M!utRG{et>SSUG~A=}lt>zT1gccHEC@rB6^-Yo>&RDzK$)T9#IHRd2?$^7dow#O7IKo zI(|`&SayAWkz7|V9bLq7AmA6qb@kFgMa=o7!-!w$a9TQGb@kE#Lo5e?dg*{6EeD=@ z>3|_E2cCNAfFaHGNk7XH&U94U@Xh_R0Rxcei zq-7VYmkt`za^S0%4jR&O;H&2$qMnC{dND+_OlyAYjjv?jK8n~WQ_VAFJhzq8t$61- z9j}Z9%$WWaiZOWL22Y>%mK=!qwRauAwnjV*?^;EUxwz9A_`2=XFVAn0@mx+%pCi-F zGhJf-+$?)Qy)>+K_0o{m)k{N)nDrnHDblP5xg`W?)`K*xNWW^#nzKOmLxIUUJm`{h zm^kmvS?K;#-$#v#d6KW5A<2E2D8>-OUT+Aq?=#YAl-b#vKKU% zuR7vOgCouSqz?{Plky{`M;0z8@kgBU%J=zSqKyv$j3+a&3?e?%EH(4tNfUbh`ms*qA`)${;EO>Ug|ZkzAQBau#AnQuTPKM4=G2y1ZyVS;A@^rw(gq5~ zNqC97n`TJFz;*_{OdBhhimerlK`#>Xr?*+~1+-jZnbdZsqT!5TvxUUGa?6EG_;}BS zOu6wnWNK(8ZMu-Sfvp!3^9c1Z+N}Zux|BpWgm0qFDQK|ReIjm$h+)e`L>eLKghhY{ zOhy*5><0Y02x;oZ7F^9t{AMQo`3r+SYf$yZSLX)lvk10JAsaqdY{H-6rj?_27QLpn8?Ea`U-8K5C_@)o*G+xJ z-?bQ3B{KC)*7Z!j;*kOow=T5KBc&14fUQv??MttJ5%cb9&2F^5TUxUlt?ya0*)v(+ zEr?Afvzuzm(9lhNwHqDSL$bOasaJZW%pR%w9w{5|*LqxdwMVLFWv}^rD5a)nvbJZk zu4nRDpfD;l0u| zw)CsL%D>VhU5A%~GBgnX?)0m@(gS;?U$dnf1dm>6J3n#V&QGN6{6zY-OwrzFWqDQ5 zY`jI<&ReAIyhYm1Tcqv0?Rf{{nw_^u+j)z0@BDV&;<}x;{n~52c#E{1w@BN0i*)bu zcHU`yldf&N)9EztbUMvDolf&kr_;PC&H5Gi^U`)+;<}xeNZVx-`cCUqToY-#Y$9#v zC(?F)B5jw=p4WcANRD2-?e{Bv-Ok&7zv#NH-S+z>uG#NbI&GK5e!tS!?Q*AeBHBaG z7}u3fr*)D}r+KH-X}QzsG;d1Vc!|2x`WDwj+Rj^~?Yu?W&ReAIyzOOs*G2qob&Ir} zmq^>&ZNFcX7uW6l?e{Bv-OkT`zk1hYzh6{uFW%z1z1{ZvmCkQ(cUo?e8D-NtO{ddx zr_*WP>2zA|bUMwO(yTLq3w_&pi?p4$NZWafw4Jv|+j-l|igwv~i*)bncHZK;oj2)) z$6+C>J#X)JP+M&6dR~6JPVM(g44Z+lt${StNA?^il)=WV}V>Fajh_Pj!e zcDY5`Ubjg1&Tp5S?g94&QJ4LiNZWafw4FD}X49c857!(^3A5h#(p# zi$=mehi1>$@Em?=TJL&-7Mv%MYUKtoP@JJPh?`B46ue1c}L zH(k~)Uz*lCpN${2L*yfV;;Ro>7oJ6vKsUZaNG^XGsY@fTxM_VJbGHn!JTGFgI*-_p zi}S$T^d^81@ghLv5i9c|k9-XvFj_PgG&e2F1F_8wNeEk-X0iV6xuckq+Ah+~l1N#@-JIl{xA_elNluxY-w1W*66LFUWRer9<~Mvaw@q`AsWbyGUE{Ly zQdcoN?Re#7Rn5aTk=9K5p}NF_wBA6g61;NJP3T%ekqb6*Ed)yz^gEr1SjmxjyMD0a z@1oJqa=MfBTb&@3o?fP+g0vVUYo&3v9>Y=nutvu5jk7KsA4M6~==gNJ+Rmvo%4 zq~S~@wUf>o1<{MJ0OtV-s^U0>vp?T;{9MuT)qk91iZB2F0{CMGPAtU<03QMGaC89g zbfCObasNnB@%7)^ithfvh$t$)*c(+eBj%}7RD6H6UeUz&SAh@o3{rG_f%SKA-jf40 zij$!bEZ#YpS zzJ>n*@H@^sz<=!gG4Q+2yNZr45GUz`RuvL^lsz~zDWlOEHBPCR_EyVV${-vc+Nz9z zfofL9<0}eZQSf!pX>*ljINWTGvYy(iK-U_baV1**qEt}Pe*ZI#1xoM_#ts8~PCgxl zb#P2`ade%Xm8PU~INx~V%@(DAV9E66SzlJ@U7TWyw|+SPN6flELq z&fyB;q%CnyYYk4!8j2cm6cjKSk3c@8H4jOmYK@YfAy3)GVYRr5;C3SpBZ$}``i0XA zOm*U#x&`NW4aCV?qj0L$6r5nyiXONMU)x~SQ35GL{jmAI!G&&)%|PdzoD{?u@G z$ zD@gqu@QEAkbNrIz4iGP#i;S~Cek=|@cDnE^xN+A!IMc(g(5W6uP@L+agmJ1zHcn-e zy(o$^*i@XkChoiFBThOWBon89CEq_`$k6v;EXPZ4x zo}jO|RIkyBQQ}+Lw~Todw;OXQ-fg^1ahuUX@h)Qy;&$YFlj3%xnd05XS14{XW>dV& zXhOUj`MykXyD^jE-Np=x+l=WH?=q$#ZbQDQ6t^2wDBf*+iQ+b6GR3=$FCyNBe3K|{ zH~x^~-NrP`t}n zjaX>pPbh9TR#Cj$_&+IbGgeZ(%UFR}XylJ6Za0=wyxaIAirb836z?+rEn=aOr4+Xt zU#EDtv4rAX#$v=m6JMpc-B?8NZettMcNzaT>EizoS}?u_xXbvz0NZ~JjqCwu zp^EMFiW~`@p zm$6H<-}ujf-!lF^Y2?oU+kOp=oC0T|k-wq1-8f0{ZsR)?w;3lW-evqXVxf`a6z?{^ zO>vuXjN)C!QN%(Me@St>afIUC#$Qm}W*nw?mvIQO(8zzIxZOBN@owY)rMS&FK=CeP zKVqSg|3Y!Qv5(^2#-CH%X6&VSm+@C<|8AT@E;RCANhAOL*VM=l!TDRz@=c0&8$Y19 z&A37FF5^04p^5)Nal3Ji;@!qSQru=-rFfTd1+mb`KTzCmT&8%p@%I$B8J8&DWn4ro zG;)FBcH=z7yA7P;PJQGY#k-8Nh=oS}j^cLX48^;Rzood%I8E^`<2}*{JR7v(yQGot zlSW|eMR~IEBgL4DQM&@CVh)uS&izY_|Mzh=z2^83qqf5c0s94E;Vvwa#@z6hC|}R9 z-j(qoc@+utNZ|*#GM-R6!fD|{?Nsx6_)rbxLk%S#Y9#qkbI6BUN8q}hs?A zC4D1&i+pXq%YL_ipufq#$$!fKI8aL0HFdVS3GILkqmGGI;BS-Un{`pf6Jiy5re#zXz3l_OmaeXF%2fNk0`%uvq~zCkYl zk40Ken$mezP$a}jpA(S3M1^kDC6s@Gfa)P_gXrhlIl#?CzbT-8P-{*@_^=r`tuBCb z>SFR_I{2mCXV{1nY=n<2bSPTHdKO%87Vlob%V<{=rw!L&3>^%gbp%cf9tW>=3eM|o zhTpmfbD9tIGR=;yh1z=qhtq zEAr#)T2Z!4a8oZ4E_rqc+)+i8A%J2WOyOi>1t%MS26yoHaiXycCmK)0Da&8PA7<^a z4nNRtBBvOg<%l&A_eDh>_{xZL5TXcm2#PG>_yuPD7}uWX(JTJ0vk1jWgcs=q!hdjF zcl-e74gQFFQ4TaFctX}59Mw-K`}`U|K}MWT^Z^Fvhhl{Sr^D3Y%;-<5M?4q5hK$dt zQE)VSv^w^QQixIH#d;R4HW9>~8Hu3ajO#I2EF4Z+`YRe&ea)efSZ6HvWF__qm8BREKKP&-6^B^zg3 ze-mfF{uNGmJxQm!eix^>UhO)y^%kAf`XNqceaa`XYK{QPIp}oYlbW<$!UiM}@(_wJ zS_VZ89Tx?J8wDvO#HrYMsJR)sETrSy$Kr(Z$?!mC!^2qsA7nXJW!B=fv(4~Cc4GBw zKh6(7s+_?5`y5Uozls_7ZJZ(gFg*tsIz)|PlmojHV~XZ@TPzm!32Fj zLCTL2T2QVCs9q<$hF~MXl8#A`o<=|Qp`b_{_Cb`AYg_W_9YIl>2vSb;8IgxLGk>B; zy5wf(37r(dZk1w;ub|W9YcT@WqtCqxJJtw&jlgp^iq47uH(0Ou9G?)s4x{~_;jH$3 z@ZA1V!FY~3KM)!hmb&u>PPG?Lu6Sa_lRJg32rP?i|AijswBC5m$*e!anXH}iOe>5j z&mVz3&*SwkP;cN9w$|tq)`MpAn?jzxI z{U#)Mej7io-hZ){Kc`pxi?sX&^%i0D=+<}=cyAwHrS)u;)|EAkIG>#VJ#bMSKOs-* zW%5fe_T}?S{`X#*|9O(<<@2AV{2#tF|J#)R{!8b-`ziBFd0w8sZ12nGm->A9{8vAv z{PO2v*HcDQA_7SU0~8n|T*2=Z}>R)qz!o zpc+?m)Iz*#slv*_Aay9-w~SK9suR@7SY4Q{wx|oRzOYMbw?pF_~ zN7WPRY4x0XNxiDxP;aaE)Q9R5yee^NUM;L8wLGmzE7NMU1`Yncb`x(7Fb@*YF^ce0 zg#U3jMbaIAE+}dqBtE04rIeHPFYz=V zQCByvVTM?w{44k^jhH89i!*OA-}v|VYf%0aKF+I(IPLc}oc#OWl;2VQ0zGW7TBepO zLvUjCe^-X$9P00(r0{;kDuSFpTRQw9ZKH3RXtC2V*Qv zdS&fQnI!n&-7g?U86lhZ87R3C-7Y_KuuCO#5>Ls^d7zuW9hZFU@a1))Hj#w(dL>S^ zzfc3R?KTPRtyXssPnpNYt%uILYkc{BsyV*zxQu??y;`S;n$YI~d2Ib{@3TdaUe!kk zsL6T#W1<`r5Ozht`$T!#4JF-yy)h_7`Q-|OW1yf&sGbqHgs^$Q^+|MfAK@zm#9F7I z7Z5JjB8Z!vkK}BJk{j1Z@=F=?W5nkfdE`|)&*$L8$6fb|~H-W3E3 zNZMyxO{FAHmVsI%OWE}x^-u8A^jYyXq(`5{x{B}$Jpaq|*(t)l=z9dzmJxo8;B5gh zMhJS2fEa_iprpg+1;q{>WM?Jhva6|-q)QH?h(b^&>n5S(uTK(JonyL7+4(b0vPs+_ z<+sUf&o3pBJSFbh(aqnEOFnk^QaafpxP81fG`j|#Y5R*>t!)sT^``_$ORy8Yv~6qY zr|GkD?cF}kg0J)~c%0q#+wB`D_sKHY;Tjv|6ybJ?%xR}fTv`};Rpys;dwx5`p3}}> z;&u+QlpWf6+WFY&FOo-cmRIe#Ec+rTbK3d8h$7{*^M6)y$;Zw?;u6aI)1SEuRH}27 zpi8K9mwNd=t2@5uc;9ggDVE3}$lYluhGsa848vyx;a%528Xa#UC}WwUK{5&O-9vv^LV~b^lzP#r~du9+|NQ^{F|Fo>+)-(dYIIp^Hx^ zYuX1s_}e`;!*1c3ghdpvNkDiH0!|=`pCHX4WKPa6>Fek!L9LBS5jQ#0Qe~c@L|;ab zcuLL;-9(qLM&NS3q8%YRantO1Brf?#T;`#&EZZ3>+tv-4r_4#5fo~d zJ{e&;!fb>Vgarso5SAmX!kxP=q*PAXBz~4+Z_mT-{IXOJc;AWDIenXsH^cf)JVVpy zt@(NEce$qA#E$Fx$|L2es;O?(j~)8iYJpmU9cgvyKy?WAq>aQ*{qfk9Hcf3(=U`vj zVs)9i54)5;Wbx?ci@hn2;A{kL|-VN^A80bvv}a@ z5&_{6i&B3|lqLaP4gqBz=OW^8oOlxM^buth@!w+0Njx(q2nzgb0e?Vnwt)IU%(GPe z0l^gnzwg9b0KD}FU8^DdwqsP9q7D_fg!&|ji#)DEDodqg*)$Y+v}MHMi~2#7{W;+m z2_6*CxrXo-g2!yQljBQo9T352-Uj_27vu|MhlpylAam)c4WjPY19=V)^sBU7`aN0!{SvK^euGv*zcnkRUzrV{-33 zw2R)<2^-c0G5!JnDv~##jbbBd@+w*LpJVa5@D1>g9XTz0l&Yd@?b##^262v~)-{SXS+6K`Q zj7@;*al&f|5?$_P6@3JGWX?8$5XaR1Xmy{icg9d5T^PfA|^L zOq3%8&k>aQiG%2Ws6}$4vW!cq>@q(qkK`$HO3u$fx!%jGzBZnppp!O|3eVMxwZU@A zoX@uXMXe^D8nsKV`bv#SJu@8FRqxCDtG~+Lq%71k6G6y27hw^?QiK%fh4%Pci?L9PLYrA|dU({+Y^|8+} z?{XFO{%`RdV+S#w8`*C#1_)eIB)*L(r%2090`|y5SJj~+4|-J>1UJFwz73utoS^C; ze39TLwTXM#RaWBKOwEt0M`4eLe%c+-FY=v#)qN@$VNPdk5IeX9guCxRYlpgqrg25d zaChT!f46`g*Es(=x@ybcod?_`UFJb4$-fu8PIQ@v@NP+vQ_`P-Vplz6BiJL)LmRhe zO7+P3+i_J>{PdLyg}zS_qwi3}={poz^aY9>`u0RFeRZOMzBkd2zBExpUz#YUFHO`r z#g`@;=o=FQ>FW~z8qe=P58t5*x-J67%6~yGUQzdR;Q_*9gboa24g?QE5Fw6$u}v#PC`G73FcAhJpk11UFbZKT0>)KsGQxC(*$6EN z3lNro?cY1^1O7xn{LTU1xjIt7^e-Q}>1m2st?9VtJW6@s$9L}I{Ib*(q6;{^2c53! z*Thvj6x_hMwX2k3uTkuP>#7%Z0q^Ed()OY}lLxv)soxZI(7cFc9!T;?C|mJqA=-j( z$DGC2K5{UAj{0q17sPX$)bsQry-css8}z~YFnxqRS|6uR)Tih(^=5spzDQrHuh3WP z>-3HK7VHOY)A#BJ^uziw{66Q5eqO(fb*!8E9sR!kNPp_k9Bzl-5p`rc3LGU^Gpch8 zbPRDcIz~FiIL13BIi@+99CIA4j>V2;j+Krzj`faBj;)Rzj&{dB$3e#tJiOl&tFzh$ z*ADbsTB%hZ5-wM8n*`2p3UUbNwSCGXSN{Ek)4Kz82j$___1-wIpUeEbrcGsSc>1kD z@3QG#U6kKmFRi-6MhQ8WQCpS~Zj-YIPdnXB`Q4P?j@J;qhU&7n>oL(EQ-1m8;>a^) z?R@O`i|V!OO1_bKR?jb?ANlTs^>3HWF7wOac6nZa|8w+{Pun+o^oLK|2kiIdQ{HP; z_Ff6)lOo|6>MaB{>NyPU_uYo4-|0}P?)&@les<{Ne)WnkX+6a+X2ll+V8uSg+Z_eJ zzf-XHTER|fmF5m&mFt%1_j1Rd(k^i9NA9L5gl`kDbIUVyc{SRDjyFDXUkf}s_`VY7 zalGpPAIE>Caa_(*C8Sv$uhGCe!u=ws4-vS8JO`!xyqe~xoE6(V35pgQQ>;RLCazG!ag)M}Awo)Q6OZpnaWAu8#3apRMh3FQ(QkO7ZVP zW!ZCrqDg4uuepi-@z6fQw&%3*VGT=3Y;^k?fPKBp-rgQ^+RC!DcF8{$=lMNxu;cdo zE_=v}`UzjP$?ysNq>b)Q(gtN87)O+s=o?Z_mX^3l&AjNo_vizY=-$2by|%ZdhaPNl z+WFh>*K=^$#_smM{5YQdyx-2g;CHPbb^Q`I8?D7WL}@|5JVaT7upD6(!de7z*ES<; zL)eM12VpGp_{CQAZ69f*$vXZ)HO9Pn17i;t@v--rs~ z*)dAH?s%k}#oVshg?}QCxJIxFdGW_(aRvBU?Yg)ssj{q~Ay3fh?fm=BzjuZSCJ1H; z=)ObnLxK+p{+!?=g1->Za1taAhF{>GYJxQc8w6BxooeS_cmB0g>)hPA+36tMr3_F8 zI5Pa#Bj_g>A{ZeUC73Os`zHkN5xh_E0l^Ld4XVqaS`0T)41!dP5g?p68)3qU zzo%BLjT{v;^`B=pmR%&_|G3?hFwgAs8i?Eg<9-@E*bY1RoIW z5D@YTNc_3*;3P;tY8OPW&M+aL5aYJqH@aInXoGG6g*g{I^r$ikFhqOZtCS z!(pP^^_(C|7D1`=I|A>a`45R=*Z)JJ{G8w;g1;d6?0s=neX$8+ZuC7l0z_l-WRkKg7eSygKByFiRU*vIU`S56J5$C-(}%zo@< z4&fW8F=x`5<;)ge%c8GB-J|`@^i3%DBlj=dkKMm?KXLz)`y)d&bnJFEu-n;h1dNc_ z@$9Mf)X6U&2U_smE=8H8TovCa#cJ44{HD4EzokBdU!a=WTsC80nO?t8R&Sdgb<35 z%R%zI2I?RL$b~PDQ6BIMqJ-d^i;zeBo0K}_XasH{j6fKL+QuUO9OChaCm^1Pa+45z zkgSopG=j?ka5+G=HlpSc$T14s#v&G_MM=nsxpM%NAg+WE=OWHSD8iLSgb`HYJ(PG4 zC4?64iSNSz6RirOKGCvVl+5e=nys!zmb6j%1JpeZ`6eLG7Z5b%_k<>uV$!5&@i&kX zd)5NT8A1>k(G^4gOy~FYu?XWkn{c<9^a-8c!5wSTXW-7gh458KqTddADhLOsL{`Sa0|d`Pqd5;a1iMo80$8r#5qg5v7M?@y?0XqVzd z{`ZjoJ>(bt^C;>+ssvE?x6zhwqb=XiCwKk`ZTOKs6QK!VHbM)++X(X!79o5MVJX70 z&L2Hfo7AMew(s|-O-Lv1?-ShHCtVtaHldNZ(Bp#6t@uKj?6uTR-2Z&sf6?9}UHLyl zZ4h_hJxKi??!bGHJYWA3!nDrOXhA;i;%NQLh-Y=qrn@l*@mpx&+n~)ySlBrRt;xq- z{)WC7*Oq|)*HLCEX#cizG-eNJNrSjwkd}MmH+o6(@qJ+i+Vt`L;j7dibhPa|UH8O; z*6c?s_TxV6$36H5NWC9Y?}xfi4?qF)An4Yc=_g#I(c50U5Rz&n&m z@Onq_>)!@daQQBk{s5(Sp!D}p`UCwEu3bj@J#e~)^mT;02oE7k3?+}~-$9PQAr43M zGr00y(9R=V!2Q06Sjcb*vPdrPfWr~}hsgI6gr5Ny@;pL#+Md;)E)Y8M)3*D%Hi1b6H5@V96^8uvtu!b(6R%i&km-TE3SE7G`E5O9fYAO$)Nu)Vk#${wPA<^>f`*kO#@(-DB>Or>vI|Nc!q=gRw08dnrMopO?jyIA zdcy^hM{q_jgvP}^oPu`mM7wwD9i11T%_FcSqAv+~s8=G_-+`;(bOE^N4VO^kWyJ4w z9;W^zTK6Mp5iP>^@YCMUo1Hr$+fHy3{p*PSH^?P9UI4cpkmU%=u>*I0C**hscYUY+ zV}!f7{u9I>LcaTm#hrhE@DSk<(oYbcB7B6<*|`&Pze7(}3AA$`+PUAQ9Vx{Q*3b^t z&Jjo;d$o9~gf?y=v^&6mjQB44-%k+VLlAxPKH{GuJOKU( z@h=d5iSPuWvvUU|6a7>4D5+KPe2M4k9cVIrhYy465ha6UOFwx+JJ+CJ*&{EY7mGXa zKJ{mzZ`rf)Zfu2!=YrJ5S_lRxe zyCCF5TlvYSXZxRUuQI#J2}y+}#JDT&sgRIHam2EX7cf5I{(?uPehYfPEp(5N9y5Lh z{2_uEGc=+KZR%8m;EZeNWn!$5Iu&(Y=+e9#oi9+1@3AC@dtW8}IOwj6TGJzx&=}2V zNcW;&klr!IN{w-AP=63PUxB@QMfp0))15-xT|)Gx3w)P;gy%+<6!%)*-w*H<$a^b# zM7Q*4DamGZ-Tkya)4Kc_cnCg1Ck~QN=!CCAOCrBW2`y=8_Y~w@$oe8};~KRF@`~OH zeNl}WT^dbG&R3}>F%yyP%}Dnu+sHEonN&4^TL-p6O&!bVd^xxG# zqyIkkdyK`b`*Zqu{qy<+?ELsc{fpT9(WKAD{*O2H7X5AP0-1+d`6B(R`q!`$n6JpKFnCH?R9EBbr-KVpx_|Ipvp zZ|Fb3y#9y!E&WIOZT-jkUHvEehx$+P%del|i>^P%4w7H!PxO!I%dP>(>lrsQewc9w zKf@@=+6q{K5v0yF)79*n>ssVm>RRDi?ONyB=-T4i?rL-GbscaWb{%t_be(aX$I8Gp z*G<^Xv&zA(VDS1 zV_C+^j5QhSGd5*x&DfFAp0O|EV8)S*;~A$i&SqT9xRP-_<5tGqj0YKyGdkQ3x5piH z$K5&ZLU*aV%5Ay_xre$f_bB&R_XPK3_jLDccZ++0dx?9wdzE{wdxLwkdz*Wwdyjj+ z`;hyn`-JOi}PLaBY?`=Q9Z^vDR*9c?PBF`u?%8VML0jn3oj1k6YW1KP3 zm}1N{nvJ=}B4eqs!dQ(}jg7_@W4nP9A&djYVdI!_(l}$BH!d63jGM+C`_7(U_ zu*y^C8;F&jM&C%^7~go`B;Pb&lW&f%)wkHU%(v3F#<$+L$+y+F!`JTH=R4>-;ydm; zwDmP?CZelkjEeN$Nf3}LVqb%icJ3?|4_f>ALSqGpWvVDpYETH z)uRReCI039RaiyZ;NR@u=HKbxc8Q??Z4-L=zoG0CRe~4 z2nUi_X(|eo1!@8ffx&@cff0exfpLL}fhmERf#$&6z@os?z>2`?z`DT3z?Q)FKwDsM z;6UJT;8@^f;7s6r;Bw$v;AY@X;C|py;Av0`x`X~;G?*PM2$lpZf_1@x!6CuM;K<;Z z;P~LA;Iv>M2tDzgA+o5}*hoL87CF}}&!{Kl;oEI(%mxXJ> z4dKDzVc`+s(cy97iQy^Xnc?Q}-0-6C((sD#>hQYo#_*Q#_HbKxZ}>p?aQImGWcW<@ zeE4$sTKH!8PWXQKQTS;@i?}2HNHmfiDTtIrDk61}fsrAR#>mLXn8^6Zq{y^LQ)Eu0 zHL^IeEV44PCbB-VDY7-PBhnt(7daR?5;-0@6*(KZ7`YO;9=R2{8+j0U9O;NUqMm3l z8jt2g3!|mcs;C(q6dfA1qNAc?qZ6W&qtm0aqb<<|(IwI4(N)p4(GAhf(QVP4(LK@q z(L>Rr(G$_r(R0yD(W}uL(c95`(TC9|F(u}Td1K*NGL{!Bij~D`Vhypuv0v*xcBn*wWaF*y`B2*v8nF*!EakY;Wv9>~QQ@>}2dr?0oEU>{{$*>`v@{ z>{09~eroBC`{U7gcDx{760eBY#RtZR#2e!y<749EOpcd|&)v{7C$G{8apG{9^n{{CfOW{BHa~{BgV^;YfHA!9+ZflPFA-CaMx< zVo+je!b*%vj7>~POioNs%uci<79^G=mM2yv)+RP2HYc_vb|&^D_9qS{jwVhdPAASK zE+wueZX|9e?j;^3o+Oo|E9p&!lgVUWvM5=WtVuQ`2PcOmMyjIjTaw$8ZOOgK1Ife5W66`rGs*MG%gJlWo5?%L`^iVir&(H-JIkLH z&C1Ry$STRI$g0a4m^CD;F>7Sjn5^+xld`5|HD%4oYRy`lwJd98)*9^L+$Y}eYL5lf z7Zbi!K8evBwfiE>Inxt{_%N<}I0hFf(`7f_axrygJrKPcKVqHhsUFAz}v zvW$0X(5?Yzl=2Wa`R3o@qWr{}_h>W{1v`Slfp#d!Uz}+h#CaMz(UooDsxqA@LkK5| zQ@o!~e}znz-9~vv&{-Umhkx%T-sUqm`D-=!mx5vnsGJh{#JhZa6;G6s znt2w=Ry+$e+g*_Q`~*ntweK@x4b!d}c`x`XX;t2}7eVJ3A-UE_?In%zU9jP_b3y!$ zLukeZWlsLKm@WUa95^T4J9!7~P-A{Y3uaLSuCGxPIZMZsA)UMno zDCy^k+id~096^^)t9&l_nO#A7WS^n_AfFwrhWN;*!$y}nmwD{CY_+`Cj1y-&UH=QJ z>lnc@!C&@dzWbX5{aHN2E$B`fLmXQKE^BA}fZ$`N_ZV9Wh-V4GDX}q_1VL(r_LwB3e!}^Wh^w-ns1CY%QBbrtYQx{cs10l6=cX1H%m z6m`(nqn1w{X% zfbu@#j7D&CpAb;CLbvgEO%rtGq{wf7X6>!$flm?nWv`R@CH|t3szQp22$phW63YF-2U}Z+C2mgzuA_8wAv=2&X=8Z;ST6$gkB>DVuEeCscI{ zZu(z#d>bH`EU7z+5 z+c`#*brccYB1-XSVC&m*Zz=D(rB?IEAjd#@BJ^v516w+o#|~vqJET&|Mo~)kMuy9T z4C+Io6VA`L993=#x|C3@AZbrg{-h1cr*FT=i7z+`O;RngrEE#B|&%nJAvbuX>?Uk9G@4s@+lzvZv}L$B{+(pi{N+xb-GjPNP#$Iv;2`}jyFZDMSzMhd?*irjU4qL*ex_3^q&)V~z>c$? zOQ@DM8tY03NwwURjc3FNoqnDK$4HVJ6=OSNT0KhvAuBM<3B-7E6+c*<(1 zY$4I75k8J^vh}jBYA3~alhF4@0RAoYiX4LHsJDcvrwtKpkxzOL3oFM)IhINp4hjBt z-}R+%vW$)*Q49A6vo~(Huy(IZ;&QGnpBFp}qz7s**-hz1OaHmYOk^LmYY)}++kj6Y zdB!T!p$EdB^_qS)I&30%+I>sqme3FSr_j|E(iQ3LMSc5a=)Vphds&QEkV@Kddw=`1 zxSfx^|L_<><3trlN^!yeEb>R+g@;~R+opnd@-=locG8>f=| z6uBO1Z>xXoT;n{fsm`CcR^b76z!E`;=PggwD+9%lJ~0jhWEDjp7){m ziBG|fPp>bGb9nQ7MZPj$jjzEs*f$KP@{ab6^G)35F5)xX%k%)ipV#=jot{%-Z}@VEQ-`49S!_>bch;Ir6mdc}X; zf6IT@|G@v)-w|*GJb_>!9>@t4Vh3tfzzhru45gi@V*?WclLONOvjZ)G1%V}j<$+a! zwSf(R&4F!!oq;`p{eeS)qk$8F(}8n=OM$C_8-d$_dx3|6CqX6X3VMU#U^18&EDDwd zYl02I!NFm{5y8>HalwhfDZ!b+=HT4mqTtftis0(ty5Pp(mf-eaTX1jiK=5$zSny=< zOz?d0a`0O4X7En%e(+K7X-EsXL;g@SlpQJvm4qrnb)kWwA)&_5$k3S3_|T-#v`|xM zPN+4sIJ7LZGPEYNKC~&cHMAqt9@-Z=7&;O<9y%2|8@d>}61pC`6}lUG5PBTy2s^@_ za4;MX=Y$KxrQxcu86FfK8n(it!ehe|!jr?(!?VLJ;RWF(;pO2~;kDrn;mzS~;ho_< z;r-!5;iKUb;nU%B;Y;DG;Tz%G;d|kS;U^I#;)-}9;Yc!)7b%LAMQS1qk-?E+kr9#6 zk#UiUktvaxk><$U$fC&7$co77$hyeJ$d<_VNLyrY(vzkLAP)W2LdG zm>C-s8yd4>qhe!Y6JnEN(_^z^EwKf$C9&nPRk5|P4YAF!ZLyuPJ+b|iD|&#`u=__IO)-Z~Q>~aQs;OWc*D0eEf3!TKs1GPW*oSQT%B_OSlvM zL^P3|C`gnfDiU>xfr%lB#>B|Pn8f(Rq{OsDQ({h{HL*CcEU_}NCb2%TDX}%NBhjAN zmpGU>k~p3?l{lNYn7ER-p175`n|P3Toajh8lAdHR8BgXU3zMbEs-&45lpLD0lB1Gi zlM|AYlhc#4lP$>w$tB6<$yLd<$qmWP$!*D<$vw&a$wSGb$rH)b$#cm|$*aj5$=k_$ z$%n}&SxS~G%bOL>N@nF{6=juW)nqkf4bB>tH6m+t*0`*RSyQrRW;JKc&03VTG;2lH z>a2BH8?&}#ZO>}U+M9JC>u}bwtdm)1vd(8+&bpR$GwV*){j5h>Pw_!ZceXz}nw_0p zkX@2pkzJQPFndULWA@1GG1=p@CuL8|ZpxmM-I~2Pds+6%>^0fzvo~dL&EApSp1m*o zVD^#h<8J8vpf1Y`gr;T`^5X?^eOC9+NY|I*=JCnp?$1A zqxy{PGojDqKGXZm?$gp|L7ydkmiJlJXKkMieKz;m)@Ns*J$?4~In?K9pA&sf_c_<+ zQlG1RZuGg`=U$(OeV*hfIj$UUPB*^;w8r!8l1&Vii6ImdEN=A6knpL03qTF%X!J304r9_2jE z)pFgr{@iG8c5Xp#Np3}MUGBi#A-RpYBXh^(j?bNxJ1w^r$aCa*@`8Esyqvtkywbd? zJTq@l-q1WNZ&cpcya{=e^QPy`&TGkAkhdgndETnLwRs!zHs@{2+nKi~Z-3sQyrX$1 z@=oWS%e$0!HSb2=?Yw(=5A&YnEBUT`Z+ z))cHS*i^8!U`IiF!M=in1xE^w7n~|MTX3=9O2PGlTLpIu9uzz-=;-U{>**Wp8}FOb zx3F(%->SZ5-$8wc_O<$s>N~dYguavePVYOrZ%f|=eV6oI-gi~swS71A-Q0Iu-<^H; z^xfb0P~W3{PxL+A_gvpgeXsVt(f4-Wdwn1FeNw0tx(dC8;lgBLUSUyTSz%3KL*d}U zVTB_KM;DGOoLD%eaAsk1;oQPSg-Z)p6s|5@SGcioOX2pyw!*!I2MP}t9xFUqc&6}t z;pM_>g*OZD6y7giP}rH>6)36)}seWhsUF>(I-}QdC`rYmK zpx@(u9Yu~JPf@TaUX)W*SX5e6Rb&5Vx`zs>@5x#CyVon zi;ByNYl<6+2Nw@39#K5HcwF(s;wi;5i<^t*7B4DZTD+oob@965jm2Avw->h+?=3!1 ze7N{n@yX&d#pjDJ7hfyBS$wDXe(|H?r}%uWyTo4-Ey*q^C@CqaD5)zMSTdxfv1DY) zn3C}&lS-zQG?mOLX)Re?vaDof$(oY&C7VjNmh32LFWFaeu;fU|@sd*|XG<=YTq(I; za;xNS$%B%|B^~`8{XPAI{p0;}`WN;u?O)a3>_4dg(Ee8cQT@mEpU{7D|LOf__iyRH zp#PHo%lohDzqbE|{+s)6>%X)Ap8os$AL@U!|B3#m`=9H7ssGjfH~Qc1f3N?;{!dDk zQdgFCmNr4vi1l+G+|E}dJtsB~%Riqh4k>qWDh79P%@xm zK;3|W1BMJ}958agm;vJlOd2q4K+}LZ16l_x95%s>;l=L1jbBtg=yM zW6LI#O)i^WHoL5)Y(d$QvgKu~%GQ=`DBE1Nt!!u6p0fRAhsutYohUn9cCPGF+10We zWw*=jl|3wbQm&M{%Dv^`@??2lc~N;;c};mk`QY+lmuHDhn%1E2}Ea%0ZPw zE3L{=m18R>R8Fp(UOBt6rE)>#lFH?kt18!4Zm8T`xvg?%<(|s@m4_;iR-ULlU3sqZ zQsvdk8!?^Qmmd{U)UxvIQX;i_a+UR6<5SyfF{L)GA_VO1llMpuoinpicZYGze) z)!eE@RZFW@RIRRBSGBQfOV#$OwyM2V2dWNN9jiK7b*Ack)#a*dRX3~dRNb$7RQ0r4 zt9DoWtE1J~)dkfh)fLrs)dQ=CR5wsGaudH5Ey}o)= z_15Yg)$P^$st;BlsXktPs`_m8#p)~7*Q;+;->rU7{kXcL#!=&`3D(4Ga%u`|N^7cW z%$h+pLu;&>Q8i<0Ce%!>nO-xyrln><&61ksHLGgY)@-QRT(hlaXU(3P{WXVbj@F#0 zIbCzE=2Feonj1B@Ywp!Nta(za)VgZDwc*-iZC-6rZCPziZA0zg+F`XLYDd?OtDRUo zrFLd*^Z#t`dw`!$zH|L8Hqv0Tex7|LN>%5q)e zu*Uc(%Ow;c7G=2EVVMI7QfEU^f~P{zj?)*{9@ zgtzyekC%7b+r8V*(PUEFW!7q<$mCvbw6@HaX)phx}Uo@+^^hQEW=9JU95~%v3l0T+E|tiuu(R}?qv_K zO>7H$gl%I_upMkC+ryq?``L@^AbXX)&W^HU>>YNJon~j)Id*|vWS7}BcAb66zGk;Q zLQkP*w@2>L;2jSBUO7?I7vC|LujhS|$3Pwfc?{$+kjFqC19=SOF_6bV9s_v{BtCPgr{Ra_m&D;OQ_m(pD?N)3)3M5pJ2#H+UN6w%wgp-a z=iK;r|f7`I|-uKkTeQq#qVBKl6>Zlb9}m9D`g(NK9rOBf<|Pem*pRwz0^`KSUmzHZQ&;oy0b2ySNzpQ(O%A zjN`f}{x&{E+318n(|kx`fZ!2#LSLQ#x4wiOm`B(FJOtjvB4{fD-H16wVj#^fBIg%w zgUoH*wx9^t3yN@^^hp|ih<-_*ll&yTKo2tYpfh1_HhsiPV9OHl$8cQ~!*%gXpuYtA zH-Y|5pnnAPk8quoN#cjBle!5(rVwNbKE`qcA7eSBy`&Gb(Z%3%F&D?a1-g9;^!x?X ze*tyEcCiC=c7V~7Z^d#|2)`@s7_+!xjkFiX_&!GM@kWcUwLVPP6#cRYzpwCCJ zPmvtO{F0-XU-G+9|6N=c6MH(r5Xc57h7>X&F6#;qz_5VWQ+4@ zbBB;aj&)jWCvx3vGPBXsbSB0njURj^1Yc#Nr;TF^_KC0s`-HSNm$68bPuMQmgJnwg zV3{O_v-v8`E<(=xqLX8qOFf%i+32KQIjzOSTqAKp$|33Gc#--GiLn!OW1kDU0o&Q+ z-@rVg8^8mxS_H9L_!uzo7{==y)XzbkoXZ5>zU;VU^;?!$jlSs?>B;;L&=b}j5v)7R z2GBP!oy2N^0@Di=m`>J7J=1(k*pki9Y4MHZ|3mQ4AL70Q*Kl2M4fG^zA^Q?FV;)iS z+dOIg67osAL~GD?4YwuvN%?7bBXmg9pO{mmoUC<1pI=6f=r2M4zXV^A@t>%Rz5+a7 z0ULh}*M+}^>uEU8rvDk>KLc$Us54OiE2#e!)U&lE=%mldI`b1OhxrNUa~0}Wp?(7D zCvctQ7hZyXe;?DywJBRYtN$T?7PezKB#-#(Sibn{ARlrMG2|Y?W6!iKHe(1L!{Ll*ZowHD=!#;wX3u#z21CJTn z5_$?7f!+voB7TywG))fKHcLG%zQsX4mxmL07Lr)~4)A;j>mYz!OF-pXf-vS0gh5XN ztJ!ob_&(+@_&(+@038ZI2g%2nF8LVKMc>19(f6R=-var+1@tMPPvN>)gX>}q(Dwp; zFVw*n5zI}&3bb9pbYTG3h1@wmn?J6OH4xJ7*L;kOO!o`}(pzA?+0? zuuK80i6lL1J&Vqy{$xEZpC{wF7wq!B?L)F((neAyIS0wLo8)>Nm&CbHEt{kEC}{(7;a8IwPHvE=U)p%hEOJy7Z;=wRF2gSW;NB zyF^~1DKV6oOB^NMl2A#!WKYSyl7l79C5KCnmK-lRS#r9htK@9S`H~AImrAabTr0Uz zaWk0DdUxi%ZkdRWr{LgnX$}L<|^}*Maq(84Q2bw8p{rqwU!+# zYcD%hcBZVmthcPMY@qCN*-+VV*+|)~vhlKsvZ=BMWwT|E%AS-xEn6*nUba#8s%%Ta zC?twq3YkKs&?`&|n}SsY6j4P=u~%_G(WGcm98t6>PAEDQor)gCIYqzXqGC{SRdHQ0 zsu)w;QA{eP6*G!C#e!l{v8-5AtSeqBUMsehLS># zpt4zcSb0==TzOJ?TG^#Mt30o~puD8KqP(WOp}eWQt-Pzer@XIxsGL_mRxT-5l+Tne zl$**oDxONLDpE;R3YAV}R9RFml}{B>B~=Zo{i;UQAyupDn5tcMN_9rnt?E_vsRmS+ zRYR&_)rjhrYFss;no>Pb&8i-$o~WLxR#nec8>&~TEj6Q-sCTJlYL!~AHmPlDRvl1B z)hYE}^#OI0xP7Xk zdQH8qeyM(~-qr{;g__+OxkjTgXv`Xi#;XZw;+j30eVT)sX3b&EQO$A9NzG|Zm*%YI zyyk-DlIDu$n&yV)rslThuI8TRzUHB3Uh`P9q*>8C)4b4ZYTjshTCuiBE7dBrI;~M_ z(YmxgZA6>YHfZ;28?}eDt=ePScI_$c8Ev<=SKFr@&|cOKX@|8V+FROj?Sytp`#?LZ zeWZP&eX3p6KG$w&Uum~=j83B4rIYDYI=#-Mv*}n}Ko`}ebbECNbWOSz-4R`z?u4#G z*Qx8#ozwN}F6st#S9RBQqq;HO9o?jES~sJc(=F&0b<4Un-Ma3j?zL{aTv%RMzPnsr zt|>Q^o68;L-ttg+ynIjjzVd_R&E<#7kCq=VKUsddysP|d`T6n-<(JB@lwT{qQGT=h zcKO}%d*%1bAC}LTKQ3P?Unzf9{-S)d{EeQc7we1kQoTa2(;M{`y-V-YNAyX3gMPoh zQGZC^sz0W0*Pqg#(Rb^6^?mvQ{bl`-epo-Ezoj47Pw1!g5A?J8NBSrFr}|a>bNz7S9H?ljXsI|-(N=MyqNAd-qNn0qMSsP` ziouGj71t|9E5<7BR7_S(SIkt*RV-92RxDSnRjgOMtax3qZ4ep?4Z96;gT`Pmm<F!&$?5!v(`7!xh6d!wtht!)?P|!#%@&!$ZTo z;jv-Kuwr;-cwyKyys6|>iYtpMrIm_GU8S+oQt7JnRYoe4l?|2qD;p~hRkl_ht8A}4 zRe7eeyRx^kuX3RBa^+CvaOFtlt;+GriOQ+U2bHsxk1C&3KCN7>d|tUx`Kofu$QUKY zT}GKvWz-u@Mw^i}28>Z-%DC5fz}RGLF&;6t8BZ8HjGe|F<2hr$@uG3ic-45_IBFa- z-Z4%Zr;RhlIpczH(YS0}Gp-w78ebc?tAtgBRlBR?RhlY8mAT4M<*f=;#jEyI?W;Oi z)m(MB>S)#Rs*_cxtGcSrR-Lc9P<5&5O4YTh8&x-}Zdcu{x>t3->S5J<)#IwAs+Fo| zRWGVGtKOJ+Cb6l=BsD2aI+M|4F}X}WQ^b@sHJJ9B8cl~xt)^q9cGD@-8B@2Z*VJbk zFkLncnTAaxrdy_Q(}Zct^uRQ0dSrTHdTLrVJvVKbUYWM4nQBS(u4-Aes#;%dsXzyw)os-$synJXt9z=?Rrgn4tRAesT7A8Gw0f-iPW5EtpMZb;bJ3`og+tePiR<#I_=v)TXfMY(|^K=Cb*0 z5nIyMVB2qNv>mdw+K$=UZKrH!Y~8kATc2&fcG)&$8@7$uZrR3d6SgVa1KX_ak?o1? zscqHv+_qtRW!thdc8PtLU1nF=^>&lpW@qgId(@t?@3kMWH`!b4N9=9(6ZQ^!r@hC1 z&fagoXdkp+wO_Z7+Q;m7?34Cs`;2|gzF=RpFWcAb>-LxS*Y<6P&{62v?T|Y(4uiw& za5%h2pS$NoRv|zq8SK$l2;V=4^MK za-MN^JA0jd&H?9T=a6&QIpVzK9CuDQr<@O*v(87(C(ftNRp)c(hVzwk%f+}Pu3avf zOXbqLOfH*?bp>2eSIV{5b->l+YH=NLwYg5XI$WKu9@jZnzw4rF&~?>y-8JeObKP-G zx~5$-t~u9&YtgmrT63+tUbsZnN9r_PRsvxOAvm0>%Ql{?|$f>cRzM7xmVoJ+%Mdl?l&xt z6|+UGlvS`g*2r2|7wcmqY?5tY_p^=cA-0u0#p9?Q^0au4 zc-lNCJRP1+PmkxEr{8nYGw8YMx$YVDjG?#kCq2`i8PA+&!L#UD_N;lCXHd)(H zyT7)v_E2qW?XlYS+EcY>YP)NDYx`;kYA@Fg)ehH=)ZVHcubrr!s(ny9Tl=W?N$u0x z)!OH^8?~=$w|tCG;@jnu`BXl=&*ZcDSYN;w^`(4!eFuC^z82pRUz_iQufx~r>+zlQ z_4_XR27On3*L|bDG2b2Eq;J|c_!fQ3zBS*v@1^gxZ`&{Q7y5Vm<$jId;5Yjn zey=~|kNfxd_xTU{oBfCVNBzhBC;g}WUH-HF^ZpC|Oa3eVYyKPloBrGWyZ(Fr`~HXi zdH-Yol7Gej%>Tl_>33_ z>EKLoF1Qd}3@!)Pg6qMT!Pmjygr~v}!n5H=;V0pz;nnc-@J9Gmcq_u7AD`V7kwsJyeZ&;8Mc7Cn5{;xHdm{%T zO_7$!kw{zQM5H6q8R?0fi}Xh>Mg}8SBiAFNk+H~~$Yf+XG837LEJPL~%aOIndgNu~ zb!59vSXWrLyG~xGsWa4>>l}67x=>xbZcp95x`TDib%*PY)*Y`qS$DdwtL|*w`ML{r zm+G$6U8}oMceCzx-QBu-b@%HY*3H*Fu3M^Gse4xUqHeS9O_UcEM~kA;s3NM18l#q| zE9#3zqRD7ObbquldMMf&Jr-?`o{FA{c1L@oebIsF<>*j!I64x&6&;UGM5m$;qO;LQ z(I?TT(beel=tlHabSuWhB(YsFSxgnv$4oI>jEx0i(O4?BH+CS_6l;kciM7Q}#5!V~ zv7XquSbywdY%q2;c0D#48;jkEO~$5UGqJhYLToX%99xU6$6m%>$F}3bcwu~ZTpriN z4RLea5%RZ^ccC2dJI8AwKxspQ_|fn-y%C3z&-mOPQ{NOmTBlIN2B$&1Osrd97uJ5WpTYtX(Lj9%sEA`jvZ`9wczg>T~{$Bn4`iJ%N^^fb9>R0NY z)xW6Utbdc@rNpVClr*JC=~Bj&CFM%_Qjt_L)sWhsYD^tUwWf}x+Eb@eXHwm%-c(;| zAayx4lp0Qrq;93g(P_%aXWi&-_cMH2#EihThoJOeil7KnL~xf~0M8IJ6c4kCdAJiU zW|EUe$N|X$Jt0RAJc~eq=UtdZOyMgqg+IX6;W`U)P6GcWxY9bOfTtJ!n!;B>3#N#L zTpj3CUm5gi$E}1zzzD0_a!p5Twz4 z(G*%k4VEM5#V2Hp2CkHU7@CKsP_5|EVKjfYiuziB)3{Bp$G3autzim3jxFM?;df(r z{kRoLq4nWy5Un=&M-dvar3KuRmoPU4MOeeM2(XHI(&qa+QD{WH8zn(7)$uKKTfaeyN zeI~fl^Yoo4?}iP!RZbpzCk>fgTG6XRF0FFl;SYf4p8+-`95#SLMn9T|#u$y3oOrn2 zxs?lF`BbFMkV{Z~{X_C(h#QnXIq|Gg@sJ*`_r*hxMQUf1aZE_TjLV@fI{MM8`TOI+ z8t$whUO)()InXha9*Z1!==uI3DcCMDU*)XEkVlOG z63Bd>_A~8O3afNR#I$3{J1c$j`UF$H9 zioiN*?$E6=*hNXEv6>+Q&}gB~TQX$b^PuwtHx>oWXwK4ds2D}(HW~Dxtl%qP?vST6 zLQD}1g2&*TL=eULi0^(uVR|r+5WLF33L==oJOzEga|eFgo;oX_SI-Q7rYy}c$`afc z1G`%ObvRGx1P$Auckb+n#xvzH+CMafUI%mHp;1SjU1Zp~P{_}KAjV`f;Ucs zAJ_s3iXll%5r2%W5d0^+F4L5E+o$B>4Z%t5Lv9@uKpdmTA{QR!t5}Zk>o5vkII=NL z7}E_H(|U{qI(wHu%q+CxR+1KcE%*V9dl0uOn8c_P9|Di9Vu~2fGc$}1wRY2^!xw{w zV$g6B>qGOXjm+3f~|hZ`7U{Bg+nbLoq+iwS{bZZ~QNq*0j+J*ISK z#b4&I0s3%v8R))f|EvPSx$G47GE5Guc?Nx`zUY;RUx)ijyQ4ux0RF$jR_yFJR5-HH z5kSz3c|^~#uLNh|+KVNNA*u_$n|c40VLhe2LE)M9Gb4fVYQTPG&|V*1X^IlYVgW`I z#*{yeDa;~)7J8Iv{7_!aX}-@w?}=e@Y?{-+=)jUP#S?}7VFP%#A_v=rqJgJIYb+Q{tN#IyjZ@V z1yjTdtdHOzV3m?X+m*p~>P{%VXUwpoQKOT=KeWf)40PJfLnJQv_V3ZN|MbUjfP%@IV~nuY=CL z;CXIk{L1zMYDH4$Y>=Yx?}B8=zBt=yMA0#Up4~K}=opnj4iy3DvkQJ3w)FE;T<{xp zcZq)sTfvNgHR4tJWm zL;083&m@oFU*T3FZl#73SN>xdoh|5h8D9lkAQ|%8opV~aUm!Yw`x5*u?kmG;{uyp1 z90Q&Lz{cNT3Wp8OE*cy3-khdnz$!Jr=!{zM9o+j)jQS?{nMOEuc0tc~nvwx6UAV8E z83)f@&^!#)SB8^YDvAn4Sh8r88y7t8^y)xU=)UN6FoRbq4e9lZzY7o!t0W)38-?a! zcz~!WtTO}O*+k!O5_|)&%B>(fqk}1Ft1zVx@8{4vx9W8P#;Ezl`%U2CH2gN`K<9^) zh8eOZD*j}!l;)vy<}YJ?1dy-tVZPH8dc0_l(V0~SOKE+mb%(i)_1QV$%(CNP=-il& zem!{q#eFvr1E-_R5lj&bgU|b5EueW)z%zxT#Lit{x-V*WGq<6yA&~q4u2PW0tuov* zRCHf1qOTnu!ge^_%`m2uR9_O1!|f2cCl=;`XXhLn^r7vVhrZHy012?_?buFd?+S<{ z16U?sfv*BM!P|Md&w@{%GGtct&V}~jBGy^Ng0}D+BSZU#mP5zPoa9h_QU0Oxa2oUU zNd>*;G9%CzoC)ka9ZF+^=F#9*0$BB!5MZ?(DBRf&%nRNcrtoiLtnxO%Z|{qz`TyNl zhD^8xRx}}?0!~75c~5;&e$J4SP?*nP`}^X_=~)OIMbqvAQ&|&336gD!97Zo??@uKe!GB5Dx zh#tY1uHxA8-gxMzuU7#HV}Q&_nB5B)Q9Ji5xqaCxrikuw`T)jqvK>n%pSsWFABtxd z*0=>OH-o-%x&KA2-L#+4_s%0LcFx|pJu*Fa=vAf*Ye;71nNIMiby)*;arfik>GTXa z_7K)5EsvttQ_9a7)-NjlFq9R{Jl01@-Z!V?a}GT8E{V2_ufTTk|1HK6mx0jhpg<0Y zf?Q(f4isg(K#q0h@(qC=?)B3;)AN*GyXl-n3Zt`=`@!1%N6;6S1N6h4EIJdW=CZ&7 zBdEgjf}icgp1L=YAt#~YPlnS2Y8KFE7xY-rJQ-F!I%d-63Dmg+!vLbh5ED``Pk#p} z-vG)8rcnAYJOlbBlsfd?Cn?^yc`Dvn-XPD*yTZH6`x&|5tvTKh6IMznQpM$BMEvrj`>r`A4vXyc_jJok{>WXmds1$nMKKuB|m2V z*GDftdciF57~aRsH2UN*Pni{-n)w;?jHhQlW&WCHWPTyw^PB>qU^mYvkP4K%dVyM? z<$X<1C2;fh3%x==?^~i}(K_#+^V)d~|28UjQMrf8eN-NzGLOn*R8T+s6;z&~@&c7j zRNn9y26f9IW0@jUq^KxR(V=2Q#e(t=7h>?Cg1&2pNutt#==;%UBPxebX+`B2D($G8 zLgfr9-Kg}U(uc|bDwk0iLS-105mauWGLFgwDpRODKxGz{N2okOy9Sid0_zh<*s1ej{e+V!AmKSTab)x*|Bda U^4IX6nfTvc!IeM%9f$n?0IO~PegFUf literal 0 HcmV?d00001 diff --git a/android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf b/android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..1226d2ab281901464fd557285ea937e14e852e3d GIT binary patch literal 41088 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 z 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

(*R-QJxnMdh<8^9 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 0f) { + if (comic.displayReadingProgress() > 0f) { Text( - text = text.progressRead((comic.readingProgress * 100).toInt()), + text = text.progressRead((comic.displayReadingProgress() * 100).toInt()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt index fcc58c5df..f87844abc 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt @@ -20,6 +20,7 @@ import io.leostrange.mrcomic.core.domain.analytics.calculateMascotProgress import io.leostrange.mrcomic.core.domain.analytics.resolveGamificationMetricsSnapshot import io.leostrange.mrcomic.core.domain.analytics.resolveMrComicMascotState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted @@ -157,7 +158,7 @@ class ContinueViewModel @Inject constructor( .filterNot { it.isCompleted } .mapTo(linkedSetOf()) { it.id } val activeReading = comics - .filter { !it.isCompleted && it.readingProgress > 0f } + .filter { !it.isCompleted && it.displayReadingProgress() > 0f } .sortedByDescending { it.lastReadDate } val currentlyReading = activeReading.take(12) val mascotProgress = calculateMascotProgress(comics) @@ -210,7 +211,7 @@ class ContinueViewModel @Inject constructor( if (ws is ContinueWarmState.Ready) { val comics = ws.snapshot.comics val activeReading = comics - .filter { !it.isCompleted && it.readingProgress > 0f } + .filter { !it.isCompleted && it.displayReadingProgress() > 0f } .sortedByDescending { it.lastReadDate } ContinueUiState( isLoading = false, diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt index 4501dde4f..0704e8e73 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt @@ -51,8 +51,8 @@ import io.leostrange.mrcomic.core.data.preferences.UserPreferences import io.leostrange.mrcomic.core.data.preferences.dataStore import io.leostrange.mrcomic.core.model.ReaderFormatCatalog import io.leostrange.mrcomic.core.model.storedReaderLocator -import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomNavigationBar -import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomNavigationItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomBar +import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomBarDestination import io.leostrange.mrcomic.core.ui.eink.LocalEInkMode import io.leostrange.mrcomic.core.ui.locale.LocalStrings import io.leostrange.mrcomic.feature.library.AudiobookPlayerScreen @@ -411,11 +411,25 @@ fun AppNavHost( onAudiobookClick = { audiobookId -> navigateToFullscreen(Screen.AudiobookPlayer.create(audiobookId)) }, - onQuoteClick = { comicId, page -> + onQuoteClick = { comicId, page, quote -> + // BUG-CANDIDATE-01: Use structured position from quote when available. + // The quote stores characterOffset and domAnchor for precise navigation; + // page is the legacy fallback when no structured position exists. + val quoteLocator = quote?.let { q -> + val pos = q.characterOffset?.takeIf { it > 0 } + val anchor = q.domAnchor?.takeIf { it.isNotBlank() } + if (pos != null || anchor != null) { + io.leostrange.mrcomic.core.model.ReaderLocator( + position = pos, + fragment = anchor + ) + } else null + } navigateToFullscreen( Screen.Reader.createForComic( comicId = comicId, - page = page + page = if (quoteLocator != null) null else page, + locator = quoteLocator ) ) }, @@ -431,9 +445,6 @@ fun AppNavHost( navController.navigate(Screen.ProgressProfile.route) { launchSingleTop = true } - }, - onOpdsCatalogClick = { - navController.navigate(Screen.OpdsCatalog.route) } ) } @@ -445,17 +456,6 @@ fun AppNavHost( ) } - composable(Screen.OpdsCatalog.route) { - val libraryVm: LibraryViewModel = hiltViewModel() - io.leostrange.mrcomic.feature.library.opds.OpdsCatalogScreen( - onNavigateBack = { navController.popBackStack() }, - onBookDownloaded = { file -> - libraryVm.addComicFromUri(android.net.Uri.fromFile(file)) - navController.popBackStack() - } - ) - } - composable(Screen.ProgressProfile.route) { val vm: LibraryViewModel = hiltViewModel() val scope = rememberCoroutineScope() @@ -654,14 +654,18 @@ private fun AppBottomBar( menuItems: List, onNavigate: (String) -> Unit ) { - MrComicBottomNavigationBar { - menuItems.forEach { item -> - MrComicBottomNavigationItem( - icon = item.icon, + MrComicBottomBar( + destinations = menuItems.map { item -> + MrComicBottomBarDestination( + route = item.route, label = item.label, - selected = currentRoute == item.route, - onClick = { onNavigate(item.destination) } + icon = item.icon, ) + }, + currentRoute = currentRoute, + onDestinationClick = { destination -> + val item = menuItems.firstOrNull { it.route == destination.route } + if (item != null) onNavigate(item.destination) } - } + ) } diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt index 75aa4821b..41b8d8214 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt @@ -22,8 +22,6 @@ sealed class Screen(val route: String) { } } - data object OpdsCatalog : Screen("opds_catalog") - data object AudiobookPlayer : Screen("audiobook_player/{audiobookId}") { fun create(audiobookId: String) = "audiobook_player/$audiobookId" } diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt index 7a12c5a65..0df4305e1 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt @@ -19,7 +19,7 @@ import io.leostrange.mrcomic.core.data.db.entity.TranslationCacheEntry TextHighlight::class, TranslationCacheEntry::class ], - version = 10, + version = 14, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt index 3b97e499d..4228477e5 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt @@ -4,6 +4,13 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase object AppDatabaseMigrations { + /** 13→14: Remove the retired online-catalog storage. */ + val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `opds_catalogs`") + } + } + /** * Columns of the `comics` table that were added to the [io.leostrange.mrcomic.core.model.Comic] * entity over time WITHOUT an accompanying `ALTER TABLE` migration (historically the only @@ -233,4 +240,82 @@ object AppDatabaseMigrations { ) } } + + /** 11→12: BUG-CANDIDATE-01 — Add structured position columns to saved_quotes. */ + val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + val existing = HashSet() + db.query("PRAGMA table_info(`saved_quotes`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex >= 0) { + while (cursor.moveToNext()) { + existing.add(cursor.getString(nameIndex)) + } + } + } + if ("positionJson" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `positionJson` TEXT") + } + if ("characterOffset" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `characterOffset` INTEGER") + } + if ("domAnchor" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `domAnchor` TEXT") + } + } + } + + /** 12→13: Add username and password columns to opds_catalogs for Basic auth. */ + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + val existing = HashSet() + db.query("PRAGMA table_info(`opds_catalogs`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex >= 0) { + while (cursor.moveToNext()) { + existing.add(cursor.getString(nameIndex)) + } + } + } + if ("username" !in existing) { + db.execSQL("ALTER TABLE `opds_catalogs` ADD COLUMN `username` TEXT NOT NULL DEFAULT ''") + } + if ("password" !in existing) { + db.execSQL("ALTER TABLE `opds_catalogs` ADD COLUMN `password` TEXT NOT NULL DEFAULT ''") + } + } + } + + /** 10→11: Add opds_catalogs table and seed default catalog sources. */ + val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `opds_catalogs` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `url` TEXT NOT NULL, + `description` TEXT NOT NULL DEFAULT '', + `isSearchable` INTEGER NOT NULL DEFAULT 0, + `isDefault` INTEGER NOT NULL DEFAULT 0, + `sortOrder` INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + // Seed default catalogs + val defaults = listOf( + Triple("Project Gutenberg", "https://www.gutenberg.org/ebooks.opds/", "Free eBooks (public domain)"), + Triple("Feedbooks", "https://catalog.feedbooks.com/catalog/public_domain", "Public domain books"), + Triple("ManyBooks", "https://manybooks.net/opds", "Free eBooks collection"), + Triple("Internet Archive", "https://archive.org/advancedsearch.php?q=&fl[]=identifier&fl[]=title&fl[]=creator&sort[]=downloads+desc&mediatype=texts&output=opds", "Archive.org books & comics"), + Triple("Standard Ebooks", "https://standardebooks.org/opds", "Beautifully formatted free classics"), + ) + defaults.forEachIndexed { index, (name, url, desc) -> + db.execSQL( + "INSERT INTO opds_catalogs (name, url, description, isSearchable, isDefault, sortOrder) " + + "VALUES ('$name', '$url', '$desc', 1, 1, $index)" + ) + } + } + } } diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt index 06e3c5a5f..c45024353 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt @@ -49,7 +49,7 @@ interface ComicDao { SET currentPage = :currentPage, readingProgress = :progress, lastReadDate = :lastReadDate, - pageCount = CASE WHEN :pageCount > pageCount THEN :pageCount ELSE pageCount END, + pageCount = :pageCount, readerLocatorPosition = CASE WHEN :characterOffset IS NOT NULL THEN :characterOffset ELSE readerLocatorPosition diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt index 96c9e5429..8fd5502dd 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt @@ -24,5 +24,11 @@ data class SavedQuote( val targetLanguage: String? = null, val createdAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(), - val contentHash: String + val contentHash: String, + /** BUG-CANDIDATE-01: Structured position JSON for precise quote navigation. */ + val positionJson: String? = null, + /** BUG-CANDIDATE-01: Character offset for text-based relocation. */ + val characterOffset: Int? = null, + /** BUG-CANDIDATE-01: DOM anchor for fragment-based navigation. */ + val domAnchor: String? = null ) diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt index 6c9cbb71e..1caef7590 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt @@ -14,6 +14,8 @@ import javax.inject.Named import io.leostrange.mrcomic.core.data.db.QuoteDao import io.leostrange.mrcomic.core.data.db.TextHighlightDao import io.leostrange.mrcomic.core.data.db.TranslationCacheDao +import io.leostrange.mrcomic.core.data.preferences.UserPreferences +import io.leostrange.mrcomic.core.data.preferences.dataStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -38,7 +40,11 @@ object DatabaseModule { AppDatabaseMigrations.MIGRATION_6_7, AppDatabaseMigrations.MIGRATION_7_8, AppDatabaseMigrations.MIGRATION_8_9, - AppDatabaseMigrations.MIGRATION_9_10 + AppDatabaseMigrations.MIGRATION_9_10, + AppDatabaseMigrations.MIGRATION_10_11, + AppDatabaseMigrations.MIGRATION_11_12, + AppDatabaseMigrations.MIGRATION_12_13, + AppDatabaseMigrations.MIGRATION_13_14 ) // Never silently drop the user's library on a forward-migration gap (a missing // migration is a bug to fix, not data to wipe). Destructive recovery is kept only for @@ -50,6 +56,12 @@ object DatabaseModule { @Singleton fun provideQuoteDao(appDatabase: AppDatabase): QuoteDao = appDatabase.quoteDao() + /** Shared preferences facade over the app DataStore. */ + @Provides + @Singleton + fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences = + UserPreferences(context.dataStore) + @Provides @Singleton fun provideAudiobookDao(db: AppDatabase): AudiobookDao = db.audiobookDao() @@ -81,4 +93,5 @@ object DatabaseModule { @Provides @Singleton fun provideTranslationCacheDao(db: AppDatabase): TranslationCacheDao = db.translationCacheDao() + } diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt deleted file mode 100644 index 9dd68fe00..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.leostrange.mrcomic.core.data.di - -import io.leostrange.mrcomic.core.data.opds.OpdsNetworkClient -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object OpdsModule { - - @Provides - @Singleton - fun provideOpdsNetworkClient(): OpdsNetworkClient = OpdsNetworkClient() -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt index 4a828a2a0..92d0c0ba3 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/dictionary/DictionaryDownloader.kt @@ -14,14 +14,52 @@ import java.util.zip.GZIPOutputStream import javax.inject.Inject import javax.inject.Singleton +/** + * Where a particular dictionary currently lives on disk. + * + * - [BUNDLED] — the extracted file came from the app's `assets/` folder and has + * not been replaced by the user. Treated as immutable: no delete, no + * re-import by the user. + * - [DOWNLOADED] — the extracted file was fetched from the GitHub release + * assets via [DictionaryDownloader.ensureDictionary]. Removable. + * - [IMPORTED] — the extracted file was supplied by the user through the + * SAF picker. Removable. + * - [NOT_INSTALLED] — no extracted file present yet. The user can download + * or import one. + */ +enum class DictionaryProvenance { + BUNDLED, + DOWNLOADED, + IMPORTED, + NOT_INSTALLED; + + val isUserOwned: Boolean + get() = this == DOWNLOADED || this == IMPORTED + + val isInstalled: Boolean + get() = this != NOT_INSTALLED +} + /** * Information about an installed dictionary. + * + * [provenance] tracks where the on-disk file came from so the UI can show the + * correct status (bundled / downloaded / imported) and enable the right + * actions (delete, import). [isBundled] is kept for backward compatibility + * with code that still needs the legacy "shipped in assets" flag. */ data class DictionaryInstallInfo( val language: String, val isBundled: Boolean, + val provenance: DictionaryProvenance, val downloadedFile: File?, val sizeBytes: Long, + val sourceName: String = when (provenance) { + DictionaryProvenance.BUNDLED -> "Asset" + DictionaryProvenance.DOWNLOADED -> "Download" + DictionaryProvenance.IMPORTED -> "Import" + DictionaryProvenance.NOT_INSTALLED -> "Catalog" + } ) /** @@ -87,6 +125,10 @@ class DictionaryDownloader @Inject constructor( // Clean up downloaded file downloadedFile.delete() + // Mark the file as user-downloaded so the UI can offer delete + // and surface the right status. + writeProvenance(config.language, DictionaryProvenance.DOWNLOADED) + onProgress?.invoke(100) Log.i(TAG, "Successfully downloaded and extracted dictionary for ${config.language}") extractedFile @@ -166,7 +208,17 @@ class DictionaryDownloader @Inject constructor( /** * Returns info about every shipped dictionary — whether it is bundled, - * downloaded, and the size of the on-disk file (or zero for not-yet-fetched). + * downloaded, imported, and the size of the on-disk file (or zero for + * not-yet-fetched). + * + * Provenance resolution: + * 1. If a `.provenance` sidecar exists next to the extracted file, that + * value wins (DOWNLOADED or IMPORTED). + * 2. Otherwise, if the extracted file is present and the same language + * ships in assets, the file came from a prior asset extraction + * (BUNDLED). The user could still overwrite it via download/import, + * which would replace both the extracted file and the sidecar. + * 3. Otherwise the dictionary is not yet installed. */ fun installedDictionaries(): List { return DictionaryAssetCatalog.shippedLanguages().map { lang -> @@ -181,17 +233,50 @@ class DictionaryDownloader @Inject constructor( downloadedExists -> downloadedFile.length() else -> 0L } + val provenance = readProvenance(lang, isExtracted, hasBundled) DictionaryInstallInfo( language = lang, isBundled = hasBundled, + provenance = provenance, downloadedFile = if (downloadedExists) downloadedFile else null, sizeBytes = sizeBytes, ) } } + private fun readProvenance( + language: String, + isExtracted: Boolean, + hasBundled: Boolean, + ): DictionaryProvenance { + if (!isExtracted) return DictionaryProvenance.NOT_INSTALLED + val sidecar = File(extractedDir, "$language.provenance") + if (sidecar.exists()) { + val raw = runCatching { sidecar.readText().trim() }.getOrDefault("") + when (raw.uppercase()) { + "DOWNLOADED", "DOWNLOAD" -> return DictionaryProvenance.DOWNLOADED + "IMPORTED", "IMPORT" -> return DictionaryProvenance.IMPORTED + "BUNDLED", "ASSET" -> return DictionaryProvenance.BUNDLED + } + } + // No sidecar → assume the file came from the bundled assets + // (this is the initial state of a fresh install). + return if (hasBundled) DictionaryProvenance.BUNDLED + else DictionaryProvenance.DOWNLOADED + } + + private fun writeProvenance(language: String, provenance: DictionaryProvenance) { + val sidecar = File(extractedDir, "$language.provenance") + runCatching { + sidecar.parentFile?.mkdirs() + sidecar.writeText(provenance.name) + } + } + /** * Deletes the extracted database and any downloaded .dbpack for the given language. + * The provenance sidecar is cleared so a future re-extract from the bundled + * asset is correctly classified as BUNDLED again. * Note: does NOT check if a download is currently in progress — callers should * guard via [DictionaryOperationState] before calling. * @@ -201,9 +286,11 @@ class DictionaryDownloader @Inject constructor( val config = DictionaryAssetCatalog.configForLanguage(language) ?: return false val extractedFile = File(extractedDir, config.extractedFileName) val downloadedFile = File(downloadsDir, "$language.dbpack") + val sidecarFile = File(extractedDir, "$language.provenance") var deleted = false if (extractedFile.exists()) { extractedFile.delete(); deleted = true } if (downloadedFile.exists()) { downloadedFile.delete(); deleted = true } + if (sidecarFile.exists()) { sidecarFile.delete(); deleted = true } return deleted } @@ -286,6 +373,11 @@ class DictionaryDownloader @Inject constructor( } backupFile.delete() + // Mark the file as user-imported so the UI can offer delete + // and surface the right status (even if a bundled asset also + // exists for the same language). + writeProvenance(language, DictionaryProvenance.IMPORTED) + tempFile.delete() Log.i(TAG, "Successfully imported dictionary for $language") extractedFile diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt deleted file mode 100644 index 08fee835b..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt +++ /dev/null @@ -1,159 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.util.Xml -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import org.xmlpull.v1.XmlPullParser -import java.io.InputStream - -/** - * Parses OPDS feeds in Atom/XML format. - * - * OPDS feeds are Atom documents with additional link relations - * defined by the OPDS specification (acquisition, navigation, search). - */ -internal object OpdsFeedParser { - - private const val ATOM_NS = "http://www.w3.org/2005/Atom" - - fun parse(input: InputStream): OpdsFeed { - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) - setInput(input, null) - } - - var title = "" - val entries = mutableListOf() - val links = mutableListOf() - var nextLink: String? = null - var searchLink: String? = null - - // Navigate to - while (parser.eventType != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.START_TAG && - parser.namespace == ATOM_NS && parser.name == "feed" - ) { - break - } - parser.next() - } - - // Parse feed children - while (parser.eventType != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.END_TAG && - parser.namespace == ATOM_NS && parser.name == "feed" - ) { - break - } - if (parser.eventType == XmlPullParser.START_TAG) { - when { - parser.namespace == ATOM_NS && parser.name == "title" -> - title = readText(parser, "title") - parser.namespace == ATOM_NS && parser.name == "link" -> { - val link = readLink(parser) - if (link != null) { - links.add(link) - if (link.isNext) nextLink = link.href - if (link.isSearch) searchLink = link.href - } - } - parser.namespace == ATOM_NS && parser.name == "entry" -> - readEntry(parser)?.let { entries.add(it) } - } - } - parser.next() - } - - return OpdsFeed( - title = title.ifBlank { "Untitled" }, - entries = entries, - links = links, - nextLink = nextLink, - searchLink = searchLink - ) - } - - private fun readEntry(parser: XmlPullParser): OpdsEntry? { - var title = "" - var author: String? = null - var summary: String? = null - var thumbnailUrl: String? = null - var updated: String? = null - val links = mutableListOf() - - val depth = parser.depth - parser.next() - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.START_TAG) { - when { - parser.namespace == ATOM_NS && parser.name == "title" -> - title = readText(parser, "title") - parser.namespace == ATOM_NS && parser.name == "author" -> - author = readAuthorName(parser) - parser.namespace == ATOM_NS && parser.name == "summary" -> - summary = readText(parser, "summary") - parser.namespace == ATOM_NS && parser.name == "content" -> - if (summary.isNullOrBlank()) summary = readText(parser, "content") - parser.namespace == ATOM_NS && parser.name == "updated" -> - updated = readText(parser, "updated") - parser.namespace == ATOM_NS && parser.name == "link" -> { - val link = readLink(parser) - if (link != null) { - links.add(link) - if (link.isThumbnail && thumbnailUrl == null) { - thumbnailUrl = link.href - } - } - } - } - } - parser.next() - } - - return OpdsEntry( - title = title.ifBlank { return null }, - author = author, - summary = summary, - thumbnailUrl = thumbnailUrl, - updated = updated, - links = links - ) - } - - private fun readLink(parser: XmlPullParser): OpdsLink? { - val href = parser.getAttributeValue(null, "href") ?: return null - val rel = parser.getAttributeValue(null, "rel") ?: "alternate" - val type = parser.getAttributeValue(null, "type") - val title = parser.getAttributeValue(null, "title") - return OpdsLink(href = href, rel = rel, type = type, title = title) - } - - private fun readAuthorName(parser: XmlPullParser): String? { - val depth = parser.depth - parser.next() - var name: String? = null - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.START_TAG && - parser.namespace == ATOM_NS && parser.name == "name" - ) { - name = readText(parser, "name") - } - parser.next() - } - return name - } - - private fun readText(parser: XmlPullParser, tag: String): String { - val depth = parser.depth - val sb = StringBuilder() - parser.next() - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.TEXT || parser.eventType == XmlPullParser.CDSECT) { - sb.append(parser.text) - } - parser.next() - } - return sb.toString().trim() - } -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt deleted file mode 100644 index a310c8267..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt +++ /dev/null @@ -1,140 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.util.Log -import io.leostrange.mrcomic.core.model.OpdsFeed -import java.io.File -import java.io.IOException -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request - -/** - * Network client for OPDS catalog operations. - * Fetches Atom/XML feeds and downloads book files. - */ -class OpdsNetworkClient( - private val client: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .followRedirects(true) - .build() -) { - - companion object { - private const val TAG = "OpdsNetworkClient" - private const val USER_AGENT = "MrComic/2.1 (Android; OPDS)" - private const val MAX_DOWNLOAD_ATTEMPTS = 3 - private const val DOWNLOAD_RETRY_DELAY_MILLIS = 500L - } - - /** Fetch an OPDS feed from the given URL. */ - suspend fun fetchFeed(url: String): OpdsFeed = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching OPDS feed: $url") - val request = Request.Builder() - .url(url) - .header("Accept", "application/atom+xml, application/xml, text/xml, */*") - .header("User-Agent", USER_AGENT) - .get() - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IOException("OPDS feed request failed: ${response.code} ${response.message}") - } - val body = response.body ?: throw IOException("Empty response body") - body.byteStream().use { stream -> - OpdsFeedParser.parse(stream) - } - } - } - - /** Download a book file from the given URL to the specified output file. */ - suspend fun downloadBook( - url: String, - outputFile: File, - onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null - ): File = withContext(Dispatchers.IO) { - outputFile.parentFile?.mkdirs() - val tempFile = File(outputFile.parentFile, ".${outputFile.name}.part") - var lastError: IOException? = null - - repeat(MAX_DOWNLOAD_ATTEMPTS) { attempt -> - try { - downloadOnce(url, tempFile, onProgress) - if (outputFile.exists()) outputFile.delete() - if (!tempFile.renameTo(outputFile)) { - tempFile.copyTo(outputFile, overwrite = true) - tempFile.delete() - } - Log.d(TAG, "Download complete: ${outputFile.length()} bytes") - return@withContext outputFile - } catch (error: IOException) { - tempFile.delete() - lastError = error - val hasAttemptsLeft = attempt + 1 < MAX_DOWNLOAD_ATTEMPTS - if (!hasAttemptsLeft || error is OpdsHttpException && !error.retryable) { - throw error - } - Log.w(TAG, "Download attempt ${attempt + 1} failed; retrying", error) - delay(DOWNLOAD_RETRY_DELAY_MILLIS * (attempt + 1)) - } - } - - throw lastError ?: IOException("Download failed without an error") - } - - private fun downloadOnce( - url: String, - outputFile: File, - onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? - ) { - Log.d(TAG, "Downloading book: $url -> ${outputFile.absolutePath}") - val request = Request.Builder() - .url(url) - .header("User-Agent", USER_AGENT) - .get() - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw OpdsHttpException( - code = response.code, - message = "Download failed: ${response.code} ${response.message}", - retryable = response.code == 408 || response.code == 429 || response.code >= 500 - ) - } - val body = response.body ?: throw IOException("Empty response body") - val totalBytes = body.contentLength() - onProgress?.invoke(0L, totalBytes) - - body.byteStream().use { input -> - outputFile.outputStream().use { output -> - val buffer = ByteArray(8192) - var bytesRead = 0L - var lastProgressReport = 0L - while (true) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - bytesRead += read - // Report progress at most every 64KB. - if (bytesRead - lastProgressReport >= 65536) { - onProgress?.invoke(bytesRead, totalBytes) - lastProgressReport = bytesRead - } - } - onProgress?.invoke(bytesRead, totalBytes) - } - } - } - } - - private class OpdsHttpException( - val code: Int, - message: String, - val retryable: Boolean - ) : IOException(message) -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt deleted file mode 100644 index 01a1bb903..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt +++ /dev/null @@ -1,157 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.content.Context -import android.util.Log -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Repository for OPDS catalog browsing and book downloading. - * Manages catalog sources, feed navigation, and book import. - */ -@Singleton -class OpdsRepository @Inject constructor( - @ApplicationContext private val context: Context, - private val networkClient: OpdsNetworkClient -) { - - companion object { - private const val TAG = "OpdsRepository" - private val DOWNLOADS_DIR = "opds_downloads" - } - - /** Pre-configured OPDS catalog sources. */ - val defaultCatalogs = listOf( - OpdsCatalogSource( - name = "Project Gutenberg", - url = "https://www.gutenberg.org/ebooks.opds/", - description = "Free eBooks (public domain)", - isSearchable = true - ), - OpdsCatalogSource( - name = "Feedbooks", - url = "https://catalog.feedbooks.com/catalog/public_domain", - description = "Public domain books" - ), - OpdsCatalogSource( - name = "ManyBooks", - url = "https://manybooks.net/opds", - description = "Free eBooks collection" - ) - ) - - /** Fetch an OPDS feed from the given URL. */ - suspend fun browse(url: String): OpdsFeed = withContext(Dispatchers.IO) { - Log.d(TAG, "Browsing: $url") - networkClient.fetchFeed(url) - } - - /** Search within an OPDS catalog. */ - suspend fun search(searchUrl: String, query: String): OpdsFeed = withContext(Dispatchers.IO) { - // OPDS search uses OpenSearch URL template: replace {searchTerms} or {?searchTerms} - val url = buildOpdsSearchUrl(searchUrl, query) - Log.d(TAG, "Searching: $url") - networkClient.fetchFeed(url) - } - - /** Download a book from an OPDS entry to local storage. */ - suspend fun downloadBook( - entry: OpdsEntry, - onProgress: ((Long, Long) -> Unit)? = null - ): File = withContext(Dispatchers.IO) { - val acquisitionLink = entry.acquisitionLink - ?: throw IllegalArgumentException("Entry has no acquisition link: ${entry.title}") - - val extension = guessExtension(acquisitionLink.type, acquisitionLink.href) - val safeName = entry.title.replace(Regex("[^a-zA-Z0-9а-яА-ЯёЁ\\-_. ]"), "_") - .take(80) - .ifBlank { "book" } - // Include the acquisition identity so equal titles cannot race on one file. - val downloadIdentity = Integer.toHexString(acquisitionLink.href.hashCode()) - val fileName = "${safeName}-${downloadIdentity}.$extension" - - val downloadsDir = File(context.filesDir, DOWNLOADS_DIR).apply { mkdirs() } - val outputFile = uniqueDownloadFile(downloadsDir, fileName) - - Log.d(TAG, "Downloading '${entry.title}' from ${acquisitionLink.href}") - networkClient.downloadBook(acquisitionLink.href, outputFile, onProgress) - } - - /** Get the local downloads directory. */ - fun getDownloadsDir(): File = File(context.filesDir, DOWNLOADS_DIR).apply { mkdirs() } - - /** Clean up downloaded files. */ - fun cleanupDownloads() { - val dir = File(context.filesDir, DOWNLOADS_DIR) - if (dir.exists()) { - dir.listFiles()?.forEach { it.delete() } - } - } - - private fun uniqueDownloadFile(downloadsDir: File, fileName: String): File { - val candidate = File(downloadsDir, fileName) - if (!candidate.exists()) return candidate - - val baseName = fileName.substringBeforeLast('.', fileName) - val extension = fileName.substringAfterLast('.', "") - var index = 2 - while (true) { - val suffix = " ($index)" + if (extension.isBlank()) "" else ".${extension}" - val next = File(downloadsDir, baseName + suffix) - if (!next.exists()) return next - index++ - } - } - - private fun guessExtension(mimeType: String?, href: String): String { - // Try MIME type first - val fromMime = when (mimeType) { - "application/epub+zip" -> "epub" - "application/x-mobipocket-ebook" -> "mobi" - "application/pdf" -> "pdf" - "application/x-fictionbook+xml" -> "fb2" - "application/rtf", "text/rtf" -> "rtf" - "text/plain" -> "txt" - "text/html" -> "html" - "application/zip" -> "zip" - else -> null - } - if (fromMime != null) return fromMime - - // Try URL extension - val urlPath = href.substringBefore("?").substringBefore("#") - val lastDot = urlPath.lastIndexOf('.') - if (lastDot >= 0) { - val ext = urlPath.substring(lastDot + 1).lowercase() - if (ext.length in 2..5) return ext - } - - return "epub" // default fallback - } -} - -/** Build a correctly encoded URL from an OPDS/OpenSearch query template. */ -internal fun buildOpdsSearchUrl(searchUrl: String, query: String): String { - val encoded = java.net.URLEncoder.encode(query, "UTF-8") - .replace("+", "%20") - - return when { - "{?searchTerms}" in searchUrl -> searchUrl.replace("{?searchTerms}", "?q=$encoded") - "{searchTerms}" in searchUrl -> searchUrl.replace("{searchTerms}", encoded) - else -> { - val separator = when { - searchUrl.endsWith("?") || searchUrl.endsWith("&") -> "" - "?" in searchUrl -> "&" - else -> "?" - } - "$searchUrl${separator}q=$encoded" - } - } -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt index ec5f78d88..8860f76a6 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.core.data.repository import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.readingProgressForPage import java.io.File @@ -79,7 +80,7 @@ import java.io.File lastModified = maxOf(existing.lastModified, backup.lastModified), folderId = existing.folderId ?: backup.folderId, lastReadDate = maxOf(existing.lastReadDate ?: 0L, backup.lastReadDate ?: 0L).takeIf { it > 0L }, - readingProgress = maxOf(existing.readingProgress, mergedProgress), + readingProgress = maxOf(existing.displayReadingProgress(), mergedProgress), currentPage = maxOf(existing.currentPage, mergedCurrentPage), isBookmarked = existing.isBookmarked || backup.isBookmarked, tags = existing.tags.ifBlank { backup.tags }, diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt index 29186ce5c..bbe52d398 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt @@ -54,7 +54,7 @@ internal class ComicFormatDetector( ) { "cbz" -> ComicFormat.CBZ "zip" -> ComicFormat.ZIP - "cbr" -> ComicFormat.RAR + "cbr" -> ComicFormat.CBR "rar" -> ComicFormat.RAR "cb7", "7z" -> ComicFormat.SEVENZ "cbt", "tar" -> ComicFormat.TAR diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt index 9af03fa5f..7ee8a606a 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt @@ -31,7 +31,11 @@ class QuoteRepository @Inject constructor( text: String, translatedText: String? = null, sourceLanguage: String? = null, - targetLanguage: String? = null + targetLanguage: String? = null, + /** BUG-CANDIDATE-01: Structured position for precise quote navigation. */ + positionJson: String? = null, + characterOffset: Int? = null, + domAnchor: String? = null ): SaveQuoteResult? { val normalizedText = normalizeQuoteText(text) if (normalizedText.isBlank()) return null @@ -49,7 +53,11 @@ class QuoteRepository @Inject constructor( translatedText = normalizedTranslation ?: existing.translatedText, sourceLanguage = sourceLanguage ?: existing.sourceLanguage, targetLanguage = targetLanguage ?: existing.targetLanguage, - updatedAt = System.currentTimeMillis() + updatedAt = System.currentTimeMillis(), + // BUG-CANDIDATE-01: Update position if provided + positionJson = positionJson ?: existing.positionJson, + characterOffset = characterOffset ?: existing.characterOffset, + domAnchor = domAnchor ?: existing.domAnchor ) quoteDao.updateQuote(merged) return SaveQuoteResult(merged, inserted = false) @@ -64,7 +72,10 @@ class QuoteRepository @Inject constructor( translatedText = normalizedTranslation, sourceLanguage = sourceLanguage, targetLanguage = targetLanguage, - contentHash = contentHash + contentHash = contentHash, + positionJson = positionJson, + characterOffset = characterOffset, + domAnchor = domAnchor ) quoteDao.insertQuote(quote) return SaveQuoteResult(quote, inserted = true) diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt deleted file mode 100644 index 213bde8a9..000000000 --- a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsLink -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) -class OpdsNetworkIntegrationTest { - - private lateinit var server: MockWebServer - private lateinit var baseUrl: String - private lateinit var repository: OpdsRepository - - @Before - fun setUp() { - server = MockWebServer() - server.start() - baseUrl = server.url("/").toString().removeSuffix("/") - repository = OpdsRepository( - context = RuntimeEnvironment.getApplication(), - networkClient = OpdsNetworkClient(OkHttpClient()) - ) - repository.cleanupDownloads() - } - - @After - fun tearDown() { - repository.cleanupDownloads() - server.shutdown() - } - - @Test - fun browseSearchAndDownloadWorkAgainstLocalOpdsServer() = kotlinx.coroutines.test.runTest { - server.enqueue(atomResponse(catalogFeed())) - val catalog = repository.browse("$baseUrl/catalog") - assertEquals("Local OPDS Catalog", catalog.title) - assertTrue(catalog.searchLink!!.contains("{searchTerms}")) - server.takeRequest() - - server.enqueue(atomResponse(searchFeed())) - val searchResult = repository.search(catalog.searchLink!!, "hello world & peace") - val searchRequest = server.takeRequest() - assertEquals("request=${searchRequest.requestUrl}", "/search", searchRequest.requestUrl!!.encodedPath) - assertEquals("request=${searchRequest.requestUrl}", "hello world & peace", searchRequest.requestUrl!!.queryParameter("q")) - assertTrue(searchRequest.requestUrl!!.encodedQuery!!.contains("%26")) - assertFalse(searchRequest.requestUrl!!.encodedQuery!!.contains(" ")) - - assertEquals("Search results", searchResult.title) - val book = searchResult.entries.single() - assertTrue(book.isBook) - assertEquals("Integration Book", book.title) - - server.enqueue( - MockResponse() - .setHeader("Content-Type", "application/epub+zip") - .setBody("integration-book-content") - ) - val downloadedFile = repository.downloadBook(book) - server.takeRequest() - assertEquals("integration-book-content", downloadedFile.readText()) - assertTrue(downloadedFile.exists()) - } - - @Test - fun downloadRetriesTransientServerFailureAndCleansTemporaryFile() = kotlinx.coroutines.test.runTest { - val book = OpdsEntry( - title = "Flaky Book", - links = listOf( - OpdsLink( - href = "$baseUrl/flaky-book", - rel = "http://opds-spec.org/acquisition/open-access", - type = "application/epub+zip" - ) - ) - ) - server.enqueue(MockResponse().setResponseCode(503).setBody("temporary failure")) - server.enqueue( - MockResponse() - .setHeader("Content-Type", "application/epub+zip") - .setBody("recovered-book-content") - ) - - val downloadedFile = repository.downloadBook(book) - - assertEquals("/flaky-book", server.takeRequest().path) - assertEquals("/flaky-book", server.takeRequest().path) - assertEquals("recovered-book-content", downloadedFile.readText()) - assertTrue(repository.getDownloadsDir().listFiles().orEmpty().none { it.name.endsWith(".part") }) - } - - private fun catalogFeed(): String = """ - - - Local OPDS Catalog - - - """.trimIndent() - - private fun searchFeed(): String = """ - - - Search results - - Integration Book - - - - """.trimIndent() - - private fun atomResponse(body: String) = MockResponse() - .setHeader("Content-Type", "application/atom+xml") - .setBody(body) -} diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt deleted file mode 100644 index 4b9b73d38..000000000 --- a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class OpdsRepositoryTest { - - @Test - fun searchTermsTemplatePercentEncodesSpacesUnicodeAndAmpersands() { - val url = buildOpdsSearchUrl( - "https://example.test/search?q={searchTerms}", - "война и мир & peace" - ) - - assertEquals( - "https://example.test/search?q=%D0%B2%D0%BE%D0%B9%D0%BD%D0%B0%20%D0%B8%20%D0%BC%D0%B8%D1%80%20%26%20peace", - url - ) - assertFalse(url.contains("+")) - } - - @Test - fun optionalSearchTermsTemplateAddsQueryParameter() { - val url = buildOpdsSearchUrl( - "https://example.test/search{?searchTerms}", - "hello world" - ) - - assertEquals("https://example.test/search?q=hello%20world", url) - } - - @Test - fun searchUrlWithoutTemplatePreservesExistingQueryParameters() { - val url = buildOpdsSearchUrl( - "https://example.test/search?lang=en", - "a&b" - ) - - assertEquals("https://example.test/search?lang=en&q=a%26b", url) - assertTrue(url.contains("lang=en")) - } -} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt new file mode 100644 index 000000000..ed23208e1 --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt @@ -0,0 +1,249 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementDefinitions +import io.leostrange.mrcomic.core.model.AchievementNotification +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRequirement +import io.leostrange.mrcomic.core.model.AchievementStatus +import io.leostrange.mrcomic.core.model.MascotStage +import io.leostrange.mrcomic.core.model.UserAchievements +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Данные о прогрессе пользователя для проверки достижений + */ +data class UserProgressData( + val pagesRead: Int = 0, + val titlesCompleted: Int = 0, + val streakDays: Int = 0, + val readingTimeMinutes: Int = 0, + val singleSessionPages: Int = 0, + val weeklyGoalCompleted: Int = 0, + val mascotStage: MascotStage = MascotStage.CHILD, + val totalXp: Int = 0 +) + +/** + * Трекер достижений + */ +@Singleton +class AchievementTracker @Inject constructor() { + + private val _userProgress = MutableStateFlow(UserProgressData()) + private val _unlockedAchievements = MutableStateFlow>(emptyMap()) + private val _notifications = MutableStateFlow>(emptyList()) + + /** + * Текущий прогресс пользователя + */ + val userProgress: StateFlow = _userProgress.asStateFlow() + + /** + * Разблокированные достижения (achievementId -> timestamp) + */ + val unlockedAchievements: Flow> = _unlockedAchievements.asStateFlow() + + /** + * Уведомления о новых достижениях + */ + val notifications: Flow> = _notifications.asStateFlow() + + /** + * Прогресс всех достижений + */ + val achievementProgress: Flow> = combine( + _userProgress, + _unlockedAchievements + ) { progress, unlocked -> + AchievementDefinitions.allAchievements.map { achievement -> + val isUnlocked = unlocked.containsKey(achievement.id) + val currentProgress = calculateProgress(achievement, progress) + AchievementProgress( + achievementId = achievement.id, + status = when { + isUnlocked -> AchievementStatus.UNLOCKED + currentProgress >= 1f -> AchievementStatus.UNLOCKED + else -> AchievementStatus.LOCKED + }, + currentProgress = currentProgress, + unlockedAt = unlocked[achievement.id] + ) + } + } + + /** + * Общая статистика достижений + */ + val userAchievements: Flow = combine( + achievementProgress, + _unlockedAchievements + ) { progressList, unlocked -> + val unlockedCount = progressList.count { it.status == AchievementStatus.UNLOCKED } + val totalXp = unlocked.keys.sumOf { id -> + AchievementDefinitions.getById(id)?.xpReward ?: 0 + } + UserAchievements( + achievements = progressList, + totalXpEarned = totalXp, + unlockedCount = unlockedCount, + totalCount = AchievementDefinitions.allAchievements.size + ) + } + + /** + * Обновить прогресс пользователя + */ + fun updateProgress(progress: UserProgressData) { + _userProgress.value = progress + checkForNewAchievements(progress) + } + + /** + * Обновить конкретную метрику + */ + fun updatePagesRead(pages: Int) { + _userProgress.value = _userProgress.value.copy(pagesRead = pages) + checkForNewAchievements(_userProgress.value) + } + + fun updateTitlesCompleted(titles: Int) { + _userProgress.value = _userProgress.value.copy(titlesCompleted = titles) + checkForNewAchievements(_userProgress.value) + } + + fun updateStreakDays(days: Int) { + _userProgress.value = _userProgress.value.copy(streakDays = days) + checkForNewAchievements(_userProgress.value) + } + + fun updateReadingTime(minutes: Int) { + _userProgress.value = _userProgress.value.copy(readingTimeMinutes = minutes) + checkForNewAchievements(_userProgress.value) + } + + fun updateSingleSessionPages(pages: Int) { + _userProgress.value = _userProgress.value.copy(singleSessionPages = pages) + checkForNewAchievements(_userProgress.value) + } + + fun updateWeeklyGoalCompleted(count: Int) { + _userProgress.value = _userProgress.value.copy(weeklyGoalCompleted = count) + checkForNewAchievements(_userProgress.value) + } + + fun updateMascotStage(stage: MascotStage) { + _userProgress.value = _userProgress.value.copy(mascotStage = stage) + checkForNewAchievements(_userProgress.value) + } + + fun updateTotalXp(xp: Int) { + _userProgress.value = _userProgress.value.copy(totalXp = xp) + checkForNewAchievements(_userProgress.value) + } + + /** + * Проверить и разблокировать новые достижения + */ + private fun checkForNewAchievements(progress: UserProgressData) { + val currentUnlocked = _unlockedAchievements.value.toMutableMap() + val newNotifications = mutableListOf() + + AchievementDefinitions.allAchievements.forEach { achievement -> + if (!currentUnlocked.containsKey(achievement.id)) { + val isUnlocked = checkRequirement(achievement.requirement, progress) + if (isUnlocked) { + currentUnlocked[achievement.id] = System.currentTimeMillis() + newNotifications.add( + AchievementNotification( + achievement = achievement, + xpEarned = achievement.xpReward, + timestamp = System.currentTimeMillis() + ) + ) + } + } + } + + if (newNotifications.isNotEmpty()) { + _unlockedAchievements.value = currentUnlocked + _notifications.value = _notifications.value + newNotifications + } + } + + /** + * Проверить выполнение требования + */ + private fun checkRequirement( + requirement: AchievementRequirement, + progress: UserProgressData + ): Boolean = when (requirement) { + is AchievementRequirement.PagesRead -> progress.pagesRead >= requirement.pages + is AchievementRequirement.TitlesCompleted -> progress.titlesCompleted >= requirement.titles + is AchievementRequirement.StreakDays -> progress.streakDays >= requirement.days + is AchievementRequirement.ReadingTimeMinutes -> progress.readingTimeMinutes >= requirement.minutes + is AchievementRequirement.GenresExplored -> false // TODO: Implement genre tracking + is AchievementRequirement.SingleSessionPages -> progress.singleSessionPages >= requirement.pages + is AchievementRequirement.WeeklyGoalCompleted -> progress.weeklyGoalCompleted >= requirement.weeks + is AchievementRequirement.MascotStage -> progress.mascotStage.ordinal >= requirement.stage.ordinal + is AchievementRequirement.TotalXp -> progress.totalXp >= requirement.xp + is AchievementRequirement.And -> requirement.requirements.all { checkRequirement(it, progress) } + is AchievementRequirement.Or -> requirement.requirements.any { checkRequirement(it, progress) } + } + + /** + * Рассчитать прогресс достижения (0.0 - 1.0) + */ + private fun calculateProgress( + achievement: Achievement, + progress: UserProgressData + ): Float { + if (_unlockedAchievements.value.containsKey(achievement.id)) return 1f + + return when (val req = achievement.requirement) { + is AchievementRequirement.PagesRead -> + (progress.pagesRead.toFloat() / req.pages).coerceIn(0f, 1f) + is AchievementRequirement.TitlesCompleted -> + (progress.titlesCompleted.toFloat() / req.titles).coerceIn(0f, 1f) + is AchievementRequirement.StreakDays -> + (progress.streakDays.toFloat() / req.days).coerceIn(0f, 1f) + is AchievementRequirement.ReadingTimeMinutes -> + (progress.readingTimeMinutes.toFloat() / req.minutes).coerceIn(0f, 1f) + is AchievementRequirement.SingleSessionPages -> + (progress.singleSessionPages.toFloat() / req.pages).coerceIn(0f, 1f) + is AchievementRequirement.WeeklyGoalCompleted -> + (progress.weeklyGoalCompleted.toFloat() / req.weeks).coerceIn(0f, 1f) + is AchievementRequirement.MascotStage -> + if (progress.mascotStage.ordinal >= req.stage.ordinal) 1f else 0f + is AchievementRequirement.TotalXp -> + (progress.totalXp.toFloat() / req.xp).coerceIn(0f, 1f) + is AchievementRequirement.And -> + req.requirements.minOf { calculateProgress(achievement.copy(requirement = it), progress) } + is AchievementRequirement.Or -> + req.requirements.maxOf { calculateProgress(achievement.copy(requirement = it), progress) } + is AchievementRequirement.GenresExplored -> 0f // TODO: Implement genre tracking + } + } + + /** + * Очистить уведомления + */ + fun clearNotifications() { + _notifications.value = emptyList() + } + + /** + * Отметить достижение как полученное + */ + fun claimAchievement(achievementId: String) { + // В текущей реализации достижения автоматически считаются полученными + // Можно расширить для подтверждения получения награды + } +} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt new file mode 100644 index 000000000..bf73ecb58 --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt @@ -0,0 +1,131 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.Comic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Интеграция системы достижений с существующими системами геймификации + */ +@Singleton +class GamificationIntegration @Inject constructor( + private val achievementTracker: AchievementTracker, + private val mascotProgressCalculator: MascotProgressCalculator, + private val dailyReadingGoalStore: DailyReadingGoalStore +) { + /** + * Инициализировать интеграцию + */ + fun initialize(scope: CoroutineScope) { + // Подписываемся на изменения в ежедневных целях + scope.launch { + dailyReadingGoalStore.goalState.collect { goalState -> + updateFromGoalState(goalState) + } + } + } + + /** + * Обновить данные о достижениях на основе состояния ежедневных целей + */ + private fun updateFromGoalState(goalState: DailyReadingGoalState) { + // Обновляем прогресс страниц + achievementTracker.updatePagesRead(goalState.pagesReadToday) + + // Обновляем серии + achievementTracker.updateStreakDays(goalState.currentStreak) + + // Обновляем недельные цели + if (goalState.isWeeklyPlanCompleted) { + achievementTracker.updateWeeklyGoalCompleted( + achievementTracker.userProgress.value.weeklyGoalCompleted + 1 + ) + } + } + + /** + * Обновить данные о достижениях на основе прогресса маскота + */ + fun updateFromMascotProgress(progress: MascotProgressState) { + achievementTracker.updateMascotStage(progress.stage) + achievementTracker.updateTotalXp(progress.xp) + achievementTracker.updatePagesRead(progress.approxPagesRead) + achievementTracker.updateTitlesCompleted(progress.completedTitles) + } + + /** + * Обновить данные о достижениях на основе списка комиксов + */ + fun updateFromComics(comics: List) { + val completedTitles = comics.count { it.isCompleted } + achievementTracker.updateTitlesCompleted(completedTitles) + } + + /** + * Записать время чтения + */ + fun recordReadingTime(durationMillis: Long) { + val minutes = (durationMillis / 60_000).toInt() + if (minutes > 0) { + achievementTracker.updateReadingTime( + achievementTracker.userProgress.value.readingTimeMinutes + minutes + ) + } + } + + /** + * Записать чтение за одну сессию + */ + fun recordSingleSessionReading(pages: Int) { + val currentMax = achievementTracker.userProgress.value.singleSessionPages + if (pages > currentMax) { + achievementTracker.updateSingleSessionPages(pages) + } + } + + /** + * Получить поток уведомлений о достижениях + */ + fun getAchievementNotifications(): Flow> { + return achievementTracker.notifications + } + + /** + * Очистить уведомления + */ + fun clearNotifications() { + achievementTracker.clearNotifications() + } + + /** + * Получить общий прогресс достижений + */ + fun getUserAchievements(): Flow { + return achievementTracker.userAchievements + } + + /** + * Получить прогресс конкретного достижения + */ + fun getAchievementProgress(achievementId: String): Flow { + return achievementTracker.achievementProgress.map { progressList -> + progressList.find { it.achievementId == achievementId } + }.distinctUntilChanged() + } +} + +/** + * Расширение для Flow + */ +private fun Flow.map(transform: (T) -> R): Flow { + return kotlinx.coroutines.flow.flow { + collect { value -> + emit(transform(value)) + } + } +} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt new file mode 100644 index 000000000..4fd15e85c --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt @@ -0,0 +1,169 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.model.WeeklyChallengeDefinitions +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress +import io.leostrange.mrcomic.core.model.WeeklyChallengeStatus +import io.leostrange.mrcomic.core.model.WeeklyChallengeType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Данные о прогрессе для еженедельных челленджей + */ +data class WeeklyChallengeData( + val pagesReadThisWeek: Int = 0, + val titlesCompletedThisWeek: Int = 0, + val streakDays: Int = 0, + val readingTimeMinutes: Int = 0, + val dailyGoalCompletedDays: Int = 0 +) + +/** + * Трекер еженедельных челленджей + */ +@Singleton +class WeeklyChallengeTracker @Inject constructor() { + + private val _progress = MutableStateFlow(WeeklyChallengeData()) + private val _challengeProgress = MutableStateFlow>(emptyMap()) + + /** + * Текущий прогресс + */ + val progress: Flow = _progress.asStateFlow() + + /** + * Прогресс челленджей + */ + val challengeProgress: Flow> = combine( + _progress, + _challengeProgress + ) { data, progressMap -> + WeeklyChallengeDefinitions.challenges.map { challenge -> + val current = when (challenge.type) { + WeeklyChallengeType.PAGES_READ -> data.pagesReadThisWeek + WeeklyChallengeType.TITLES_COMPLETED -> data.titlesCompletedThisWeek + WeeklyChallengeType.STREAK_DAYS -> data.streakDays + WeeklyChallengeType.READING_TIME -> data.readingTimeMinutes + WeeklyChallengeType.DAILY_GOAL -> data.dailyGoalCompletedDays + WeeklyChallengeType.NEW_GENRE -> 0 // TODO: Implement genre tracking + } + + val existing = progressMap[challenge.id] + val status = when { + existing?.status == WeeklyChallengeStatus.COMPLETED -> WeeklyChallengeStatus.COMPLETED + current >= challenge.target -> WeeklyChallengeStatus.COMPLETED + System.currentTimeMillis() > challenge.endDate -> WeeklyChallengeStatus.EXPIRED + else -> WeeklyChallengeStatus.ACTIVE + } + + WeeklyChallengeProgress( + challengeId = challenge.id, + current = current, + target = challenge.target, + status = status, + completedAt = if (status == WeeklyChallengeStatus.COMPLETED) { + existing?.completedAt ?: System.currentTimeMillis() + } else null + ) + } + } + + /** + * Обновить прогресс + */ + fun updateProgress(data: WeeklyChallengeData) { + _progress.value = data + checkForCompletedChallenges(data) + } + + /** + * Обновить конкретную метрику + */ + fun updatePagesRead(pages: Int) { + _progress.value = _progress.value.copy(pagesReadThisWeek = pages) + checkForCompletedChallenges(_progress.value) + } + + fun updateTitlesCompleted(titles: Int) { + _progress.value = _progress.value.copy(titlesCompletedThisWeek = titles) + checkForCompletedChallenges(_progress.value) + } + + fun updateStreakDays(days: Int) { + _progress.value = _progress.value.copy(streakDays = days) + checkForCompletedChallenges(_progress.value) + } + + fun updateReadingTime(minutes: Int) { + _progress.value = _progress.value.copy(readingTimeMinutes = minutes) + checkForCompletedChallenges(_progress.value) + } + + fun updateDailyGoalCompletedDays(days: Int) { + _progress.value = _progress.value.copy(dailyGoalCompletedDays = days) + checkForCompletedChallenges(_progress.value) + } + + /** + * Проверить завершённые челленджи + */ + private fun checkForCompletedChallenges(data: WeeklyChallengeData) { + val currentProgress = _challengeProgress.value.toMutableMap() + + WeeklyChallengeDefinitions.challenges.forEach { challenge -> + if (!currentProgress.containsKey(challenge.id)) { + val current = when (challenge.type) { + WeeklyChallengeType.PAGES_READ -> data.pagesReadThisWeek + WeeklyChallengeType.TITLES_COMPLETED -> data.titlesCompletedThisWeek + WeeklyChallengeType.STREAK_DAYS -> data.streakDays + WeeklyChallengeType.READING_TIME -> data.readingTimeMinutes + WeeklyChallengeType.DAILY_GOAL -> data.dailyGoalCompletedDays + WeeklyChallengeType.NEW_GENRE -> 0 + } + + if (current >= challenge.target) { + currentProgress[challenge.id] = WeeklyChallengeProgress( + challengeId = challenge.id, + current = current, + target = challenge.target, + status = WeeklyChallengeStatus.COMPLETED, + completedAt = System.currentTimeMillis() + ) + } + } + } + + _challengeProgress.value = currentProgress + } + + /** + * Получить завершённые челленджи + */ + fun getCompletedChallenges(): List { + return _challengeProgress.value.values.filter { + it.status == WeeklyChallengeStatus.COMPLETED + } + } + + /** + * Получить общий XP за завершённые челленджи + */ + fun getTotalXpEarned(): Int { + return getCompletedChallenges().sumOf { progress -> + WeeklyChallengeDefinitions.getById(progress.challengeId)?.xpReward ?: 0 + } + } + + /** + * Сбросить прогресс (вызывается в начале новой недели) + */ + fun resetWeeklyProgress() { + _progress.value = WeeklyChallengeData() + _challengeProgress.value = emptyMap() + } +} diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt new file mode 100644 index 000000000..2c766cae3 --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt @@ -0,0 +1,313 @@ +package io.leostrange.mrcomic.core.model + +/** + * Определения всех достижений в системе + */ +object AchievementDefinitions { + + val allAchievements: List = listOf( + // === READING достижения === + Achievement( + id = "first_page", + category = AchievementCategory.READING, + rarity = AchievementRarity.COMMON, + title = "Первая страница", + description = "Прочитайте первую страницу", + xpReward = 10, + requirement = AchievementRequirement.PagesRead(1) + ), + Achievement( + id = "page_turner_100", + category = AchievementCategory.READING, + rarity = AchievementRarity.COMMON, + title = "Книжный червь", + description = "Прочитайте 100 страниц", + xpReward = 50, + requirement = AchievementRequirement.PagesRead(100) + ), + Achievement( + id = "page_turner_500", + category = AchievementCategory.READING, + rarity = AchievementRarity.UNCOMMON, + title = "Библиофил", + description = "Прочитайте 500 страниц", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(500) + ), + Achievement( + id = "page_turner_1000", + category = AchievementCategory.READING, + rarity = AchievementRarity.RARE, + title = "Книголюб", + description = "Прочитайте 1000 страниц", + xpReward = 200, + requirement = AchievementRequirement.PagesRead(1000) + ), + Achievement( + id = "page_turner_5000", + category = AchievementCategory.READING, + rarity = AchievementRarity.EPIC, + title = "Мастер чтения", + description = "Прочитайте 5000 страниц", + xpReward = 500, + requirement = AchievementRequirement.PagesRead(5000) + ), + Achievement( + id = "page_turner_10000", + category = AchievementCategory.READING, + rarity = AchievementRarity.LEGENDARY, + title = "Легенда чтения", + description = "Прочитайте 10000 страниц", + xpReward = 1000, + requirement = AchievementRequirement.PagesRead(10000) + ), + + // === COLLECTION достижения === + Achievement( + id = "first_comic", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.COMMON, + title = "Первый тайтл", + description = "Добавьте первый комикс в библиотеку", + xpReward = 10, + requirement = AchievementRequirement.TitlesCompleted(1) + ), + Achievement( + id = "collector_5", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.COMMON, + title = "Коллекционер", + description = "Завершите 5 тайтлов", + xpReward = 50, + requirement = AchievementRequirement.TitlesCompleted(5) + ), + Achievement( + id = "collector_10", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.UNCOMMON, + title = "Библиотекарь", + description = "Завершите 10 тайтлов", + xpReward = 100, + requirement = AchievementRequirement.TitlesCompleted(10) + ), + Achievement( + id = "collector_25", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.RARE, + title = "Архивариус", + description = "Завершите 25 тайтлов", + xpReward = 200, + requirement = AchievementRequirement.TitlesCompleted(25) + ), + Achievement( + id = "collector_50", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.EPIC, + title = "Хранитель знаний", + description = "Завершите 50 тайтлов", + xpReward = 500, + requirement = AchievementRequirement.TitlesCompleted(50) + ), + + // === STREAK достижения === + Achievement( + id = "streak_3", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.COMMON, + title = "Начало пути", + description = "Читайте 3 дня подряд", + xpReward = 30, + requirement = AchievementRequirement.StreakDays(3) + ), + Achievement( + id = "streak_7", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.UNCOMMON, + title = "Недельный ритм", + description = "Читайте 7 дней подряд", + xpReward = 70, + requirement = AchievementRequirement.StreakDays(7) + ), + Achievement( + id = "streak_14", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.RARE, + title = "Двухнедельный марафон", + description = "Читайте 14 дней подряд", + xpReward = 140, + requirement = AchievementRequirement.StreakDays(14) + ), + Achievement( + id = "streak_30", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.EPIC, + title = "Месяц чтения", + description = "Читайте 30 дней подряд", + xpReward = 300, + requirement = AchievementRequirement.StreakDays(30) + ), + Achievement( + id = "streak_100", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.LEGENDARY, + title = "Легенда постоянства", + description = "Читайте 100 дней подряд", + xpReward = 1000, + requirement = AchievementRequirement.StreakDays(100) + ), + + // === EXPLORATION достижения === + Achievement( + id = "marathon_reader", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.UNCOMMON, + title = "Марафонец", + description = "Прочитайте 100 страниц за одну сессию", + xpReward = 80, + requirement = AchievementRequirement.SingleSessionPages(100) + ), + Achievement( + id = "weekly_champion", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Чемпион недели", + description = "Выполните недельную цель 4 раза", + xpReward = 200, + requirement = AchievementRequirement.WeeklyGoalCompleted(4) + ), + Achievement( + id = "time_reader_60", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.UNCOMMON, + title = "Час чтения", + description = "Проведите 60 минут за чтением", + xpReward = 60, + requirement = AchievementRequirement.ReadingTimeMinutes(60) + ), + Achievement( + id = "time_reader_300", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Пять часов", + description = "Проведите 300 минут за чтением", + xpReward = 150, + requirement = AchievementRequirement.ReadingTimeMinutes(300) + ), + Achievement( + id = "time_reader_1000", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.EPIC, + title = "Тысяча минут", + description = "Проведите 1000 минут за чтением", + xpReward = 500, + requirement = AchievementRequirement.ReadingTimeMinutes(1000) + ), + + // === MILESTONE достижения === + Achievement( + id = "mascot_teen", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.UNCOMMON, + title = "Подросток", + description = "Достигните стадии TEEN для маскота", + xpReward = 100, + requirement = AchievementRequirement.MascotStage(MascotStage.TEEN) + ), + Achievement( + id = "mascot_young", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.RARE, + title = "Юность", + description = "Достигните стадии YOUNG для маскота", + xpReward = 200, + requirement = AchievementRequirement.MascotStage(MascotStage.YOUNG) + ), + Achievement( + id = "mascot_adult", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.EPIC, + title = "Взрослый", + description = "Достигните стадии ADULT для маскота", + xpReward = 500, + requirement = AchievementRequirement.MascotStage(MascotStage.ADULT) + ), + Achievement( + id = "xp_1000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.UNCOMMON, + title = "Тысяча XP", + description = "Накопите 1000 XP", + xpReward = 100, + requirement = AchievementRequirement.TotalXp(1000) + ), + Achievement( + id = "xp_5000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.RARE, + title = "Пять тысяч XP", + description = "Накопите 5000 XP", + xpReward = 300, + requirement = AchievementRequirement.TotalXp(5000) + ), + Achievement( + id = "xp_10000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.EPIC, + title = "Десять тысяч XP", + description = "Накопите 10000 XP", + xpReward = 500, + requirement = AchievementRequirement.TotalXp(10000) + ), + + // === Секретные достижения === + Achievement( + id = "secret_night_owl", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Сова", + description = "Читайте после полуночи", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(10), // Упрощённое условие + isSecret = true + ), + Achievement( + id = "secret_early_bird", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Ранняя пташка", + description = "Читайте до 6 утра", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(10), // Упрощённое условие + isSecret = true + ) + ) + + /** + * Получить достижение по ID + */ + fun getById(id: String): Achievement? = allAchievements.find { it.id == id } + + /** + * Получить достижения по категории + */ + fun getByCategory(category: AchievementCategory): List = + allAchievements.filter { it.category == category } + + /** + * Получить достижения по редкости + */ + fun getByRarity(rarity: AchievementRarity): List = + allAchievements.filter { it.rarity == rarity } + + /** + * Получить несекретные достижения + */ + fun getVisible(): List = + allAchievements.filter { !it.isSecret } + + /** + * Получить секретные достижения + */ + fun getSecret(): List = + allAchievements.filter { it.isSecret } +} diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt new file mode 100644 index 000000000..15dc32cee --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt @@ -0,0 +1,99 @@ +package io.leostrange.mrcomic.core.model + +/** + * Типы достижений в системе геймификации + */ +enum class AchievementCategory { + READING, // Достижения связанные с чтением + COLLECTION, // Достижения связанные с коллекцией + STREAK, // Достижения связанные с сериями + EXPLORATION, // Достижения связанные с исследованием + MILESTONE // Веховые достижения +} + +/** + * Редкость достижения + */ +enum class AchievementRarity { + COMMON, // Обычное + UNCOMMON, // Необычное + RARE, // Редкое + EPIC, // Эпическое + LEGENDARY // Легендарное +} + +/** + * Статус достижения + */ +enum class AchievementStatus { + LOCKED, // Заблокировано + UNLOCKED, // Разблокировано + CLAIMED // Получено +} + +/** + * Модель достижения + */ +data class Achievement( + val id: String, + val category: AchievementCategory, + val rarity: AchievementRarity, + val title: String, + val description: String, + val iconRes: String? = null, + val xpReward: Int = 0, + val requirement: AchievementRequirement, + val isSecret: Boolean = false +) + +/** + * Требования для получения достижения + */ +sealed class AchievementRequirement { + data class PagesRead(val pages: Int) : AchievementRequirement() + data class TitlesCompleted(val titles: Int) : AchievementRequirement() + data class StreakDays(val days: Int) : AchievementRequirement() + data class ReadingTimeMinutes(val minutes: Int) : AchievementRequirement() + data class GenresExplored(val genres: Int) : AchievementRequirement() + data class SingleSessionPages(val pages: Int) : AchievementRequirement() + data class WeeklyGoalCompleted(val weeks: Int) : AchievementRequirement() + data class MascotStage( + val stage: io.leostrange.mrcomic.core.model.MascotStage, + ) : AchievementRequirement() + data class TotalXp(val xp: Int) : AchievementRequirement() + data class And(val requirements: List) : AchievementRequirement() + data class Or(val requirements: List) : AchievementRequirement() +} + +/** + * Прогресс достижения + */ +data class AchievementProgress( + val achievementId: String, + val status: AchievementStatus, + val currentProgress: Float, // 0.0 - 1.0 + val unlockedAt: Long? = null, + val claimedAt: Long? = null +) + +/** + * Состояние всех достижений пользователя + */ +data class UserAchievements( + val achievements: List, + val totalXpEarned: Int, + val unlockedCount: Int, + val totalCount: Int +) { + val completionRate: Float + get() = if (totalCount == 0) 0f else unlockedCount.toFloat() / totalCount.toFloat() +} + +/** + * Уведомление о новом достижении + */ +data class AchievementNotification( + val achievement: Achievement, + val xpEarned: Int, + val timestamp: Long +) diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt index 3e494965a..37bcde9e2 100644 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt @@ -65,15 +65,36 @@ fun Comic.readingStatus(): ComicReadingStatus { storedReaderLocator() != null val progressHasAuthority = normalizedProgress > 0.001f && (hasStableReadingSignal || normalizedPageCount > 1) - val completedByProgress = normalizedProgress >= 0.999f && - (hasStableReadingSignal || normalizedPageCount > 1) + // Reflowable formats (especially legacy RTF records) can persist a stale + // `readingProgress = 1` before pagination has produced a real position. + // A known page count makes the page index authoritative; only an explicit + // completion flag or the last confirmed page may complete the book. + val completedByProgress = normalizedProgress >= 0.999f && when { + normalizedPageCount > 1 -> hasStableReadingSignal && normalizedCurrentPage >= normalizedPageCount - 1 + format.isTextReadingFormat() -> { + val loc = storedReaderLocator() + loc?.progression != null && loc.progression!! >= 0.99f + } + else -> hasStableReadingSignal + } + // BUG-B3: For reflowable text formats whose page count is still unknown + // (pageCount <= 1, i.e. pagination hasn't resolved yet), a stale + // readingProgress=1 must not mark the book as COMPLETED unless a real + // reader locator (href/progression) anchors the end position. Neither + // lastReadDate ("was opened once") nor a bare currentPage index prove the + // end was reached while the total page count is unknown. + val reflowableUnverifiedCompletion = format.isTextReadingFormat() && + normalizedPageCount <= 1 && + completedByProgress && + (storedReaderLocator()?.progression?.let { it < 0.99f } ?: true) + val effectiveCompleted = completedByProgress && !reflowableUnverifiedCompletion val hasReadingActivity = hasStableReadingSignal || progressHasAuthority // Completion is driven only by an explicit flag or a confirmed full-progress // signal. Landing on the last page index (currentPage >= pageCount - 1) is no // longer a completion condition: a crash, a deferred-count clamp or a text book // parked on its final section must not fake a 100% badge. return when { - isCompleted || completedByProgress -> ComicReadingStatus.COMPLETED + isCompleted || effectiveCompleted -> ComicReadingStatus.COMPLETED hasReadingActivity -> ComicReadingStatus.READING else -> ComicReadingStatus.NEW } @@ -97,10 +118,24 @@ fun readingProgressForPage(currentPage: Int, pageCount: Int): Float { fun Comic.displayReadingProgress(): Float = when (readingStatus()) { ComicReadingStatus.NEW -> 0f ComicReadingStatus.COMPLETED -> 1f - ComicReadingStatus.READING -> maxOf( - readingProgress.coerceIn(0f, 1f), - readingProgressForPage(currentPage, pageCount) - ) + ComicReadingStatus.READING -> { + // Canonical progress is always derived from currentPage/pageCount when pageCount > 1. + // For legacy data where pageCount is unknown (0), fall back to stored readingProgress + // so existing library cards and progress bars still show meaningful values. + if (pageCount > 1) { + readingProgressForPage(currentPage, pageCount) + } else if (format.isTextReadingFormat()) { + val loc = storedReaderLocator() + loc?.progression?.toFloat()?.coerceIn(0f, 1f) ?: 0f + } else { + // Page count is unresolved (≤ 1): stored readingProgress may be a stale + // placeholder from legacy records or backup merges. A full-progress value + // (≥ 0.999) without a real reader locator proves no actual reading occurred, + // so show 0 % instead of a misleading 100 % badge. + val stored = readingProgress.coerceIn(0f, 1f) + if (stored >= 0.999f && storedReaderLocator() == null) 0f else stored + } + } } fun Comic.storedReaderLocator(): ReaderLocator? { @@ -154,7 +189,6 @@ enum class ComicFormat { ODT, CHM, XPS, - OPDS, DJVU, FOLDER, UNKNOWN diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt deleted file mode 100644 index b0392e450..000000000 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.leostrange.mrcomic.core.model - -/** - * OPDS (Open Publication Distribution System) data models. - * - * OPDS is an Atom/XML-based standard for distributing electronic books. - * Catalogs contain feeds with navigation links and book acquisition entries. - */ - -/** A top-level OPDS feed (Atom document). */ -data class OpdsFeed( - val title: String, - val entries: List, - val links: List, - val nextLink: String? = null, - val searchLink: String? = null -) - -/** A single entry in an OPDS feed (Atom ). */ -data class OpdsEntry( - val title: String, - val author: String? = null, - val summary: String? = null, - val thumbnailUrl: String? = null, - val updated: String? = null, - val links: List = emptyList() -) { - /** Returns the first acquisition link (downloadable book). */ - val acquisitionLink: OpdsLink? - get() = links.firstOrNull { it.isAcquisition } - - /** Returns the first navigation link (sub-catalog). */ - val navigationLink: OpdsLink? - get() = links.firstOrNull { it.isNavigation } - - /** Whether this entry is a downloadable book. */ - val isBook: Boolean - get() = acquisitionLink != null - - /** Whether this entry is a sub-catalog / category. */ - val isCatalog: Boolean - get() = navigationLink != null && !isBook -} - -/** A link within an OPDS entry or feed. */ -data class OpdsLink( - val href: String, - val rel: String, - val type: String? = null, - val title: String? = null -) { - /** Whether this link is an acquisition (download) link. */ - val isAcquisition: Boolean - get() = rel.startsWith("http://opds-spec.org/acquisition") || - rel == "http://opds-spec.org/acquisition/open-access" - - /** Whether this link is a navigation (sub-catalog) link. */ - val isNavigation: Boolean - get() = rel == "subsection" || rel == "alternate" || - rel == "http://opds-spec.org/featured" || - rel == "http://opds-spec.org/new" || - rel == "http://opds-spec.org/popular" - - /** Whether this link is a search link. */ - val isSearch: Boolean - get() = rel == "search" || rel == "http://opds-spec.org/search" - - /** Whether this link points to a next page. */ - val isNext: Boolean - get() = rel == "next" - - /** Whether this link is a thumbnail image. */ - val isThumbnail: Boolean - get() = rel == "http://opds-spec.org/image/thumbnail" || - rel == "http://opds-spec.org/thumbnail" -} - -/** A pre-configured OPDS catalog source. */ -data class OpdsCatalogSource( - val name: String, - val url: String, - val description: String = "", - val isSearchable: Boolean = false -) diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt new file mode 100644 index 000000000..764e61359 --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt @@ -0,0 +1,136 @@ +package io.leostrange.mrcomic.core.model + +/** + * Типы еженедельных челленджей + */ +enum class WeeklyChallengeType { + PAGES_READ, // Прочитать определённое количество страниц + TITLES_COMPLETED, // Завершить определённое количество тайтлов + STREAK_DAYS, // Поддерживать серию дней + READING_TIME, // Провести определённое время за чтением + DAILY_GOAL, // Выполнить ежедневную цель несколько дней + NEW_GENRE // Прочитать тайтл нового жанра +} + +/** + * Статус еженедельного челленджа + */ +enum class WeeklyChallengeStatus { + ACTIVE, // Активный + COMPLETED, // Завершённый + FAILED, // Проваленный + EXPIRED // Истёкший +} + +/** + * Модель еженедельного челленджа + */ +data class WeeklyChallenge( + val id: String, + val type: WeeklyChallengeType, + val title: String, + val description: String, + val target: Int, + val xpReward: Int, + val startDate: Long, + val endDate: Long, + val status: WeeklyChallengeStatus = WeeklyChallengeStatus.ACTIVE +) + +/** + * Прогресс еженедельного челленджа + */ +data class WeeklyChallengeProgress( + val challengeId: String, + val current: Int, + val target: Int, + val status: WeeklyChallengeStatus, + val completedAt: Long? = null +) { + val progress: Float + get() = if (target == 0) 0f else (current.toFloat() / target).coerceIn(0f, 1f) + + val isCompleted: Boolean + get() = status == WeeklyChallengeStatus.COMPLETED +} + +/** + * Определения еженедельных челленджей + */ +object WeeklyChallengeDefinitions { + + val challenges: List = listOf( + WeeklyChallenge( + id = "weekly_pages_100", + type = WeeklyChallengeType.PAGES_READ, + title = "Стостраничный марафон", + description = "Прочитайте 100 страниц за неделю", + target = 100, + xpReward = 100, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_pages_300", + type = WeeklyChallengeType.PAGES_READ, + title = "Трёхсотстраничный вызов", + description = "Прочитайте 300 страниц за неделю", + target = 300, + xpReward = 300, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_titles_2", + type = WeeklyChallengeType.TITLES_COMPLETED, + title = "Двойной удар", + description = "Завершите 2 тайтла за неделю", + target = 2, + xpReward = 150, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_streak_5", + type = WeeklyChallengeType.STREAK_DAYS, + title = "Пятидневная серия", + description = "Читайте 5 дней подряд", + target = 5, + xpReward = 120, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_time_60", + type = WeeklyChallengeType.READING_TIME, + title = "Часовой марафон", + description = "Проведите 60 минут за чтением", + target = 60, + xpReward = 80, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_daily_5", + type = WeeklyChallengeType.DAILY_GOAL, + title = "Пятидневная цель", + description = "Выполните ежедневную цель 5 дней", + target = 5, + xpReward = 200, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ) + ) + + /** + * Получить челлендж по ID + */ + fun getById(id: String): WeeklyChallenge? = challenges.find { it.id == id } + + /** + * Получить активные челленджи + */ + fun getActive(): List = challenges.filter { + it.status == WeeklyChallengeStatus.ACTIVE + } +} diff --git a/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt b/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt index a488add7c..800a8ded9 100644 --- a/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt +++ b/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt @@ -90,6 +90,30 @@ class ComicReadingStatusTest { assertTrue(comic.isReadCompleted()) } + @Test + fun rtfSingleSectionBookDoesNotFalseCompleteWhenOpened() { + val rtfOpened = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L + ) + assertFalse(rtfOpened.isReadCompleted()) + assertTrue(rtfOpened.isReadingInProgress()) + assertEquals(0f, rtfOpened.displayReadingProgress()) + + val rtfWithEndLocator = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + readerLocatorProgression = 0.995 + ) + assertTrue(rtfWithEndLocator.isReadCompleted()) + } + @Test fun placeholderFullProgressWithoutAnyReadingSignalStaysNew() { val comic = Comic(pageCount = 1, currentPage = 0, readingProgress = 1f, lastReadDate = null) @@ -97,4 +121,186 @@ class ComicReadingStatusTest { assertEquals(ComicReadingStatus.NEW, comic.readingStatus()) assertFalse(comic.isReadCompleted()) } + + // ── BUG-READER-04: displayReadingProgress() consistency ── + + @Test + fun displayReadingProgress_completedBookAlwaysReturnsOne() { + // Explicit isCompleted must always yield 1f regardless of page values. + val comic = Comic(pageCount = 100, currentPage = 0, isCompleted = true) + assertEquals(1f, comic.displayReadingProgress()) + } + + @Test + fun displayReadingProgress_readingBookDerivesFromPageCount() { + // Canonical progress is always derived from currentPage/pageCount when pageCount > 1. + val comic = Comic(pageCount = 200, currentPage = 100, lastReadDate = 123L) + // readingProgressForPage(100, 200) = 100 / 199 ≈ 0.5025 + assertEquals(0.5025f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_ignoresStoredProgressWhenPageCountKnown() { + // Even if stored readingProgress is stale/wrong, pageCount-based calculation wins. + val comic = Comic(pageCount = 100, currentPage = 50, readingProgress = 0.99f, lastReadDate = 123L) + val progress = comic.displayReadingProgress() + // readingProgressForPage(50, 100) = 50/99 ≈ 0.505 + assertEquals(0.505f, progress, 0.001f) + } + + @Test + fun displayReadingProgress_fallsBackToStoredProgressWhenPageCountZero() { + // Legacy records with pageCount=0 use stored readingProgress as fallback. + val comic = Comic(pageCount = 0, currentPage = 0, readingProgress = 0.45f, lastReadDate = 123L) + assertEquals(0.45f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_staleFullProgressWithUnknownPageCountShowsZero() { + // RTF/EPUB with pageCount=0 and readingProgress=1.0 (stale) but no reader locator + // must display 0 %, not 100 %. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + ) + assertEquals(0f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_nonStaleProgressWithUnknownPageCountStillFallsBack() { + // Genuine partial progress (< 1.0) with unresolved page count should be preserved. + val comic = Comic(pageCount = 0, currentPage = 0, readingProgress = 0.3f, lastReadDate = 123L) + assertEquals(0.3f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_newBookReturnsZero() { + val comic = Comic(pageCount = 100, currentPage = 0, lastReadDate = null) + assertEquals(0f, comic.displayReadingProgress()) + } + + @Test + fun staleKnownPageCountProgressDoesNotMarkRtfAsRead() { + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 370, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + assertEquals(0f, comic.displayReadingProgress()) + } + + @Test + fun rtfIsCompletedOnlyOnLastConfirmedPage() { + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 370, + currentPage = 369, + readingProgress = 1f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + assertEquals(1f, comic.displayReadingProgress()) + } + + // ── BUG-B3: reflowable with unresolved pageCount must not fake 100% ── + + @Test + fun rtfWithUnresolvedPageCountAndStaleProgressIsNotCompleted() { + // RTF opened once (lastReadDate set) but never actually read: + // pageCount=0 (not yet paginated), readingProgress=1.0 (stale legacy), + // currentPage=0, locator=null → must NOT be COMPLETED. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + // Stale full-progress without a real reader locator → display shows 0 %, not 100 %. + assertEquals(0f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun rtfWithPageCountOneAndStaleProgressIsNotCompleted() { + // Same scenario but pageCount=1 (single unresolved section). + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 456L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun rtfWithUnresolvedPageCountButRealLocatorIsCompleted() { + // RTF with stale progress=1 BUT a real locator → genuinely read to the end. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + readerLocatorHref = "chapter-5.xhtml", + readerLocatorProgression = 1.0, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + } + + @Test + fun rtfWithUnresolvedPageCountButRealCurrentPageIsNotCompleted() { + // RTF with stale progress=1, no locator, but currentPage > 0 + // → has been read, but pageCount unknown → still READING. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 5, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun epubWithUnresolvedPageCountAndStaleProgressIsNotCompleted() { + // Same guard applies to EPUB and other text formats with unresolved page counts. + val comic = Comic( + format = ComicFormat.EPUB, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 789L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun nonTextFormatWithUnresolvedPageCountCanStillCompleteByProgress() { + // Non-text formats (e.g. UNKNOWN) with stale progress=1 and lastReadDate + // should still complete — the reflowable guard doesn't apply. + val comic = Comic( + format = ComicFormat.UNKNOWN, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + } } diff --git a/android/core-ui/build.gradle.kts b/android/core-ui/build.gradle.kts index f151a05dd..bacacceee 100644 --- a/android/core-ui/build.gradle.kts +++ b/android/core-ui/build.gradle.kts @@ -16,6 +16,7 @@ android { } dependencies { implementation(project(":core-model")) + implementation(project(":core-interfaces")) api(platform(libs.androidx.compose.bom)) api(libs.androidx.compose.ui) api(libs.androidx.compose.ui.tooling.preview) diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt index 89109337a..44ccf37c8 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt @@ -29,7 +29,7 @@ fun MrComicButton( contentPadding: PaddingValues = PaddingValues(horizontal = 22.dp, vertical = 10.dp), content: @Composable RowScope.() -> Unit ) { - val shape = RoundedCornerShape(MrComicRadiusTokens.pill) + val shape = RoundedCornerShape(MrComicCornerScale.pill) when (variant) { MrComicButtonVariant.Filled -> Button( onClick = onClick, @@ -63,7 +63,7 @@ fun MrComicButton( enabled = enabled, shape = shape, contentPadding = contentPadding, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.44f)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = MrComicAlphaTokens.Subtle)), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.primary ), diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt index c57aa948a..1ee3d47e0 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt @@ -8,52 +8,23 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -@Composable -fun MrComicPanelCard( - title: String, - modifier: Modifier = Modifier, - hint: String? = null, - content: @Composable ColumnScope.() -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - val lightChrome = colorScheme.background.luminance() > 0.45f - OutlinedCard( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(MrComicRadiusTokens.xl), - border = BorderStroke( - width = 1.dp, - color = colorScheme.outlineVariant.copy(alpha = if (lightChrome) 0.44f else 0.28f) - ), - colors = CardDefaults.outlinedCardColors( - containerColor = colorScheme.surface.copy(alpha = if (lightChrome) 0.94f else 0.9f) - ) - ) { - Column( - modifier = Modifier.padding(horizontal = MrComicSpacingTokens.x4, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(9.dp) - ) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - if (!hint.isNullOrBlank()) { - Text(hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - content() - } - } -} +/** + * Editorial Ink cards. + * + * `MrComicPanelCard` was removed in the Editorial Ink migration. Use + * [MrComicSectionHeader] + flat content (a Column of [MrComicListItem] rows) for + * section groups, or [MrComicCardSurface] / [MrComicSurfaceCard] for visual + * surfaces that need a container. + */ @Composable fun MrComicCard( @@ -65,7 +36,7 @@ fun MrComicCard( contentColor: Color = MaterialTheme.colorScheme.onSurface, border: BorderStroke? = null, shape: Shape? = null, - cornerRadius: Dp = MrComicRadiusTokens.xl, + cornerRadius: Dp = MrComicCornerScale.xl, tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, contentPadding: PaddingValues = PaddingValues(MrComicSpacingTokens.x4), @@ -103,21 +74,29 @@ fun MrComicCardSurface( contentColor: Color = MaterialTheme.colorScheme.onSurface, border: BorderStroke? = null, shape: Shape? = null, - cornerRadius: Dp = MrComicRadiusTokens.xl, + cornerRadius: Dp = MrComicCornerScale.xl, tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, content: @Composable () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + // Editorial Ink: container roles are pure M3 tones, no alpha dimming. + // Material 3 already exposes surfaceContainer*, primaryContainer, etc. + // with predictable luminance, so transparent overlays are no longer + // needed to keep contrast on decorative backgrounds. val resolvedContainer = containerColor ?: when { - selected -> colorScheme.primaryContainer.copy(alpha = 0.84f) - variant == MrComicCardVariant.Muted -> colorScheme.surfaceContainerHigh.copy(alpha = 0.62f) - variant == MrComicCardVariant.Primary -> colorScheme.primaryContainer.copy(alpha = 0.42f) - variant == MrComicCardVariant.Secondary -> colorScheme.secondaryContainer.copy(alpha = 0.34f) - variant == MrComicCardVariant.Tertiary -> colorScheme.tertiaryContainer.copy(alpha = 0.26f) + selected -> colorScheme.primaryContainer + variant == MrComicCardVariant.Muted -> colorScheme.surfaceContainerLow + variant == MrComicCardVariant.Primary -> colorScheme.primaryContainer + variant == MrComicCardVariant.Secondary -> colorScheme.secondaryContainer + variant == MrComicCardVariant.Tertiary -> colorScheme.tertiaryContainer else -> colorScheme.surface } - val resolvedBorder = border ?: if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.34f)) else null + val resolvedBorder = border ?: if (selected) { + BorderStroke(1.dp, colorScheme.primary.copy(alpha = MrComicAlphaTokens.Subtle)) + } else { + null + } Surface( modifier = if (fillMaxWidth) modifier.fillMaxWidth() else modifier, shape = shape ?: RoundedCornerShape(cornerRadius), @@ -139,12 +118,14 @@ fun MrComicSurfaceCard( verticalSpacing: Dp = MrComicSpacingTokens.x1, content: @Composable ColumnScope.() -> Unit ) { + // Editorial Ink: surfaceContainerHigh is a pure M3 tone (no alpha dimming). + val colorScheme = MaterialTheme.colorScheme MrComicCard( modifier = modifier, fillMaxWidth = fillMaxWidth, selected = selected, - containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.84f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.98f), - cornerRadius = MrComicRadiusTokens.lg, + containerColor = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerHigh, + cornerRadius = MrComicCornerScale.lg, contentPadding = contentPadding, verticalSpacing = verticalSpacing, content = content diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt index 29084b165..e53dbca26 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -41,7 +42,7 @@ fun MrComicFilterChip( onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = minHeight), - shape = RoundedCornerShape(MrComicRadiusTokens.pill), + shape = RoundedCornerShape(MrComicCornerScale.pill), colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, @@ -66,7 +67,7 @@ fun MrComicPill( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(MrComicRadiusTokens.pill), + shape = RoundedCornerShape(MrComicCornerScale.pill), color = containerColor, contentColor = contentColor, border = border @@ -82,25 +83,42 @@ fun MrComicPill( @Composable fun MrComicFormatBadge(label: String, isGraphic: Boolean, modifier: Modifier = Modifier) { - val container = MaterialTheme.colorScheme.surface.copy(alpha = if (isGraphic) 0.9f else 0.92f) - val borderAlpha = if (isGraphic) 0.3f else 0.26f - Surface( + val colorScheme = MaterialTheme.colorScheme + // BUG-UI-01: Ensure sufficient contrast for badge text on all backgrounds. + val containerColor = colorScheme.surface.copy(alpha = if (isGraphic) 0.92f else 0.94f) + val contentColor = ensureBadgeContrast(colorScheme.onSurface, containerColor) + MrComicPill( modifier = modifier, - shape = RoundedCornerShape(MrComicRadiusTokens.sm), - color = container, - border = BorderStroke(0.6.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = borderAlpha)) + containerColor = containerColor, + contentColor = contentColor, + border = BorderStroke(0.6.dp, colorScheme.outlineVariant.copy(alpha = if (isGraphic) 0.35f else 0.3f)), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 3.dp) ) { Text( text = label, - modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall.copy(fontSize = MrComicTypographyTokens.badge), - color = MaterialTheme.colorScheme.onSurface, + color = contentColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +/** + * Ensures badge text has sufficient contrast against its container. + * Falls back to black or white if the contrast ratio is below 3:1. + */ +private fun ensureBadgeContrast(foreground: Color, background: Color): Color { + val bgLum = background.luminance().coerceIn(0.001f, 0.999f) + val fgLum = foreground.luminance().coerceIn(0.001f, 0.999f) + val ratio = if (bgLum > fgLum) (bgLum + 0.05f) / (fgLum + 0.05f) + else (fgLum + 0.05f) / (bgLum + 0.05f) + if (ratio >= 3f) return foreground + val blackRatio = (bgLum + 0.05f) / (0.0f + 0.05f) + val whiteRatio = (1.0f + 0.05f) / (bgLum + 0.05f) + return if (blackRatio >= whiteRatio) Color.Black else Color.White +} + @Composable fun MrComicStatusBadge( text: String, @@ -113,11 +131,11 @@ fun MrComicStatusBadge( ) { val colorScheme = MaterialTheme.colorScheme val (toneContainerColor, toneContentColor) = when (tone) { - MrComicStatusTone.Neutral -> colorScheme.surface.copy(alpha = 0.9f) to colorScheme.onSurface - MrComicStatusTone.Info -> colorScheme.primary.copy(alpha = 0.14f) to colorScheme.primary - MrComicStatusTone.Success -> colorScheme.tertiary.copy(alpha = 0.16f) to colorScheme.tertiary - MrComicStatusTone.Warning -> colorScheme.secondary.copy(alpha = 0.18f) to colorScheme.secondary - MrComicStatusTone.Error -> colorScheme.error.copy(alpha = 0.14f) to colorScheme.error + MrComicStatusTone.Neutral -> colorScheme.surface.copy(alpha = 0.92f) to colorScheme.onSurface + MrComicStatusTone.Info -> colorScheme.primary.copy(alpha = 0.22f) to colorScheme.primary + MrComicStatusTone.Success -> colorScheme.tertiary.copy(alpha = 0.24f) to colorScheme.tertiary + MrComicStatusTone.Warning -> colorScheme.secondary.copy(alpha = 0.24f) to colorScheme.secondary + MrComicStatusTone.Error -> colorScheme.error.copy(alpha = 0.22f) to colorScheme.error } val resolvedContainerColor = containerColor ?: toneContainerColor val resolvedContentColor = contentColor ?: toneContentColor diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt index fd36919fa..b872d6b90 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt @@ -1,29 +1,20 @@ package io.leostrange.mrcomic.core.ui.designsystem -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +/** + * Editorial Ink control primitives. + * + * `MrComicSwitchRow` and `MrComicSliderTile` were removed in the Editorial Ink + * migration; use `MrComicListItem` with [MrComicListItemTrailing.Switch] and a + * `MrComicSlider` row instead. + */ @Composable fun MrComicProgressLine(progress: () -> Float, modifier: Modifier = Modifier, color: Color? = null, trackColor: Color? = null) { LinearProgressIndicator( @@ -66,66 +57,3 @@ fun mrComicCompletedColor(): Color = if (MaterialTheme.colorScheme.background.lu } else { mrComicArgbColor(MrComicColorTokens.InkPaperDarkCompletedArgb) } - -@Composable -fun MrComicSwitchRow( - title: String, - subtitle: String? = null, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - switchScale: Float = 1f -) { - Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(20.dp)) { - androidx.compose.foundation.layout.Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall, color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)) - if (!subtitle.isNullOrBlank()) { - Spacer(Modifier.height(2.dp)) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)) - } - } - Spacer(Modifier.width(MrComicSpacingTokens.x3)) - Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange, modifier = Modifier.scale(switchScale)) - } - } -} - -@Composable -fun MrComicSliderTile( - title: String, - valueLabel: String, - value: Float, - onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange, - modifier: Modifier = Modifier, - steps: Int = 0, - subtitle: String? = null, - enabled: Boolean = true -) { - Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(20.dp)) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2) - ) { - androidx.compose.foundation.layout.Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall) - if (!subtitle.isNullOrBlank()) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.width(MrComicSpacingTokens.x3)) - Text(valueLabel, modifier = Modifier.widthIn(max = 112.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) - } - Slider(value = value, onValueChange = onValueChange, valueRange = valueRange, steps = steps, enabled = enabled, modifier = Modifier.fillMaxWidth()) - } - } -} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt new file mode 100644 index 000000000..f008b75e5 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt @@ -0,0 +1,22 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +/** + * Semantic opacity levels for the Editorial Ink design system. + * + * Use these instead of raw `.copy(alpha = 0.42f)` so the visual language is + * consistent and tunable in one place. Existing screens still use raw alpha + * values; new components should prefer these tokens. + */ +object MrComicAlphaTokens { + /** Faint, ghosted — borders, dividers, disabled icon containers. */ + const val Subtle: Float = 0.6f + + /** Light veil — secondary containers, scrims, badges. */ + const val Soft: Float = 0.8f + + /** Solid — primary surfaces, primary text on background. */ + const val Solid: Float = 1.0f + + /** Half-strength divider — used by inline list separators. */ + const val Hairline: Float = 0.3f +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt new file mode 100644 index 000000000..9f3f521c5 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt @@ -0,0 +1,154 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Editorial Ink bottom navigation bar. + * + * • 64 dp content height plus the navigation-bars inset. + * • Indicator: 64×32 dp pill with 14 dp radius, [MrComicCornerScale.lg]. + * • Icon: 24 dp; label: 11 sp Medium ([MrComicType.navLabel]). + * • Up to 5 destinations per Material 3 spec. + */ +@Composable +fun MrComicBottomBar( + destinations: List, + currentRoute: String?, + onDestinationClick: (MrComicBottomBarDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + Surface( + modifier = modifier.fillMaxWidth(), + color = colorScheme.surface, + contentColor = colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column { + HorizontalDivider( + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .height(BAR_HEIGHT) + .padding(horizontal = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + destinations.forEach { destination -> + MrComicBottomBarItem( + destination = destination, + selected = currentRoute == destination.route, + onClick = { onDestinationClick(destination) }, + ) + } + } + } + } +} + +/** A single bottom-bar entry. */ +data class MrComicBottomBarDestination( + val route: String, + val label: String, + val icon: ImageVector, + val contentDescription: String = label, +) + +@Composable +private fun RowScope.MrComicBottomBarItem( + destination: MrComicBottomBarDestination, + selected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val contentColor = if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant + + Box( + modifier = Modifier + .weight(1f) + .height(BAR_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Box( + modifier = Modifier + .size(width = INDICATOR_WIDTH, height = INDICATOR_HEIGHT) + .background( + color = if (selected) { + colorScheme.primaryContainer + } else { + colorScheme.surface + }, + shape = RoundedCornerShape(MrComicCornerScale.lg), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = destination.icon, + contentDescription = destination.contentDescription, + modifier = Modifier.size(24.dp), + tint = contentColor, + ) + } + Text( + text = destination.label, + style = MrComicType.navLabel.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + color = contentColor, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + textAlign = TextAlign.Center, + ) + } + } +} + +private val BAR_HEIGHT: Dp = 64.dp +private val INDICATOR_WIDTH: Dp = 64.dp +private val INDICATOR_HEIGHT: Dp = 32.dp diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt new file mode 100644 index 000000000..6b894a1a1 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt @@ -0,0 +1,26 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Corner scale for the Editorial Ink design system. + * + * Use this for new components. `MrComicRadiusTokens` is the legacy scale used by + * existing screens; both scales coexist during migration. + * + * xs 4 dp — micro containers, dense pills + * sm 6 dp — inline controls, small chips + * md 10 dp — inputs, small cards, icon containers + * lg 14 dp — cards, list items, default container + * xl 20 dp — panels, hero surfaces, sheets + * pill 999 dp — fully rounded + */ +object MrComicCornerScale { + val xs: Dp = 4.dp + val sm: Dp = 6.dp + val md: Dp = 10.dp + val lg: Dp = 14.dp + val xl: Dp = 20.dp + val pill: Dp = 999.dp +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt index 8b850a359..a93f92133 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt @@ -69,7 +69,9 @@ object MrComicColorTokens { const val InkPaperSurfaceContainerHighArgb = 0xFFE6DFD7L const val InkPaperSurfaceContainerHighestArgb = 0xFFE0D9D0L const val InkPaperOutlineArgb = 0xFF7C756AL - const val InkPaperCompletedArgb = 0xFF4CAF50L + // STYLE-PASTEL: soft sage instead of vivid Material green — calmer on cream + // paper backgrounds and slightly higher contrast for the completed badge. + const val InkPaperCompletedArgb = 0xFF5E9C67L const val InkPaperDarkPrimaryArgb = 0xFFAEC8EFL const val InkPaperDarkSecondaryArgb = 0xFFE6C79BL @@ -164,8 +166,11 @@ object MrComicThemePresetTokens { backgroundArgb = null, ) val neon = MrComicThemePresetToken( - primaryArgb = 0xFFE91E63L, - secondaryArgb = 0xFF00BCD4L, + // STYLE-PASTEL: "neon" re-tuned from hot magenta/cyan (0xFFE91E63 / + // 0xFF00BCD4) to gentle dusk pastels — soft lavender over soft teal. + // The preset keeps its id for backward compatibility with saved configs. + primaryArgb = 0xFFB79CEDL, + secondaryArgb = 0xFF93CFC4L, backgroundArgb = 0xFF0D0D1AL, ) val gray = MrComicThemePresetToken( diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt new file mode 100644 index 000000000..0f84d1157 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt @@ -0,0 +1,216 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** Aspect ratio of the cover in a [MrComicLibraryCard]. */ +enum class MrComicLibraryCardAspect(val ratio: Float) { + /** 2:3 — default, used for books, comics, manga. */ + Portrait(2f / 3f), + + /** 16:9 — wide, used for landscape covers and banners. */ + Wide(16f / 9f), +} + +/** + * Editorial Ink library card. Cover image on top (2:3 by default), 1-2 lines + * of title and an optional meta line below. No drop shadow; the cover + * itself provides visual weight, the card frame uses [MrComicCornerScale.md] + * and a 0.5 dp hairline border. + */ +@Composable +fun MrComicLibraryCard( + coverUrl: String?, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + aspect: MrComicLibraryCardAspect = MrComicLibraryCardAspect.Portrait, + progress: Float? = null, + onClick: (() -> Unit)? = null, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val sanitizedProgress = progress?.coerceIn(0f, 1f) + + Column( + modifier = modifier + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ), + verticalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspect.ratio) + .clip(RoundedCornerShape(MrComicCornerScale.md)) + .background(colorScheme.surfaceContainerHigh), + ) { + if (!coverUrl.isNullOrBlank()) { + AsyncImage( + model = coverUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (sanitizedProgress != null) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(2.dp) + .background(colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(sanitizedProgress) + .height(2.dp) + .background(colorScheme.primary), + ) + } + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** + * Wide row variant of the library card. Cover on the left (72 dp square by + * default), title/subtitle on the right, optional trailing meta. Useful for + * continue-reading rows and search results. + */ +@Composable +fun MrComicLibraryCardRow( + coverUrl: String?, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + trailing: String? = null, + onClick: (() -> Unit)? = null, + coverSize: Dp = 72.dp, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ) + .padding(vertical = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + Surface( + modifier = Modifier.size(coverSize), + shape = RoundedCornerShape(MrComicCornerScale.md), + color = colorScheme.surfaceContainerHigh, + border = BorderStroke( + width = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ), + ) { + if (!coverUrl.isNullOrBlank()) { + AsyncImage( + model = coverUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(MrComicCornerScale.md)) + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.bodySm, + color = colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (!trailing.isNullOrBlank()) { + Text( + text = trailing, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt new file mode 100644 index 000000000..192613d4b --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt @@ -0,0 +1,222 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** Leading slot for [MrComicListItem]. */ +sealed interface MrComicListItemLeading { + data class Icon( + val image: ImageVector, + val contentDescription: String? = null, + val tintContainer: Boolean = true, + ) : MrComicListItemLeading + + data class Custom(val content: @Composable () -> Unit) : MrComicListItemLeading +} + +/** Trailing slot for [MrComicListItem]. */ +sealed interface MrComicListItemTrailing { + data class Value(val text: String) : MrComicListItemTrailing + data class Chevron(val show: Boolean = true) : MrComicListItemTrailing + data class Switch( + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + val enabled: Boolean = true, + ) : MrComicListItemTrailing + data class Custom(val content: @Composable () -> Unit) : MrComicListItemTrailing +} + +/** + * Editorial Ink list item. Flat (no card background) with an optional hairline + * divider. Use this for settings rows, picker rows, and any list where each + * row belongs to a section rather than its own container. + * + * Sizing: 16 dp vertical padding, 20 dp horizontal padding (or screen padding + * via [modifier]). Leading icon container is 36 dp square with 10 dp radius. + * Title is 16 sp Medium ([MrComicType.listTitle]); subtitle is 14 sp Normal + * ([MrComicType.bodySm]). + */ +@Composable +fun MrComicListItem( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + leading: MrComicListItemLeading? = null, + trailing: MrComicListItemTrailing? = MrComicListItemTrailing.Chevron(), + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + divider: Boolean = true, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val titleAlpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle + val subtitleAlpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Hairline + + Surface( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null && enabled) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ), + color = Color.Transparent, + contentColor = colorScheme.onSurface, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MrComicSpacingTokens.x5, vertical = MrComicSpacingTokens.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + if (leading != null) { + MrComicListItemLeading(leading, enabled) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface.copy(alpha = titleAlpha), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.bodySm, + color = colorScheme.onSurfaceVariant.copy(alpha = subtitleAlpha), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + MrComicListItemTrailing(trailing, enabled) + } + } + if (divider) { + HorizontalDivider( + modifier = Modifier.padding( + start = if (leading != null) MrComicSpacingTokens.x10 else MrComicSpacingTokens.x5, + ), + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + } + } + } +} + +@Composable +private fun MrComicListItemLeading(leading: MrComicListItemLeading, enabled: Boolean) { + when (leading) { + is MrComicListItemLeading.Icon -> { + val colorScheme = MaterialTheme.colorScheme + val containerColor = if (leading.tintContainer) { + colorScheme.primary.copy(alpha = MrComicAlphaTokens.Hairline) + } else { + Color.Transparent + } + val contentColor = colorScheme.primary + Box( + modifier = Modifier + .size(36.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(MrComicCornerScale.md), + color = containerColor, + contentColor = contentColor.copy(alpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = leading.image, + contentDescription = leading.contentDescription, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + is MrComicListItemLeading.Custom -> leading.content() + } +} + +@Composable +private fun MrComicListItemTrailing(trailing: MrComicListItemTrailing, enabled: Boolean) { + val colorScheme = MaterialTheme.colorScheme + when (trailing) { + is MrComicListItemTrailing.Value -> { + Surface( + shape = RoundedCornerShape(MrComicCornerScale.pill), + color = colorScheme.primary.copy(alpha = MrComicAlphaTokens.Hairline), + contentColor = colorScheme.primary, + ) { + Text( + text = trailing.text, + style = MrComicType.micro, + color = colorScheme.primary.copy( + alpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + is MrComicListItemTrailing.Chevron -> if (trailing.show) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) MrComicAlphaTokens.Subtle else MrComicAlphaTokens.Hairline, + ), + ) + } + is MrComicListItemTrailing.Switch -> { + Switch( + checked = trailing.checked, + onCheckedChange = trailing.onCheckedChange, + enabled = enabled && trailing.enabled, + ) + } + is MrComicListItemTrailing.Custom -> trailing.content() + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt new file mode 100644 index 000000000..6b9f0d5a6 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt @@ -0,0 +1,65 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Section header for in-screen sections (Editorial Ink design system). + * + * Visual rhythm: 24 dp top padding / 8 dp bottom padding. Title is 24 sp + * SemiBold ([MrComicType.h2]), subtitle is 12 sp onSurfaceVariant ([MrComicType.meta]). + * An optional [trailing] slot hosts a "See all" link, count chip, or any + * other trailing action. + */ +@Composable +fun MrComicSectionHeader( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, +) { + val colorScheme = MaterialTheme.colorScheme + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = MrComicSpacingTokens.x6, bottom = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.h2, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + trailing() + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt new file mode 100644 index 000000000..69d0f2f3d --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt @@ -0,0 +1,225 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Compact (64 dp) or expanded (96 dp with subtitle) variant of the top app bar. + */ +enum class MrComicTopAppBarVariant(val minHeight: Dp) { + Compact(64.dp), + Expanded(96.dp), +} + +/** A single top-bar action — either a direct icon button or an overflow menu item. */ +sealed interface MrComicTopAppBarAction { + val key: String + + data class Icon( + override val key: String, + val icon: ImageVector, + val contentDescription: String, + val tint: androidx.compose.ui.graphics.Color? = null, + val onClick: () -> Unit, + ) : MrComicTopAppBarAction + + data class Overflow( + override val key: String, + val label: String, + val onClick: () -> Unit, + ) : MrComicTopAppBarAction +} + +/** + * Editorial Ink top app bar. Single 64 dp (or 96 dp expanded) row with an + * optional tinted back button, a 24 sp title, a subtitle, and 1-3 direct + * icon actions with a trailing overflow menu. + * + * • Container: `surface` (no alpha tricks). + * • Border-bottom: 0.5 dp `outlineVariant` at [MrComicAlphaTokens.Hairline]. + * • Default action density: 4 dp between same-group icons, 16 dp between + * groups, single overflow at the end. + */ +@Composable +fun MrComicTopAppBar( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + onNavigateUp: (() -> Unit)? = null, + actions: List = emptyList(), + overflowActions: List = emptyList(), + variant: MrComicTopAppBarVariant = MrComicTopAppBarVariant.Compact, +) { + val colorScheme = MaterialTheme.colorScheme + val resolvedVariant = if (!subtitle.isNullOrBlank()) MrComicTopAppBarVariant.Expanded else variant + val (iconActions, remainingOverflow) = remember(actions, overflowActions) { + val direct = actions.filterIsInstance() + val inline = actions.filterIsInstance() + val effectiveOverflow = (inline + overflowActions).distinctBy { it.key } + direct to effectiveOverflow + } + + Surface( + modifier = modifier.fillMaxWidth(), + color = colorScheme.surface, + contentColor = colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .heightIn(min = resolvedVariant.minHeight) + .padding(horizontal = MrComicSpacingTokens.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2), + ) { + if (onNavigateUp != null) { + MrComicTopAppBarIconButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = onNavigateUp, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.h1, + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank() && resolvedVariant == MrComicTopAppBarVariant.Expanded) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + MrComicTopAppBarActions(iconActions, remainingOverflow) + } + HorizontalDivider( + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + } + } +} + +@Composable +private fun MrComicTopAppBarActions( + iconActions: List, + overflowActions: List, +) { + val visibleIcons = iconActions.take(MAX_INLINE_ICONS) + val hiddenIcons = iconActions.drop(MAX_INLINE_ICONS) + val colorScheme = MaterialTheme.colorScheme + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x1), + ) { + visibleIcons.forEach { action -> + MrComicTopAppBarIconButton( + icon = action.icon, + contentDescription = action.contentDescription, + tint = action.tint, + onClick = action.onClick, + ) + } + if (hiddenIcons.isNotEmpty() || overflowActions.isNotEmpty()) { + var menuExpanded by remember { mutableStateOf(false) } + Box { + MrComicTopAppBarIconButton( + icon = Icons.Default.MoreVert, + contentDescription = "More", + onClick = { menuExpanded = true }, + ) + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + shape = RoundedCornerShape(MrComicCornerScale.lg), + containerColor = colorScheme.surfaceContainer, + ) { + (hiddenIcons.map { icon -> + MrComicTopAppBarAction.Overflow( + key = icon.key, + label = icon.contentDescription, + onClick = icon.onClick, + ) + } + overflowActions).forEach { item -> + DropdownMenuItem( + text = { Text(item.label, style = MrComicType.body) }, + onClick = { + menuExpanded = false + item.onClick() + }, + ) + } + } + } + } + } +} + +@Composable +private fun MrComicTopAppBarIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + tint: androidx.compose.ui.graphics.Color? = null, +) { + val colorScheme = MaterialTheme.colorScheme + IconButton( + onClick = onClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint ?: colorScheme.onSurface, + ) + } +} + +private const val MAX_INLINE_ICONS = 2 diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt new file mode 100644 index 000000000..ef87f0ad7 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt @@ -0,0 +1,126 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** + * Editorial Ink type roles. + * + * New components should use these `TextStyle` constants directly instead of + * the Material 3 `MaterialTheme.typography.*` slots. The two systems coexist + * during migration. + * + * Roles are named for their semantic purpose (display, h1..h3, body, meta, + * micro) rather than visual size so that screen text and chrome text can + * evolve independently of the underlying scale. + */ +object MrComicType { + + // ── Display & Headings ──────────────────────────────────────────────── + + /** Hero / cover. 44 / 52, SemiBold 600, -0.015em. */ + val display: TextStyle = TextStyle( + fontSize = 44.sp, + lineHeight = 52.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.015).em, + ) + + /** Screen title (top app bar). 32 / 40, SemiBold 600, -0.01em. */ + val h1: TextStyle = TextStyle( + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.01).em, + ) + + /** Section header. 24 / 32, SemiBold 600, -0.005em. */ + val h2: TextStyle = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.005).em, + ) + + /** Card / block title. 20 / 28, Medium 500, 0em. */ + val h3: TextStyle = TextStyle( + fontSize = 20.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + ) + + // ── Body ────────────────────────────────────────────────────────────── + + /** Default body. 16 / 26, Normal 400, 0em. */ + val body: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Normal, + ) + + /** Compact body. 14 / 22, Normal 400, 0em. */ + val bodySm: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Normal, + ) + + // ── List item roles ─────────────────────────────────────────────────── + + /** List item title. 16 / 24, Medium 500, 0em. */ + val listTitle: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + ) + + /** List item subtitle (alias of [bodySm] for discoverability). */ + val listSubtitle: TextStyle = bodySm + + // ── Meta / micro ────────────────────────────────────────────────────── + + /** Meta label. 12 / 18, Medium 500, 0.01em. */ + val meta: TextStyle = TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.01.em, + ) + + /** Micro / badge. 11 / 16, Medium 500, 0.02em. */ + val micro: TextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.02.em, + ) + + // ── Button labels ───────────────────────────────────────────────────── + + /** Default button label. 14 / 20, Medium 500, 0.01em. */ + val button: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.01.em, + ) + + /** Large / primary CTA label. 16 / 24, SemiBold 600, 0em. */ + val buttonLg: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + + // ── Top bar / Bottom bar ────────────────────────────────────────────── + + /** Bottom navigation label. 11 / 16, Medium 500, 0.02em. */ + val navLabel: TextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.02.em, + ) +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt.bak similarity index 100% rename from android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt rename to android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt.bak diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt.bak similarity index 100% rename from android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt rename to android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt.bak diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt index baa186f3b..1ada21953 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt @@ -28,6 +28,10 @@ object ReaderTextFontCatalog { "Roboto Slab" to "RobotoSlab-Regular.ttf", "PT Serif" to "PTSerif-Regular.ttf", "Literata" to "Literata-Regular.ttf", + "OpenDyslexic" to "OpenDyslexic-Regular.otf", + "Accessible DfA" to "AccessibleDfA.otf", + "iA Writer Duospace" to "iAWriterDuospace-Regular.ttf", + "Liberation Sans" to "LiberationSans-Regular.ttf", "Lora" to "Lora-Regular.ttf", "Source Serif 4" to "SourceSerif4-Regular.ttf" ) diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt new file mode 100644 index 000000000..35ad5acc3 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt @@ -0,0 +1,263 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRarity +import io.leostrange.mrcomic.core.model.AchievementStatus + +/** + * Карточка достижения + */ +@Composable +fun AchievementCard( + achievement: Achievement, + progress: AchievementProgress, + modifier: Modifier = Modifier, + showProgress: Boolean = true +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.currentProgress, + animationSpec = tween(durationMillis = 500), + label = "achievement_progress" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = getAchievementBackgroundColor(progress.status) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка достижения + AchievementIcon( + achievement = achievement, + status = progress.status, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация о достижении + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = achievement.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = achievement.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Статус + AchievementStatusBadge(status = progress.status) + } + + // Прогресс-бар + if (showProgress && progress.status != AchievementStatus.UNLOCKED) { + Spacer(modifier = Modifier.height(12.dp)) + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${(animatedProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = getAchievementColor(achievement.rarity), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } + + // Награда XP + if (achievement.xpReward > 0) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "+${achievement.xpReward} XP", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +/** + * Иконка достижения + */ +@Composable +private fun AchievementIcon( + achievement: Achievement, + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val backgroundColor = when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.surfaceVariant + AchievementStatus.UNLOCKED -> getAchievementColor(achievement.rarity) + AchievementStatus.CLAIMED -> getAchievementColor(achievement.rarity) + } + + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = when (status) { + AchievementStatus.LOCKED -> Icons.Default.Lock + AchievementStatus.UNLOCKED -> Icons.Default.Star + AchievementStatus.CLAIMED -> Icons.Default.CheckCircle + }, + contentDescription = null, + tint = when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.onSurfaceVariant + AchievementStatus.UNLOCKED -> Color.White + AchievementStatus.CLAIMED -> Color.White + }, + modifier = Modifier.size(24.dp) + ) + } +} + +/** + * Бейдж статуса достижения + */ +@Composable +private fun AchievementStatusBadge( + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val (text, color) = when (status) { + AchievementStatus.LOCKED -> "Заблокировано" to MaterialTheme.colorScheme.surfaceVariant + AchievementStatus.UNLOCKED -> "Разблокировано" to MaterialTheme.colorScheme.primary + AchievementStatus.CLAIMED -> "Получено" to MaterialTheme.colorScheme.tertiary + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = color.copy(alpha = 0.1f) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } +} + +/** + * Получить цвет фона карточки в зависимости от статуса + */ +@Composable +private fun getAchievementBackgroundColor(status: AchievementStatus): Color { + return when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.surface + AchievementStatus.UNLOCKED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + AchievementStatus.CLAIMED -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + } +} + +/** + * Получить цвет достижения в зависимости от редкости + */ +@Composable +private fun getAchievementColor(rarity: AchievementRarity): Color { + return when (rarity) { + AchievementRarity.COMMON -> MaterialTheme.colorScheme.primary + AchievementRarity.UNCOMMON -> MaterialTheme.colorScheme.secondary + AchievementRarity.RARE -> MaterialTheme.colorScheme.tertiary + AchievementRarity.EPIC -> MaterialTheme.colorScheme.error + AchievementRarity.LEGENDARY -> Color(0xFFFFD700) // Золотой + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt new file mode 100644 index 000000000..253327978 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt @@ -0,0 +1,343 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRarity +import io.leostrange.mrcomic.core.model.AchievementStatus + +/** + * Экран деталей достижения + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AchievementDetailScreen( + achievement: Achievement, + progress: AchievementProgress, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.currentProgress, + animationSpec = tween(durationMillis = 1000), + label = "detail_progress" + ) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Достижение", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Иконка достижения + Box( + modifier = Modifier + .size(120.dp) + .background( + color = getAchievementColor(achievement.rarity), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = when (progress.status) { + AchievementStatus.LOCKED -> Icons.Default.Lock + AchievementStatus.UNLOCKED -> Icons.Default.Star + AchievementStatus.CLAIMED -> Icons.Default.CheckCircle + }, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(64.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Название достижения + Text( + text = achievement.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Описание + Text( + text = achievement.description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Прогресс + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)), + color = getAchievementColor(achievement.rarity), + trackColor = MaterialTheme.colorScheme.surface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "${(animatedProgress * 100).toInt()}%", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = getAchievementColor(achievement.rarity), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Информация + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + InfoRow( + label = "Категория", + value = getCategoryName(achievement.category.name) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Редкость", + value = getRarityName(achievement.rarity) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Награда", + value = "+${achievement.xpReward} XP" + ) + + if (progress.unlockedAt != null) { + val unlockedAt = progress.unlockedAt ?: 0L + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Получено", + value = formatDate(unlockedAt) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Статус + StatusBadge(status = progress.status) + } + } +} + +/** + * Строка информации + */ +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } +} + +/** + * Бейдж статуса + */ +@Composable +private fun StatusBadge( + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val (text, color) = when (status) { + AchievementStatus.LOCKED -> "Заблокировано" to MaterialTheme.colorScheme.error + AchievementStatus.UNLOCKED -> "Разблокировано" to MaterialTheme.colorScheme.primary + AchievementStatus.CLAIMED -> "Получено" to MaterialTheme.colorScheme.tertiary + } + + Card( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) + ) + } +} + +/** + * Получить цвет достижения в зависимости от редкости + */ +@Composable +private fun getAchievementColor(rarity: AchievementRarity): Color { + return when (rarity) { + AchievementRarity.COMMON -> MaterialTheme.colorScheme.primary + AchievementRarity.UNCOMMON -> MaterialTheme.colorScheme.secondary + AchievementRarity.RARE -> MaterialTheme.colorScheme.tertiary + AchievementRarity.EPIC -> MaterialTheme.colorScheme.error + AchievementRarity.LEGENDARY -> Color(0xFFFFD700) // Золотой + } +} + +/** + * Получить название категории + */ +private fun getCategoryName(category: String): String { + return when (category) { + "READING" -> "Чтение" + "COLLECTION" -> "Коллекция" + "STREAK" -> "Серии" + "EXPLORATION" -> "Исследование" + "MILESTONE" -> "Вехи" + else -> category + } +} + +/** + * Получить название редкости + */ +private fun getRarityName(rarity: AchievementRarity): String { + return when (rarity) { + AchievementRarity.COMMON -> "Обычное" + AchievementRarity.UNCOMMON -> "Необычное" + AchievementRarity.RARE -> "Редкое" + AchievementRarity.EPIC -> "Эпическое" + AchievementRarity.LEGENDARY -> "Легендарное" + } +} + +/** + * Форматировать дату + */ +private fun formatDate(timestamp: Long): String { + val date = java.util.Date(timestamp) + val formatter = java.text.SimpleDateFormat("dd.MM.yyyy HH:mm", java.util.Locale.getDefault()) + return formatter.format(date) +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt new file mode 100644 index 000000000..d5f460960 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt @@ -0,0 +1,205 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.height +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.AchievementCategory +import io.leostrange.mrcomic.core.model.AchievementDefinitions +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.UserAchievements + +/** + * Экран списка достижений + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AchievementListScreen( + userAchievements: UserAchievements, + onBack: () -> Unit, + onAchievementClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Достижения", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Статистика + AchievementStats( + userAchievements = userAchievements, + modifier = Modifier.padding(16.dp) + ) + + // Список достижений по категориям + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AchievementCategory.entries.forEach { category -> + val achievements = AchievementDefinitions.getByCategory(category) + val progressMap = userAchievements.achievements.associateBy { it.achievementId } + + if (achievements.isNotEmpty()) { + item { + CategoryHeader(category = category) + } + + items(achievements) { achievement -> + val progress = progressMap[achievement.id] ?: AchievementProgress( + achievementId = achievement.id, + status = io.leostrange.mrcomic.core.model.AchievementStatus.LOCKED, + currentProgress = 0f + ) + + AchievementCard( + achievement = achievement, + progress = progress, + modifier = Modifier.fillMaxWidth(), + showProgress = true + ) + } + } + } + } + } + } +} + +/** + * Заголовок категории + */ +@Composable +private fun CategoryHeader( + category: AchievementCategory, + modifier: Modifier = Modifier +) { + val title = when (category) { + AchievementCategory.READING -> "Чтение" + AchievementCategory.COLLECTION -> "Коллекция" + AchievementCategory.STREAK -> "Серии" + AchievementCategory.EXPLORATION -> "Исследование" + AchievementCategory.MILESTONE -> "Вехи" + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(vertical = 8.dp) + ) +} + +/** + * Статистика достижений + */ +@Composable +private fun AchievementStats( + userAchievements: UserAchievements, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + Text( + text = "Общий прогресс", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp)) + + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + label = "Разблокировано", + value = "${userAchievements.unlockedCount}/${userAchievements.totalCount}" + ) + + StatItem( + label = "XP заработано", + value = "${userAchievements.totalXpEarned}" + ) + + StatItem( + label = "Процент", + value = "${(userAchievements.completionRate * 100).toInt()}%" + ) + } + } +} + +/** + * Элемент статистики + */ +@Composable +private fun StatItem( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt new file mode 100644 index 000000000..b5a7aad9c --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt @@ -0,0 +1,163 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.AchievementNotification +import kotlinx.coroutines.delay + +/** + * Уведомление о новом достижении + */ +@Composable +fun AchievementNotificationPopup( + notification: AchievementNotification, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + var visible by remember { mutableStateOf(true) } + + LaunchedEffect(notification) { + delay(3000) // Автоматическое скрытие через 3 секунды + visible = false + delay(300) // Время для анимации исчезновения + onDismiss() + } + + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка достижения + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Новое достижение!", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = notification.achievement.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = notification.achievement.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (notification.xpEarned > 0) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "+${notification.xpEarned} XP", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Кнопка закрытия + IconButton( + onClick = { + visible = false + onDismiss() + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Закрыть", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Контейнер для уведомлений о достижениях + */ +@Composable +fun AchievementNotificationContainer( + notifications: List, + onDismiss: (AchievementNotification) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + notifications.forEach { notification -> + AchievementNotificationPopup( + notification = notification, + onDismiss = { onDismiss(notification) } + ) + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt new file mode 100644 index 000000000..f89d4c401 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt @@ -0,0 +1,362 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +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.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.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.MascotProgressState +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.UserAchievements + +/** + * Экран статистики геймификации + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GamificationStatsScreen( + userAchievements: UserAchievements, + mascotProgress: MascotProgressState, + goalState: DailyReadingGoalState, + onBack: () -> Unit, + onAchievementsClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Статистика", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Общая статистика + item { + OverallStatsCard( + userAchievements = userAchievements, + mascotProgress = mascotProgress + ) + } + + // Ежедневные цели + item { + DailyGoalCard(goalState = goalState) + } + + // Достижения + item { + AchievementsPreviewCard( + userAchievements = userAchievements, + onClick = onAchievementsClick + ) + } + + // Маскот + item { + MascotProgressCard(mascotProgress = mascotProgress) + } + } + } +} + +/** + * Карточка общей статистики + */ +@Composable +private fun OverallStatsCard( + userAchievements: UserAchievements, + mascotProgress: MascotProgressState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Общая статистика", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.MenuBook, + label = "Страниц", + value = "${mascotProgress.approxPagesRead}" + ) + + StatItem( + icon = Icons.Default.EmojiEvents, + label = "Тайтлов", + value = "${mascotProgress.completedTitles}" + ) + + StatItem( + icon = Icons.Default.Star, + label = "XP", + value = "${mascotProgress.xp}" + ) + } + } + } +} + +/** + * Карточка ежедневных целей + */ +@Composable +private fun DailyGoalCard( + goalState: DailyReadingGoalState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Ежедневные цели", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.MenuBook, + label = "Сегодня", + value = "${goalState.pagesReadToday}/${goalState.targetPages}" + ) + + StatItem( + icon = Icons.Default.LocalFireDepartment, + label = "Серия", + value = "${goalState.currentStreak} дней" + ) + + StatItem( + icon = Icons.Default.Timer, + label = "Неделя", + value = "${goalState.pagesReadThisWeek}/${goalState.weeklyTargetPages}" + ) + } + } + } +} + +/** + * Карточка превью достижений + */ +@Composable +private fun AchievementsPreviewCard( + userAchievements: UserAchievements, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + onClick = onClick + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Достижения", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "${userAchievements.unlockedCount}/${userAchievements.totalCount}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Нажмите для просмотра всех достижений", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Карточка прогресса маскота + */ +@Composable +private fun MascotProgressCard( + mascotProgress: MascotProgressState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Маскот", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.Star, + label = "Стадия", + value = getStageName(mascotProgress.stage) + ) + + StatItem( + icon = Icons.Default.MenuBook, + label = "Прогресс", + value = "${(mascotProgress.stageProgress * 100).toInt()}%" + ) + } + } + } +} + +/** + * Элемент статистики + */ +@Composable +private fun StatItem( + icon: ImageVector, + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Получить название стадии + */ +private fun getStageName(stage: io.leostrange.mrcomic.core.model.MascotStage): String { + return when (stage) { + io.leostrange.mrcomic.core.model.MascotStage.CHILD -> "Ребёнок" + io.leostrange.mrcomic.core.model.MascotStage.TEEN -> "Подросток" + io.leostrange.mrcomic.core.model.MascotStage.YOUNG -> "Юность" + io.leostrange.mrcomic.core.model.MascotStage.ADULT -> "Взрослый" + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt new file mode 100644 index 000000000..f9bfd8faa --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt @@ -0,0 +1,255 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.Canvas +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.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingCalendarDay + +/** + * График чтения за неделю + */ +@Composable +fun WeeklyReadingChart( + recentActivity: List, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Активность за неделю", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (recentActivity.isEmpty()) { + Text( + text = "Нет данных", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // График + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) { + val maxValue = recentActivity.maxOfOrNull { it.pagesRead }?.toFloat() ?: 1f + val width = size.width + val height = size.height + val stepX = width / (recentActivity.size - 1).coerceAtLeast(1) + + // Рисуем линию графика + val path = Path() + recentActivity.forEachIndexed { index, day -> + val x = index * stepX + val y = height - (day.pagesRead / maxValue) * height + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = Color(0xFF6200EE), + style = Stroke(width = 3.dp.toPx()) + ) + + // Рисуем точки + recentActivity.forEachIndexed { index, day -> + val x = index * stepX + val y = height - (day.pagesRead / maxValue) * height + + drawCircle( + color = Color(0xFF6200EE), + radius = 4.dp.toPx(), + center = Offset(x, y) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Подписи дней + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + recentActivity.forEach { day -> + Text( + text = getDayLabel(day.dayKey), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +/** + * График чтения за месяц + */ +@Composable +fun MonthlyReadingChart( + historyActivity: List, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Активность за месяц", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (historyActivity.isEmpty()) { + Text( + text = "Нет данных", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // Тепловая карта + val weeks = historyActivity.chunked(7) + weeks.forEach { week -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + week.forEach { day -> + val intensity = when { + day.pagesRead == 0 -> 0.1f + day.pagesRead < 10 -> 0.3f + day.pagesRead < 30 -> 0.5f + day.pagesRead < 50 -> 0.7f + else -> 1.0f + } + + Canvas( + modifier = Modifier + .weight(1f) + .height(24.dp) + .padding(2.dp) + ) { + drawRoundRect( + color = Color(0xFF6200EE).copy(alpha = intensity), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(4.dp.toPx()) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Легенда + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.1f), label = "0") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.3f), label = "<10") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.5f), label = "<30") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.7f), label = "<50") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 1.0f), label = "50+") + } + } + } + } +} + +/** + * Элемент легенды + */ +@Composable +private fun LegendItem( + color: Color, + label: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas( + modifier = Modifier + .height(12.dp) + .padding(2.dp) + ) { + drawRoundRect( + color = color, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()) + ) + } + + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Получить метку дня + */ +private fun getDayLabel(dayKey: String): String { + return try { + val day = dayKey.takeLast(2).toInt() + when (day % 7) { + 0 -> "Вс" + 1 -> "Пн" + 2 -> "Вт" + 3 -> "Ср" + 4 -> "Чт" + 5 -> "Пт" + 6 -> "Сб" + else -> day.toString() + } + } catch (e: Exception) { + dayKey.takeLast(2) + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt new file mode 100644 index 000000000..6912ee5f8 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt @@ -0,0 +1,210 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.WeeklyChallenge +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress +import io.leostrange.mrcomic.core.model.WeeklyChallengeStatus + +/** + * Карточка еженедельного челленджа + */ +@Composable +fun WeeklyChallengeCard( + challenge: WeeklyChallenge, + progress: WeeklyChallengeProgress, + modifier: Modifier = Modifier +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.progress, + animationSpec = tween(durationMillis = 500), + label = "challenge_progress" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = getChallengeBackgroundColor(progress.status) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка + Icon( + imageVector = when (progress.status) { + WeeklyChallengeStatus.COMPLETED -> Icons.Default.CheckCircle + WeeklyChallengeStatus.ACTIVE -> Icons.Default.EmojiEvents + else -> Icons.Default.Timer + }, + contentDescription = null, + tint = getChallengeIconColor(progress.status), + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = challenge.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = challenge.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Награда XP + if (challenge.xpReward > 0) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "+${challenge.xpReward}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "XP", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Прогресс + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${progress.current}/${progress.target}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = getChallengeProgressColor(progress.status), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + + // Статус + if (progress.status == WeeklyChallengeStatus.COMPLETED) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "✓ Завершено", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +/** + * Получить цвет фона карточки + */ +@Composable +private fun getChallengeBackgroundColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.surface + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.surfaceVariant + } +} + +/** + * Получить цвет иконки + */ +@Composable +private fun getChallengeIconColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.error + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +/** + * Получить цвет прогресса + */ +@Composable +private fun getChallengeProgressColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.error + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.onSurfaceVariant + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt new file mode 100644 index 000000000..0f5c57a0c --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt @@ -0,0 +1,99 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.WeeklyChallengeDefinitions +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress + +/** + * Экран еженедельных челленджей + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeeklyChallengesScreen( + challengeProgress: List, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Еженедельные челленджи", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Заголовок + Text( + text = "Выполните челленджи и получите XP", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Список челленджей + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(WeeklyChallengeDefinitions.challenges) { challenge -> + val progress = challengeProgress.find { it.challengeId == challenge.id } + ?: WeeklyChallengeProgress( + challengeId = challenge.id, + current = 0, + target = challenge.target, + status = io.leostrange.mrcomic.core.model.WeeklyChallengeStatus.ACTIVE + ) + + WeeklyChallengeCard( + challenge = challenge, + progress = progress, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt index 1a8217501..4f49dd953 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt @@ -549,6 +549,34 @@ fun LibraryBackdropLayer( Box(modifier = modifier.fillMaxSize().background(spec.baseColor)) + // AMBIENT-WASH: three soft scheme-tinted radial blobs give every backdrop + // style a gentle sense of depth and tie the background to the active theme + // palette without touching any individual style spec. Skipped for IMAGE + // (user photo already carries color) and reduced-visual-effects mode. + if (!performanceHints.reducedVisualEffects && normalizedStyle != "IMAGE") { + Canvas(modifier = modifier.fillMaxSize()) { + val dim = maxOf(size.width, size.height) + val washAlpha = (0.05f + effectiveBackdropStrength.coerceIn(0f, 1f) * 0.15f) * + when (variant) { + LibraryBackdropVariant.LIGHT -> 1.00f + LibraryBackdropVariant.DARK -> 0.78f + LibraryBackdropVariant.AMOLED -> 0.38f + } + fun drawTintBlob(color: Color, alpha: Float, cx: Float, cy: Float, radiusScale: Float) { + drawRect( + brush = Brush.radialGradient( + colors = listOf(color.copy(alpha = alpha), Color.Transparent), + center = Offset(size.width * cx, size.height * cy), + radius = dim * radiusScale + ) + ) + } + drawTintBlob(colorScheme.primary, washAlpha, 0.14f, 0.10f, 0.85f) + drawTintBlob(colorScheme.secondary, washAlpha * 0.90f, 0.90f, 0.34f, 0.75f) + drawTintBlob(colorScheme.tertiary, washAlpha * 0.70f, 0.42f, 0.98f, 0.90f) + } + } + if (normalizedStyle == "IMAGE" && !backgroundImageUri.isNullOrBlank()) { AsyncImage( model = Uri.parse(backgroundImageUri), diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt index 080490264..113ec8f00 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt @@ -108,7 +108,7 @@ const val DEFAULT_LIBRARY_GRAPHIC_COVER_STYLE = MrComicLibraryStyleTokens.CoverM const val DEFAULT_LIBRARY_CARD_STYLE = "BALANCED" const val DEFAULT_LIBRARY_THUMBNAIL_MODE = "RECTANGLE" const val DEFAULT_LIBRARY_COVER_SCALE = "CROP" -const val DEFAULT_LIBRARY_BACKDROP_STRENGTH = 0.22f +const val DEFAULT_LIBRARY_BACKDROP_STRENGTH = 0.42f const val DEFAULT_LIBRARY_BACKGROUND_BLUR = 0.16f const val DEFAULT_LIBRARY_BACKGROUND_VEIL = 0.12f const val DEFAULT_LIBRARY_SHELF_DEPTH = 0.42f @@ -124,10 +124,12 @@ private fun resolveLibraryBackdropIntensity( variant: LibraryBackdropVariant, style: String ): Float { + // STYLE-BACKDROP: raised across the board — the previous values rendered the + // styled backdrops nearly invisible (≈0.03 alpha), which read as flat gray. val variantScale = when (variant) { - LibraryBackdropVariant.LIGHT -> 0.72f - LibraryBackdropVariant.DARK -> 0.52f - LibraryBackdropVariant.AMOLED -> 0.28f + LibraryBackdropVariant.LIGHT -> 0.92f + LibraryBackdropVariant.DARK -> 0.66f + LibraryBackdropVariant.AMOLED -> 0.34f } val styleScale = when (style) { "CITY_LIBRARY" -> 0.74f @@ -140,7 +142,7 @@ private fun resolveLibraryBackdropIntensity( "IMAGE" -> 0.64f else -> 0.88f } - return (0.022f + backdropStrength.coerceIn(0f, 1f) * 0.11f) * variantScale * styleScale + return (0.032f + backdropStrength.coerceIn(0f, 1f) * 0.17f) * variantScale * styleScale } internal fun resolveLibraryDetailIntensity( diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt index c2e116db5..5a991aec9 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt @@ -293,6 +293,8 @@ data class AppStrings( val dictLangTurkish: String get() = dictStrings.dictLangTurkish val dictLangChinese: String get() = dictStrings.dictLangChinese val dictStatusBundled: String get() = dictStrings.dictStatusBundled + val dictStatusDownloaded: String get() = dictStrings.dictStatusDownloaded + val dictStatusImported: String get() = dictStrings.dictStatusImported val dictStatusInstalled: String get() = dictStrings.dictStatusInstalled val dictStatusNotInstalled: String get() = dictStrings.dictStatusNotInstalled val dictBtnDownload: String get() = dictStrings.dictBtnDownload diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt index 59f12f906..08680a3f3 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt @@ -20,22 +20,6 @@ val English = AppStrings( actionSquare = "Square", actionFile = "File", actionFolder = "Folder", - opdsCatalog = "Online catalogs", - opdsCatalogs = "Online catalogs", - opdsSearch = "Search", - opdsSearchPlaceholder = "Search…", - opdsCatalogPickerTitle = "Book catalogs", - opdsCategories = "Categories", - opdsBooks = "Books", - opdsLoadMore = "Load more", - opdsDownload = "Download", - opdsRetry = "Retry", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "Free eBooks (public domain)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "Public domain books", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "Free eBooks collection", readerPages = "Pages", readerBookmark = "Bookmark", readerBookmarked = "Bookmarked", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt index f9e3399d3..644a74175 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt @@ -20,22 +20,6 @@ val Japanese = AppStrings( actionSquare = "正方形", actionFile = "ファイル", actionFolder = "フォルダ", - opdsCatalog = "オンラインカタログ", - opdsCatalogs = "オンラインカタログ", - opdsSearch = "検索", - opdsSearchPlaceholder = "検索…", - opdsCatalogPickerTitle = "ブックカタログ", - opdsCategories = "カテゴリ", - opdsBooks = "書籍", - opdsLoadMore = "さらに読み込む", - opdsDownload = "ダウンロード", - opdsRetry = "再試行", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "無料の電子書籍(パブリックドメイン)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "パブリックドメインの書籍", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "無料電子書籍コレクション", readerPages = "ページ", readerBookmark = "しおり", readerBookmarked = "しおり済み", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt index fee4e3d94..d8a9175b5 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt @@ -20,22 +20,6 @@ val Korean = AppStrings( actionSquare = "정사각형", actionFile = "파일", actionFolder = "폴더", - opdsCatalog = "온라인 카탈로그", - opdsCatalogs = "온라인 카탈로그", - opdsSearch = "검색", - opdsSearchPlaceholder = "검색…", - opdsCatalogPickerTitle = "도서 카탈로그", - opdsCategories = "카테고리", - opdsBooks = "도서", - opdsLoadMore = "더 불러오기", - opdsDownload = "다운로드", - opdsRetry = "다시 시도", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "무료 전자책(퍼블릭 도메인)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "퍼블릭 도메인 도서", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "무료 전자책 모음", readerPages = "페이지", readerBookmark = "북마크", readerBookmarked = "북마크됨", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt index 0aad4f664..413341422 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt @@ -20,22 +20,6 @@ val Russian = AppStrings( actionSquare = "Квадрат", actionFile = "Файл", actionFolder = "Папка", - opdsCatalog = "Онлайн-каталоги", - opdsCatalogs = "Онлайн-каталоги", - opdsSearch = "Поиск", - opdsSearchPlaceholder = "Поиск…", - opdsCatalogPickerTitle = "Каталоги книг", - opdsCategories = "Категории", - opdsBooks = "Книги", - opdsLoadMore = "Загрузить ещё", - opdsDownload = "Скачать", - opdsRetry = "Повторить", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "Бесплатные электронные книги (общественное достояние)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "Книги общественного достояния", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "Коллекция бесплатных электронных книг", readerPages = "Страницы", readerBookmark = "Закладка", readerBookmarked = "В закладках", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt index 374815c6b..1d907ee30 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt @@ -20,22 +20,6 @@ val ChineseSimplified = AppStrings( actionSquare = "方形", actionFile = "文件", actionFolder = "文件夹", - opdsCatalog = "在线目录", - opdsCatalogs = "在线目录", - opdsSearch = "搜索", - opdsSearchPlaceholder = "搜索…", - opdsCatalogPickerTitle = "书籍目录", - opdsCategories = "分类", - opdsBooks = "书籍", - opdsLoadMore = "加载更多", - opdsDownload = "下载", - opdsRetry = "重试", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "免费电子书(公版)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "公版书籍", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "免费电子书合集", readerPages = "分页", readerBookmark = "书签", readerBookmarked = "已加书签", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt index b70e967c8..0f85c0ea0 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/DictionaryStrings.kt @@ -37,7 +37,11 @@ data class DictionaryStrings( val dictLangChinese: String, /** Status chip: Bundled */ val dictStatusBundled: String, - /** Status chip: Installed */ + /** Status chip: Downloaded (user-fetched from the release server) */ + val dictStatusDownloaded: String, + /** Status chip: Imported (user-supplied SAF file) */ + val dictStatusImported: String, + /** Status chip: Installed (legacy label, kept for callers that haven't migrated) */ val dictStatusInstalled: String, /** Status chip: Not installed */ val dictStatusNotInstalled: String, @@ -92,6 +96,8 @@ data class DictionaryStrings( dictLangTurkish = "Turkish", dictLangChinese = "Chinese", dictStatusBundled = "Bundled", + dictStatusDownloaded = "Downloaded", + dictStatusImported = "Imported", dictStatusInstalled = "Installed", dictStatusNotInstalled = "Not installed", dictBtnDownload = "Download", @@ -127,6 +133,8 @@ data class DictionaryStrings( dictLangTurkish = "トルコ語", dictLangChinese = "中国語", dictStatusBundled = "バンドル済み", + dictStatusDownloaded = "ダウンロード済み", + dictStatusImported = "インポート済み", dictStatusInstalled = "インストール済み", dictStatusNotInstalled = "未インストール", dictBtnDownload = "ダウンロード", @@ -162,6 +170,8 @@ data class DictionaryStrings( dictLangTurkish = "土耳其语", dictLangChinese = "中文", dictStatusBundled = "内置", + dictStatusDownloaded = "已下载", + dictStatusImported = "已导入", dictStatusInstalled = "已安装", dictStatusNotInstalled = "未安装", dictBtnDownload = "下载", @@ -197,6 +207,8 @@ data class DictionaryStrings( dictLangTurkish = "터키어", dictLangChinese = "중국어", dictStatusBundled = "번들 포함", + dictStatusDownloaded = "다운로드됨", + dictStatusImported = "가져옴", dictStatusInstalled = "설치됨", dictStatusNotInstalled = "미설치", dictBtnDownload = "다운로드", @@ -232,6 +244,8 @@ data class DictionaryStrings( dictLangTurkish = "Турецкий", dictLangChinese = "Китайский", dictStatusBundled = "В комплекте", + dictStatusDownloaded = "Скачан", + dictStatusImported = "Импортирован", dictStatusInstalled = "Установлено", dictStatusNotInstalled = "Не установлено", dictBtnDownload = "Скачать", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt index c007fa409..cb4a0a139 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt @@ -2,8 +2,8 @@ package io.leostrange.mrcomic.core.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme @@ -11,6 +11,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance @@ -247,10 +248,8 @@ fun MrComicTheme( val context = LocalContext.current val systemDark = isSystemInDarkTheme() + @Suppress("NAME_SHADOWING") val colorScheme = if (isEInk) { - // On e-ink: always use the high-contrast grayscale scheme. - // Ignore user theme settings — e-ink panels are typically monochrome - // or have a very limited color gamut where Material colors are meaningless. EInkColorScheme } else { val darkTheme = when (themeConfig.themeMode) { diff --git a/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt b/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt new file mode 100644 index 000000000..994867337 --- /dev/null +++ b/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt @@ -0,0 +1,143 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Token contract tests for the Editorial Ink design system. + * + * These tests pin the values of new tokens so accidental refactors are caught + * by the build. They run on the JVM (no Compose runtime needed) because + * `TextUnit` and `Dp` are plain data classes. + */ +class EditorialInkTokensTest { + + // ── Alpha ───────────────────────────────────────────────────────────── + + @Test + fun alphaTokensAreOrderedByOpacity() { + assertTrue( + "Subtle must be more transparent than Soft", + MrComicAlphaTokens.Subtle < MrComicAlphaTokens.Soft, + ) + assertTrue( + "Soft must be more transparent than Solid", + MrComicAlphaTokens.Soft < MrComicAlphaTokens.Solid, + ) + assertTrue( + "Hairline must be the most transparent", + MrComicAlphaTokens.Hairline < MrComicAlphaTokens.Subtle, + ) + assertEquals(1.0f, MrComicAlphaTokens.Solid, 0.0f) + } + + // ── Corner scale ────────────────────────────────────────────────────── + + @Test + fun cornerScaleIsMonotonic() { + val radii = listOf( + MrComicCornerScale.xs, + MrComicCornerScale.sm, + MrComicCornerScale.md, + MrComicCornerScale.lg, + MrComicCornerScale.xl, + ) + for (i in 1 until radii.size) { + assertTrue( + "Corner scale must be strictly increasing: ${radii[i - 1]} < ${radii[i]}", + radii[i - 1] < radii[i], + ) + } + } + + @Test + fun cornerScaleHasExpectedStepValues() { + assertEquals(4.dp, MrComicCornerScale.xs) + assertEquals(6.dp, MrComicCornerScale.sm) + assertEquals(10.dp, MrComicCornerScale.md) + assertEquals(14.dp, MrComicCornerScale.lg) + assertEquals(20.dp, MrComicCornerScale.xl) + assertEquals(999.dp, MrComicCornerScale.pill) + } + + // ── Type scale ──────────────────────────────────────────────────────── + + @Test + fun typeRolesHaveNonEmptyStyles() { + // Sanity: each role exists and has a positive font size and line height. + val roles = mapOf( + "display" to MrComicType.display, + "h1" to MrComicType.h1, + "h2" to MrComicType.h2, + "h3" to MrComicType.h3, + "body" to MrComicType.body, + "bodySm" to MrComicType.bodySm, + "listTitle" to MrComicType.listTitle, + "listSubtitle" to MrComicType.listSubtitle, + "meta" to MrComicType.meta, + "micro" to MrComicType.micro, + "button" to MrComicType.button, + "buttonLg" to MrComicType.buttonLg, + "navLabel" to MrComicType.navLabel, + ) + roles.forEach { (name, style) -> + assertNotNull("$name style must not be null", style) + val fontSize: TextUnit = style.fontSize + val lineHeight: TextUnit = style.lineHeight + assertTrue( + "$name font size must be > 0 (got $fontSize)", + fontSize.value > 0f, + ) + assertTrue( + "$name line height must be >= font size (got lineHeight=$lineHeight, fontSize=$fontSize)", + lineHeight.value >= fontSize.value, + ) + } + } + + @Test + fun typeScaleIsMonotonicByFontSize() { + // The display…bodySm cascade is strictly increasing in size. + val sizes = listOf( + MrComicType.micro.fontSize, + MrComicType.meta.fontSize, + MrComicType.bodySm.fontSize, + MrComicType.body.fontSize, + MrComicType.h3.fontSize, + MrComicType.h2.fontSize, + MrComicType.h1.fontSize, + MrComicType.display.fontSize, + ) + for (i in 1 until sizes.size) { + assertTrue( + "Type scale must be strictly increasing: ${sizes[i - 1]} < ${sizes[i]}", + sizes[i - 1] < sizes[i], + ) + } + } + + @Test + fun badgeMicroMeetsMinimumReadableSize() { + // Micro replaces the legacy 9 sp badge token — must be at least 11 sp. + assertTrue( + "Micro font size must be >= 11 sp (got ${MrComicType.micro.fontSize})", + MrComicType.micro.fontSize.value >= 11f, + ) + } + + @Test + fun listItemTitleUsesSixteenSp() { + // List item title is 16 sp Medium — verify it stayed at body size for density. + assertEquals(16.sp, MrComicType.listTitle.fontSize) + } +} + +private fun Dp.assertEqualDp(other: Dp) { + assertEquals(this.value, other.value, 0.0f) +} diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt index 973032aa8..f53183636 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt @@ -103,7 +103,10 @@ internal class EpubTocResolver( return result.sortedBy { it.order }.mapNotNull { nav -> if (EpubFootnoteResolver.isFootnoteTocEntry(nav.src, nav.title)) return@mapNotNull null val href = try { URLDecoder.decode(nav.src, "UTF-8") } catch (_: Exception) { nav.src } - srcToPageIndex(href, ncxDir, fallbackBaseDir = opfDir)?.let { TocEntry(nav.title, it) } + val anchorId = href.substringAfter('#', "").takeIf { it.isNotBlank() } + srcToPageIndex(href, ncxDir, fallbackBaseDir = opfDir)?.let { + TocEntry(nav.title, it, anchorId = anchorId, sectionIndex = it) + } } } @@ -126,8 +129,9 @@ internal class EpubTocResolver( val title = CHUNK_HTML_TAG_RE.replace(match.groupValues[2], "").trim() if (title.isEmpty()) continue if (EpubFootnoteResolver.isFootnoteTocEntry(href, title)) continue + val anchorId = href.substringAfter('#', "").takeIf { it.isNotBlank() } val pageIdx = srcToPageIndex(href, navDir, fallbackBaseDir = opfDir) ?: continue - result.add(TocEntry(title, pageIdx)) + result.add(TocEntry(title, pageIdx, anchorId = anchorId, sectionIndex = pageIdx)) } return result } diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt index ee2a03c6b..6e28d275c 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt @@ -605,7 +605,7 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left val tocEntries = tocRaw.mapNotNull { (title, rawSectionIdx) -> val clampedIdx = rawSectionIdx.coerceAtMost((rawSections.size - 1).coerceAtLeast(0)) val pageIdx = rawToMergedPage[clampedIdx] ?: 0 - TocEntry(title, pageIdx) + TocEntry(title, pageIdx, sectionIndex = pageIdx) }.toMutableList() val anchorPageMap = sectionRawStarts.mapValues { (_, rawSectionIdx) -> val clampedIdx = rawSectionIdx.coerceAtMost((rawSections.size - 1).coerceAtLeast(0)) diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt index 198a31420..2ee041362 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt @@ -30,8 +30,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -47,8 +45,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -73,7 +69,10 @@ import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButtonVariant import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider -import io.leostrange.mrcomic.core.ui.library.RootChromeTopBarHost +import io.leostrange.mrcomic.core.ui.designsystem.MrComicTopAppBar +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import coil.compose.AsyncImage @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @@ -102,29 +101,10 @@ fun AudiobookPlayerScreen( containerColor = MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - RootChromeTopBarHost { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ), - windowInsets = WindowInsets(0, 0, 0, 0), - title = { - Text( - text = audiobook?.title ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - MrComicIconButton( - onClick = onNavigateBack, - variant = MrComicIconButtonVariant.Tonal - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") - } - } - ) - } + MrComicTopAppBar( + title = audiobook?.title.orEmpty(), + onNavigateUp = onNavigateBack + ) } ) { padding -> Column( @@ -140,10 +120,19 @@ fun AudiobookPlayerScreen( .fillMaxWidth() .aspectRatio(1f) .heightIn(max = 180.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surface), + .clip(RoundedCornerShape(16.dp)), contentAlignment = Alignment.Center ) { + val playerFallbackKind = if (audiobook?.sourceIsFolder == true) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook?.title.orEmpty(), + kind = playerFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook?.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -151,21 +140,13 @@ fun AudiobookPlayerScreen( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - imageVector = Icons.Default.Headphones, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) } } Text( text = audiobook?.title ?: "", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, + style = MrComicType.h3, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 24.dp) ) @@ -174,7 +155,7 @@ fun AudiobookPlayerScreen( val chapter = audiobook.chapters.getOrNull(uiState.currentChapterIndex) Text( text = chapter?.title ?: "Глава ${uiState.currentChapterIndex + 1}", - style = MaterialTheme.typography.bodySmall, + style = MrComicType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -203,12 +184,12 @@ fun AudiobookPlayerScreen( ) { Text( text = uiState.positionMs.toTimeString(), - style = MaterialTheme.typography.labelSmall, + style = MrComicType.meta, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = uiState.durationMs.toTimeString(), - style = MaterialTheme.typography.labelSmall, + style = MrComicType.meta, color = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt index 805e6d506..57e052fec 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt @@ -47,6 +47,7 @@ import coil.compose.AsyncImage import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicLibraryShelf import io.leostrange.mrcomic.core.model.ComicReadingStatus +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.libraryShelfCategory import io.leostrange.mrcomic.core.model.readingStatus import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip @@ -195,13 +196,13 @@ internal fun ComicInfoSheet( strings.libraryProgressTemplate.format( comic.currentPage + 1, comic.pageCount, - (comic.readingProgress * 100).toInt() + (comic.displayReadingProgress() * 100).toInt() ), style = MaterialTheme.typography.bodySmall ) } MrComicProgressLine( - progress = { comic.readingProgress }, + progress = { comic.displayReadingProgress() }, modifier = Modifier.fillMaxWidth() ) } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFilterSheet.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFilterSheet.kt index 9b006e2b5..57b1631af 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFilterSheet.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFilterSheet.kt @@ -31,6 +31,7 @@ import io.leostrange.mrcomic.core.model.SortOrder import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip import io.leostrange.mrcomic.core.ui.locale.LocalStrings +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun FilterSheet( sortOrder: SortOrder, diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt index 6dfb3210b..1eb80e87c 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,6 +36,8 @@ import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.library.components.CoverArt import io.leostrange.mrcomic.feature.library.components.FolderBackgroundStack import io.leostrange.mrcomic.feature.library.components.FolderCoverTreatment +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import io.leostrange.mrcomic.feature.library.components.libraryGridCoverRatio @Composable @@ -341,6 +342,17 @@ internal fun FolderCover( modifier = Modifier.fillMaxSize() .let { if (hasCover) it.clip(RoundedCornerShape(12.dp)) else it } ) + // Cover the FolderOpen icon treatment with the new fallback when there + // is no cover, so folder cards without artwork still get a distinct + // gradient/monogram instead of a single flat icon. + if (!hasCover) { + LibraryFallbackCover( + title = title, + kind = LibraryFallbackCoverKind.FOLDER, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxSize(), + ) + } FolderCoverTreatment( title = title, hasCover = hasCover, @@ -479,10 +491,19 @@ internal fun AudiobookGridItem( Box( modifier = Modifier .size(thumbSize.first, thumbSize.second) - .clip(RoundedCornerShape((radiusBase * 0.52f).coerceAtLeast(4.dp))) - .background(MaterialTheme.colorScheme.surfaceVariant), + .clip(RoundedCornerShape((radiusBase * 0.52f).coerceAtLeast(4.dp))), contentAlignment = Alignment.Center ) { + val listFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = listFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -490,17 +511,6 @@ internal fun AudiobookGridItem( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - Icons.Default.Headphones, - contentDescription = null, - // Scale icon proportionally to the thumb container (~45% of shorter side). - modifier = Modifier.size( - minOf(thumbSize.first.value, thumbSize.second.value) - .times(0.45f).coerceIn(20f, 40f).dp - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.56f) - ) } } Column( @@ -530,7 +540,7 @@ internal fun AudiobookGridItem( ) { Icon( Icons.Default.PlayArrow, - contentDescription = "Воспроизвести", + contentDescription = null, modifier = Modifier.padding(8.dp).size(20.dp), tint = MaterialTheme.colorScheme.primary ) @@ -550,10 +560,19 @@ internal fun AudiobookGridItem( Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(coverRatio) - .background(MaterialTheme.colorScheme.surfaceVariant), + .aspectRatio(coverRatio), contentAlignment = Alignment.Center ) { + val gridFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = gridFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -561,14 +580,6 @@ internal fun AudiobookGridItem( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - Icons.Default.Headphones, - contentDescription = null, - // Scale icon relative to the cover Box so it matches other grid items. - modifier = Modifier.fillMaxSize(0.38f), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) } if (showCoverTitles) { Surface( @@ -611,7 +622,7 @@ internal fun AudiobookGridItem( ) { Icon( Icons.Default.PlayArrow, - contentDescription = "Воспроизвести", + contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary ) diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt index 2475cb532..ae0045380 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt @@ -40,6 +40,7 @@ import io.leostrange.mrcomic.core.domain.analytics.resolveMrComicMascotState import io.leostrange.mrcomic.core.data.db.entity.SavedQuote import io.leostrange.mrcomic.core.model.Audiobook import io.leostrange.mrcomic.core.model.isReadCompleted +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSpacingTokens import io.leostrange.mrcomic.core.ui.locale.LocalStrings import io.leostrange.mrcomic.core.ui.locale.libraryQuoteSourceMissingLabel import io.leostrange.mrcomic.feature.library.components.AchievementQuestTransition @@ -53,13 +54,13 @@ import kotlinx.coroutines.launch @Composable fun LibraryScreen( onComicClick: (String) -> Unit, - onQuoteClick: (String, Int) -> Unit, + /** BUG-CANDIDATE-01: Pass the full SavedQuote so the reader can use structured position. */ + onQuoteClick: (comicId: String, page: Int, quote: io.leostrange.mrcomic.core.data.db.entity.SavedQuote?) -> Unit, onAddFileClick: () -> Unit, onAddFolderClick: () -> Unit, onSettingsClick: () -> Unit, onAudiobookClick: (String) -> Unit, onProgressProfileClick: (() -> Unit)? = null, - onOpdsCatalogClick: (() -> Unit)? = null, viewModel: LibraryViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -334,7 +335,6 @@ fun LibraryScreen( onThumbnailModeChange = viewModel::setThumbnailMode, onAddFileClick = onAddFileClick, onAddFolderClick = onAddFolderClick, - onOpdsCatalogClick = onOpdsCatalogClick, canNavigateUp = canNavigateUpWithinLibrary, onNavigateUp = navigateUpWithinLibrary, onSettingsClick = onSettingsClick @@ -356,9 +356,12 @@ fun LibraryScreen( LibraryBackground( backgroundStyle = uiState.backgroundStyle, backgroundImageUri = uiState.backgroundImageUri, - backdropStrength = (uiState.backdropStrength * 1.2f).coerceIn(0f, 1f), + // Editorial Ink: use the user-controlled strength directly + // (no 1.2× boost). BUG-UI-04 — backdrops were overpowering + // content and washing out card contrast. + backdropStrength = uiState.backdropStrength.coerceIn(0f, 1f), backgroundBlur = uiState.backgroundBlur, - backgroundVeil = (uiState.backgroundVeil * 1.2f).coerceIn(0f, 1f) + backgroundVeil = uiState.backgroundVeil.coerceIn(0f, 1f) ) Box( @@ -467,7 +470,12 @@ fun LibraryScreen( } LazyVerticalGrid( columns = columns, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), + contentPadding = PaddingValues( + start = MrComicSpacingTokens.x5, + end = MrComicSpacingTokens.x5, + top = MrComicSpacingTokens.x3, + bottom = MrComicSpacingTokens.x6, + ), horizontalArrangement = Arrangement.spacedBy(itemSpacing), verticalArrangement = Arrangement.spacedBy(itemSpacing), modifier = Modifier.fillMaxSize() diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt index 62daaa908..5ef7e9f64 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt @@ -126,7 +126,8 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( isBookmarkSection: Boolean, isAchievementSection: Boolean, strings: AppStrings, - onQuoteClick: (String, Int) -> Unit, + /** BUG-CANDIDATE-01: Pass the full SavedQuote so the reader can use structured position. */ + onQuoteClick: (comicId: String, page: Int, quote: SavedQuote?) -> Unit, onComicClick: (String) -> Unit, onAudiobookClick: (String) -> Unit, onSelectComicId: (String) -> Unit, @@ -142,11 +143,11 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( EmptyQuotesPlaceholder(showMascot = uiState.mascotUiEnabled) } } else { - items(uiState.quotes, key = { "quote_${it.id}" }) { quote -> + items(uiState.quotes, key = { "quote_${it.id}" }, contentType = { "quote" }) { quote -> QuoteCard( quote = quote, sourceAvailable = quote.comicId in uiState.availableQuoteComicIds, - onClick = { onQuoteClick(quote.comicId, quote.page) }, + onClick = { onQuoteClick(quote.comicId, quote.page, quote) }, onLongClick = { onSetQuoteToDelete(quote) }, onUnavailableSourceClick = onShowMissingSourceSnackbar ) @@ -202,7 +203,7 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( .padding(top = 8.dp, bottom = 4.dp) ) } - items(comics, key = { it.id }) { comic -> + items(comics, key = { it.id }, contentType = { "comic" }) { comic -> LibraryGridCell( isGrid = uiState.viewMode != LibraryViewMode.LIST, tileSizeDp = uiState.tileSizeDp @@ -282,6 +283,14 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( items( items = activeDisplayItems, key = { it.key }, + contentType = { item -> + when (item) { + is LibrarySectionDividerItem -> "divider" + is LibraryComicItem -> "comic" + is LibraryFolderItem -> "folder" + else -> "unknown" + } + }, span = { item -> if (item is LibrarySectionDividerItem) { GridItemSpan(maxLineSpan) @@ -364,7 +373,7 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( icon = Icons.Default.Headphones ) } - items(visibleAudiobooks, key = { "ab_${it.id}" }) { audiobook -> + items(visibleAudiobooks, key = { "ab_${it.id}" }, contentType = { "audiobook" }) { audiobook -> LibraryGridCell( isGrid = uiState.viewMode != LibraryViewMode.LIST, tileSizeDp = uiState.tileSizeDp diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt index a050eb0ac..3ece88163 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt @@ -17,9 +17,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape import io.leostrange.mrcomic.core.model.* import io.leostrange.mrcomic.core.ui.library.* import io.leostrange.mrcomic.core.ui.locale.* +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.feature.library.components.ComicGridItem @Composable @@ -69,7 +72,7 @@ internal fun LibrarySectionChip( val colorScheme = MaterialTheme.colorScheme Surface( modifier = Modifier.clickable(enabled = enabled, onClick = onClick), - shape = RootChromePillShape, + shape = RoundedCornerShape(MrComicCornerScale.md), color = if (enabled) { rootChromePillContainerColor(colorScheme, selected) } else { @@ -79,8 +82,8 @@ internal fun LibrarySectionChip( ) { Text( text = text, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), - style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MrComicType.button, color = if (enabled) { rootChromePillContentColor(colorScheme, selected) } else { diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt index 7a43d548f..c44b33ffe 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt @@ -3,12 +3,12 @@ package io.leostrange.mrcomic.feature.library import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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 @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon @@ -38,6 +37,8 @@ import coil.compose.AsyncImage import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicProgressLine import io.leostrange.mrcomic.core.ui.locale.LocalStrings +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import io.leostrange.mrcomic.core.ui.locale.audiobookPauseActionLabel import io.leostrange.mrcomic.core.ui.locale.audiobookPlayActionLabel import io.leostrange.mrcomic.core.ui.locale.audiobookStopActionLabel @@ -98,10 +99,19 @@ fun MiniAudiobookPlayer( Box( modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .clip(RoundedCornerShape(6.dp)), contentAlignment = Alignment.Center ) { + val miniFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = miniFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -109,13 +119,6 @@ fun MiniAudiobookPlayer( contentScale = ContentScale.Crop, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)) ) - } else { - Icon( - imageVector = Icons.Default.Headphones, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt index b8679f7f8..4a7b42718 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.feature.library.components.LibraryAchievement import java.text.SimpleDateFormat import java.util.Calendar @@ -88,7 +89,7 @@ internal fun mrComicPresenceText( "ko" -> "작품이 몇 권 더 모이면 이 코너가 제대로 된 읽기 허브가 됩니다." else -> "Добавь несколько тайтлов, и этот уголок превратится в полноценный читательский центр." } - recentComic != null && recentComic.readingProgress in 0.05f..0.98f -> when (language) { + recentComic != null && recentComic.displayReadingProgress() in 0.05f..0.98f -> when (language) { "en" -> "You are mid-run on \"${recentComic.title}\". The shelf is already warmed up for the next session." "ja" -> "「${recentComic.title}」を読み進めています。次の読書セッションの準備はできています。" "zh" -> "你还在读《${recentComic.title}》,书架已经为下一次阅读准备好了。" diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt index 46fb39414..3fcb19fec 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/ComicGridItem.kt @@ -275,8 +275,11 @@ private fun BoxScope.GridCardBadges( if (showCoverTitles) { // Keep the title backing opaque enough to remain readable on artwork. // A transparent gradient lets the title collide with the cover/icon. - val titlePanelColor = MaterialTheme.colorScheme.surface.copy( - alpha = (0.92f + titlePanelOpacity.coerceIn(0f, 1f) * 0.08f).coerceIn(0.92f, 1f) + // The title must sit on its own opaque paper surface. Blending it with + // the artwork made filenames unreadable and visually merged the card + // metadata with the cover. + val titlePanelColor = MaterialTheme.colorScheme.surfaceContainerLowest.copy( + alpha = 0.96f + titlePanelOpacity.coerceIn(0f, 1f) * 0.04f ) val scaledFontSize = 12.sp * titleScale.coerceIn(0.85f, 1.3f) Column( @@ -323,7 +326,7 @@ private fun BoxScope.GridCardBadges( // BUG-B1: white surface background instead of Info tone gray. MrComicStatusBadge( text = "${(comic.displayReadingProgress() * 100).toInt()}%", - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, contentColor = MaterialTheme.colorScheme.onSurface ) } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt index 084917b9a..da61420f7 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material3.Icon @@ -95,6 +94,7 @@ internal fun BoxScope.ComicCoverTreatment( comic: Comic, shape: RoundedCornerShape, graphicCoverStyle: String = "POSTER", + showCompletedFold: Boolean = true, modifier: Modifier = Modifier ) { val normalizedGraphicStyle = normalizeLibraryGraphicCoverStyle(graphicCoverStyle) @@ -118,7 +118,7 @@ internal fun BoxScope.ComicCoverTreatment( shape = shape ) ) - if (comic.isReadCompleted()) { + if (showCompletedFold && comic.isReadCompleted()) { Surface( modifier = Modifier .align(Alignment.TopEnd) @@ -185,7 +185,7 @@ internal fun BoxScope.ComicCoverTreatment( ) ) } - if (comic.isReadCompleted()) { + if (showCompletedFold && comic.isReadCompleted()) { Surface( modifier = Modifier .align(Alignment.TopEnd) @@ -608,8 +608,22 @@ internal fun CoverArt( modifier: Modifier = Modifier, emptyGraphic: Boolean = false ) { - Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) { + val fallbackKind = if (emptyGraphic) { + LibraryFallbackCoverKind.GRAPHIC + } else { + LibraryFallbackCoverKind.BOOK + } + Box(modifier = modifier) { if (coverPath != null) { + // Crossfade is enabled globally on the shared Coil ImageLoader + // (ComicApplication.newImageLoader, 180ms): covers fade in instead of + // popping in. The fallback cover painted underneath doubles as a + // neutral placeholder during the fade-in so there is no white flash. + LibraryFallbackCover( + title = title, + kind = fallbackKind, + modifier = Modifier.fillMaxSize(), + ) AsyncImage( model = rememberCoverModel(coverPath), contentDescription = title, @@ -617,11 +631,10 @@ internal fun CoverArt( modifier = Modifier.fillMaxSize() ) } else { - Icon( - Icons.AutoMirrored.Filled.MenuBook, - contentDescription = title, - modifier = Modifier.align(Alignment.Center).size(if (emptyGraphic) 44.dp else 40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + LibraryFallbackCover( + title = title, + kind = fallbackKind, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt new file mode 100644 index 000000000..a8e42b8cb --- /dev/null +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt @@ -0,0 +1,438 @@ +package io.leostrange.mrcomic.feature.library.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.abs + +/** + * Kind of fallback cover to render. Each kind picks a distinct visual recipe + * (icon, label shape, accent) so library shelves remain readable when the + * source file has no embedded artwork. + */ +enum class LibraryFallbackCoverKind { + /** A regular book or text document. */ + BOOK, + + /** A single audiobook file (mp3/m4b/ogg/...). */ + AUDIO_FILE, + + /** A folder audiobook (multiple chapters). */ + AUDIO_FOLDER, + + /** A folder of mixed content (books, graphic volumes, etc.). */ + FOLDER, + + /** A graphic volume (comic, manga, PDF) without a cover. */ + GRAPHIC, +} + +/** + * Visual recipe for a fallback cover. The helper is pure so it can be unit + * tested without a Compose runtime; the Composable consumes it via + * [rememberLibraryFallbackCoverSpec]. + */ +data class LibraryFallbackCoverSpec( + val seedHue: Float, + val accent: Color, + val highlight: Color, + val deep: Color, + val letter: String, + val showLetter: Boolean, +) + +/** + * Build a deterministic visual spec from a title and a [LibraryFallbackCoverKind]. + * + * - [accent] / [highlight] / [deep] are derived from the active theme so the + * fallback stays on-brand across light/dark/AMOLED variants. + * - [seedHue] is a stable angle (0..360) used to position the gradient + * highlights so two cards with the same title look identical. + * - [letter] is the first non-trivial grapheme of the title. Cyrillic, + * CJK, digits and emoji are all supported — the helper trims emoji + * variation selectors and zero-width joiners. + */ +fun buildLibraryFallbackCoverSpec( + title: String, + kind: LibraryFallbackCoverKind, + primary: Color, + secondary: Color, + tertiary: Color, + onSurface: Color, +): LibraryFallbackCoverSpec { + val seedHue = titleFallbackSeedHue(title, kind) + val palette = fallbackCoverPaletteForKind( + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = seedHue, + ) + return LibraryFallbackCoverSpec( + seedHue = seedHue, + accent = palette.accent, + highlight = palette.highlight, + deep = palette.deep, + letter = firstTitleGrapheme(title, kind), + showLetter = kind != LibraryFallbackCoverKind.FOLDER, + ) +} + +internal data class FallbackCoverPalette( + val accent: Color, + val highlight: Color, + val deep: Color, +) + +internal fun fallbackCoverPaletteForKind( + kind: LibraryFallbackCoverKind, + primary: Color, + secondary: Color, + tertiary: Color, + seedHue: Float, +): FallbackCoverPalette { + val baseColor = when (kind) { + LibraryFallbackCoverKind.BOOK -> primary + LibraryFallbackCoverKind.GRAPHIC -> primary + LibraryFallbackCoverKind.AUDIO_FILE -> secondary + LibraryFallbackCoverKind.AUDIO_FOLDER -> secondary + LibraryFallbackCoverKind.FOLDER -> tertiary + } + // 12% of hue drift on the base keeps same-title cards stable while still + // giving a different look across distinct titles. The shift is wrapped so + // negative values stay in 0..1 range. + val hueShift = (((seedHue - 180f) / 720f) + 1f) % 1f + val hueShifted = baseColor.copy( + alpha = 1f, + red = (baseColor.red + hueShift * 0.08f).coerceIn(0f, 1f), + green = (baseColor.green + hueShift * 0.05f).coerceIn(0f, 1f), + blue = (baseColor.blue + hueShift * 0.06f).coerceIn(0f, 1f), + ) + val isLightSurface = hueShifted.luminance() > 0.55f + val highlight = lerp(hueShifted, Color.White, if (isLightSurface) 0.22f else 0.34f) + val deep = lerp(hueShifted, Color.Black, if (isLightSurface) 0.42f else 0.18f) + return FallbackCoverPalette( + accent = hueShifted, + highlight = highlight, + deep = deep, + ) +} + +internal fun titleFallbackSeedHue(title: String, kind: LibraryFallbackCoverKind): Float { + if (title.isBlank()) return kind.ordinal * 67f + var hash = 0 + var saltShift = kind.ordinal * 31 + for (codePoint in title.codePoints()) { + hash = (hash * 131 + codePoint + saltShift) xor (hash ushr 13) + saltShift = (saltShift + 17) and 0x3F + } + val normalized = abs(hash) % 36_000 + return (normalized / 100f) % 360f +} + +/** + * Pick the first meaningful grapheme of [title] for the monogram. + * + * Strips: + * - leading whitespace + * - common "The / A / An" English articles + * - leading list markers ("- ", "— ") + * - emoji variation selectors and zero-width joiners + * + * Returns "•" for blank titles so callers always have a renderable string. + */ +internal fun firstTitleGrapheme(title: String, kind: LibraryFallbackCoverKind): String { + val cleaned = title.trim() + if (cleaned.isEmpty()) return defaultFallbackLetter(kind) + // Skip leading invisible/formatting code points: emoji variation selector + // (0xFE0F), ZWJ (0x200D) and ZWSP (0x200B/0x200C). After skipping, if the + // string is empty, fall back to the kind-specific placeholder. + var offset = 0 + while (offset < cleaned.length) { + val cp = cleaned.codePointAt(offset) + val isSkippable = cp == 0xFE0F.toInt() || + cp == 0x200D.toInt() || + cp == 0x200B.toInt() || + cp == 0x200C.toInt() + if (!isSkippable) break + offset += Character.charCount(cp) + } + if (offset >= cleaned.length) return defaultFallbackLetter(kind) + val trimmed = cleaned.substring(offset) + val firstCodePoint = trimmed.codePointAt(0) + // Strip leading articles in any language we know about. Longer articles + // are checked first so that "An Apple" doesn't match the single-letter + // "a " article. + val lower = trimmed.lowercase() + val articles = listOf( + "the ", "an ", + "der ", "die ", "das ", + "le ", "la ", "les ", + "el ", "los ", "las ", + "a ", + ) + for (article in articles) { + if (lower.startsWith(article) && lower.length > article.length) { + return trimmed.substring(article.length, article.length + 1).uppercase() + } + } + // Strip common list markers. + val listMarkers = listOf("- ", "— ", "* ", "· ", "• ") + for (marker in listMarkers) { + if (trimmed.startsWith(marker) && trimmed.length > marker.length) { + return trimmed.substring(marker.length, marker.length + 1).uppercase() + } + } + return String(Character.toChars(firstCodePoint)).uppercase() +} + +private fun defaultFallbackLetter(kind: LibraryFallbackCoverKind): String = when (kind) { + LibraryFallbackCoverKind.BOOK -> "B" + LibraryFallbackCoverKind.GRAPHIC -> "G" + LibraryFallbackCoverKind.AUDIO_FILE -> "♪" + LibraryFallbackCoverKind.AUDIO_FOLDER -> "♫" + LibraryFallbackCoverKind.FOLDER -> "•" +} + +/** + * Stable spec for the active theme. Compose callers should always go through + * this helper to keep the recipe in sync with [buildLibraryFallbackCoverSpec]. + */ +@Composable +fun rememberLibraryFallbackCoverSpec( + title: String, + kind: LibraryFallbackCoverKind, +): LibraryFallbackCoverSpec { + val primary = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + val tertiary = MaterialTheme.colorScheme.tertiary + val onSurface = MaterialTheme.colorScheme.onSurface + return remember(title, kind, primary, secondary, tertiary, onSurface) { + buildLibraryFallbackCoverSpec( + title = title, + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + } +} + +/** + * Drop-in fallback cover for any library card whose source file has no + * embedded artwork. Renders a soft diagonal gradient with a monogram letter + * and a small type icon, both themed to the current palette. + */ +@Composable +fun LibraryFallbackCover( + title: String, + kind: LibraryFallbackCoverKind, + modifier: Modifier = Modifier, + shape: Shape? = null, + showIcon: Boolean = true, + letterFontSize: TextUnit = 28.sp, + iconSize: Dp = 18.dp, +) { + val spec = rememberLibraryFallbackCoverSpec(title = title, kind = kind) + val onAccent = if (spec.accent.luminance() > 0.55f) Color.Black else Color.White + val baseModifier = if (shape != null) modifier.clip(shape) else modifier + Box(modifier = baseModifier.background(spec.deep)) { + FallbackCoverBackdrop( + accent = spec.accent, + highlight = spec.highlight, + seedHue = spec.seedHue, + modifier = Modifier.fillMaxSize(), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (showIcon) { + Icon( + imageVector = fallbackCoverIcon(kind), + contentDescription = null, + tint = onAccent.copy(alpha = 0.78f), + modifier = Modifier.size(iconSize), + ) + } else { + Box(modifier = Modifier.size(iconSize)) + } + if (spec.showLetter) { + Text( + text = spec.letter, + color = onAccent.copy(alpha = 0.94f), + fontWeight = FontWeight.Black, + fontSize = letterFontSize, + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Visible, + ) + } + } + } +} + +/** + * Backwards-compatible convenience wrapper that renders a folder-style + * cover (icon only, no monogram). The legacy call sites use this shape + * for LibraryFolderItem and audiobook folder covers. + */ +@Composable +fun LibraryFallbackFolderCover( + title: String, + kind: LibraryFallbackCoverKind = LibraryFallbackCoverKind.FOLDER, + modifier: Modifier = Modifier, + shape: Shape? = null, +) { + LibraryFallbackCover( + title = title, + kind = kind, + modifier = modifier, + shape = shape, + showIcon = true, + letterFontSize = 18.sp, + iconSize = 28.dp, + ) +} + +/** + * Small badge-style fallback (used in the mini-player and audio list rows). + */ +@Composable +fun LibraryFallbackCoverBadge( + title: String, + kind: LibraryFallbackCoverKind, + modifier: Modifier = Modifier, + shape: Shape? = null, + iconSize: Dp = 18.dp, + showLetter: Boolean = false, +) { + val spec = rememberLibraryFallbackCoverSpec(title = title, kind = kind) + val onAccent = if (spec.accent.luminance() > 0.55f) Color.Black else Color.White + val baseModifier = if (shape != null) modifier.clip(shape) else modifier + Box(modifier = baseModifier.background(spec.accent), contentAlignment = Alignment.Center) { + Icon( + imageVector = fallbackCoverIcon(kind), + contentDescription = null, + tint = onAccent.copy(alpha = 0.92f), + modifier = Modifier.size(iconSize), + ) + if (showLetter) { + Text( + text = spec.letter, + color = onAccent.copy(alpha = 0.96f), + fontWeight = FontWeight.Black, + fontSize = 16.sp, + maxLines = 1, + ) + } + } +} + +internal fun fallbackCoverIcon(kind: LibraryFallbackCoverKind) = when (kind) { + LibraryFallbackCoverKind.BOOK, + LibraryFallbackCoverKind.GRAPHIC -> Icons.AutoMirrored.Filled.MenuBook + LibraryFallbackCoverKind.AUDIO_FILE -> Icons.Filled.Headphones + LibraryFallbackCoverKind.AUDIO_FOLDER -> Icons.Filled.LibraryMusic + LibraryFallbackCoverKind.FOLDER -> Icons.Filled.FolderOpen +} + +@Composable +private fun BoxScope.FallbackCoverBackdrop( + accent: Color, + highlight: Color, + seedHue: Float, + modifier: Modifier = Modifier, +) { + val angle = (seedHue / 360f) * (Math.PI * 2).toFloat() + val centerX = 0.5f + kotlin.math.cos(angle) * 0.18f + val centerY = 0.5f + kotlin.math.sin(angle) * 0.18f + val highlightCenter = Offset( + x = (0.5f + kotlin.math.cos(angle + Math.PI.toFloat()) * 0.32f).coerceIn(0f, 1f), + y = (0.5f + kotlin.math.sin(angle + Math.PI.toFloat()) * 0.32f).coerceIn(0f, 1f), + ) + Canvas(modifier = modifier) { + // Diagonal wash. + drawRect( + brush = Brush.linearGradient( + colors = listOf(highlight, accent), + start = Offset(size.width * 0.05f, size.height * 0.05f), + end = Offset(size.width * 0.95f, size.height * 0.95f), + ), + ) + // Soft accent blob in the upper third. + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + highlight.copy(alpha = 0.62f), + accent.copy(alpha = 0.18f), + Color.Transparent, + ), + center = Offset(size.width * centerX, size.height * centerY), + radius = size.maxDimension * 0.72f, + ), + radius = size.maxDimension * 0.72f, + center = Offset(size.width * centerX, size.height * centerY), + ) + // Bottom shadow to keep letters readable. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.18f), + ), + startY = size.height * 0.55f, + endY = size.height, + ), + topLeft = Offset(0f, size.height * 0.55f), + size = androidx.compose.ui.geometry.Size(size.width, size.height * 0.45f), + ) + // Sparkle highlight in the opposite quadrant. + drawCircle( + color = Color.White.copy(alpha = 0.18f), + radius = size.minDimension * 0.06f, + center = Offset(size.width * highlightCenter.x, size.height * highlightCenter.y), + ) + } +} + +internal fun DrawScope.drawFallbackCoverDebugOverlay() { + // Reserved for future visual debug overlays; intentionally empty. +} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt index 2bdef0733..5126b4922 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt @@ -13,7 +13,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.ViewList -import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Crop169 import androidx.compose.material.icons.filled.CropSquare import androidx.compose.material.icons.filled.FolderOpen @@ -34,10 +33,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import io.leostrange.mrcomic.core.ui.designsystem.MrComicTypographyTokens +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButtonVariant import io.leostrange.mrcomic.core.model.SortOrder @@ -69,7 +67,6 @@ fun LibraryTopBar( onThumbnailModeChange: (String) -> Unit, onAddFileClick: () -> Unit, onAddFolderClick: () -> Unit, - onOpdsCatalogClick: (() -> Unit)? = null, canNavigateUp: Boolean, onNavigateUp: () -> Unit, onSettingsClick: () -> Unit @@ -102,11 +99,7 @@ fun LibraryTopBar( LibraryContentSection.ACHIEVEMENTS -> strings.libraryAchievements else -> strings.navLibrary }, - style = MaterialTheme.typography.displaySmall.copy( - fontSize = MrComicTypographyTokens.libraryTitle, - fontWeight = FontWeight.Black, - letterSpacing = MrComicTypographyTokens.LetterSpacing.display - ), + style = MrComicType.h1, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -225,17 +218,6 @@ fun LibraryTopBar( ) } } - if (onOpdsCatalogClick != null) { - MrComicIconButton( - onClick = onOpdsCatalogClick, - variant = MrComicIconButtonVariant.Tonal - ) { - Icon( - Icons.Default.CloudDownload, - contentDescription = strings.opdsCatalog - ) - } - } } } MrComicIconButton( diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt deleted file mode 100644 index 8bae8a3e2..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt +++ /dev/null @@ -1,241 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import android.util.Log -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File - -/** - * OPDS catalog browsing, search and book download (4.2 slice 4). - * - * Extracted from OpdsCatalogViewModel into an explicit-dependency controller - * (delegate-controller pattern, AGENTS.md): the ViewModel stays the single - * owner of state and lifecycle; this controller needs only the repository, - * the scope and the UI state flow. - */ -internal class OpdsCatalogController( - private val opdsRepository: OpdsRepository, - private val scope: CoroutineScope, - private val uiState: MutableStateFlow, -) { - - companion object { - private const val TAG = "OpdsCatalogViewModel" - } - - private var feedRequestJob: Job? = null - - init { - uiState.update { it.copy(catalogs = opdsRepository.defaultCatalogs) } - } - - /** Open a catalog source. */ - fun openCatalog(source: OpdsCatalogSource) { - uiState.update { - it.copy( - showCatalogPicker = false, - currentFeed = null, - feedStack = listOf(source.url), - searchQuery = "", - isSearchMode = false - ) - } - loadFeed(source.url) - } - - /** Navigate to a sub-feed (catalog or next page). */ - fun navigateTo(url: String) { - uiState.update { - it.copy( - showCatalogPicker = false, - isSearchMode = false, - searchQuery = "", - feedStack = it.feedStack + url - ) - } - loadFeed(url) - } - - /** Go back to the previous feed. */ - fun goBack() { - val stack = uiState.value.feedStack - if (stack.size <= 1) { - feedRequestJob?.cancel() - uiState.update { - it.copy( - showCatalogPicker = true, - currentFeed = null, - feedStack = emptyList(), - isLoading = false, - error = null, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - return - } - val newStack = stack.dropLast(1) - uiState.update { - it.copy( - feedStack = newStack, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - loadFeed(newStack.last()) - } - - /** Load the next page of the current feed. */ - fun loadNextPage() { - val nextLink = uiState.value.currentFeed?.nextLink ?: return - navigateTo(nextLink) - } - - /** Start a search. */ - fun search(query: String) { - val feed = uiState.value.currentFeed ?: return - val searchUrl = feed.searchLink ?: return - feedRequestJob?.cancel() - uiState.update { - it.copy( - isSearchMode = true, - searchQuery = query, - isLoading = true, - error = null, - failedDownload = null - ) - } - feedRequestJob = scope.launch { - try { - val result = opdsRepository.search(searchUrl, query) - uiState.update { it.copy(currentFeed = result, isLoading = false) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Search failed: $query", e) - uiState.update { it.copy(isLoading = false, error = e.message ?: "Search failed") } - } - } - } - - /** Retry the currently visible feed or search request. */ - fun retry() { - uiState.value.failedDownload?.let { entry -> - uiState.update { it.copy(error = null, failedDownload = null) } - downloadBook(entry) - return - } - if (uiState.value.isSearchMode && uiState.value.searchQuery.isNotBlank()) { - search(uiState.value.searchQuery) - } else { - uiState.value.feedStack.lastOrNull()?.let(::loadFeed) - } - } - - /** Exit search mode and return to the current catalog. */ - fun exitSearch() { - uiState.update { it.copy(isSearchMode = false, searchQuery = "") } - uiState.value.feedStack.lastOrNull()?.let(::loadFeed) - } - - /** Download a book from an OPDS entry. */ - fun downloadBook(entry: OpdsEntry) { - // Acquisition href is stable per resource and avoids title collisions. - val progressKey = entry.acquisitionLink?.href ?: entry.title - if (progressKey in uiState.value.downloadProgress) return - - scope.launch { - uiState.update { - it.copy( - error = null, - failedDownload = null, - downloadProgress = it.downloadProgress + (progressKey to 0f) - ) - } - try { - val file = opdsRepository.downloadBook(entry) { bytesRead, totalBytes -> - val progress = if (totalBytes > 0) bytesRead.toFloat() / totalBytes else 0f - uiState.update { it.copy(downloadProgress = it.downloadProgress + (progressKey to progress)) } - } - uiState.update { - it.copy( - downloadedBooks = it.downloadedBooks + file, - downloadProgress = it.downloadProgress - progressKey, - failedDownload = null - ) - } - Log.d(TAG, "Downloaded: ${file.name} (${file.length()} bytes)") - } catch (e: CancellationException) { - uiState.update { it.copy(downloadProgress = it.downloadProgress - progressKey) } - throw e - } catch (e: Exception) { - Log.e(TAG, "Download failed: ${entry.title}", e) - uiState.update { - it.copy( - error = "Download failed: ${e.message}", - downloadProgress = it.downloadProgress - progressKey, - failedDownload = entry - ) - } - } - } - } - - /** Clear one queued downloaded book after it has been imported. */ - fun clearDownloadedBook(file: File) { - uiState.update { state -> - state.copy(downloadedBooks = state.downloadedBooks - file) - } - } - - /** Clear the first queued downloaded book after it has been imported. */ - fun clearDownloadedBook() { - uiState.value.downloadedBooks.firstOrNull()?.let(::clearDownloadedBook) - } - - /** Show the catalog picker again. */ - fun showCatalogPicker() { - feedRequestJob?.cancel() - uiState.update { - it.copy( - showCatalogPicker = true, - currentFeed = null, - feedStack = emptyList(), - isLoading = false, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - } - - /** Clear error state. */ - fun clearError() { - uiState.update { it.copy(error = null) } - } - - private fun loadFeed(url: String) { - feedRequestJob?.cancel() - uiState.update { it.copy(isLoading = true, error = null, failedDownload = null) } - feedRequestJob = scope.launch { - try { - val feed = opdsRepository.browse(url) - uiState.update { it.copy(currentFeed = feed, isLoading = false) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Failed to load feed: $url", e) - uiState.update { it.copy(isLoading = false, error = e.message ?: "Unknown error") } - } - } - } -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt deleted file mode 100644 index b7dc5df91..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt +++ /dev/null @@ -1,317 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -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.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.AsyncImage -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.ui.locale.LocalStrings - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OpdsCatalogScreen( - onNavigateBack: () -> Unit, - onBookDownloaded: (java.io.File) -> Unit, - viewModel: OpdsCatalogViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsState() - val strings = LocalStrings.current - - LaunchedEffect(uiState.downloadedBooks) { - uiState.downloadedBooks.firstOrNull()?.let { file -> - onBookDownloaded(file) - viewModel.clearDownloadedBook(file) - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { - if (uiState.isSearchMode) { - Text("${strings.opdsSearch}: ${uiState.searchQuery}") - } else { - Text(uiState.currentFeed?.title ?: strings.opdsCatalogs) - } - }, - navigationIcon = { - IconButton(onClick = { - if (uiState.isSearchMode) viewModel.exitSearch() - else if (!uiState.showCatalogPicker) viewModel.goBack() - else onNavigateBack() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.back) - } - }, - actions = { - if (!uiState.showCatalogPicker && uiState.currentFeed?.searchLink != null) { - var showSearch by remember { mutableStateOf(false) } - var searchText by remember { mutableStateOf("") } - - if (showSearch) { - OutlinedTextField( - value = searchText, - onValueChange = { searchText = it }, - placeholder = { Text(strings.opdsSearchPlaceholder) }, - singleLine = true, - modifier = Modifier.weight(1f), - trailingIcon = { - IconButton(onClick = { - if (searchText.isNotBlank()) viewModel.search(searchText) - showSearch = false - }) { - Icon(Icons.Default.Search, contentDescription = strings.opdsSearch) - } - } - ) - } else { - IconButton(onClick = { showSearch = true }) { - Icon(Icons.Default.Search, contentDescription = strings.opdsSearch) - } - } - } - if (!uiState.showCatalogPicker) { - IconButton(onClick = { viewModel.showCatalogPicker() }) { - Icon(Icons.Default.List, contentDescription = strings.opdsCatalogs) - } - } - } - ) - } - ) { padding -> - Box(modifier = Modifier.padding(padding).fillMaxSize()) { - when { - uiState.isLoading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - uiState.error != null -> { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - Text(uiState.error!!, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.retry() }) { Text(strings.opdsRetry) } - } - } - uiState.showCatalogPicker -> { - CatalogPicker( - catalogs = uiState.catalogs, - onSelect = { viewModel.openCatalog(it) } - ) - } - uiState.currentFeed != null -> { - FeedContent( - feed = uiState.currentFeed!!, - downloadProgress = uiState.downloadProgress, - onEntryClick = { entry -> - when { - entry.isCatalog -> entry.navigationLink?.let { viewModel.navigateTo(it.href) } - entry.isBook -> viewModel.downloadBook(entry) - } - }, - onLoadNextPage = { viewModel.loadNextPage() } - ) - } - } - } - } -} - -private fun OpdsCatalogSource.localized(strings: io.leostrange.mrcomic.core.ui.locale.AppStrings): OpdsCatalogSource = when { - url.contains("gutenberg.org/ebooks.opds") -> copy( - name = strings.opdsProjectGutenberg, - description = strings.opdsProjectGutenbergDescription - ) - url.contains("feedbooks.com/catalog/public_domain") -> copy( - name = strings.opdsFeedbooks, - description = strings.opdsFeedbooksDescription - ) - url.contains("manybooks.net/opds") -> copy( - name = strings.opdsManyBooks, - description = strings.opdsManyBooksDescription - ) - else -> this -} - -@Composable -private fun CatalogPicker( - catalogs: List, - onSelect: (OpdsCatalogSource) -> Unit -) { - val strings = LocalStrings.current - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Text( - strings.opdsCatalogPickerTitle, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - items(catalogs) { catalog -> - val displayCatalog = catalog.localized(strings) - Card( - modifier = Modifier.fillMaxWidth().clickable { onSelect(catalog) } - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text(displayCatalog.name, style = MaterialTheme.typography.titleMedium) - if (displayCatalog.description.isNotBlank()) { - Text( - displayCatalog.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -@Composable -private fun FeedContent( - feed: io.leostrange.mrcomic.core.model.OpdsFeed, - downloadProgress: Map, - onEntryClick: (OpdsEntry) -> Unit, - onLoadNextPage: () -> Unit -) { - val strings = LocalStrings.current - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Show navigation entries first - val navEntries = feed.entries.filter { it.isCatalog } - if (navEntries.isNotEmpty()) { - item { - Text(strings.opdsCategories, style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(bottom = 4.dp)) - } - items(navEntries) { entry -> - NavigationEntryCard(entry = entry, onClick = { onEntryClick(entry) }) - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - - // Show book entries - val bookEntries = feed.entries.filter { it.isBook } - if (bookEntries.isNotEmpty()) { - item { - Text(strings.opdsBooks, style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(bottom = 4.dp)) - } - items(bookEntries) { entry -> - BookEntryCard( - entry = entry, - downloadProgress = downloadProgress[entry.acquisitionLink?.href ?: entry.title], - onClick = { onEntryClick(entry) } - ) - } - } - - // Next page button - if (feed.nextLink != null) { - item { - Button( - onClick = onLoadNextPage, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.ArrowForward, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(strings.opdsLoadMore) - } - } - } - } -} - -@Composable -private fun NavigationEntryCard(entry: OpdsEntry, onClick: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Folder, contentDescription = null, - tint = MaterialTheme.colorScheme.primary) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text(entry.title, style = MaterialTheme.typography.bodyLarge) - entry.summary?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - } - } -} - -@Composable -private fun BookEntryCard( - entry: OpdsEntry, - downloadProgress: Float?, - onClick: () -> Unit -) { - val strings = LocalStrings.current - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) - ) { - Row(modifier = Modifier.padding(12.dp)) { - // Thumbnail - entry.thumbnailUrl?.let { url -> - AsyncImage( - model = url, - contentDescription = entry.title, - modifier = Modifier.size(60.dp, 80.dp).clip(MaterialTheme.shapes.small), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(12.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text(entry.title, style = MaterialTheme.typography.bodyLarge, maxLines = 2) - entry.author?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - entry.summary?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - maxLines = 2, overflow = TextOverflow.Ellipsis) - } - // Download progress - if (downloadProgress != null) { - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { downloadProgress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth() - ) - } - } - // Download icon - if (entry.acquisitionLink != null && downloadProgress == null) { - Icon(Icons.Default.Download, contentDescription = strings.opdsDownload, - tint = MaterialTheme.colorScheme.primary) - } - } - } -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt deleted file mode 100644 index 4cf5f41f2..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import java.io.File - -/** UI state for the OPDS catalog browser. */ -data class OpdsCatalogUiState( - val catalogs: List = emptyList(), - val currentFeed: OpdsFeed? = null, - val feedStack: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null, - val searchQuery: String = "", - val isSearchMode: Boolean = false, - val downloadProgress: Map = emptyMap(), - val downloadedBooks: List = emptyList(), - val failedDownload: OpdsEntry? = null, - val showCatalogPicker: Boolean = true -) { - /** Backwards-compatible view of the first queued download result. */ - val downloadedBook: File? - get() = downloadedBooks.firstOrNull() -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt deleted file mode 100644 index 3801b5d75..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.io.File -import javax.inject.Inject - -/** - * OPDS catalog browser state owner (4.2). - * - * Browsing/search/download logic lives in [OpdsCatalogController] (explicit-dependency - * controller, AGENTS.md delegate-controller pattern); this ViewModel only owns the - * state flow, the controller wiring and the public API used by [OpdsCatalogScreen]. - */ -@HiltViewModel -class OpdsCatalogViewModel @Inject constructor( - opdsRepository: OpdsRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(OpdsCatalogUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val controller = OpdsCatalogController( - opdsRepository = opdsRepository, - scope = viewModelScope, - uiState = _uiState, - ) - - /** Open a catalog source. */ - fun openCatalog(source: OpdsCatalogSource) = controller.openCatalog(source) - - /** Navigate to a sub-feed (catalog or next page). */ - fun navigateTo(url: String) = controller.navigateTo(url) - - /** Go back to the previous feed. */ - fun goBack() = controller.goBack() - - /** Load the next page of the current feed. */ - fun loadNextPage() = controller.loadNextPage() - - /** Start a search. */ - fun search(query: String) = controller.search(query) - - /** Retry the currently visible feed or search request. */ - fun retry() = controller.retry() - - /** Exit search mode and return to the current catalog. */ - fun exitSearch() = controller.exitSearch() - - /** Download a book from an OPDS entry. */ - fun downloadBook(entry: OpdsEntry) = controller.downloadBook(entry) - - /** Clear one queued downloaded book after it has been imported. */ - fun clearDownloadedBook(file: File) = controller.clearDownloadedBook(file) - - /** Clear the first queued downloaded book after it has been imported. */ - fun clearDownloadedBook() = controller.clearDownloadedBook() - - /** Show the catalog picker again. */ - fun showCatalogPicker() = controller.showCatalogPicker() - - /** Clear error state. */ - fun clearError() = controller.clearError() -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt index d7bd93da0..d0ccbca3b 100644 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.library import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.SortOrder import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -143,6 +144,28 @@ class LibraryContentPipelineTest { assertEquals(2, derived.displayItems.size) } + @Test + fun recentlyReadExcludesNewRtfWithUnknownTotalAndStaleProgress() { + // RTF with pageCount=0, readingProgress=1.0 (stale), opened once, + // no real locator → displayReadingProgress=0, isReadingInProgress() depends on + // readingStatus which returns READING (has stable signal). Verify the pipeline + // still includes it in recentlyRead (it is READING) but that display progress is 0. + val rtf = Comic( + id = "rtf-stale", + format = ComicFormat.RTF, + pageCount = 0, + readingProgress = 1f, + lastReadDate = 100L, + ) + val state = LibraryUiState() + + val derived = pipeline.derive(state, listOf(rtf), emptyList(), listOf(rtf)) + + // The RTF book is considered READING (has lastReadDate), so it appears in recentlyRead. + assertEquals(1, derived.recentlyRead.size) + assertEquals(1, derived.readingComicCount) + } + @Test fun recentlyReadIsLimitedToTen() { val comics = (1..12).map { comic("c$it", isReading = true, lastReadDate = it.toLong()) } diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt index e9c54625d..da58d84b8 100644 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt @@ -184,6 +184,12 @@ class LibraryStatelessHelpersTest { assertEquals(2, folderA.fileCount) assertEquals(2, folderA.subfolderCount) assertEquals(500, folderA.totalSize) + // pageCount=0 (default) → readingProgressForPage returns 0 → fallback to stored readingProgress. + // f1: readingProgress=1.0, lastReadDate=1 → READING → displayReadingProgress=1.0 + // f2: readingProgress=0.0, lastReadDate=1 → READING → displayReadingProgress=0.0 + // f3: readingProgress=0.5, lastReadDate=1 → READING → displayReadingProgress=0.5 + // f4: readingProgress=0.5, lastReadDate=1 → READING → displayReadingProgress=0.5 + // Average of descendants (f1,f2,f3,f4) = (1.0+0.0+0.5+0.5)/4 = 0.5 assertEquals(0.5f, folderA.progress, 0.001f) assertEquals("a", folderA.title) } diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt new file mode 100644 index 000000000..6ac2a5146 --- /dev/null +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt @@ -0,0 +1,315 @@ +package io.leostrange.mrcomic.feature.library.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryFallbackCoverTest { + + private val primary = Color(0xFF3A6EA5) + private val secondary = Color(0xFFB58463) + private val tertiary = Color(0xFF5E8C61) + private val onSurface = Color(0xFF1F1F1F) + + // --- firstTitleGrapheme --- + + @Test + fun firstTitleGraphemeReturnsUppercaseForPlainAscii() { + assertEquals("H", firstTitleGrapheme("hello world", LibraryFallbackCoverKind.BOOK)) + assertEquals("M", firstTitleGrapheme("mr. comic", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingEnglishArticle() { + assertEquals("O", firstTitleGrapheme("The Old Man and the Sea", LibraryFallbackCoverKind.BOOK)) + assertEquals("A", firstTitleGrapheme("An Apple", LibraryFallbackCoverKind.BOOK)) + assertEquals("S", firstTitleGrapheme("A Short Story", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingGermanArticle() { + assertEquals("Z", firstTitleGrapheme("Der Zauberberg", LibraryFallbackCoverKind.BOOK)) + assertEquals("S", firstTitleGrapheme("Die Schatzinsel", LibraryFallbackCoverKind.BOOK)) + assertEquals("K", firstTitleGrapheme("Das Kapital", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingFrenchArticle() { + assertEquals("M", firstTitleGrapheme("Le Misanthrope", LibraryFallbackCoverKind.BOOK)) + assertEquals("P", firstTitleGrapheme("La Peste", LibraryFallbackCoverKind.BOOK)) + assertEquals("M", firstTitleGrapheme("Les Misérables", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingSpanishArticle() { + assertEquals("Q", firstTitleGrapheme("El Quixote", LibraryFallbackCoverKind.BOOK)) + assertEquals("T", firstTitleGrapheme("Los Tres", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemePreservesCyrillic() { + // Russian titles: "Братья Карамазовы", "Война и мир" + assertEquals("Б", firstTitleGrapheme("Братья Карамазовы", LibraryFallbackCoverKind.BOOK)) + assertEquals("В", firstTitleGrapheme("Война и мир", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemePreservesCjk() { + // Chinese: 红楼梦 + assertEquals("红", firstTitleGrapheme("红楼梦", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsListMarkers() { + assertEquals("A", firstTitleGrapheme("- Alpha", LibraryFallbackCoverKind.BOOK)) + assertEquals("B", firstTitleGrapheme("— Beta", LibraryFallbackCoverKind.BOOK)) + assertEquals("G", firstTitleGrapheme("· Gamma", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsEmojiVariationSelector() { + // 0xFE0F should be skipped, returning the actual letter + assertEquals("A", firstTitleGrapheme("️A", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeFallsBackForBlankTitle() { + assertEquals("B", firstTitleGrapheme("", LibraryFallbackCoverKind.BOOK)) + assertEquals("G", firstTitleGrapheme(" ", LibraryFallbackCoverKind.GRAPHIC)) + assertEquals("♪", firstTitleGrapheme("", LibraryFallbackCoverKind.AUDIO_FILE)) + assertEquals("♫", firstTitleGrapheme("", LibraryFallbackCoverKind.AUDIO_FOLDER)) + assertEquals("•", firstTitleGrapheme("", LibraryFallbackCoverKind.FOLDER)) + } + + @Test + fun firstTitleGraphemeFallbackForKindFollowsSemantics() { + // Sanity: each kind has a different blank-title fallback so cards + // remain visually distinct even when titles are missing. + val defaults = LibraryFallbackCoverKind.values() + .associateWith { firstTitleGrapheme("", it) } + assertEquals(5, defaults.size) + assertTrue(defaults.values.distinct().size >= 4) + } + + @Test + fun firstTitleGraphemeHandlesArticleWhereRemovingItWouldLeaveBlank() { + // If the title is just "The " (article only), we should still return the + // uppercase "T" of the article rather than a placeholder. + assertEquals("T", firstTitleGrapheme("The ", LibraryFallbackCoverKind.BOOK)) + } + + // --- buildLibraryFallbackCoverSpec --- + + @Test + fun buildSpecIsDeterministicForSameTitleAndKind() { + val a = buildLibraryFallbackCoverSpec( + title = "Война и мир", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val b = buildLibraryFallbackCoverSpec( + title = "Война и мир", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(a.seedHue, b.seedHue, 0f) + assertEquals(a.letter, b.letter) + assertEquals(a.accent.value.toLong(), b.accent.value.toLong()) + assertEquals(a.highlight.value.toLong(), b.highlight.value.toLong()) + assertEquals(a.deep.value.toLong(), b.deep.value.toLong()) + assertEquals(a.showLetter, b.showLetter) + } + + @Test + fun buildSpecDiffersAcrossDistinctTitles() { + val a = buildLibraryFallbackCoverSpec( + title = "Alpha", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val b = buildLibraryFallbackCoverSpec( + title = "Zulu", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + // Different titles should land on different seed hues (no collisions). + assertNotEquals(a.seedHue, b.seedHue) + } + + @Test + fun buildSpecPicksBaseColorFromKind() { + val bookSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val audioSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.AUDIO_FILE, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val folderSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.FOLDER, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + // Kinds drive the base color. If two specs resolve to the same accent + // (after deterministic hue shift), the test would still differentiate + // them by the same accent but different letter/shows. Here we make + // sure the seeds are different so cards of different kinds are stable. + assertNotEquals(bookSpec.seedHue, audioSpec.seedHue) + assertNotEquals(audioSpec.seedHue, folderSpec.seedHue) + } + + @Test + fun buildSpecHidesLetterForFolders() { + val spec = buildLibraryFallbackCoverSpec( + title = "Manga collection", + kind = LibraryFallbackCoverKind.FOLDER, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(false, spec.showLetter) + } + + @Test + fun buildSpecShowsLetterForBooks() { + val spec = buildLibraryFallbackCoverSpec( + title = "Manga collection", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(true, spec.showLetter) + // Letter comes from the cleaned title (first grapheme of "Manga" -> "M"). + assertEquals("M", spec.letter) + } + + @Test + fun buildSpecStripsLeadingTheAndUppercasesFirst() { + val spec = buildLibraryFallbackCoverSpec( + title = "The Hobbit", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals("H", spec.letter) + } + + // --- fallbackCoverPaletteForKind --- + + @Test + fun paletteForKindStaysBoundedBetweenBlackAndWhite() { + LibraryFallbackCoverKind.values().forEach { kind -> + val palette = fallbackCoverPaletteForKind( + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 123.4f, + ) + assertTrue( + "highlight must be <= 0.99 luminance for $kind", + palette.highlight.luminance() <= 0.99f + ) + assertTrue( + "deep must be >= 0.0 luminance for $kind", + palette.deep.luminance() >= 0f + ) + assertTrue( + "accent must be >= 0.0 luminance for $kind", + palette.accent.luminance() >= 0f + ) + } + } + + @Test + fun paletteForKindIsDeterministic() { + val a = fallbackCoverPaletteForKind( + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 42f, + ) + val b = fallbackCoverPaletteForKind( + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 42f, + ) + assertEquals(a.accent.value.toLong(), b.accent.value.toLong()) + assertEquals(a.highlight.value.toLong(), b.highlight.value.toLong()) + assertEquals(a.deep.value.toLong(), b.deep.value.toLong()) + } + + // --- titleFallbackSeedHue --- + + @Test + fun seedHueIsWithin0to360ForLongTitles() { + val title = "Some very long title ".repeat(50) + for (kind in LibraryFallbackCoverKind.values()) { + val hue = titleFallbackSeedHue(title, kind) + assertTrue("seedHue=$hue out of range for $kind", hue in 0f..360f) + } + } + + @Test + fun seedHueIsWithin0to360ForEmptyTitle() { + for (kind in LibraryFallbackCoverKind.values()) { + val hue = titleFallbackSeedHue("", kind) + assertTrue("seedHue=$hue out of range for $kind", hue in 0f..360f) + } + } + + @Test + fun seedHueIsStableAcrossInvocations() { + val a = titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.BOOK) + val b = titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.BOOK) + assertEquals(a, b, 0f) + } + + @Test + fun seedHueDiffersAcrossKindsForSameTitle() { + val hues = LibraryFallbackCoverKind.values() + .map { titleFallbackSeedHue("The Return of the King", it) } + assertEquals(5, hues.size) + // We don't require every pair to differ, but the kind signal should + // introduce at least some spread (e.g. audio vs folder diverge). + assertNotEquals( + titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.AUDIO_FILE), + titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.FOLDER) + ) + } +} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt deleted file mode 100644 index b77dcf428..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.leostrange.mrcomic.feature.library.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import io.leostrange.mrcomic.core.model.SortOrder -import io.leostrange.mrcomic.core.ui.locale.LocalStrings -import io.leostrange.mrcomic.core.ui.locale.appStringsForCode -import io.leostrange.mrcomic.feature.library.GroupByMode -import io.leostrange.mrcomic.feature.library.LibraryContentSection -import io.leostrange.mrcomic.feature.library.LibraryFormatFilter -import io.leostrange.mrcomic.feature.library.LibraryStatusFilter -import io.leostrange.mrcomic.feature.library.LibraryViewMode -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [35]) -class LibraryTopBarOpdsTest { - - @get:Rule - val composeRule = createComposeRule() - - private val strings = appStringsForCode("en") - - private fun setTopBar(onOpdsCatalogClick: () -> Unit) { - composeRule.setContent { - CompositionLocalProvider(LocalStrings provides strings) { - MaterialTheme { - Box { - LibraryTopBar( - contentSection = LibraryContentSection.FILES, - isControlsExpanded = true, - sortOrder = SortOrder.DATE_ADDED_DESC, - statusFilter = LibraryStatusFilter.ALL, - formatFilter = LibraryFormatFilter.ALL, - groupByMode = GroupByMode.FOLDER, - thumbnailMode = "RECTANGLE", - viewMode = LibraryViewMode.GRID, - onToggleControls = {}, - onToggleView = {}, - onOpenFilters = {}, - onThumbnailModeChange = {}, - onAddFileClick = {}, - onAddFolderClick = {}, - onOpdsCatalogClick = onOpdsCatalogClick, - canNavigateUp = false, - onNavigateUp = {}, - onSettingsClick = {} - ) - } - } - } - } - } - - @Test - fun opdsIsNotRenderedInsideLocalImportMenu() { - setTopBar {} - - composeRule.onNodeWithContentDescription(strings.actionFolder).performClick() - composeRule.onNodeWithText(strings.actionFile).assertIsDisplayed() - composeRule.onNodeWithText(strings.actionFolder).assertIsDisplayed() - composeRule.onNodeWithText(strings.opdsCatalog).assertDoesNotExist() - } - - @Test - fun opdsCloudActionNavigatesFromTopBar() { - var clicks = 0 - setTopBar { clicks++ } - - composeRule.onNodeWithContentDescription(strings.opdsCatalog).performClick() - composeRule.waitForIdle() - - assertEquals(1, clicks) - } -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt deleted file mode 100644 index 8e6ab8a7b..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt +++ /dev/null @@ -1,289 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import android.util.Log -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File - -@OptIn(ExperimentalCoroutinesApi::class) -class OpdsCatalogControllerTest { - - private val opdsRepository = mockk(relaxed = true) - - @Before - fun setUp() { - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 - every { Log.e(any(), any(), any()) } returns 0 - every { Log.d(any(), any()) } returns 0 - every { Log.d(any(), any(), any()) } returns 0 - } - - @After - fun tearDown() { - unmockkStatic(Log::class) - } - - private fun createController( - scope: CoroutineScope, - uiState: MutableStateFlow, - ) = OpdsCatalogController( - opdsRepository = opdsRepository, - scope = scope, - uiState = uiState, - ) - - private fun feed( - title: String = "Feed", - entries: List = emptyList(), - nextLink: String? = null, - searchLink: String? = null, - ) = OpdsFeed(title = title, entries = entries, links = emptyList(), nextLink = nextLink, searchLink = searchLink) - - private fun acquisitionEntry(title: String = "Book", href: String = "https://example.com/book.epub") = - OpdsEntry(title = title, links = listOf(OpdsLink(href = href, rel = "http://opds-spec.org/acquisition"))) - - @Test - fun catalogsSeededFromRepositoryDefaults() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - every { opdsRepository.defaultCatalogs } returns listOf(source) - val uiState = MutableStateFlow(OpdsCatalogUiState()) - - createController(scope = this, uiState = uiState) - - assertEquals(listOf(source), uiState.value.catalogs) - } - - @Test - fun openCatalogLoadsFeedAndHidesPicker() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.browse(source.url) } returns feed(title = "Gutenberg") - val controller = createController(scope = this, uiState = uiState) - - controller.openCatalog(source) - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse(source.url) } - assertFalse(uiState.value.showCatalogPicker) - assertEquals(listOf(source.url), uiState.value.feedStack) - assertEquals("Gutenberg", uiState.value.currentFeed?.title) - assertFalse(uiState.value.isLoading) - } - - @Test - fun navigateToAppendsUrlToStackAndLoadsFeed() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(feedStack = listOf("https://example.com/root"))) - coEvery { opdsRepository.browse("https://example.com/sub") } returns feed(title = "Sub") - val controller = createController(scope = this, uiState = uiState) - - controller.navigateTo("https://example.com/sub") - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/sub") } - assertEquals(listOf("https://example.com/root", "https://example.com/sub"), uiState.value.feedStack) - assertEquals("Sub", uiState.value.currentFeed?.title) - } - - @Test - fun goBackPopsStackAndReloadsPreviousFeed() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/a", "https://example.com/b", "https://example.com/c"), - showCatalogPicker = false, - ) - ) - coEvery { opdsRepository.browse("https://example.com/b") } returns feed(title = "B") - val controller = createController(scope = this, uiState = uiState) - - controller.goBack() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/b") } - assertEquals(listOf("https://example.com/a", "https://example.com/b"), uiState.value.feedStack) - assertEquals("B", uiState.value.currentFeed?.title) - assertFalse(uiState.value.showCatalogPicker) - } - - @Test - fun goBackAtRootReturnsToCatalogPicker() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(feedStack = listOf("https://example.com/a"))) - val controller = createController(scope = this, uiState = uiState) - - controller.goBack() - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.browse(any()) } - assertTrue(uiState.value.showCatalogPicker) - assertNull(uiState.value.currentFeed) - assertEquals(emptyList(), uiState.value.feedStack) - } - - @Test - fun loadNextPageNavigatesToCurrentFeedNextLink() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/page1"), - currentFeed = feed(nextLink = "https://example.com/page2"), - ) - ) - coEvery { opdsRepository.browse("https://example.com/page2") } returns feed(title = "Page 2") - val controller = createController(scope = this, uiState = uiState) - - controller.loadNextPage() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/page2") } - assertEquals(listOf("https://example.com/page1", "https://example.com/page2"), uiState.value.feedStack) - } - - @Test - fun loadNextPageWithoutNextLinkIsNoOp() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(feedStack = listOf("https://example.com/a"), currentFeed = feed()) - ) - val controller = createController(scope = this, uiState = uiState) - - controller.loadNextPage() - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.browse(any()) } - assertEquals(listOf("https://example.com/a"), uiState.value.feedStack) - } - - @Test - fun searchUpdatesFeedAndSearchState() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(currentFeed = feed(searchLink = "https://example.com/search?q={searchTerms}")) - ) - coEvery { opdsRepository.search(any(), "harry") } returns feed(title = "Results") - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.search("https://example.com/search?q={searchTerms}", "harry") } - assertTrue(uiState.value.isSearchMode) - assertEquals("harry", uiState.value.searchQuery) - assertEquals("Results", uiState.value.currentFeed?.title) - assertFalse(uiState.value.isLoading) - } - - @Test - fun searchWithoutSearchLinkIsNoOp() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(currentFeed = feed())) - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.search(any(), any()) } - assertFalse(uiState.value.isSearchMode) - } - - @Test - fun searchFailureSetsError() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(currentFeed = feed(searchLink = "https://example.com/search?q={searchTerms}")) - ) - coEvery { opdsRepository.search(any(), any()) } throws RuntimeException("network down") - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - assertFalse(uiState.value.isLoading) - assertEquals("network down", uiState.value.error) - } - - @Test - fun exitSearchClearsModeAndReloadsLastFeed() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/a"), - isSearchMode = true, - searchQuery = "harry", - ) - ) - coEvery { opdsRepository.browse("https://example.com/a") } returns feed(title = "A") - val controller = createController(scope = this, uiState = uiState) - - controller.exitSearch() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/a") } - assertFalse(uiState.value.isSearchMode) - assertEquals("", uiState.value.searchQuery) - assertEquals("A", uiState.value.currentFeed?.title) - } - - @Test - fun downloadBookTracksProgressAndClearsOnSuccess() = runTest { - val entry = acquisitionEntry() - val file = File("/tmp/book.epub") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.downloadBook(eq(entry), any()) } answers { - secondArg<(Long, Long) -> Unit>().invoke(50, 100) - file - } - val controller = createController(scope = this, uiState = uiState) - - controller.downloadBook(entry) - advanceUntilIdle() - - assertEquals(listOf(file), uiState.value.downloadedBooks) - assertFalse(uiState.value.downloadProgress.containsKey(entry.acquisitionLink?.href)) - assertNull(uiState.value.error) - } - - @Test - fun downloadBookFailureSetsErrorAndClearsProgress() = runTest { - val entry = acquisitionEntry(title = "Broken", href = "https://example.com/broken.epub") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.downloadBook(eq(entry), any()) } throws RuntimeException("no space") - val controller = createController(scope = this, uiState = uiState) - - controller.downloadBook(entry) - advanceUntilIdle() - - assertTrue(uiState.value.downloadedBooks.isEmpty()) - assertEquals("Download failed: no space", uiState.value.error) - assertFalse(uiState.value.downloadProgress.containsKey("https://example.com/broken.epub")) - } - - @Test - fun loadFeedFailureSetsError() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.browse(source.url) } throws RuntimeException("timeout") - val controller = createController(scope = this, uiState = uiState) - - controller.openCatalog(source) - advanceUntilIdle() - - assertFalse(uiState.value.isLoading) - assertEquals("timeout", uiState.value.error) - assertNull(uiState.value.currentFeed) - } -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt deleted file mode 100644 index 8ec4900e6..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.File -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class OpdsCatalogViewModelTest { - - private val repository = mockk() - private val mainDispatcher = StandardTestDispatcher() - - @Before - fun setUp() { - Dispatchers.setMain(mainDispatcher) - every { repository.defaultCatalogs } returns emptyList() - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun concurrentDownloadsQueueBothResultsAndKeepProgressIndependent() = runTest { - val firstEntry = bookEntry("same title", "https://example.test/one.epub") - val secondEntry = bookEntry("same title", "https://example.test/two.epub") - val firstFile = File("one.epub") - val secondFile = File("two.epub") - coEvery { repository.downloadBook(any(), any()) } answers { - when (firstArg().acquisitionLink?.href) { - firstEntry.acquisitionLink?.href -> firstFile - else -> secondFile - } - } - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.downloadBook(firstEntry) - viewModel.downloadBook(secondEntry) - advanceUntilIdle() - - assertEquals(listOf(firstFile, secondFile), viewModel.uiState.value.downloadedBooks) - assertTrue(viewModel.uiState.value.downloadProgress.isEmpty()) - } - - @Test - fun retryReloadsFailedCatalog() = runTest { - val source = OpdsCatalogSource("Test", "https://example.test/catalog") - val feed = feed("Recovered") - coEvery { repository.browse(source.url) } throws RuntimeException("offline") andThen feed - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.openCatalog(source) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.error!!.contains("offline")) - - viewModel.retry() - advanceUntilIdle() - - assertEquals(feed, viewModel.uiState.value.currentFeed) - assertNull(viewModel.uiState.value.error) - assertFalse(viewModel.uiState.value.isLoading) - } - - @Test - fun navigatingToNewFeedCancelsStaleRequest() = runTest { - val firstSource = OpdsCatalogSource("First", "https://example.test/first") - val secondSource = OpdsCatalogSource("Second", "https://example.test/second") - val firstResponse = CompletableDeferred() - val secondResponse = CompletableDeferred() - coEvery { repository.browse(firstSource.url) } coAnswers { firstResponse.await() } - coEvery { repository.browse(secondSource.url) } coAnswers { secondResponse.await() } - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.openCatalog(firstSource) - advanceUntilIdle() - viewModel.navigateTo(secondSource.url) - secondResponse.complete(feed("Second feed")) - firstResponse.complete(feed("Stale first feed")) - advanceUntilIdle() - - assertEquals("Second feed", viewModel.uiState.value.currentFeed?.title) - } - - private fun bookEntry(title: String, href: String) = OpdsEntry( - title = title, - links = listOf( - OpdsLink( - href = href, - rel = "http://opds-spec.org/acquisition/open-access", - type = "application/epub+zip" - ) - ) - ) - - private fun feed(title: String) = OpdsFeed( - title = title, - entries = emptyList(), - links = emptyList() - ) -} diff --git a/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt b/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt index a3f46c316..d63e4aa10 100644 --- a/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt +++ b/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt @@ -45,6 +45,7 @@ class OnboardingViewModel @Inject constructor( prefs.set(PreferencesKeys.READER_PAGE_ANIMATION, style.pageAnimation) prefs.set(PreferencesKeys.READER_PAGE_SOUND, false) prefs.set(PreferencesKeys.TEXT_COLOR_SCHEME, style.textColorScheme) + prefs.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, style.textColorScheme) prefs.set(PreferencesKeys.TEXT_FONT_FAMILY, style.fontFamily) prefs.set(PreferencesKeys.TEXT_LINE_HEIGHT, style.lineHeight) prefs.set(PreferencesKeys.TEXT_LETTER_SPACING, style.letterSpacing) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt index 168c96ea5..a134b54d8 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt @@ -10,20 +10,25 @@ package io.leostrange.mrcomic.feature.reader.domain.progress internal class EpubSectionPageCountStore { private val lock = Any() private val counts = mutableMapOf() + private var sessionEstimate: Int? = null fun reset() { synchronized(lock) { counts.clear() + sessionEstimate = null } } fun recordAndSnapshot(sectionIndex: Int, pageCount: Int): Map = synchronized(lock) { if (sectionIndex >= 0 && pageCount > 0) { counts[sectionIndex] = pageCount + if (sessionEstimate == null) sessionEstimate = pageCount } sortedSnapshot() } + fun stableEstimate(): Int? = synchronized(lock) { sessionEstimate } + fun snapshot(): Map = synchronized(lock) { sortedSnapshot() } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt index 3164947e5..89ab948f3 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt @@ -21,12 +21,18 @@ internal object EpubProgressCalculator { * The fractional average is deliberately applied before conversion to Int so the * persisted total stays consistent with the previous reader progress calculation. */ - fun estimatedTotalPages(sectionPageCounts: Map, totalSections: Int): Int { + fun estimatedTotalPages( + sectionPageCounts: Map, + totalSections: Int, + stableEstimateOverride: Int? = null + ): Int { if (sectionPageCounts.isEmpty()) return 0 val orderedCounts = orderedSectionPageCounts(sectionPageCounts) val visitedPages = orderedCounts.values.sum() val visitedSections = orderedCounts.size if (totalSections <= visitedSections) return visitedPages + val estimate = stableEstimateOverride?.coerceAtLeast(1) + if (estimate != null) return estimate * totalSections return visitedPages + stableEstimate(orderedCounts) * (totalSections - visitedSections) } @@ -47,33 +53,56 @@ internal object EpubProgressCalculator { sectionPageCounts: Map, sectionIndex: Int, sectionPageIndex: Int, - totalSections: Int = 0 + totalSections: Int = 0, + stableEstimateOverride: Int? = null ): AccumulatedProgress { if (sectionPageCounts.isEmpty()) return AccumulatedProgress(0, 0) val orderedCounts = orderedSectionPageCounts(sectionPageCounts) val safePageIndex = sectionPageIndex.coerceAtLeast(0) val visitedTotal = orderedCounts.values.sum() - val stableEstimate = stableEstimate(orderedCounts) + val stableEstimate = stableEstimateOverride?.coerceAtLeast(1) + ?: stableEstimate(orderedCounts) var current = 0 for (index in 0 until sectionIndex) { current += orderedCounts[index] ?: stableEstimate } current += safePageIndex + // The effective section count must be at least large enough to cover the + // section we are currently in, even when totalBookSections is still + // provisional (deferred page-count not yet resolved). Without this floor, + // the total can be smaller than current, causing the progress bar to + // show 100% (or even a "page N / N" where N < current after coercion). + val effectiveTotalSections = totalSections.coerceAtLeast(sectionIndex + 1) // Estimate total using the stable baseline for unvisited sections. // This prevents the "floating total" where progress jumps backwards // when a new section loads with more pages than the running average. - val total = if (totalSections > orderedCounts.size) { - val unvisitedSections = totalSections - orderedCounts.size - visitedTotal + stableEstimate * unvisitedSections + val total = if (effectiveTotalSections > orderedCounts.size) { + if (stableEstimateOverride != null) { + stableEstimate * effectiveTotalSections + } else { + val unvisitedSections = effectiveTotalSections - orderedCounts.size + visitedTotal + stableEstimate * unvisitedSections + } } else { visitedTotal } + val isResolved = effectiveTotalSections > 0 && orderedCounts.size >= effectiveTotalSections return AccumulatedProgress( accumulatedTotalPages = total, - accumulatedCurrentPage = current.coerceAtMost(total) + accumulatedCurrentPage = current.coerceAtMost(total), + isResolved = isResolved ) } + /** + * Returns true if all spine sections have measured visual page counts. + */ + fun isResolved(sectionPageCounts: Map, totalSections: Int): Boolean { + if (totalSections <= 0 || sectionPageCounts.isEmpty()) return false + val orderedCounts = orderedSectionPageCounts(sectionPageCounts) + return orderedCounts.size >= totalSections + } + /** * Accumulated absolute page for progress persistence. Sums the visual page counts of * all sections preceding [sectionIndex], then adds the in-section offset. Unvisited @@ -112,7 +141,8 @@ internal object EpubProgressCalculator { data class AccumulatedProgress( val accumulatedTotalPages: Int, - val accumulatedCurrentPage: Int + val accumulatedCurrentPage: Int, + val isResolved: Boolean = false ) /** diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt index c1245277f..e2681cd96 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt @@ -333,6 +333,7 @@ internal fun HtmlPageView( } }, update = { webView -> + webView.setBackgroundColor(bgColor) webView.pagedModeScrollLock = pagedMode webView.onFreeScrollPositionChanged = if (pagedMode) { null @@ -434,8 +435,12 @@ internal fun HtmlPageView( return false; })(); """.trimIndent() - webView.evaluateJavascript(script) { _ -> - onConsumeWebtoonSectionState.value() + webView.evaluateJavascript(script) { rawValue -> + // Keep the cursor pending until the target section exists and + // the runtime confirms that it actually scrolled to it. + if (rawValue?.trim('"') == "true") { + onConsumeWebtoonSectionState.value() + } } } val viewportWidthPx = webView.readerCssViewportWidthPxOrNull() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt index aa3571dfa..0abe07ae3 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt @@ -517,7 +517,13 @@ private fun ReaderAudioSliderRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = title, style = MaterialTheme.typography.bodySmall) + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) Text( text = valueText, style = MaterialTheme.typography.labelSmall, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt index 2c32eadd0..026b25a27 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt @@ -2,11 +2,14 @@ package io.leostrange.mrcomic.feature.reader.ui import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -14,6 +17,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text @@ -26,9 +31,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import kotlin.math.roundToInt /** @@ -138,17 +144,16 @@ internal fun ReaderAutoScrollChromeControls( Column(modifier = Modifier.weight(1f)) { Text( text = "Автопрокрутка", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, + style = MrComicType.h3, ) Text( text = when { isTemporarilyPaused -> "Временно приостановлена" autoScrollEnabled && readingMode == ReadingMode.WEBTOON -> "Плавная прокрутка ленты" - autoScrollEnabled -> "До следующей страницы" + autoScrollEnabled -> "Автопереход по страницам" else -> "Выключена" }, - style = MaterialTheme.typography.labelSmall, + style = MrComicType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -156,11 +161,61 @@ internal fun ReaderAutoScrollChromeControls( Spacer(Modifier.width(8.dp)) Text( text = ReaderAutoScrollPrecision.valueLabel(draftSpeed, readingMode), - style = MaterialTheme.typography.labelMedium, + style = MrComicType.meta, color = MaterialTheme.colorScheme.primary, ) } + Text( + text = if (readingMode == ReadingMode.WEBTOON) { + "Скорость плавной ленты" + } else { + "Задержка между страницами" + }, + style = MrComicType.meta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(30f, 80f, 180f).forEach { preset -> + val selected = ReaderAutoScrollPrecision.normalize(draftSpeed) == preset + OutlinedButton( + onClick = { + val normalized = ReaderAutoScrollPrecision.normalize(preset) + draftSpeed = normalized + onSpeedPreview(normalized) + onSpeedCommit(normalized) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(MrComicCornerScale.md), + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + ) { + Text( + text = when (preset) { + 30f -> "Медленно" + 80f -> "Обычно" + else -> "Быстро" + }, + style = MrComicType.button, + maxLines = 1, + ) + } + } + } + Slider( value = draftSpeed, onValueChange = { rawSpeed -> diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt index a25b70881..48962f4d7 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt @@ -29,15 +29,7 @@ internal fun readerAutoScrollDockHeightDp( containerKind: ReaderContainerKind, chromeHidden: Boolean, enabled: Boolean, -): Int = if ( - chromeHidden && enabled && - (containerKind == ReaderContainerKind.TEXT_PAGE || - containerKind == ReaderContainerKind.RASTER_PAGE) -) { - 72 -} else { - 0 -} +): Int = 0 internal fun requestReaderAutoPageAdvance( containerKind: ReaderContainerKind, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt index 3f5f8d5be..6a38d6a3c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt @@ -6,6 +6,7 @@ import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsEvent import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsTracker import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderNavigationProgressSource import io.leostrange.mrcomic.engine.api.BookSession import io.leostrange.mrcomic.engine.api.FormatReader import io.leostrange.mrcomic.engine.api.RenderDeviceTier @@ -145,6 +146,8 @@ internal class ReaderBookOpeningController( loadInitialPages(comic, prepared, activeReader, config) scheduleDeferredPageCountIfNeeded(comic, activeReader, prepared, config, requestToken) schedulePostOpenTasks(comic, config.startPage, config.initialPages) + // BUG-READER-03: start periodic position snapshots to survive process kills. + progressController.startPeriodicPositionSave() sessionLifecycleCoordinator.markReadyAfterBeginOpen() } catch (e: CancellationException) { sessionLifecycleCoordinator.reset() @@ -269,7 +272,26 @@ internal class ReaderBookOpeningController( val requestedPage = pendingRequestedPage val shouldDeferCount = prepared.deferPageCount val initialPages = if (shouldDeferCount) 1 else prepared.pages.coerceAtLeast(1) - val requestedStartPage = requestedPage ?: restoredPosition?.engineSectionIndex ?: comic.currentPage + // BUG-READER-03: legacy records without readerPositionJson fall back to comic.currentPage. + // That raw int is a visual-page index from the old persistence, not a section index. + // Wrapping it in a synthetic ReaderPosition and routing through planReaderPositionRestore + // ensures the structured restore path (section → sub-page → anchor) is always used, + // even for records that predate the structured schema. + val effectivePosition = restoredPosition ?: comic.currentPage.takeIf { it > 0 }?.let { + ReaderPosition(engineSectionIndex = it, mode = openingMode) + } + val requestedStartPage = requestedPage ?: effectivePosition?.let { pos -> + if (initialPages > 0) { + planReaderPositionRestore( + position = pos, + openingMode = openingMode, + resolvedTotalPages = initialPages, + normalizePage = { page, mode, total -> navigationController.normalizePageForMode(page, mode, total) } + )?.startPage + } else { + pos.engineSectionIndex + } + } ?: 0 val startPage = navigationController.normalizePageForMode(requestedStartPage, openingMode, initialPages) pendingRequestedPage = null progressController.lastPersistedProgress = PersistedProgressMarker( @@ -287,7 +309,9 @@ internal class ReaderBookOpeningController( startPage = startPage, requestedStartPage = requestedStartPage, requestedPage = requestedPage, - restoredPosition = restoredPosition + // BUG-READER-03: pass the synthetic position so the deferred page-count + // resolution path also uses the structured restore for legacy records. + restoredPosition = effectivePosition ) } @@ -492,24 +516,34 @@ internal class ReaderBookOpeningController( normalizePage = { page, mode, total -> navigationController.normalizePageForMode(page, mode, total) } ) } - val resolvedStartPage = restorePlan?.startPage ?: normalizedStartPage + val restoredStartPage = restorePlan?.startPage ?: normalizedStartPage val restoresWebtoon = openingMode == ReadingMode.WEBTOON + // T6 FIX: Only apply the restored start page if the user hasn't navigated away + // from it. During the deferred resolution delay, the user may have turned pages. + // Overwriting their current position causes the "page flip rolls back" bug. + val currentPage = _uiState.value.currentPage + val userHasNavigated = currentPage != restoredStartPage && currentPage != 0 + val resolvedStartPage = if (userHasNavigated) currentPage else restoredStartPage _uiState.update { it.copy( totalPages = realPages, currentPage = resolvedStartPage, isLoading = false, - sectionCurrentPage = if (restoresWebtoon) { - 0 + sectionCurrentPage = if (userHasNavigated || restoresWebtoon) { + if (restoresWebtoon) 0 else it.sectionCurrentPage } else { restorePlan?.sectionCurrentPage ?: it.sectionCurrentPage }, - sectionCharacterOffset = if (restoresWebtoon) { - 0 + sectionCharacterOffset = if (userHasNavigated || restoresWebtoon) { + if (restoresWebtoon) 0 else it.sectionCharacterOffset } else { restorePlan?.characterOffset ?: it.sectionCharacterOffset }, - pendingScrollToAnchor = restorePlan?.domAnchor ?: it.pendingScrollToAnchor, + pendingScrollToAnchor = if (userHasNavigated) { + it.pendingScrollToAnchor + } else { + restorePlan?.domAnchor ?: it.pendingScrollToAnchor + }, pendingWebtoonSectionIndex = if (restoresWebtoon) { restorePlan?.webtoonSectionIndex ?: it.pendingWebtoonSectionIndex } else { @@ -540,6 +574,15 @@ internal class ReaderBookOpeningController( } } bookmarkController.loadBookmarks(comic.id, realPages) + // BUG-READER-01: persist the resolved page count immediately so the library card, + // progress bar, and next-open restore all see the correct total — even if the user + // closes the reader without turning another page. Without this, pageCount stays 0 + // in the database until the next saveProgress call, causing displayReadingProgress() + // to fall back to the stored (possibly stale) readingProgress value. + progressController.saveProgress( + page = resolvedStartPage, + progressSource = ReaderNavigationProgressSource.READING + ) } private fun schedulePostOpenTasks(comic: Comic, startPage: Int, initialPages: Int) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt index 6aeaf6740..42c68b46b 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt @@ -163,7 +163,7 @@ internal fun ReaderBottomSheets( ) } - // ── Настройки текста (ModalBottomSheet) ──────────────────────────────────── + // ── Настройки ридера (Control center: STYLE/READING/SERVICES tabs) ────── if (uiState.showTextSettings) { ReaderControlCenterSheet( uiState = uiState, @@ -233,7 +233,14 @@ internal fun ReaderBottomSheets( onTtsSpeedChange = viewModel.settingsController::setTtsSpeed, onTtsPitchChange = viewModel.settingsController::setTtsPitch, onTtsVolumeChange = viewModel.settingsController::setTtsVolume, - onTtsSleepTimerChange = viewModel.settingsController::setTtsSleepTimerMode + onTtsSleepTimerChange = viewModel.settingsController::setTtsSleepTimerMode, + autoScrollActions = ReaderAutoScrollActions( + toggle = viewModel.autoScrollSettingsController::toggle, + previewSpeed = viewModel.autoScrollSettingsController::previewSpeed, + commitSpeed = { speed -> + viewModel.autoScrollSettingsController.commitSpeed(uiState.readingMode, speed) + } + ) ) } pendingCustomFontDeletion?.let { fontName -> diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt index aef13dced..10ef38563 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt @@ -43,8 +43,8 @@ fun ReaderExpandedBottomPanel( if (useCompactLandscapeImagePanel) { ReaderCompactLandscapeBottomPanel( - currentPage = uiState.currentPage, - totalPages = uiState.totalPages, + currentPage = uiState.effectiveCurrentPage, + totalPages = uiState.effectiveTotalPages, readingMode = uiState.readingMode, bookmarked = uiState.currentPage in uiState.bookmarkedPages, onToggleBookmark = onToggleBookmark, @@ -108,6 +108,10 @@ fun ReaderExpandedBottomPanel( ?.title, epubAccumulatedTotalPages = uiState.epubAccumulatedTotalPages, epubAccumulatedCurrentPage = uiState.epubAccumulatedCurrentPage, + isTextPaginationResolved = uiState.isTextPaginationResolved, + isTextWebtoon = uiState.readerContainerKind == ReaderContainerKind.TEXT_WEBTOON, + freeScrollProgression = uiState.freeScrollProgression, + rasterWebtoonScrollProgression = uiState.rasterWebtoonScrollProgression, onReadingModeChange = onReadingModeChange, onPageChange = onPageChange ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt index b6125e394..5fb781da5 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.library.RootChromeTopBarHost +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.core.ui.locale.LocalStrings @OptIn(ExperimentalMaterial3Api::class) @@ -63,7 +65,7 @@ fun ReaderMinimalBar( text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium + style = MrComicType.h3 ) }, navigationIcon = { @@ -113,9 +115,9 @@ internal fun ReaderPanelChip( selected = selected, onClick = onClick, modifier = modifier.heightIn(min = 38.dp), - shape = RoundedCornerShape(999.dp), + shape = RoundedCornerShape(MrComicCornerScale.lg), colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, labelColor = MaterialTheme.colorScheme.onSurface, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer @@ -212,6 +214,18 @@ fun ReaderExpandedBar( } Spacer(Modifier.width(44.dp)) } + if (title.isNotBlank()) { + Text( + text = title, + style = MrComicType.meta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + ) + } } @Composable diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt index 157df3a03..f62d97e79 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt @@ -315,13 +315,13 @@ internal fun BoxScope.ReaderTopChromeBar( uiState.readingMode == ReadingMode.PAGE_RTL, directionShortcutActive = directionShortcutActive, showBrightnessRow = showBrightnessRow, - useDirectActions = isTextReader, + useDirectActions = true, chromeIconOrder = uiState.chromeIconOrder, showTocIcon = readerShouldShowTocChromeButton( isTextReader = isTextReader, buttonEnabled = uiState.chromeShowTocIcon, ), - showTextSettingsIcon = uiState.chromeShowStyleIcon, + showTextSettingsIcon = uiState.chromeShowStyleIcon || !isTextReader, showAudioIcon = uiState.chromeShowAudioIcon && isTextReader, showDirectionIcon = uiState.chromeShowDirectionIcon && (uiState.readingMode == ReadingMode.PAGE_LTR || uiState.readingMode == ReadingMode.PAGE_RTL), showTranslateIcon = uiState.chromeShowTranslateIcon, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt index e44b0259b..907ab6ff1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt @@ -156,6 +156,10 @@ internal object ReaderContentPathResolver { } fun hasReadAccess(context: Context, path: String): Boolean { + if (!path.startsWith("content://")) { + // Fast path for local files: avoid opening an InputStream just to test permission. + return isLocalFileReadable(path) + } return try { context.contentResolver.openInputStream(Uri.parse(path))?.use { true } ?: false } catch (_: Exception) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt index ef8623842..1d7071a85 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt @@ -34,6 +34,12 @@ internal enum class ReaderChromeEditorTab { ORDER } +internal data class ReaderAutoScrollActions( + val toggle: () -> Unit, + val previewSpeed: (Float) -> Unit, + val commitSpeed: (Float) -> Unit +) + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ReaderControlCenterSheet( @@ -98,7 +104,8 @@ internal fun ReaderControlCenterSheet( onTtsSpeedChange: (Float) -> Unit, onTtsPitchChange: (Float) -> Unit, onTtsVolumeChange: (Float) -> Unit, - onTtsSleepTimerChange: (String) -> Unit + onTtsSleepTimerChange: (String) -> Unit, + autoScrollActions: ReaderAutoScrollActions ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -247,7 +254,10 @@ internal fun ReaderControlCenterSheet( onTtsSpeedChange = onTtsSpeedChange, onTtsPitchChange = onTtsPitchChange, onTtsVolumeChange = onTtsVolumeChange, - onTtsSleepTimerChange = onTtsSleepTimerChange + onTtsSleepTimerChange = onTtsSleepTimerChange, + onAutoScrollToggle = autoScrollActions.toggle, + onAutoScrollSpeedPreview = autoScrollActions.previewSpeed, + onAutoScrollSpeedCommit = autoScrollActions.commitSpeed ) } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt index 336b00dec..b8079cd9d 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt @@ -162,7 +162,9 @@ private fun resolveReaderProgressLabel( totalPages: Int ): String { if (totalPages <= 0 || visiblePages.isEmpty()) return "" - val progress = (((visiblePages.last() + 1).toFloat() / totalPages.toFloat()) * 100f) + // Use the first visible page for progress calculation to match library display. + // For DUAL_PAGE mode, this shows progress for the left page, which is the canonical position. + val progress = (((visiblePages.first() + 1).toFloat() / totalPages.toFloat()) * 100f) .roundToInt() .coerceIn(0, 100) return "$progress%" diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt index 9fb29b793..1bf47e5ac 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt @@ -21,26 +21,23 @@ internal fun readerMaterialColorScheme( ): ColorScheme { val surfaceAlpha = fallback.surface.alpha val baseScheme = if (!isTextReader) { - darkColorScheme( - primary = Color(0xFF7DB7E8), - onPrimary = Color(0xFF0F1C29), - primaryContainer = Color(0xFF243748), - onPrimaryContainer = Color(0xFFE3F1FE), - secondary = Color(0xFFD9B982), - onSecondary = Color(0xFF36250D), - secondaryContainer = Color(0xFF544122), - onSecondaryContainer = Color(0xFFF7E7CA), - background = Color(0xFF090B0E), - onBackground = Color(0xFFF2F2F2), - surface = Color(0xFF14181D), - onSurface = Color(0xFFF2F2F2), - surfaceVariant = Color(0xFF232A31), - onSurfaceVariant = Color(0xFFC5CBD2), - outline = Color(0xFF5B6772), - outlineVariant = Color(0xFF313A44), - error = fallback.error, - onError = fallback.onError - ) + // Raster reader: preset-based tinting so DAY/SEPIA/NIGHT/OLED change the page + // gutter AND the chrome controls. BUG-UI-05: light presets previously wrapped + // their colors into a darkColorScheme, so switches, sliders, progress bars and + // chrome containers kept dark companions and did not follow the Day/Sepia + // presets in light themes. Light presets now resolve to complete + // lightColorSchemes; dark presets stay dark. Background/surface roles keep the + // exact values pinned by ReaderMaterialColorSchemeTest. + val presetScheme = when { + readerPreset == ReadingPreset.OLED_BLACK -> rasterOledDarkScheme() + readerPreset == ReadingPreset.SEPIA_BOOK || textColorScheme == "SEPIA" -> rasterSepiaLightScheme() + readerPreset == ReadingPreset.NIGHT_INK || textColorScheme == "NIGHT" -> rasterNightDarkScheme() + readerPreset == ReadingPreset.NEWSPAPER -> rasterNewspaperLightScheme() + readerPreset == ReadingPreset.EINK -> rasterEinkLightScheme() + readerPreset == ReadingPreset.PAPER || textColorScheme == "DAY" -> rasterDayLightScheme() + else -> rasterFallbackDarkScheme() + } + presetScheme.copy(error = fallback.error, onError = fallback.onError) } else { when { readerPreset == ReadingPreset.OLED_BLACK -> darkColorScheme( @@ -227,3 +224,142 @@ private fun ensureContrast( val whiteRatio = (1.0f + 0.05f) / (bgLum + 0.05f) return if (blackRatio >= whiteRatio) Color.Black else Color.White } + +// ── Raster-reader preset schemes ───────────────────────────────────────────── +// Background/surface roles keep the exact values pinned by +// ReaderMaterialColorSchemeTest; companion roles (primary/secondary/containers/ +// outline*) mirror the matching text-reader palettes so switches, sliders, +// progress bars and chrome containers track the preset in light themes too. + +private fun rasterOledDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF243748), + onPrimaryContainer = Color(0xFFE3F1FE), + secondary = Color(0xFFD9B982), + onSecondary = Color(0xFF36250D), + secondaryContainer = Color(0xFF544122), + onSecondaryContainer = Color(0xFFF7E7CA), + background = Color(0xFF000000), + onBackground = Color(0xFFF2F2F2), + surface = Color(0xFF050505), + onSurface = Color(0xFFF2F2F2), + surfaceVariant = Color(0xFF121212), + onSurfaceVariant = Color(0xFFC5CBD2), + outline = Color(0xFF5B6772), + outlineVariant = Color(0xFF313A44) +) + +private fun rasterNightDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF253748), + onPrimaryContainer = Color(0xFFE2F0FD), + secondary = Color(0xFFD4B384), + onSecondary = Color(0xFF3F2A11), + secondaryContainer = Color(0xFF594225), + onSecondaryContainer = Color(0xFFF3E2C6), + background = Color(0xFF16181C), + onBackground = Color(0xFFE8E2D8), + surface = Color(0xFF1F2328), + onSurface = Color(0xFFE8E2D8), + surfaceVariant = Color(0xFF2A2F36), + onSurfaceVariant = Color(0xFFC5C0B6), + outline = Color(0xFF716A60), + outlineVariant = Color(0xFF3B403E) +) + +private fun rasterFallbackDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF243748), + onPrimaryContainer = Color(0xFFE3F1FE), + secondary = Color(0xFFD9B982), + onSecondary = Color(0xFF36250D), + secondaryContainer = Color(0xFF544122), + onSecondaryContainer = Color(0xFFF7E7CA), + background = Color(0xFF090B0E), + onBackground = Color(0xFFF2F2F2), + surface = Color(0xFF14181D), + onSurface = Color(0xFFF2F2F2), + surfaceVariant = Color(0xFF232A31), + onSurfaceVariant = Color(0xFFC5CBD2), + outline = Color(0xFF5B6772), + outlineVariant = Color(0xFF313A44) +) + +private fun rasterSepiaLightScheme() = lightColorScheme( + primary = Color(0xFF835D2F), + onPrimary = Color(0xFFFFF7EA), + primaryContainer = Color(0xFFF0DEC2), + onPrimaryContainer = Color(0xFF43280A), + secondary = Color(0xFF966B3A), + onSecondary = Color(0xFFFFF7EA), + secondaryContainer = Color(0xFFF5E3C7), + onSecondaryContainer = Color(0xFF45270C), + background = Color(0xFFEADFC2), + onBackground = Color(0xFF372719), + surface = Color(0xFFF4ECD8), + onSurface = Color(0xFF372719), + surfaceVariant = Color(0xFFE3D4B4), + onSurfaceVariant = Color(0xFF6A543B), + outline = Color(0xFF94785A), + outlineVariant = Color(0xFFC7B08C) +) + +private fun rasterDayLightScheme() = lightColorScheme( + primary = Color(0xFF345C7C), + onPrimary = Color(0xFFF9F4EA), + primaryContainer = Color(0xFFDCE6ED), + onPrimaryContainer = Color(0xFF142D3D), + secondary = Color(0xFF8B6841), + onSecondary = Color(0xFFF9F1E7), + secondaryContainer = Color(0xFFE8D8BF), + onSecondaryContainer = Color(0xFF382411), + background = Color(0xFFF6F1E7), + onBackground = Color(0xFF2B2118), + surface = Color(0xFFEEE6D7), + onSurface = Color(0xFF2B2118), + surfaceVariant = Color(0xFFE2D6C3), + onSurfaceVariant = Color(0xFF675745), + outline = Color(0xFF8F7D67), + outlineVariant = Color(0xFFCDBEAA) +) + +private fun rasterNewspaperLightScheme() = lightColorScheme( + primary = Color(0xFF31404F), + onPrimary = Color(0xFFF7F7F5), + primaryContainer = Color(0xFFDCE1E6), + onPrimaryContainer = Color(0xFF19232D), + secondary = Color(0xFF5E6975), + onSecondary = Color(0xFFF7F7F5), + secondaryContainer = Color(0xFFE2E6EA), + onSecondaryContainer = Color(0xFF242C34), + background = Color(0xFFF1EEE7), + onBackground = Color(0xFF202020), + surface = Color(0xFFE9E5DD), + onSurface = Color(0xFF202020), + surfaceVariant = Color(0xFFDED8D0), + onSurfaceVariant = Color(0xFF55504A), + outline = Color(0xFF80776E), + outlineVariant = Color(0xFFC3BBB1) +) + +private fun rasterEinkLightScheme() = lightColorScheme( + primary = Color(0xFF1A1A1A), + onPrimary = Color(0xFFF3F3F1), + primaryContainer = Color(0xFFD7D7D3), + onPrimaryContainer = Color(0xFF111111), + secondary = Color(0xFF4C4C4C), + onSecondary = Color(0xFFF5F5F3), + secondaryContainer = Color(0xFFE0E0DC), + onSecondaryContainer = Color(0xFF1E1E1E), + background = Color(0xFFF0EFE9), + onBackground = Color(0xFF121212), + surface = Color(0xFFE8E7E1), + onSurface = Color(0xFF121212), + surfaceVariant = Color(0xFFDDDCDA), + onSurfaceVariant = Color(0xFF50504D), + outline = Color(0xFF777773), + outlineVariant = Color(0xFFBDBCB7) +) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt index f80615ec2..7b6ae0c8f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt @@ -58,6 +58,7 @@ internal object ReaderPreferenceRestorer { val preload: Int, val fontSize: Int, val colorScheme: String, + val graphicColorScheme: String, val customTextColor: Long?, val customBackgroundColor: Long?, val customAccentColor: Long?, @@ -147,6 +148,7 @@ internal object ReaderPreferenceRestorer { // Text reader settings val fontSize = pref(PreferencesKeys.TEXT_FONT_SIZE, 18).coerceIn(12, 32) val colorScheme = pref(PreferencesKeys.TEXT_COLOR_SCHEME, "DAY") + val graphicColorScheme = pref(PreferencesKeys.GRAPHIC_COLOR_SCHEME, DEFAULT_GRAPHIC_COLOR_SCHEME) val customTextColor = pref(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, Long.MIN_VALUE) .takeUnless { it == Long.MIN_VALUE } val customBackgroundColor = pref(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, Long.MIN_VALUE) @@ -254,6 +256,7 @@ internal object ReaderPreferenceRestorer { preload = preload, fontSize = fontSize, colorScheme = colorScheme, + graphicColorScheme = graphicColorScheme, customTextColor = customTextColor, customBackgroundColor = customBackgroundColor, customAccentColor = customAccentColor, @@ -346,6 +349,7 @@ internal object ReaderPreferenceRestorer { preloadPages = p.preload, textFontSize = p.fontSize, textColorScheme = p.colorScheme, + graphicColorScheme = p.graphicColorScheme, textCustomTextColor = p.customTextColor, textCustomBackgroundColor = p.customBackgroundColor, textCustomAccentColor = p.customAccentColor, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt index b20955e6f..a1a5eb47f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt @@ -77,6 +77,36 @@ internal class ReaderProgressController( */ internal var totalBookSections: Int = 0 + /** BUG-READER-03: periodic position save job — runs every 5 s while the reader is active. */ + private var periodicSaveJob: Job? = null + + /** + * BUG-READER-03: Start periodic position snapshots so a process-kill between page turns + * cannot lose more than ~5 seconds of reading progress. Only one periodic job runs at a time. + */ + fun startPeriodicPositionSave() { + periodicSaveJob?.cancel() + periodicSaveJob = viewModelScope.launch { + while (true) { + delay(PERIODIC_SAVE_INTERVAL_MS) + val comic = _uiState.value.comic ?: continue + val positionJson = buildPositionJson( + _uiState.value, comic.format, _uiState.value.currentPage + ) ?: continue + // Only write when the position actually changed since the last persist. + if (!isSamePersistedPosition(lastPersistedPositionJson, positionJson)) { + enqueuePositionOnlySave(comic, _uiState.value.currentPage, positionJson) + } + } + } + } + + /** Stop the periodic position save (called when the reader is closed). */ + fun stopPeriodicPositionSave() { + periodicSaveJob?.cancel() + periodicSaveJob = null + } + /** Returns a stable, section-ordered snapshot for EPUB progress accumulation. */ internal fun snapshotSectionPageCounts(): Map = sectionPageCounts.snapshot() @@ -148,6 +178,70 @@ internal class ReaderProgressController( enqueuePositionOnlySave(comic, _uiState.value.currentPage, positionJson) } + /** + * Force-persists the current position on reader close, bypassing the dedup guard. + * + * BUG-READER-03: [savePositionSnapshot] skips the write when the position JSON matches + * [lastPersistedPositionJson]. During a rapid exit the WebView may not have reported its + * latest scroll back to [_uiState], so the snapshot looks identical — but the database + * might actually be empty or stale (e.g. a previous flush was lost to a process kill). + * This method always enqueues a position-only write so the close path is lossless. + */ + private fun forceSavePositionOnClose() { + val comic = _uiState.value.comic ?: return + val positionJson = buildPositionJson(_uiState.value, comic.format, _uiState.value.currentPage) + ?: return + val normalizedPage = _uiState.value.currentPage.coerceAtLeast(0) + val pending = PendingProgressSave( + comicId = comic.id, + page = normalizedPage, + totalPages = _uiState.value.totalPages.coerceAtLeast(1), + countsTowardReadingProgress = false, + characterOffset = if (_uiState.value.readingMode == ReadingMode.WEBTOON) { + _uiState.value.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + _uiState.value.sectionCharacterOffset.takeIf { it > 0 } + }, + positionJson = positionJson, + positionOnly = true + ) + // Only skip if there is already an identical *pending* save queued (avoids duplicate work + // within the same close sequence). We intentionally do NOT check lastPersistedPositionJson + // because the close path must always write to the database even if it appears redundant. + if (pending == pendingProgressSave) return + pendingProgressSave = pending + } + + /** + * BUG-READER-02: Immediate position save used when the user explicitly changes reading mode. + * Unlike [savePositionSnapshot] (debounced 220 ms) this flushes the pending write synchronously + * so a rapid close after a mode switch cannot lose the new mode in the structured position. + */ + fun savePositionImmediate() { + val comic = _uiState.value.comic ?: return + val positionJson = buildPositionJson(_uiState.value, comic.format, _uiState.value.currentPage) + ?: return + val normalizedPage = _uiState.value.currentPage.coerceAtLeast(0) + val pending = PendingProgressSave( + comicId = comic.id, + page = normalizedPage, + totalPages = _uiState.value.totalPages.coerceAtLeast(1), + countsTowardReadingProgress = false, + characterOffset = if (_uiState.value.readingMode == ReadingMode.WEBTOON) { + _uiState.value.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + _uiState.value.sectionCharacterOffset.takeIf { it > 0 } + }, + positionJson = positionJson, + positionOnly = true + ) + pendingProgressSave = pending + progressSaveJob?.cancel() + progressSaveJob = viewModelScope.launch { + flushPendingProgressSave() + } + } + private fun enqueuePositionOnlySave( comic: io.leostrange.mrcomic.core.model.Comic, page: Int, @@ -186,8 +280,7 @@ internal class ReaderProgressController( val previousPersistedPage = lastPersistedProgress ?.takeIf { it.comicId == pending.comicId } ?.page - val storedPageCount = libraryRepository.getComicById(pending.comicId)?.pageCount ?: 0 - val safeTotalPages = maxOf(pending.totalPages, storedPageCount).coerceAtLeast(1) + val safeTotalPages = pending.totalPages.coerceAtLeast(1) if (!pending.positionOnly) { libraryRepository.updateProgress( comicId = pending.comicId, @@ -235,8 +328,7 @@ internal class ReaderProgressController( lastPersistedProgress = persistedPageMarkerAfterFlush(lastPersistedProgress, pending) lastPersistedPositionJson = pending.positionJson val currentComic = _uiState.value.comic ?: return - val authoritativeTotal = maxOf(pending.totalPages, storedPageCount) - val reachedLastPageSafe = authoritativeTotal > 0 && pending.page >= authoritativeTotal - 1 + val reachedLastPageSafe = pending.totalPages > 0 && pending.page >= pending.totalPages - 1 val isHeavy = currentComic.format.isHeavyReflowableFormat() || currentComic.format.isTextReadingFormat() val titleCompletionPolicy = resolveTitleCompletionPolicy( reachedLastPage = reachedLastPageSafe, @@ -246,7 +338,7 @@ internal class ReaderProgressController( sessionManualPageTurns = readerSessionCoordinator.currentManualPageTurns, goalProgressDelta = goalProgressDelta, isHeavyReflowable = isHeavy, - totalPages = authoritativeTotal + totalPages = pending.totalPages ) if (titleCompletionPolicy.shouldComplete) { libraryRepository.markCompleted(pending.comicId, completed = true) @@ -427,16 +519,37 @@ internal class ReaderProgressController( fun accumulatedTotalPagesForEpub(): Int { return EpubProgressCalculator.estimatedTotalPages( sectionPageCounts = sectionPageCounts.snapshot(), - totalSections = totalBookSections + totalSections = totalBookSections, + stableEstimateOverride = sectionPageCounts.stableEstimate() ) } // ── Session lifecycle ────────────────────────────────────────────────── - fun emitReaderClosed(appScope: io.leostrange.mrcomic.core.domain.coroutines.AppCoroutineScope) { - // Close can happen between scroll callbacks; enqueue one final semantic snapshot before - // the ViewModel cancels its local debounce job. - savePositionSnapshot() + /** + * Prepare the reader-close snapshot and return the analytics payload. + * + * BUG-READER-03: The actual database flush is **not** done here — it is the caller's + * responsibility to invoke [flushPendingProgressSave] synchronously *before* tearing down + * reader resources. This two-phase design (prepare → flush) replaces the former + * fire-and-forget `appScope.launch { flush }` that could lose the position on a fast + * process kill after `onCleared`. + */ + fun emitReaderClosed(): ReaderClosedAnalytics? { + // Close can happen between scroll callbacks; enqueue one final semantic snapshot and + // flush immediately so the 220ms debounce cannot lose the position on rapid exit. + // + // BUG-READER-03: use forceSavePositionOnClose instead of savePositionSnapshot. + // The normal snapshot is a no-op when the position JSON matches lastPersistedPositionJson, + // but during a rapid exit the WebView's scroll position may not have been reported back + // to _uiState yet (the 120ms free-scroll debounce hasn't fired). By force-enqueuing + // a position-only save and bypassing the dedup check, we guarantee the reader's last + // known position is always persisted on close — even if it appears identical to the + // previously stored value. The extra write is a single UPDATE on a single row and + // only happens once per reader session close. + forceSavePositionOnClose() + progressSaveJob?.cancel() + // NOTE: flush is NOT done here — caller must flush synchronously. val state = _uiState.value val currentComic = state.comic val closedSession = readerSessionCoordinator.close( @@ -444,16 +557,29 @@ internal class ReaderProgressController( currentComicCompleted = currentComic?.isCompleted == true, currentPage = state.currentPage ) - ?: return + ?: return null val session = closedSession.session val sessionMetrics = closedSession.metrics val finishedAtMillis = System.currentTimeMillis() - if (shouldRecordReaderSessionMinutes(sessionMetrics)) { + return ReaderClosedAnalytics( + session = session, + sessionMetrics = sessionMetrics, + readingModeName = state.readingMode.name, + finishedAtMillis = finishedAtMillis + ) + } + + /** + * BUG-READER-03: Record session minutes and track the reader-closed analytics event. + * Called from [appScope] after the synchronous flush in [ReaderViewModel.onCleared]. + */ + fun trackReaderClosed(analytics: ReaderClosedAnalytics, appScope: io.leostrange.mrcomic.core.domain.coroutines.AppCoroutineScope) { + if (shouldRecordReaderSessionMinutes(analytics.sessionMetrics)) { appScope.launch { runCatching { dailyReadingGoalStore.recordSessionMinutes( - durationMillis = finishedAtMillis - session.startedAtMillis, - nowMillis = finishedAtMillis + durationMillis = analytics.finishedAtMillis - analytics.session.startedAtMillis, + nowMillis = analytics.finishedAtMillis ) }.onFailure { error -> Log.e("ReaderProgressController", "Failed to record reading session minutes", error) @@ -462,17 +588,25 @@ internal class ReaderProgressController( } analyticsTracker.track( buildReaderClosedAnalyticsEvent( - comicId = session.comicId, - format = session.format, - totalPages = session.totalPages, - readingMode = state.readingMode.name, - startedAtMillis = session.startedAtMillis, - finishedAtMillis = finishedAtMillis, - sessionMetrics = sessionMetrics + comicId = analytics.session.comicId, + format = analytics.session.format, + totalPages = analytics.session.totalPages, + readingMode = analytics.readingModeName, + startedAtMillis = analytics.session.startedAtMillis, + finishedAtMillis = analytics.finishedAtMillis, + sessionMetrics = analytics.sessionMetrics ) ) } + /** Data needed to fire the reader-closed analytics event outside the synchronous flush path. */ + internal data class ReaderClosedAnalytics( + val session: io.leostrange.mrcomic.feature.reader.domain.session.ReaderSessionSnapshot, + val sessionMetrics: io.leostrange.mrcomic.feature.reader.domain.session.ReaderClosedSessionMetrics, + val readingModeName: String, + val finishedAtMillis: Long, + ) + // ── Structured position (TEXT-01) ────────────────────────────────────── /** @@ -512,4 +646,9 @@ internal class ReaderProgressController( } // ── Internal helpers ─────────────────────────────────────────────────── + + private companion object { + /** Interval between periodic position snapshots (BUG-READER-03). */ + const val PERIODIC_SAVE_INTERVAL_MS = 5_000L + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt index ed11a8741..e1c436e83 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt @@ -32,7 +32,9 @@ internal class ReaderReadingModeController( private val markReaderPresetCustom: () -> Unit, private val getLastTextWebtoonCursor: () -> ReaderTextWebtoonCursor? = { null }, private val seedTextWebtoonCursor: (ReaderTextWebtoonCursor?) -> Unit = {}, - private val onAutoScrollModeChanged: (ReadingMode) -> Unit = {} + private val onAutoScrollModeChanged: (ReadingMode) -> Unit = {}, + /** BUG-READER-02: immediate position save on mode change to survive rapid close. */ + private val savePositionImmediate: () -> Unit = {} ) { var portraitReadingMode: ReadingMode = ReadingMode.PAGE_LTR var portraitPagedReadingMode: ReadingMode = ReadingMode.PAGE_LTR @@ -49,11 +51,13 @@ internal class ReaderReadingModeController( return } rememberPortraitMode(mode) - markReaderPresetCustom() applyReadingMode(mode) viewModelScope.launch { readerPreferences.set(PreferencesKeys.READING_MODE, mode.name) } + // BUG-READER-02: Flush the structured position immediately so the new mode + // survives a rapid close (the normal 220 ms debounce could lose it). + savePositionImmediate() } fun onOrientationChanged( @@ -118,7 +122,7 @@ internal class ReaderReadingModeController( pagedSubpageIndex = currentState.sectionCurrentPage, pagedSubpageCount = currentState.sectionPageCount, totalWebtoonSections = sectionCount, - characterOffset = currentState.sectionCharacterOffset.takeIf { it > 0 }, + characterOffset = currentState.sectionCharacterOffset.takeIf { it >= 0 }, fragment = currentState.pendingScrollToAnchor ) else -> null @@ -172,8 +176,12 @@ internal class ReaderReadingModeController( val textPagePosition = resolvedPosition as? ReaderContainerPosition.TextPage val textWebtoonPosition = resolvedPosition as? ReaderContainerPosition.TextWebtoon val nextIsTextWebtoon = nextContainerKind == ReaderContainerKind.TEXT_WEBTOON + // BUG-READER-05: Explicitly preserve readerPreset and textColorScheme + // to ensure mode changes don't reset the theme. state.copy( readingMode = mode, + readerPreset = state.readerPreset, + textColorScheme = state.textColorScheme, currentPage = alignedPage, sectionCurrentPage = when { textPagePosition != null -> textPagePosition.pageInSplit diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt index 2dd26bbc5..810055714 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.feature.reader.ui import android.util.Log import io.leostrange.mrcomic.core.data.repository.QuoteRepository +import io.leostrange.mrcomic.core.model.isTextReadingFormat import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsEvent import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsTracker import kotlinx.coroutines.CoroutineScope @@ -61,6 +62,15 @@ internal class ReaderSaveQuoteController( ) { val comic = _uiState.value.comic ?: return val page = _uiState.value.currentPage + val state = _uiState.value + // BUG-CANDIDATE-01: Build structured position for precise quote navigation + val positionJson = buildQuotePositionJson(state, comic.format, page) + val characterOffset = if (state.readingMode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + state.sectionCharacterOffset.takeIf { it > 0 } + } + val domAnchor = state.pendingScrollToAnchor viewModelScope.launch { runCatching { quoteRepository.saveQuote( @@ -69,7 +79,10 @@ internal class ReaderSaveQuoteController( text = text, translatedText = translatedText, sourceLanguage = sourceLanguage, - targetLanguage = targetLanguage + targetLanguage = targetLanguage, + positionJson = positionJson, + characterOffset = characterOffset, + domAnchor = domAnchor ) }.onSuccess { result -> val readerText = localizedReaderText() @@ -93,4 +106,39 @@ internal class ReaderSaveQuoteController( } } } + + /** + * BUG-CANDIDATE-01: Build structured position JSON for a quote. + * Mirrors [ReaderProgressController.buildPositionJson] so the quote carries + * enough information to navigate back to the exact reading position. + */ + private fun buildQuotePositionJson( + state: ReaderUiState, + format: io.leostrange.mrcomic.core.model.ComicFormat, + page: Int + ): String? { + val mode = state.readingMode + val isText = format.isTextReadingFormat() + val webtoonFraction = if (mode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollProgression.takeIf { it in 0.0..1.0 }?.toFloat() + } else { + null + } + val position = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition( + engineSectionIndex = if (isText) state.currentPage.coerceAtLeast(0) else page.coerceAtLeast(0), + visualPageIndex = if (isText) state.sectionCurrentPage.coerceAtLeast(0) else page.coerceAtLeast(0), + characterOffset = if (mode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollCharacterOffset.takeIf { it >= 0 } + ?: state.sectionCharacterOffset.takeIf { it > 0 } + } else { + state.sectionCharacterOffset.takeIf { it > 0 } + }, + domAnchor = state.pendingScrollToAnchor, + mode = mode, + webtoonScrollFraction = webtoonFraction, + updatedAtMillis = System.currentTimeMillis(), + schemaVersion = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition.SCHEMA_VERSION + ) + return io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPositionCodec.encode(position) + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt index d78119284..c87ce04bc 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt @@ -31,7 +31,10 @@ internal fun ReaderServicesTab( onTtsSpeedChange: (Float) -> Unit, onTtsPitchChange: (Float) -> Unit, onTtsVolumeChange: (Float) -> Unit, - onTtsSleepTimerChange: (String) -> Unit + onTtsSleepTimerChange: (String) -> Unit, + onAutoScrollToggle: () -> Unit, + onAutoScrollSpeedPreview: (Float) -> Unit, + onAutoScrollSpeedCommit: (Float) -> Unit ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -53,6 +56,19 @@ internal fun ReaderServicesTab( verticalArrangement = Arrangement.spacedBy(6.dp) ) { item { ReaderSectionTitle(readerText.servicesQuickActionsTitle) } + item { ReaderSectionTitle("Авточтение") } + item { + ReaderAutoScrollChromeControls( + speed = uiState.autoScrollSpeed, + readingMode = uiState.readingMode, + autoScrollEnabled = uiState.autoScrollEnabled, + isTemporarilyPaused = uiState.isAutoScrollTemporarilyPaused, + countdownProgress = uiState.autoScrollCountdownProgress, + onToggleAutoScroll = onAutoScrollToggle, + onSpeedPreview = onAutoScrollSpeedPreview, + onSpeedCommit = onAutoScrollSpeedCommit + ) + } item { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { item("ocr") { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index d6eef9889..cd1ba849a 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -25,6 +25,7 @@ import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetEntry import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSlot import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot import io.leostrange.mrcomic.feature.reader.domain.preset.parseReaderStylePreset +import io.leostrange.mrcomic.feature.reader.ui.gesture.ReaderColorScheme @OptIn(ExperimentalLayoutApi::class) @Composable @@ -69,7 +70,10 @@ internal fun ReaderStyleTab( var chromeEditorTab by remember { mutableStateOf(ReaderChromeEditorTab.VISIBILITY) } val configurableChromeButtons = remember(uiState.chromeIconOrder) { ReaderChromeButton.resolveOrder(uiState.chromeIconOrder) - .filterNot { it == ReaderChromeButton.STYLE } + // The graphic reader must keep the style/settings entry: it is + // the only route to image scaling, crop and graphic presets. + // Text-reader controls keep the style entry in their own tab. + .filterNot { isTextReader && it == ReaderChromeButton.STYLE } } val supportsMarginCrop = remember(uiState.comic?.format, isTextReader) { !isTextReader && (uiState.comic?.format == ComicFormat.PDF || uiState.comic?.format == ComicFormat.DJVU) @@ -235,13 +239,12 @@ internal fun ReaderStyleTab( item { ReaderSectionTitle(readerText.colorSchemeTitle) } item { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - items( - listOf( - "DAY" to readerText.day, - "SEPIA" to readerText.sepia, - "NIGHT" to readerText.night - ) - ) { (id, label) -> + items(ReaderColorScheme.graphicQuickChoices) { id -> + val label = when (id) { + "SEPIA" -> readerText.sepia + "NIGHT" -> readerText.night + else -> readerText.day + } ReaderChoiceChip( selected = uiState.graphicColorScheme == id, onClick = { onColorSchemeChange(id) }, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt index 7a8d092a9..1c22d2682 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt @@ -189,6 +189,8 @@ data class ReaderUiState( val epubAccumulatedTotalPages: Int = 0, /** Accumulated current visual page position across all visited EPUB sections. */ val epubAccumulatedCurrentPage: Int = 0, + /** Whether all sections in the book have completed visual pagination. */ + val isTextPaginationResolved: Boolean = false, /** Whether hardware volume buttons should turn pages inside the reader. */ val volumeKeysPagingEnabled: Boolean = false, /** System TTS defaults used by the reader services tab. */ diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt index 6b14eae42..0a161942f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt @@ -268,7 +268,8 @@ class ReaderViewModel @Inject constructor( markReaderPresetCustom = { settingsController.markReaderPresetCustom() }, getLastTextWebtoonCursor = { navigationController.lastTextWebtoonCursor }, seedTextWebtoonCursor = { navigationController.seedTextWebtoonCursor(it) }, - onAutoScrollModeChanged = { mode -> autoScrollSettingsController.switchMode(mode) } + onAutoScrollModeChanged = { mode -> autoScrollSettingsController.switchMode(mode) }, + savePositionImmediate = { progressController.savePositionImmediate() } ) private val openGuard = io.leostrange.mrcomic.feature.reader.domain.session.ReaderOpenGuard() @@ -370,7 +371,8 @@ class ReaderViewModel @Inject constructor( sectionPageCounts = sectionPageCountSnapshot, sectionIndex = sectionIndex, sectionPageIndex = safePageIndex, - totalSections = progressController.totalBookSections + totalSections = progressController.totalBookSections, + stableEstimateOverride = progressController.sectionPageCounts.stableEstimate() ) _uiState.update { it.copy( @@ -378,7 +380,8 @@ class ReaderViewModel @Inject constructor( sectionCurrentPage = safePageIndex, sectionCharacterOffset = safeCharacterOffset, epubAccumulatedTotalPages = progress.accumulatedTotalPages, - epubAccumulatedCurrentPage = progress.accumulatedCurrentPage + epubAccumulatedCurrentPage = progress.accumulatedCurrentPage, + isTextPaginationResolved = progress.isResolved ) } } @@ -405,6 +408,16 @@ class ReaderViewModel @Inject constructor( } } + /** + * BUG-VERTICAL-01: Called by raster WebtoonView when scroll progression changes. + * Updates the seekbar position to match the actual scroll position. + */ + internal fun onRasterWebtoonScrollProgressionChanged(progression: Float) { + _uiState.update { + it.copy(rasterWebtoonScrollProgression = progression.toDouble()) + } + } + fun tocDisplayPage(enginePageIndex: Int): Int = pageCacheController.tocDisplayPage(enginePageIndex) @@ -432,12 +445,23 @@ class ReaderViewModel @Inject constructor( private suspend fun localizedReaderText(): ReaderUiText = readerUiText(readerLanguageCode()) override fun onCleared() { - // Snapshot the pending IO work, then run it on an application-scoped coroutine so leaving - // the reader never blocks the main thread. These paths (progress save, session close) only - // touch Room/DataStore/engine registry — all independent of the resources torn down below — - // so completing them slightly after onCleared returns is safe. Previously three runBlocking - // calls here caused an ANR on slow storage. - progressController.emitReaderClosed(appScope) + // BUG-READER-03: flush progress synchronously before tearing down resources. + // Previously two separate appScope.launch {} fire-and-forget coroutines could both + // lose the race against a fast process kill. One runBlocking(Dispatchers.IO) is + // acceptable here — Room's suspend DAO already dispatches to its internal IO pool + // and the write is a single UPDATE on a single row (~1 ms in the typical case). + // The old ANR was caused by THREE sequential runBlocking calls; this is one. + progressController.stopPeriodicPositionSave() + val closedAnalytics = progressController.emitReaderClosed() + progressController.progressSaveJob?.cancel() + try { + kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { + progressController.flushPendingProgressSave() + } + } catch (e: Exception) { + Log.e("ReaderViewModel", "Failed to flush progress on close", e) + } + closedAnalytics?.let { progressController.trackReaderClosed(it, appScope) } super.onCleared() openingController.cancelPendingOpen() pageCacheController.cancelPendingToc() @@ -450,11 +474,8 @@ class ReaderViewModel @Inject constructor( // is a no-op and matching reset() runs _after_ the cleanup. sessionLifecycleCoordinator.beginClose() textReaderOrchestrator.cancelAllJobs() - progressController.progressSaveJob?.cancel() sessionManager.closeReaderResources() appScope.launch { - runCatching { progressController.flushPendingProgressSave() } - .onFailure { Log.e("ReaderViewModel", "Failed to flush progress on close", it) } runCatching { sessionManager.closeBookSessionAsync() } .onFailure { Log.w(TAG, "Failed to close book session on close", it) } // markClosed throws if the ledger was Opening when beginClose() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt index a5189676f..46eee9633 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt @@ -605,6 +605,37 @@ internal class ReaderWebView(context: android.content.Context) : WebView(context else{target.scrollIntoView({block:'start',inline:'nearest'});} return true; } + if($sectionIndex>=0){ + var secTarget=document.querySelector('.mrcomic-text-webtoon-section[data-mrcomic-page-index="'+$sectionIndex+'"]'); + if(secTarget){ + if($characterOffset>0){ + var walker=document.createTreeWalker(secTarget,NodeFilter.SHOW_TEXT,null); + var remaining=$characterOffset; + var node=null; + while((node=walker.nextNode())){ + var length=(node.nodeValue||'').length; + if(remaining<=length)break; + remaining-=length; + } + if(node){ + var range=document.createRange(); + var start=Math.max(0,Math.min((node.nodeValue||'').length,remaining)); + range.setStart(node,start); + range.setEnd(node,Math.min((node.nodeValue||'').length,start+1)); + var rect=range.getBoundingClientRect(); + if(rect&&isFinite(rect.top)){ + window.scrollBy(0,Math.round(rect.top-16)); + range.detach&&range.detach(); + return true; + } + range.detach&&range.detach(); + } + } + if(window.__mrcomicScrollToAnchor){window.__mrcomicScrollToAnchor(secTarget);} + else{secTarget.scrollIntoView({block:'start',inline:'nearest'});} + return true; + } + } if($characterOffset>=0){ var content=document.querySelector($characterScopeSelector)|| document.querySelector('[data-mrcomic-text-webtoon-document]')||document.body; @@ -636,14 +667,6 @@ internal class ReaderWebView(context: android.content.Context) : WebView(context window.scrollTo(0,Math.round(max*$progression)); return true; } - if($sectionIndex>=0){ - target=document.querySelector('.mrcomic-text-webtoon-section[data-mrcomic-page-index="'+$sectionIndex+'"]'); - } - if(target){ - if(window.__mrcomicScrollToAnchor){window.__mrcomicScrollToAnchor(target);} - else{target.scrollIntoView({block:'start',inline:'nearest'});} - return true; - } return false; }catch(e){return false;} })() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt index 6a4e6dbc5..6d1589b37 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt @@ -36,6 +36,7 @@ internal class ReaderWebViewLoadController { is ReaderWebViewRuntimeEvent.DocumentCommitted -> handleCommitted(event.generation) is ReaderWebViewRuntimeEvent.LayoutReady -> handleLayoutReady(event) is ReaderWebViewRuntimeEvent.RestoreAcknowledged -> handleRestoreAcknowledged(event.generation) + is ReaderWebViewRuntimeEvent.RestoreRejected -> handleRestoreRejected(event.generation) is ReaderWebViewRuntimeEvent.LoadFailed -> handleFailure(event.generation, event.reason.ifBlank { "load failed" }) is ReaderWebViewRuntimeEvent.ContentBlank -> handleFailure(event.generation, blankReason()) ReaderWebViewRuntimeEvent.Disposed -> { @@ -101,9 +102,16 @@ internal class ReaderWebViewLoadController { if (runtimeState.restoreIssued) return emptyList() runtimeState = runtimeState.copy( phase = ReaderWebViewRuntimePhase.RESTORING, - restoreIssued = true + restoreIssued = true, + restoreAttempt = FIRST_RESTORE_ATTEMPT + ) + return listOf( + ReaderWebViewRuntimeEffect.Restore( + runtimeState.generation, + restoreTarget, + FIRST_RESTORE_ATTEMPT + ) ) - return listOf(ReaderWebViewRuntimeEffect.Restore(runtimeState.generation, restoreTarget)) } if (runtimeState.phase == ReaderWebViewRuntimePhase.READY) return emptyList() runtimeState = runtimeState.copy(phase = ReaderWebViewRuntimePhase.READY) @@ -119,6 +127,24 @@ internal class ReaderWebViewLoadController { return listOf(ReaderWebViewRuntimeEffect.PublishReady(generation, metrics)) } + private fun handleRestoreRejected(generation: Long): List { + if (!isActive(generation) || runtimeState.phase != ReaderWebViewRuntimePhase.RESTORING) { + return emptyList() + } + val target = runtimeState.restoreTarget ?: return emptyList() + if (runtimeState.restoreAttempt >= MAX_RESTORE_ATTEMPTS) { + val metrics = runtimeState.layoutMetrics ?: return emptyList() + runtimeState = runtimeState.copy( + phase = ReaderWebViewRuntimePhase.READY, + error = "restore target unavailable" + ) + return listOf(ReaderWebViewRuntimeEffect.PublishReady(generation, metrics)) + } + val nextAttempt = runtimeState.restoreAttempt + 1 + runtimeState = runtimeState.copy(restoreAttempt = nextAttempt) + return listOf(ReaderWebViewRuntimeEffect.Restore(generation, target, nextAttempt)) + } + private fun handleFailure( generation: Long, reason: String @@ -131,6 +157,7 @@ internal class ReaderWebViewLoadController { committed = false, layoutMetrics = null, restoreIssued = false, + restoreAttempt = 0, error = reason ) return listOf( @@ -222,5 +249,7 @@ internal class ReaderWebViewLoadController { private companion object { const val PRIMARY_LOAD_ATTEMPT = 1 const val FALLBACK_LOAD_ATTEMPT = 2 + const val FIRST_RESTORE_ATTEMPT = 1 + const val MAX_RESTORE_ATTEMPTS = 5 } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt index 40fd72a9f..ba2befebc 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt @@ -9,7 +9,8 @@ internal sealed interface ReaderWebViewRuntimeEffect { data class Restore( val generation: Long, - val target: ReaderWebViewRestoreTarget + val target: ReaderWebViewRestoreTarget, + val attempt: Int = 1 ) : ReaderWebViewRuntimeEffect data class PublishReady( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt index 6d215c85a..9c4859448 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt @@ -64,10 +64,12 @@ internal class ReaderWebViewRuntimeOwner( webView.loadInlineFallbackNow() } } - is ReaderWebViewRuntimeEffect.Restore -> webView.restoreRuntimeTarget( - generation = effect.generation, - target = effect.target - ) { restored -> + is ReaderWebViewRuntimeEffect.Restore -> { + val restore = { + webView.restoreRuntimeTarget( + generation = effect.generation, + target = effect.target + ) { restored -> if (!restored) { Log.w(HTML_READER_TAG, "Restore target was not found for generation=${effect.generation}") } else { @@ -77,11 +79,18 @@ internal class ReaderWebViewRuntimeOwner( executeEffects( webView = webView, effects = loadController.dispatch( - ReaderWebViewRuntimeEvent.RestoreAcknowledged(effect.generation) + if (restored) { + ReaderWebViewRuntimeEvent.RestoreAcknowledged(effect.generation) + } else { + ReaderWebViewRuntimeEvent.RestoreRejected(effect.generation) + } ), onConsumeAnchor = onConsumeAnchor, onConsumeSection = onConsumeSection ) + } + } + if (effect.attempt == 1) restore() else webView.postDelayed(restore, RESTORE_RETRY_DELAY_MS) } is ReaderWebViewRuntimeEffect.PublishReady -> Unit is ReaderWebViewRuntimeEffect.ShowTerminalError -> { @@ -91,4 +100,8 @@ internal class ReaderWebViewRuntimeOwner( } } } + + private companion object { + const val RESTORE_RETRY_DELAY_MS = 80L + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt index 27d64c80e..99f8ad22c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt @@ -49,6 +49,7 @@ internal data class ReaderWebViewRuntimeState( val layoutMetrics: ReaderWebViewLayoutMetrics? = null, val restoreTarget: ReaderWebViewRestoreTarget? = null, val restoreIssued: Boolean = false, + val restoreAttempt: Int = 0, val error: String? = null ) @@ -66,6 +67,7 @@ internal sealed interface ReaderWebViewRuntimeEvent { ) : ReaderWebViewRuntimeEvent data class RestoreAcknowledged(val generation: Long) : ReaderWebViewRuntimeEvent + data class RestoreRejected(val generation: Long) : ReaderWebViewRuntimeEvent data class LoadFailed(val generation: Long, val reason: String) : ReaderWebViewRuntimeEvent data class ContentBlank(val generation: Long) : ReaderWebViewRuntimeEvent data object Disposed : ReaderWebViewRuntimeEvent diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt index 95bb5234d..14c09f2be 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt @@ -40,7 +40,13 @@ internal object TextBookSessionBridge { val title = item.title.trim() if (title.isNotBlank()) { resolveBookTocPageIndex(item, reader)?.let { pageIndex -> - entries += TocEntry(title = title, pageIndex = pageIndex) + val anchorId = item.locator?.fragment?.takeIf { it.isNotBlank() } + entries += TocEntry( + title = title, + pageIndex = pageIndex, + anchorId = anchorId, + sectionIndex = pageIndex + ) } } if (item.children.isNotEmpty()) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt index d504a26ff..082f33252 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -38,6 +39,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.ReaderImageScaleMode import io.leostrange.mrcomic.core.ui.eink.LocalEInkMode import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState @@ -59,6 +61,7 @@ fun PageView( ) { val isEInk = LocalEInkMode.current val isDualPage = uiState.readingMode == ReadingMode.DUAL_PAGE + val isDjvu = uiState.comic?.format == ComicFormat.DJVU val leftPage = uiState.currentPage val imageCrop = remember(marginCropHorizontal, marginCropVertical) { ReaderImageCrop( @@ -123,6 +126,8 @@ fun PageView( alignment = Alignment.CenterEnd, imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = if (isDjvu) (-8).dp else 0.dp, + pageOffsetY = if (isDjvu) 20.dp else 0.dp, modifier = Modifier.weight(1f) ) if (rightPage != null) { @@ -132,6 +137,8 @@ fun PageView( alignment = Alignment.CenterStart, imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = if (isDjvu) (-8).dp else 0.dp, + pageOffsetY = if (isDjvu) 20.dp else 0.dp, modifier = Modifier.weight(1f) ) } else { @@ -164,6 +171,8 @@ fun PageView( contentDescription = "Page ${page + 1}", imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = if (isDjvu) (-8).dp else 0.dp, + pageOffsetY = if (isDjvu) 20.dp else 0.dp, modifier = Modifier.fillMaxSize() ) } @@ -179,7 +188,9 @@ private fun PagePane( modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - crop: ReaderImageCrop = ReaderImageCrop() + crop: ReaderImageCrop = ReaderImageCrop(), + pageOffsetX: androidx.compose.ui.unit.Dp = 0.dp, + pageOffsetY: androidx.compose.ui.unit.Dp = 0.dp ) { BoxWithConstraints(modifier = modifier.clipToBounds(), contentAlignment = alignment) { if (bitmap == null) { @@ -222,6 +233,7 @@ private fun PagePane( modifier = Modifier .width(imageWidth) .height(imageHeight) + .offset(x = pageOffsetX, y = pageOffsetY) ) { CroppedBitmapImage( bitmap = bitmap, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt index 30ec24e7c..642585f07 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.locale.LocalStrings @@ -31,8 +32,14 @@ fun ReaderBottomBar( chapterTitle: String? = null, epubAccumulatedTotalPages: Int = 0, epubAccumulatedCurrentPage: Int = 0, + isTextPaginationResolved: Boolean = true, + isTextWebtoon: Boolean = false, + freeScrollProgression: Double = -1.0, + /** BUG-VERTICAL-01: Raster webtoon scroll progression for seekbar sync. */ + rasterWebtoonScrollProgression: Double = -1.0, onReadingModeChange: (ReadingMode) -> Unit, onPageChange: (Int) -> Unit, + onProgressionChange: ((Float) -> Unit)? = null, modifier: Modifier = Modifier ) { val strings = LocalStrings.current @@ -48,10 +55,14 @@ fun ReaderBottomBar( sectionPageCount = sectionPageCount, epubAccumulatedCurrentPage = epubAccumulatedCurrentPage, epubAccumulatedTotalPages = epubAccumulatedTotalPages, + isTextPaginationResolved = isTextPaginationResolved, ) val effectiveTotalPages = effectiveProgress.totalPages val effectiveCurrentPage = effectiveProgress.currentPage - val bookProgress = if (effectiveTotalPages > 0) ((effectiveCurrentPage + 1) * 100f / effectiveTotalPages).toInt() else 0 + // BUG-READER-04: Use same formula as database: currentPage / (pageCount - 1) + val bookProgress = if (effectiveTotalPages > 1) { + (effectiveCurrentPage.toFloat() / (effectiveTotalPages - 1) * 100f).toInt().coerceIn(0, 100) + } else 0 Column( modifier = modifier @@ -76,7 +87,13 @@ fun ReaderBottomBar( style = MaterialTheme.typography.labelMedium ) Text( - text = if (showSectionPage) "${sectionCurrentPage + 1}/$sectionPageCount" else "${currentPage + 1} / $totalPages", + text = if (showSectionPage && effectiveTotalPages <= sectionPageCount) { + "${sectionCurrentPage + 1}/$sectionPageCount" + } else if (!effectiveProgress.isResolved) { + "${effectiveCurrentPage + 1} / …" + } else { + "${effectiveCurrentPage + 1} / $effectiveTotalPages" + }, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium ) @@ -110,15 +127,21 @@ fun ReaderBottomBar( Spacer(Modifier.height(12.dp)) if (showPageCountText) { val counterText = when { + chapterTitle != null && !effectiveProgress.isResolved -> + "$chapterTitle (${effectiveCurrentPage + 1}/…)" chapterTitle != null -> "$chapterTitle (${effectiveCurrentPage + 1}/$effectiveTotalPages)" + !effectiveProgress.isResolved -> + "${effectiveCurrentPage + 1} / …" else -> "${effectiveCurrentPage + 1} / $effectiveTotalPages" } Text( text = counterText, color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) if (isTextBook && totalPages > 0) { Spacer(Modifier.height(2.dp)) @@ -134,7 +157,37 @@ fun ReaderBottomBar( } } - if (totalPages > 1) { + if (isTextWebtoon && freeScrollProgression in 0.0..1.0) { + // Continuous slider for text webtoon — uses scroll progression (0.0..1.0) + Slider( + value = freeScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { onProgressionChange?.invoke(it) }, + valueRange = 0f..1f, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant, + ) + ) + } else if (!isTextWebtoon && readingMode == ReadingMode.WEBTOON && rasterWebtoonScrollProgression in 0.0..1.0) { + // BUG-VERTICAL-01: Continuous slider for raster webtoon — uses tracked scroll progression. + // Converting progression fraction to page index so the user can scrub through the document. + Slider( + value = rasterWebtoonScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { fraction -> + val targetPage = (fraction * totalPages).toInt().coerceIn(0, (totalPages - 1).coerceAtLeast(0)) + onPageChange(targetPage) + }, + valueRange = 0f..1f, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant, + ) + ) + } else if (totalPages > 1) { Slider( value = currentPage.toFloat(), onValueChange = { onPageChange(it.toInt()) }, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt index 20fa18b8c..63cba5aeb 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt @@ -3,6 +3,7 @@ package io.leostrange.mrcomic.feature.reader.ui.components internal data class ReaderBottomProgress( val currentPage: Int, val totalPages: Int, + val isResolved: Boolean = true, ) internal fun resolveReaderBottomProgress( @@ -12,17 +13,21 @@ internal fun resolveReaderBottomProgress( sectionPageCount: Int, epubAccumulatedCurrentPage: Int, epubAccumulatedTotalPages: Int, + isTextPaginationResolved: Boolean = true, ): ReaderBottomProgress = when { isTextBook && epubAccumulatedTotalPages > 0 -> ReaderBottomProgress( currentPage = epubAccumulatedCurrentPage.coerceIn(0, epubAccumulatedTotalPages - 1), totalPages = epubAccumulatedTotalPages, + isResolved = isTextPaginationResolved, ) isTextBook && sectionPageCount > 0 -> ReaderBottomProgress( currentPage = currentPage.coerceAtLeast(0), totalPages = totalPages.coerceAtLeast(1), + isResolved = isTextPaginationResolved, ) else -> ReaderBottomProgress( currentPage = currentPage.coerceAtLeast(0), totalPages = totalPages.coerceAtLeast(1), + isResolved = true, ) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt index 2bdc4cfd8..0edc1f0aa 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt @@ -104,21 +104,49 @@ fun WebtoonView( viewModel.pageLoader.preloadWebtoonWindow(pages) } - // User scrolled the list в†’ update the ViewModel's current page. - // snapshotFlow + distinctUntilChanged prevents re-entrancy: the emission - // only fires when firstVisibleItemIndex *actually* changes, and navigateTo() - // updating uiState.currentPage does NOT scroll the list here (that's the - // second effect below), so there is no feedback loop. + // Guard against feedback loop: suppress scroll->page sync while applying external page->scroll. + var suppressScrollSync by remember(uiState.comic?.id) { mutableStateOf(false) } + + // User scrolled the list -> update the ViewModel’s current page. LaunchedEffect(listState, uiState.comic?.id) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() - .collect { index -> viewModel.navigationController.navigateTo(index, ReaderNavigationProgressSource.JUMP) } + .collect { index -> + if (!suppressScrollSync) { + viewModel.navigationController.navigateTo(index, ReaderNavigationProgressSource.JUMP) + } + } + } + + // BUG-VERTICAL-01: Track scroll progression (0..1) for seekbar synchronization. + LaunchedEffect(listState, uiState.comic?.id, uiState.totalPages) { + snapshotFlow { + val totalItems = uiState.totalPages.coerceAtLeast(1) + val firstVisible = listState.firstVisibleItemIndex + val scrollOffset = listState.firstVisibleItemScrollOffset + val visibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() + val itemHeight = visibleItem?.size?.coerceAtLeast(1) ?: 1 + val progression = if (totalItems > 0) { + ((firstVisible.toFloat() + scrollOffset.toFloat() / itemHeight) / totalItems) + .coerceIn(0f, 1f) + } else 0f + progression + } + .distinctUntilChanged() + .debounce(50L) + .collect { progression -> + if (!suppressScrollSync) { + viewModel.onRasterWebtoonScrollProgressionChanged(progression) + } + } } - // External page change (e.g. bottom bar slider) в†’ scroll the list. + // External page change (e.g. bottom bar slider) -> scroll the list. LaunchedEffect(uiState.comic?.id, uiState.currentPage) { if (listState.firstVisibleItemIndex != uiState.currentPage) { + suppressScrollSync = true listState.scrollToItem(uiState.currentPage) + suppressScrollSync = false } if (uiState.totalPages > 0) { val start = (uiState.currentPage - webtoonPreloadBehind).coerceAtLeast(0) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt index 264898a84..dd20a20e1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt @@ -39,28 +39,31 @@ data class ReaderViewportGeometry( /** * Top inset for CSS injection (in CSS pixels). - * Accounts for: status bar + cutout + toolbar (if visible) + reader padding. + * Accounts for: toolbar (if visible) + reader padding + safety margin. + * Note: System insets (status bar, cutout) are NOT included — + * they are already handled by Compose WindowInsetsPadding modifier. * VERTICAL-01: Includes safety margin for edge-to-edge mode. */ val contentTopInsetCssPx: Int get() { - val systemInset = maxOf(statusBarInsetPx, displayCutoutInsetPx) val chromeReserve = if (hideToolbarsWhileReading) 0 else topToolbarHeightPx val safetyMarginPx = if (hideToolbarsWhileReading) MIN_SAFETY_MARGIN_PX else 0 - val totalPx = systemInset + chromeReserve + readerTopPaddingPx + safetyMarginPx + val totalPx = chromeReserve + readerTopPaddingPx + safetyMarginPx return (totalPx / densityScale).roundToInt().coerceAtLeast(MIN_INSET_CSS_PX) } /** * Bottom inset for CSS injection (in CSS pixels). - * Accounts for: navigation bar + toolbar (if visible) + reader padding. - * VERTICAL-02: Includes safety margin for edge-to-edge mode. + * Accounts for: toolbar (if visible) + reader padding + safety margin. + * Note: System insets (navigation bar, cutout) are NOT included — + * they are already handled by Compose WindowInsetsPadding modifier. + * BUG-PAGED-02: Use symmetric calculation with top inset. */ val contentBottomInsetCssPx: Int get() { val chromeReserve = if (hideToolbarsWhileReading) 0 else bottomToolbarHeightPx val safetyMarginPx = if (hideToolbarsWhileReading) MIN_SAFETY_MARGIN_PX else 0 - val totalPx = navigationBarInsetPx + chromeReserve + readerBottomPaddingPx + safetyMarginPx + val totalPx = chromeReserve + readerBottomPaddingPx + safetyMarginPx return (totalPx / densityScale).roundToInt().coerceAtLeast(MIN_INSET_CSS_PX) } @@ -77,11 +80,13 @@ data class ReaderViewportGeometry( /** * Bottom inset for paged layout calculation (in physical pixels). + * BUG-PAGED-02: Use symmetric calculation with top inset. */ val contentBottomInsetPx: Int get() { + val systemInset = maxOf(navigationBarInsetPx, displayCutoutInsetPx) val chromeReserve = if (hideToolbarsWhileReading) 0 else bottomToolbarHeightPx - return navigationBarInsetPx + chromeReserve + readerBottomPaddingPx + return systemInset + chromeReserve + readerBottomPaddingPx } /** @@ -104,12 +109,15 @@ data class ReaderViewportGeometry( // ── Chrome-reserve-only CSS insets ───────────────────────────────── // System bars are handled by Compose WindowInsetsPadding modifiers and // the sentence gutter by vertical padding on the text reader modifier; - // only the visible chrome reserve is injected into CSS. When toolbars - // are hidden the reserve (and therefore the CSS inset) is zero. + // only the chrome reserve is injected into CSS. The value is constant + // while toolbars are pinned (auto-hide off), so toggling chrome never + // reflows text (BUG-PAGED-02 / T1/T2). /** * Top chrome-reserve inset in CSS pixels. - * Returns 0 when toolbars are hidden. + * Constant for pinned toolbars regardless of transient visibility. + * Note: System insets (status bar, cutout) are NOT included here — + * they are already handled by Compose WindowInsetsPadding modifier. */ val chromeTopInsetCssPx: Int get() { @@ -120,6 +128,8 @@ data class ReaderViewportGeometry( /** * Bottom chrome-reserve inset in CSS pixels. * Returns 0 when toolbars are hidden. + * Note: System insets (navigation bar, cutout) are NOT included here — + * they are already handled by Compose WindowInsetsPadding modifier. */ val chromeBottomInsetCssPx: Int get() { @@ -129,9 +139,9 @@ data class ReaderViewportGeometry( companion object { /** Minimum safety margin in physical pixels for edge-to-edge mode. */ - private const val MIN_SAFETY_MARGIN_PX = 8 + private const val MIN_SAFETY_MARGIN_PX = 4 /** Minimum CSS inset to prevent text from touching screen edges. */ - private const val MIN_INSET_CSS_PX = 4 + private const val MIN_INSET_CSS_PX = 2 /** * Creates a [ReaderViewportGeometry] from measured Compose values. * diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt index ccb9e953f..a0f384f11 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt @@ -35,7 +35,7 @@ object PagedLayoutParams { ): Int { val lineHeight = if (lineHeightPx > 0f) lineHeightPx else 27f // 18sp * 1.5 val clipHeight = max(lineHeight * 3, viewportHeightPx.toFloat()) - val safetyMargin = max(2f, lineHeight * 0.12f) + val safetyMargin = max(6f, kotlin.math.ceil(lineHeight * 0.25f)) val rawUsableHeight = max(lineHeight * 3, clipHeight - topInsetPx - bottomInsetPx - safetyMargin) val usableLineCount = max(3, floor(rawUsableHeight / lineHeight).toInt()) return max(lineHeight * 3, usableLineCount * lineHeight).toInt() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt index 5d1c3120a..a9bcdfa26 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt @@ -10,6 +10,9 @@ import io.leostrange.mrcomic.core.ui.theme.ReadingPreset */ internal object ReaderColorScheme { + /** The only quick schemes exposed by the graphic (PDF/DJVU/raster) reader. */ + val graphicQuickChoices: List = listOf("DAY", "SEPIA", "NIGHT") + /** Background → foreground color pair for a named color scheme. */ fun palette(scheme: String): Pair = when (scheme) { "SEPIA" -> "#f4ecd8" to "#3b2a1a" diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt index 22543f521..35bed2003 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt @@ -16,6 +16,7 @@ internal suspend fun persistReaderStylePresetSnapshot( readerPreferences.set(PreferencesKeys.READER_PRESET, snapshot.readerPreset) readerPreferences.set(PreferencesKeys.TEXT_FONT_SIZE, snapshot.textFontSize) readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, snapshot.textColorScheme) + readerPreferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, snapshot.textColorScheme) readerPreferences.set(PreferencesKeys.TEXT_FONT_FAMILY, snapshot.textFontFamily) readerPreferences.set(PreferencesKeys.TEXT_LINE_HEIGHT, snapshot.textLineHeight) readerPreferences.set(PreferencesKeys.TEXT_LETTER_SPACING, snapshot.textLetterSpacing) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt index af5c801da..ec1cd9387 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt @@ -5,6 +5,7 @@ import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState +import io.leostrange.mrcomic.feature.reader.ui.isTextContainer /** * Pure state reducer for reader style presets. @@ -37,8 +38,11 @@ object ReaderStylePresetReducer { /** * Applies a built-in [ReadingPreset] to the state. * - * For non-CUSTOM presets, all typography fields are replaced with the - * preset's values and custom colors are cleared. Chrome is expanded. + * BUG-PAGED-02 / T3: Only colors and chrome-related settings are updated + * when switching presets. Typography (fontSize, lineHeight, letterSpacing, + * wordSpacing, paragraphSpacing, alignment, bold, fontFamily) is preserved + * from the current state so color-only preset changes don't trigger + * re-pagination or shift text. * * For [ReadingPreset.CUSTOM], only the preset name is updated (the user * keeps their current typography values). @@ -54,19 +58,16 @@ object ReaderStylePresetReducer { return state.copy( readerPreset = preset.name, textColorScheme = style.textColorScheme, + graphicColorScheme = style.textColorScheme, textCustomTextColor = null, textCustomBackgroundColor = null, textCustomAccentColor = null, - textFontFamily = style.fontFamily, - textLineHeight = style.lineHeight, - textLetterSpacing = style.letterSpacing, - textWordSpacing = style.wordSpacing, - textParagraphSpacing = style.paragraphSpacing, - textAlignment = style.textAlignment, - textBold = style.textBold, immersiveMode = style.immersiveMode, readerPageAnimation = style.pageAnimation, chromeState = ReaderChromeState.EXPANDED + // Typography fields (textFontFamily, textLineHeight, textLetterSpacing, + // textWordSpacing, textParagraphSpacing, textAlignment, textBold) are + // deliberately preserved from the current state to avoid re-pagination. ) } @@ -91,6 +92,7 @@ object ReaderStylePresetReducer { readerPreset = ReadingPreset.CUSTOM.name, textFontSize = DEFAULT_FONT_SIZE, textColorScheme = DEFAULT_COLOR_SCHEME, + graphicColorScheme = io.leostrange.mrcomic.feature.reader.ui.DEFAULT_GRAPHIC_COLOR_SCHEME, textCustomTextColor = null, textCustomBackgroundColor = null, textCustomAccentColor = null, @@ -120,8 +122,14 @@ object ReaderStylePresetReducer { fun setFontSize(state: ReaderUiState, size: Int): ReaderUiState = state.copy(readerPreset = ReadingPreset.CUSTOM.name, textFontSize = size) - fun setColorScheme(state: ReaderUiState, scheme: String): ReaderUiState = - state.copy(readerPreset = ReadingPreset.CUSTOM.name, textColorScheme = scheme) + fun setColorScheme(state: ReaderUiState, scheme: String): ReaderUiState { + val isText = state.readerContainerKind.isTextContainer() + return state.copy( + readerPreset = ReadingPreset.CUSTOM.name, + textColorScheme = if (isText) scheme else state.textColorScheme, + graphicColorScheme = if (!isText) scheme else state.graphicColorScheme + ) + } fun setFontFamily(state: ReaderUiState, family: String): ReaderUiState = state.copy(readerPreset = ReadingPreset.CUSTOM.name, textFontFamily = family) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt index 99ab709bf..561775bdd 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt @@ -33,6 +33,7 @@ internal fun ReaderUiState.applyReaderStylePreset( readerPreset = snapshot.readerPreset, textFontSize = snapshot.textFontSize, textColorScheme = snapshot.textColorScheme, + graphicColorScheme = snapshot.textColorScheme, textCustomTextColor = snapshot.textCustomTextColor, textCustomBackgroundColor = snapshot.textCustomBackgroundColor, textCustomAccentColor = snapshot.textCustomAccentColor, diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt index 8b069a919..83eb54d61 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt @@ -139,4 +139,21 @@ class ReaderPositionCodecTest { assertFalse(json.isBlank()) assertTrue(json.contains("\"s\":0")) } + + // ── BUG-READER-02: mode must survive roundtrip for all reading modes ── + + @Test + fun roundTrip_preservesAllReadingModes() { + // Every reading mode must survive a JSON roundtrip so per-book mode restore works. + for (mode in ReadingMode.entries) { + val position = ReaderPosition( + engineSectionIndex = 5, + mode = mode, + schemaVersion = ReaderPosition.SCHEMA_VERSION, + ) + val decoded = ReaderPositionCodec.decode(ReaderPositionCodec.encode(position)) + assertNotNull("Mode $mode must survive roundtrip", decoded) + assertEquals("Mode $mode must be preserved", mode, decoded!!.mode) + } + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt index 6448d0187..ded2098cd 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.reader.ui import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class EpubProgressCalculatorTest { @@ -71,4 +72,117 @@ class EpubProgressCalculatorTest { assertEquals(18, first.accumulatedCurrentPage) assertEquals(first, second) } + + @Test + fun accumulateKeepsTheSessionEstimateStableAsMoreSectionsAreMeasured() { + val firstMeasurement = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 2), + sectionIndex = 0, + sectionPageIndex = 0, + totalSections = 4, + stableEstimateOverride = 2 + ) + val afterAnotherSection = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 2, 1 to 8), + sectionIndex = 1, + sectionPageIndex = 0, + totalSections = 4, + stableEstimateOverride = 2 + ) + + assertEquals(8, firstMeasurement.accumulatedTotalPages) + assertEquals(8, afterAnotherSection.accumulatedTotalPages) + } + + /** + * T3 regression: when totalSections is provisional (deferred page-count still resolving) + * and is smaller than the section the user is currently in, the accumulated current page + * must not exceed the accumulated total. Before the fix, total could be smaller than + * current (e.g. current=63, total=12) causing the progress to show 100%. + */ + @Test + fun accumulateCoversCurrentSectionWhenTotalSectionsIsProvisional() { + // Simulates: EPUB with 100 sections, but deferred count only knows about 1. + // User is on section 5 with 12 visual pages, on page 3. + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(5 to 12), + sectionIndex = 5, + sectionPageIndex = 3, + totalSections = 1 // provisional — hasn't resolved yet + ) + + // effectiveTotalSections = max(1, 5+1) = 6 + // visitedTotal = 12, stableEstimate = 12 + // total = 12 + 12 * (6 - 1) = 12 + 60 = 72 + // current = 12*5 + 3 = 63 + assertEquals(72, progress.accumulatedTotalPages) + assertEquals(63, progress.accumulatedCurrentPage) + } + + /** + * T3 regression: accumulatedCurrentPage must never exceed accumulatedTotalPages, + * even in edge cases with very small provisional totalSections. + */ + @Test + fun accumulateNeverShowsPageBeyondTotal() { + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 20), + sectionIndex = 0, + sectionPageIndex = 19, + totalSections = 0 + ) + + // effectiveTotalSections = max(0, 0+1) = 1 + // total = 20, current = 19 + assertEquals(20, progress.accumulatedTotalPages) + assertEquals(19, progress.accumulatedCurrentPage) + assertTrue( + "Current page must not exceed total pages", + progress.accumulatedCurrentPage <= progress.accumulatedTotalPages + ) + } + + /** + * T3 regression: when the user is in the last section and totalSections is accurate, + * the progress should reach 100% only at the very last visual page. + */ + @Test + fun accumulateReachesHundredPercentOnlyAtLastPage() { + val lastSectionProgress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 10, 1 to 5), + sectionIndex = 1, + sectionPageIndex = 4, + totalSections = 2 + ) + + // current = 10 + 4 = 14, total = 15 + assertEquals(15, lastSectionProgress.accumulatedTotalPages) + assertEquals(14, lastSectionProgress.accumulatedCurrentPage) + assertTrue(lastSectionProgress.isResolved) + } + + @Test + fun isResolvedReturnsFalseWhenUnvisitedSectionsRemain() { + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 10), + sectionIndex = 0, + sectionPageIndex = 2, + totalSections = 5 + ) + assertEquals(false, progress.isResolved) + assertEquals(false, EpubProgressCalculator.isResolved(mapOf(0 to 10), 5)) + } + + @Test + fun isResolvedReturnsTrueWhenAllSectionsMeasured() { + val map = mapOf(0 to 10, 1 to 12, 2 to 8) + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = map, + sectionIndex = 1, + sectionPageIndex = 2, + totalSections = 3 + ) + assertEquals(true, progress.isResolved) + assertEquals(true, EpubProgressCalculator.isResolved(map, 3)) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt index e8c16b31f..73c1b8f83 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt @@ -108,39 +108,22 @@ class ReaderAutoScrollDispatchTest { } @Test - fun `hidden chrome reserves a dock only for paged readers`() { - assertEquals( - 72, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.TEXT_PAGE, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.TEXT_WEBTOON, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.RASTER_WEBTOON, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.RASTER_PAGE, - chromeHidden = true, - enabled = false, - ), - ) + fun `auto scroll dock height is always zero — page turn uses timer, not scroll`() { + ReaderContainerKind.entries.forEach { kind -> + listOf(true, false).forEach { chromeHidden -> + listOf(true, false).forEach { enabled -> + assertEquals( + "kind=$kind chromeHidden=$chromeHidden enabled=$enabled", + 0, + readerAutoScrollDockHeightDp( + containerKind = kind, + chromeHidden = chromeHidden, + enabled = enabled, + ), + ) + } + } + } } @Test diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt index f4a6b34b4..2889caa81 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt @@ -309,6 +309,44 @@ class ReaderBookOpeningControllerTest { greenPath ) } - // (close-tests are covered below by the dedicated SessionCoordinatorTest; - // this file focuses on the open-side integration only.) + // ── BUG-READER-03: legacy fallback must route through planReaderPositionRestore ── + + @Test + fun legacyFallback_currentPageBecomesStartPage() = runTest { + // A legacy record has readerPositionJson=null and currentPage=5. + // The opening pipeline must synthesize a ReaderPosition from currentPage and + // route it through planReaderPositionRestore so the structured path is always used. + val comic = comic(currentPage = 5) + createController(fetchResult = comic) + + assertEquals("Legacy currentPage=5 should become startPage=5", 5, uiState.value.currentPage) + assertEquals("c1", uiState.value.comic?.id) + } + + @Test + fun legacyFallback_zeroPageStartsAtZero() = runTest { + // currentPage=0 with no structured position means "start from the beginning". + val comic = comic(currentPage = 0) + createController(fetchResult = comic) + + assertEquals(0, uiState.value.currentPage) + } + + @Test + fun structuredPosition_takesPrecedenceOverCurrentPage() = runTest { + // When a valid readerPositionJson exists, it must be used instead of currentPage. + val structuredPosition = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition( + engineSectionIndex = 3, + mode = io.leostrange.mrcomic.core.model.ReadingMode.PAGE_LTR, + ) + val encodedJson = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPositionCodec.encode(structuredPosition) + val comic = comic(currentPage = 99).copy(readerPositionJson = encodedJson) + createController(fetchResult = comic) + + assertEquals( + "Structured position (section=3) must win over legacy currentPage=99", + 3, + uiState.value.currentPage + ) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt index 32a346ea7..ba3087906 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt @@ -121,6 +121,24 @@ class ReaderHtmlCssJsTest { ) } + @Test + fun textSettingsJs_justifiesOnlyReadingParagraphsAndKeepsTheirLastLineNatural() { + val js = textSettingsJs( + fontSize = 18, + bg = "#fafafa", + fg = "#1a1a1a", + align = "justify", + pagedMode = true + ) + + assertTrue(js.contains("text-align-last:start !important")) + assertTrue(js.contains("hyphens:auto !important")) + assertFalse( + "headings and structural containers must retain publisher alignment", + js.contains("querySelectorAll('p,div,section,article,blockquote,li,td,th,h1,h2,h3,h4,h5,h6')") + ) + } + @Test fun tapHandler_blocksSelectionInPagedMode() { assertTrue(JS_TAP_HANDLER.contains("window.__mrcomicPagedModeScrollLock||hasActivePagedLayout()")) @@ -128,6 +146,12 @@ class ReaderHtmlCssJsTest { assertTrue(JS_TAP_HANDLER.contains("e.preventDefault();")) } + @Test + fun tapHandler_distinguishesAHandleDragFromSelectionCreatedByPageSwipe() { + assertTrue(JS_TAP_HANDLER.contains("window.__readerHadSelectionAtTouchStart")) + assertTrue(JS_TAP_HANDLER.contains("&&!window.__readerHadSelectionAtTouchStart")) + } + @Test fun tapHandler_resolvesFootnoteAtTapPointBeforePagedEdgeFallback() { assertTrue(JS_TAP_HANDLER.contains("function footnoteLinkAtEvent(e)")) @@ -305,7 +329,7 @@ class ReaderHtmlCssJsTest { ) assertTrue( "media pages keep the bottom shield below the last content line", - js.contains("var shieldTop=Math.max(") && js.contains("rawVisibleHeight+1") + js.contains("var shieldTop=Math.max(") && js.contains("rawVisibleHeight+2") ) } @@ -317,7 +341,7 @@ class ReaderHtmlCssJsTest { assertTrue( "shield must start after the last content line", - js.contains("rawVisibleHeight+1") + js.contains("rawVisibleHeight+2") ) assertFalse( "shield must not be raised by a bottom text gutter", @@ -359,7 +383,7 @@ class ReaderHtmlCssJsTest { assertTrue( "the bottom shield must not cover the last legal line", - js.contains("rawVisibleHeight+1") + js.contains("rawVisibleHeight+2") ) } @@ -426,7 +450,7 @@ class ReaderHtmlCssJsTest { } @Test - fun readerHtmlPageSourceReloadKeyUsesDocumentIdentityOnly() { + fun readerHtmlPageSourceReloadKeyUsesDocumentIdentityAndTheme() { val key = readerHtmlPageSourceReloadKey( html = "

Hello

", resolvedBaseUrl = "https://appassets.androidplatform.net/content/book.xhtml", diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt index bafd5d980..8bdeb120f 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt @@ -77,6 +77,80 @@ class ReaderMaterialColorSchemeTest { assertTrue("Expected ≥ 3:1, got $ratio", ratio >= 3f) } + @Test + fun `raster SEPIA preset changes background to warm tone`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + // Sepia background should be warm (R > B) + assertTrue("Expected warm background", scheme.background.red > scheme.background.blue) + } + + @Test + fun `raster NIGHT preset changes background to dark`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.NIGHT_INK, + textColorScheme = "NIGHT", + fallback = darkColorScheme() + ) + assertTrue("Expected dark background", scheme.background.luminance() < 0.1f) + val ratio = contrastRatio(scheme.onBackground, scheme.background) + assertTrue("Expected ≥ 3:1, got $ratio", ratio >= 3f) + } + + @Test + fun `raster OLED preset uses pure black background`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.OLED_BLACK, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + assertTrue("Expected pure black", scheme.background == Color(0xFF000000)) + } + + @Test + fun `raster default preset differs from SEPIA`() { + val default = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + val sepia = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + assertTrue("Backgrounds should differ", default.background != sepia.background) + } + + // ── BUG-UI-02: DAY-scheme presets must have distinct backgrounds in raster reader ── + + @Test + fun `raster PAPER, NEWSPAPER, and EINK have distinct backgrounds`() { + val paper = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + val newspaper = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.NEWSPAPER, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + val eink = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.EINK, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + assertTrue("PAPER background ≠ NEWSPAPER background", paper.background != newspaper.background) + assertTrue("PAPER background ≠ EINK background", paper.background != eink.background) + assertTrue("NEWSPAPER background ≠ EINK background", newspaper.background != eink.background) + } + @Test fun `EINK preset maintains high contrast`() { val scheme = readerMaterialColorScheme( @@ -90,4 +164,51 @@ class ReaderMaterialColorSchemeTest { assertTrue("Background contrast ≥ 4.5:1, got $bgRatio", bgRatio >= 4.5f) assertTrue("Surface contrast ≥ 4.5:1, got $surfaceRatio", surfaceRatio >= 4.5f) } + + // ── BUG-UI-05: raster light presets must resolve to LIGHT color schemes so + // switches, sliders and progress bars follow the Day/Sepia preset ───────────── + + private fun isLightScheme(scheme: androidx.compose.material3.ColorScheme): Boolean = + scheme.primaryContainer.luminance() > 0.5f && + scheme.secondaryContainer.luminance() > 0.5f && + scheme.surfaceContainerHigh.luminance() > 0.5f + + @Test + fun `raster DAY preset resolves to a light scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + assertTrue( + "Expected light companions for raster DAY, primaryContainer lum=${scheme.primaryContainer.luminance()}", + isLightScheme(scheme) + ) + } + + @Test + fun `raster SEPIA preset resolves to a light scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + assertTrue("Expected light companions for raster SEPIA", isLightScheme(scheme)) + } + + @Test + fun `raster NIGHT preset stays a dark scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.NIGHT_INK, + textColorScheme = "NIGHT", + fallback = darkColorScheme() + ) + assertTrue( + "Expected dark companions for raster NIGHT", + scheme.primaryContainer.luminance() < 0.3f + ) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt index 2c7670e8e..fdf97a524 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt @@ -53,6 +53,28 @@ class ReaderWebViewRuntimeControllerTest { assertTrue(controller.dispatch(ReaderWebViewRuntimeEvent.RestoreAcknowledged(2L)).isEmpty()) } + @Test + fun rejectedRestoreRetriesBeforePublishingReady() { + val controller = ReaderWebViewLoadController() + controller.dispatch(ReaderWebViewRuntimeEvent.LoadRequested("book/page", 12L, target)) + controller.dispatch(ReaderWebViewRuntimeEvent.DocumentCommitted(12L)) + controller.dispatch(ReaderWebViewRuntimeEvent.LayoutReady(12L, metrics)) + + repeat(4) { rejectedAttempt -> + assertEquals( + listOf(ReaderWebViewRuntimeEffect.Restore(12L, target, rejectedAttempt + 2)), + controller.dispatch(ReaderWebViewRuntimeEvent.RestoreRejected(12L)) + ) + assertEquals(ReaderWebViewRuntimePhase.RESTORING, controller.runtimeState.phase) + } + assertEquals( + listOf(ReaderWebViewRuntimeEffect.PublishReady(12L, metrics)), + controller.dispatch(ReaderWebViewRuntimeEvent.RestoreRejected(12L)) + ) + assertEquals(ReaderWebViewRuntimePhase.READY, controller.runtimeState.phase) + assertEquals(target, controller.runtimeState.restoreTarget) + } + @Test fun staleGenerationEventsNeverMutateTheActiveLoad() { val controller = ReaderWebViewLoadController() diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt index 296b71c9c..7baaf0902 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt @@ -19,4 +19,84 @@ class ReaderBottomProgressPolicyTest { assertEquals(14, progress.currentPage) assertEquals(352, progress.totalPages) } + + /** + * T3 regression: when accumulated pages are available, they must always be preferred + * even when sectionPageCount is also present. + */ + @Test + fun textBook_accumulatedPagesAlwaysWinOverSectionFallback() { + val progress = resolveReaderBottomProgress( + currentPage = 10, + totalPages = 50, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 200, + epubAccumulatedTotalPages = 800, + ) + + assertEquals(200, progress.currentPage) + assertEquals(800, progress.totalPages) + } + + /** + * T3 regression: non-text books should use raw page values regardless of accumulated data. + */ + @Test + fun nonTextBook_ignoresAccumulatedEpubData() { + val progress = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 20, + isTextBook = false, + sectionPageCount = 0, + epubAccumulatedCurrentPage = 999, + epubAccumulatedTotalPages = 999, + ) + + assertEquals(5, progress.currentPage) + assertEquals(20, progress.totalPages) + } + + /** + * T3 regression: text book without accumulated data falls back to raw values. + */ + @Test + fun textBook_fallbackToRawValuesWhenNoAccumulatedData() { + val progress = resolveReaderBottomProgress( + currentPage = 3, + totalPages = 15, + isTextBook = true, + sectionPageCount = 10, + epubAccumulatedCurrentPage = 0, + epubAccumulatedTotalPages = 0, + ) + + assertEquals(3, progress.currentPage) + assertEquals(15, progress.totalPages) + } + + @Test + fun textBook_preservesIsResolvedFlag() { + val unresolved = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 91, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 14, + epubAccumulatedTotalPages = 352, + isTextPaginationResolved = false, + ) + assertEquals(false, unresolved.isResolved) + + val resolved = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 91, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 14, + epubAccumulatedTotalPages = 352, + isTextPaginationResolved = true, + ) + assertEquals(true, resolved.isResolved) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt index 39906f4f0..b8ba7d26a 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt @@ -44,11 +44,12 @@ class ReaderViewportGeometryTest { densityScale = 2.75f ) - // VERTICAL-01/02: hidden toolbars add 8px safety margin - // top: (84 + 0 + 0 + 8) / 2.75 ≈ 33 CSS px - assertEquals(33, geo.contentTopInsetCssPx) - // bottom: (126 + 0 + 0 + 8) / 2.75 ≈ 49 CSS px - assertEquals(49, geo.contentBottomInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets — + // they are already handled by Compose WindowInsetsPadding modifier. + // top: round((4 safety) / 2.75) = 1 → coerced up to MIN_INSET_CSS_PX = 2 + assertEquals(2, geo.contentTopInsetCssPx) + // bottom: same symmetric calculation → 2 + assertEquals(2, geo.contentBottomInsetCssPx) } // ── Toolbars visible ──────────────────────────────────────────────── @@ -85,10 +86,11 @@ class ReaderViewportGeometryTest { densityScale = 2.75f ) - // 252 / 2.75 ≈ 92 CSS px - assertEquals(92, geo.contentTopInsetCssPx) - // 318 / 2.75 ≈ 116 CSS px - assertEquals(116, geo.contentBottomInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets. + // top: (168 chrome + 0 reader padding + 0 safety) / 2.75 ≈ 61 CSS px + assertEquals(61, geo.contentTopInsetCssPx) + // bottom: (192 chrome + 0 reader padding + 0 safety) / 2.75 ≈ 70 CSS px + assertEquals(70, geo.contentBottomInsetCssPx) } // ── Display cutout ────────────────────────────────────────────────── @@ -193,8 +195,9 @@ class ReaderViewportGeometryTest { ) // Should not crash, densityScale clamped to 1 - // VERTICAL-01: safety margin 8px added when toolbars hidden - assertEquals(92, geo.contentTopInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets. + // top: (0 chrome + 0 reader padding + 4 safety) / 1 = 4 CSS px + assertEquals(4, geo.contentTopInsetCssPx) } // ── Chrome-reserve-only CSS insets (LAYOUT-02) ───────────────────── diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt index a7fb5d1f4..2619a6b6e 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt @@ -302,4 +302,189 @@ class PagedGesturePolicyTest { ) ) } + + // ── BUG-T4 regression: footnote link at screen edge ──────────────────── + + /** + * When the user taps a footnote link at the left edge (xPercent=0.05), + * the gesture policy must return PASS_THROUGH so the WebView can handle + * the link click, not turn the page. + */ + @Test + fun classifyPagedGesture_tapOnFootnoteAtLeftEdge_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.05f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * When the user taps a footnote link at the right edge (xPercent=0.95), + * the gesture policy must return PASS_THROUGH so the WebView can handle + * the link click, not turn the page. + */ + @Test + fun classifyPagedGesture_tapOnFootnoteAtRightEdge_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.95f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * When a tap at the left edge is NOT on a link, it should still be + * classified as TAP_LEFT (page turn) — this is the normal edge-tap + * behavior that must not be broken. + */ + @Test + fun classifyPagedGesture_edgeTapNotOnLink_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.05f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * Edge tap on a link in the center zone should still pass through + * (the link click should be handled by WebView). + */ + @Test + fun classifyPagedGesture_linkInCenter_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + // ── BUG-T5 regression: accidental selection during swipe ─────────────── + + /** + * When the finger has moved and there's no active selection, selection + * should always be suppressed — even when the user has held for >500ms + * before moving. + */ + @Test + fun shouldSuppressSelectionOnMove_movedEvenWithLongHold_returnsTrue() { + assertTrue( + PagedGesturePolicy.shouldSuppressSelectionOnMove( + hasMoved = true, + hasActiveSelection = false + ) + ) + } + + /** + * A slow vertical swipe (small dx, moderate dy) should be classified + * as RESOLVED (consumed as page gesture), preventing any selection. + */ + @Test + fun classifyPagedGesture_slowVerticalSwipe_resolved() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 3f, dy = 35f, + elapsed = 600L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.RESOLVED, result) + } + + /** + * When the user is dragging a selection handle (hasActiveSelection=true), + * even a large vertical swipe should pass through so the WebView handles + * the selection extension. + */ + @Test + fun classifyPagedGesture_verticalSwipeWithActiveSelection_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 3f, dy = 50f, + elapsed = 400L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = true, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * A horizontal swipe with small vertical component should still be + * classified as a page turn (TAP_LEFT), not pass through. + */ + @Test + fun classifyPagedGesture_horizontalSwipeWithSmallVertical_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = -70f, dy = 15f, + elapsed = 400L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * A tap near the left edge that hasn't moved and isn't on a link should + * be TAP_LEFT (normal page-turn edge tap). + */ + @Test + fun classifyPagedGesture_nearLeftEdgeTap_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 1f, dy = 1f, + elapsed = 100L, + xPercent = 0.10f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * A tap near the right edge that hasn't moved and isn't on a link should + * be TAP_RIGHT (normal page-turn edge tap). + */ + @Test + fun classifyPagedGesture_nearRightEdgeTap_tapRight() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 1f, dy = 1f, + elapsed = 100L, + xPercent = 0.90f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_RIGHT, result) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt index a55f53e7a..07d41ba4b 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt @@ -10,6 +10,11 @@ import org.junit.Test */ class ReaderColorSchemeTest { + @Test + fun graphicQuickChoices_exposesOnlyDaySepiaAndNight() { + assertEquals(listOf("DAY", "SEPIA", "NIGHT"), ReaderColorScheme.graphicQuickChoices) + } + @Test fun palette_dayReturnsLightColors() { val (bg, fg) = ReaderColorScheme.palette("DAY") diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt index 112594487..388c6faa6 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt @@ -4,6 +4,7 @@ import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot +import io.leostrange.mrcomic.feature.reader.ui.ReaderContainerKind import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -20,7 +21,7 @@ class ReaderStylePresetReducerTest { // ── Apply built-in preset ────────────────────────────────────────────── @Test - fun applyBuiltInPreset_setsAllTypographyFields() { + fun applyBuiltInPreset_setsColorSchemeAndClearsCustomColors() { val before = ReaderUiState( readerPreset = ReadingPreset.CUSTOM.name, textFontSize = 30, @@ -44,14 +45,17 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.PAPER.name, after.readerPreset) val style = ReadingPreset.PAPER.style() + // Color scheme is updated to preset (both text and graphic for global presets) assertEquals(style.textColorScheme, after.textColorScheme) - assertEquals(style.fontFamily, after.textFontFamily) - assertEquals(style.lineHeight, after.textLineHeight, 0.001f) - assertEquals(style.letterSpacing, after.textLetterSpacing, 0.001f) - assertEquals(style.wordSpacing, after.textWordSpacing, 0.001f) - assertEquals(style.paragraphSpacing, after.textParagraphSpacing, 0.001f) - assertEquals(style.textAlignment, after.textAlignment) - assertEquals(style.textBold, after.textBold) + assertEquals(style.textColorScheme, after.graphicColorScheme) + // Typography is PRESERVED from the current state (BUG-PAGED-02 / T3) + assertEquals(before.textFontFamily, after.textFontFamily) + assertEquals(before.textLineHeight, after.textLineHeight, 0.001f) + assertEquals(before.textLetterSpacing, after.textLetterSpacing, 0.001f) + assertEquals(before.textWordSpacing, after.textWordSpacing, 0.001f) + assertEquals(before.textParagraphSpacing, after.textParagraphSpacing, 0.001f) + assertEquals(before.textAlignment, after.textAlignment) + assertEquals(before.textBold, after.textBold) assertEquals(style.immersiveMode, after.immersiveMode) assertEquals(style.pageAnimation, after.readerPageAnimation) // Built-in presets clear custom colors @@ -146,6 +150,7 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.CUSTOM.name, after.readerPreset) assertEquals(ReaderStylePresetReducer.DEFAULT_FONT_SIZE, after.textFontSize) assertEquals(ReaderStylePresetReducer.DEFAULT_COLOR_SCHEME, after.textColorScheme) + assertEquals(io.leostrange.mrcomic.feature.reader.ui.DEFAULT_GRAPHIC_COLOR_SCHEME, after.graphicColorScheme) assertNull(after.textCustomTextColor) assertNull(after.textCustomBackgroundColor) assertNull(after.textCustomAccentColor) @@ -195,6 +200,7 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.OLED_BLACK.name, after.readerPreset) assertEquals(20, after.textFontSize) assertEquals("NIGHT", after.textColorScheme) + assertEquals("NIGHT", after.graphicColorScheme) assertEquals("Roboto", after.textFontFamily) assertEquals(1.6f, after.textLineHeight, 0.001f) assertEquals(0.02f, after.textLetterSpacing, 0.001f) @@ -228,12 +234,31 @@ class ReaderStylePresetReducerTest { @Test fun setColorScheme_marksCustomAndUpdatesValue() { - val before = ReaderUiState(readerPreset = ReadingPreset.PAPER.name, textColorScheme = "DAY") - - val after = ReaderStylePresetReducer.setColorScheme(before, "SEPIA") + // Text reader: setTextScheme updates textColorScheme + val textState = ReaderUiState( + readerPreset = ReadingPreset.PAPER.name, + textColorScheme = "DAY", + readerContainerKind = ReaderContainerKind.TEXT_PAGE + ) + val textAfter = ReaderStylePresetReducer.setColorScheme(textState, "SEPIA") + assertEquals(ReadingPreset.CUSTOM.name, textAfter.readerPreset) + assertEquals("SEPIA", textAfter.textColorScheme) + } - assertEquals(ReadingPreset.CUSTOM.name, after.readerPreset) - assertEquals("SEPIA", after.textColorScheme) + @Test + fun setColorScheme_graphicReaderUpdatesGraphicColorScheme() { + // Graphic (raster) reader: setColorScheme updates graphicColorScheme, not textColorScheme + val graphicState = ReaderUiState( + readerPreset = ReadingPreset.PAPER.name, + textColorScheme = "DAY", + graphicColorScheme = "NIGHT", + readerContainerKind = ReaderContainerKind.RASTER_PAGE + ) + val graphicAfter = ReaderStylePresetReducer.setColorScheme(graphicState, "SEPIA") + assertEquals(ReadingPreset.CUSTOM.name, graphicAfter.readerPreset) + // textColorScheme stays "DAY" — only graphicColorScheme changes + assertEquals("DAY", graphicAfter.textColorScheme) + assertEquals("SEPIA", graphicAfter.graphicColorScheme) } @Test diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt new file mode 100644 index 000000000..4ab0d0b7b --- /dev/null +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt @@ -0,0 +1,149 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.leostrange.mrcomic.core.domain.analytics.AchievementTracker +import io.leostrange.mrcomic.core.domain.analytics.GamificationIntegration +import io.leostrange.mrcomic.core.domain.analytics.MascotProgressState +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.AchievementNotification +import io.leostrange.mrcomic.core.model.UserAchievements +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Состояние экрана геймификации + */ +data class GamificationUiState( + val userAchievements: UserAchievements? = null, + val mascotProgress: MascotProgressState? = null, + val goalState: DailyReadingGoalState? = null, + val notifications: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null +) + +/** + * ViewModel для экранов геймификации + */ +@HiltViewModel +class GamificationViewModel @Inject constructor( + private val gamificationIntegration: GamificationIntegration, + private val achievementTracker: AchievementTracker +) : ViewModel() { + + private val _uiState = MutableStateFlow(GamificationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + observeNotifications() + } + + /** + * Загрузить данные + */ + private fun loadData() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + + // Подписываемся на изменения достижений + combine( + gamificationIntegration.getUserAchievements(), + achievementTracker.userProgress + ) { achievements, progress -> + GamificationUiState( + userAchievements = achievements, + mascotProgress = MascotProgressState( + approxPagesRead = progress.pagesRead, + completedTitles = progress.titlesCompleted, + xp = progress.totalXp, + stage = progress.mascotStage + ), + goalState = DailyReadingGoalState( + pagesReadToday = progress.pagesRead, + currentStreak = progress.streakDays + ), + isLoading = false + ) + }.collect { state -> + _uiState.value = state + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message + ) + } + } + } + + /** + * Наблюдать за уведомлениями + */ + private fun observeNotifications() { + viewModelScope.launch { + gamificationIntegration.getAchievementNotifications().collect { notifications -> + _uiState.value = _uiState.value.copy(notifications = notifications) + } + } + } + + /** + * Очистить уведомление + */ + fun dismissNotification(notification: AchievementNotification) { + gamificationIntegration.clearNotifications() + } + + /** + * Обновить прогресс чтения + */ + fun updateReadingProgress(pagesRead: Int, sessionPages: Int = 0) { + achievementTracker.updatePagesRead(pagesRead) + if (sessionPages > 0) { + gamificationIntegration.recordSingleSessionReading(sessionPages) + } + } + + /** + * Обновить время чтения + */ + fun updateReadingTime(durationMillis: Long) { + gamificationIntegration.recordReadingTime(durationMillis) + } + + /** + * Обновить завершённые тайтлы + */ + fun updateCompletedTitles(count: Int) { + achievementTracker.updateTitlesCompleted(count) + } + + /** + * Обновить серию дней + */ + fun updateStreak(days: Int) { + achievementTracker.updateStreakDays(days) + } + + /** + * Обновить стадию маскота + */ + fun updateMascotStage(stage: io.leostrange.mrcomic.core.model.MascotStage) { + achievementTracker.updateMascotStage(stage) + } + + /** + * Обновить общий XP + */ + fun updateTotalXp(xp: Int) { + achievementTracker.updateTotalXp(xp) + } +} diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt index 3d57a4eb6..a79588497 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt @@ -43,10 +43,11 @@ import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.data.preferences.PerfProfile import io.leostrange.mrcomic.core.data.preferences.PerfRenderQuality import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip -import io.leostrange.mrcomic.core.ui.designsystem.MrComicPanelCard -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSliderTile +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSectionHeader +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider import io.leostrange.mrcomic.core.ui.designsystem.MrComicSurfaceCard -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow import kotlinx.coroutines.delay import java.util.Locale @@ -433,8 +434,22 @@ private fun PerfCard( hint: String? = null, content: @Composable ColumnScope.() -> Unit ) { - MrComicPanelCard(title = title, hint = hint) { - content() + // Editorial Ink: flat section header + body. No outer card frame. + Column(modifier = Modifier.fillMaxWidth()) { + MrComicSectionHeader(title = title) + if (!hint.isNullOrBlank()) { + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content, + ) } } @@ -538,15 +553,23 @@ private fun PerfSliderRow( steps: Int, onValueChange: (Float) -> Unit ) { - MrComicSliderTile( - title = title, - valueLabel = subtitle, - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - modifier = Modifier.padding(vertical = 10.dp) - ) + Column(modifier = Modifier.fillMaxWidth()) { + MrComicListItem( + title = title, + trailing = MrComicListItemTrailing.Value(subtitle), + onClick = null, + divider = false, + ) + MrComicSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + ) + } } @Composable @@ -590,11 +613,13 @@ private fun PerfSwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { - MrComicSwitchRow( + MrComicListItem( title = title, subtitle = subtitle, - checked = checked, - onCheckedChange = onCheckedChange + trailing = MrComicListItemTrailing.Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ), ) } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt index 94f541753..88ce76380 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt @@ -7,6 +7,7 @@ package io.leostrange.mrcomic.feature.settings.ui +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface @@ -40,6 +42,7 @@ import io.leostrange.mrcomic.core.ui.theme.style internal data class AboutSectionText( val title: String, val description: String, + val versionTitle: String, val overviewTitle: String, val overviewBody: String, val featuresTitle: String, @@ -60,13 +63,14 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "en" -> AboutSectionText( title = "About the app", description = "What the app does, what it is built with, and how to contact the developer.", + versionTitle = "Installed version", overviewTitle = "Program description", overviewBody = "Mr.Comic is an Android reader for books and comics from a local library. It combines file management, reading modes for graphics and text, OCR and dictionary tools, reading progress, and backup features in one app.", featuresTitle = "Key features", features = listOf( "Local library with files, folders, bookmarks, quotes, and the Mr.Comic tab.", "Reader modes for page reading, webtoon scrolling, and text formats with saved progress.", - "OCR, offline dictionaries, translation, and text explanation tools.", + "Developing OCR, offline dictionary, translation, and text explanation tools.", "Theme customization, progress export/import, and library access recovery." ), librariesTitle = "Main libraries", @@ -92,6 +96,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "ja" -> AboutSectionText( title = "アプリについて", description = "アプリの役割、使用している技術、開発者への連絡先をまとめています。", + versionTitle = "インストール済みバージョン", overviewTitle = "プログラム概要", overviewBody = "Mr.Comic は、ローカルライブラリの本とコミックを読むための Android リーダーです。ファイル管理、画像とテキストの読書モード、OCR と辞書、読書進捗、バックアップをひとつにまとめています。", featuresTitle = "主な機能", @@ -124,6 +129,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "zh" -> AboutSectionText( title = "关于应用", description = "这里汇总应用用途、技术栈以及开发者联系方式。", + versionTitle = "已安装版本", overviewTitle = "程序说明", overviewBody = "Mr.Comic 是一款用于阅读本地书库中图书和漫画的 Android 阅读器。它把文件管理、图像与文本阅读模式、OCR 与词典工具、阅读进度和备份功能集中在一个应用里。", featuresTitle = "主要功能", @@ -156,6 +162,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "ko" -> AboutSectionText( title = "앱 정보", description = "앱의 역할, 사용한 기술, 개발자 연락처를 한곳에 모았습니다.", + versionTitle = "설치된 버전", overviewTitle = "프로그램 설명", overviewBody = "Mr.Comic 은 로컬 라이브러리의 책과 코믹을 읽기 위한 Android 리더입니다. 파일 관리, 그래픽/텍스트 읽기 모드, OCR과 사전 도구, 읽기 진행도와 백업 기능을 하나의 앱으로 묶었습니다.", featuresTitle = "주요 기능", @@ -188,13 +195,14 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua else -> AboutSectionText( title = "О приложении", description = "Здесь собраны назначение приложения, стек, лицензии и контакты разработчика.", + versionTitle = "Установленная версия", overviewTitle = "Описание программы", overviewBody = "Mr.Comic — Android-приложение для чтения книг и комиксов из локальной библиотеки. Оно объединяет управление файлами, режимы чтения для графики и текста, OCR и словарные инструменты, прогресс чтения и резервное копирование.", featuresTitle = "Основные функции", features = listOf( "Локальная библиотека: файлы, папки, закладки, цитаты и вкладка Mr.Comic.", "Ридер для постраничного чтения, webtoon-режима и текстовых форматов с сохранением прогресса.", - "OCR, офлайн-словари, перевод и объяснение выделенного текста.", + "Развивающиеся инструменты OCR, офлайн-словарей, перевода и объяснения текста.", "Темы и кастомизация, экспорт/импорт прогресса и восстановление доступа к библиотеке." ), librariesTitle = "Основные библиотеки", @@ -235,6 +243,17 @@ internal fun AboutSection( modifier: Modifier = Modifier ) { val sectionText = remember(strings.languageCode) { aboutSectionText(strings.languageCode) } + val context = LocalContext.current + val packageInfo = remember(context.packageName) { + context.packageManager.getPackageInfo(context.packageName, 0) + } + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + val installedVersion = "${packageInfo.versionName.orEmpty()} ($versionCode)" val contacts = remember { listOf( "xmetalcore@outlook.com", @@ -260,6 +279,15 @@ internal fun AboutSection( ) ) } + item { + SettingsCard(title = sectionText.versionTitle) { + Text( + text = installedVersion, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } item { SettingsCard(title = sectionText.overviewTitle) { Text( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt index 6b578eaea..10b9a2f34 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt @@ -29,6 +29,7 @@ import io.leostrange.mrcomic.core.data.repository.ComicRepository import io.leostrange.mrcomic.core.data.repository.QuoteRepository import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.data.db.entity.SavedQuote import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.ThemeMode @@ -374,7 +375,7 @@ internal class SettingsBackupController( put("addedDate", comic.addedDate) put("lastModified", comic.lastModified) put("folderId", comic.folderId) - put("readingProgress", comic.readingProgress.toDouble()) + put("readingProgress", comic.displayReadingProgress().toDouble()) put("lastReadDate", comic.lastReadDate ?: 0L) put("isBookmarked", comic.isBookmarked) put("tags", comic.tags) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt index ef4742057..5b0e0c1ea 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsDictionarySection.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.leostrange.mrcomic.core.data.dictionary.DictionaryAssetCatalog import io.leostrange.mrcomic.core.data.dictionary.DictionaryInstallInfo +import io.leostrange.mrcomic.core.data.dictionary.DictionaryProvenance import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface import io.leostrange.mrcomic.core.ui.locale.AppStrings @@ -221,6 +222,16 @@ private fun DictionaryLanguageCard( viewModel.exportDictionary(langCode, uri, context.contentResolver) } + // Per-card SAF import launcher (Bug #3): every language card exposes + // its own import affordance, so the user can drop a custom .dbpack + // onto any language, not just the global Import button at the top. + val importLauncherForLang = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + viewModel.importDictionary(langCode, uri, context.contentResolver) + } + MrComicCardSurface( modifier = Modifier.fillMaxWidth() ) { @@ -296,9 +307,21 @@ private fun DictionaryLanguageCard( ) { Icon(Icons.Default.CloudDownload, contentDescription = strings.dictBtnDownload) } + // Per-language Import button (Bug #3): every card must + // surface the SAF picker so the user can install a custom + // file even if a bundled one already exists. + IconButton( + onClick = { importLauncherForLang.launch(arrayOf("application/*", "application/octet-stream")) }, + enabled = !isAnyOperationActive, + ) { + Icon(Icons.Default.FileUpload, contentDescription = strings.dictBtnImport) + } } else { - // Delete button (not for bundled-only) - if (!info.isBundled) { + // Delete button — only for user-owned dictionaries + // (DOWNLOADED or IMPORTED). A pure BUNDLED file is + // considered immutable, even if the user has never + // touched it. + if (info.provenance.isUserOwned) { IconButton( onClick = { viewModel.deleteDictionary(info.language) }, enabled = !isAnyOperationActive, @@ -306,6 +329,15 @@ private fun DictionaryLanguageCard( Icon(Icons.Default.Delete, contentDescription = strings.dictBtnDelete, tint = MaterialTheme.colorScheme.error) } } + // Re-import: always available, even for bundled. Lets the + // user overwrite the bundled file with their own .dbpack + // (the new file is then marked IMPORTED). + IconButton( + onClick = { importLauncherForLang.launch(arrayOf("application/*", "application/octet-stream")) }, + enabled = !isAnyOperationActive, + ) { + Icon(Icons.Default.FileUpload, contentDescription = strings.dictBtnImport) + } // Export button IconButton( onClick = { exportLauncher.launch("dictionary_${info.language}.dbpack") }, @@ -328,15 +360,21 @@ private fun DictionaryStatusChip( info: DictionaryInstallInfo, strings: AppStrings ) { - val (label, color) = when { - info.sizeBytes > 0L && info.isBundled -> strings.dictStatusBundled to MaterialTheme.colorScheme.tertiaryContainer - info.sizeBytes > 0L -> strings.dictStatusInstalled to MaterialTheme.colorScheme.primaryContainer - else -> strings.dictStatusNotInstalled to MaterialTheme.colorScheme.surfaceVariant + val (label, color) = when (info.provenance) { + DictionaryProvenance.BUNDLED -> + strings.dictStatusBundled to MaterialTheme.colorScheme.tertiaryContainer + DictionaryProvenance.DOWNLOADED -> + strings.dictStatusDownloaded to MaterialTheme.colorScheme.primaryContainer + DictionaryProvenance.IMPORTED -> + strings.dictStatusImported to MaterialTheme.colorScheme.secondaryContainer + DictionaryProvenance.NOT_INSTALLED -> + strings.dictStatusNotInstalled to MaterialTheme.colorScheme.surfaceVariant } - val onColor = when { - info.sizeBytes > 0L && info.isBundled -> MaterialTheme.colorScheme.onTertiaryContainer - info.sizeBytes > 0L -> MaterialTheme.colorScheme.onPrimaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant + val onColor = when (info.provenance) { + DictionaryProvenance.BUNDLED -> MaterialTheme.colorScheme.onTertiaryContainer + DictionaryProvenance.DOWNLOADED -> MaterialTheme.colorScheme.onPrimaryContainer + DictionaryProvenance.IMPORTED -> MaterialTheme.colorScheme.onSecondaryContainer + DictionaryProvenance.NOT_INSTALLED -> MaterialTheme.colorScheme.onSurfaceVariant } Surface( shape = MaterialTheme.shapes.small, diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt index f521ceb7e..32294c74e 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt @@ -106,8 +106,20 @@ internal fun readerTapZoneActionLabel(language: String, action: String): String } internal fun readerInfoSlotPreviewValue(language: String, slot: String): String = when (ReaderInfoSlot.fromStored(slot)) { ReaderInfoSlot.NONE -> "" - ReaderInfoSlot.BOOK_TITLE -> if (language == "en") "Book title" else "Название книги" - ReaderInfoSlot.CHAPTER_TITLE -> if (language == "en") "Chapter 3" else "Глава 3" + ReaderInfoSlot.BOOK_TITLE -> when (language) { + "en" -> "Book title" + "ja" -> "本のタイトル" + "zh" -> "书名" + "ko" -> "책 제목" + else -> "Название книги" + } + ReaderInfoSlot.CHAPTER_TITLE -> when (language) { + "en" -> "Chapter 3" + "ja" -> "第3章" + "zh" -> "第3章" + "ko" -> "제3장" + else -> "Глава 3" + } ReaderInfoSlot.TIME -> "12:48" ReaderInfoSlot.PROGRESS -> "78%" ReaderInfoSlot.PAGE -> "124 / 320" diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt index 986d96a04..7cd80abc2 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt @@ -446,7 +446,11 @@ internal fun ReaderModeCard( MrComicFilterChip( selected = uiState.readingMode == ReadingMode.PAGE_LTR || uiState.readingMode == ReadingMode.PAGE_RTL, - onClick = { viewModel.setReadingMode(ReadingMode.PAGE_LTR) }, + onClick = { + if (shouldApplyPagedReadingMode(uiState.readingMode)) { + viewModel.setReadingMode(ReadingMode.PAGE_LTR) + } + }, label = { Text(readerModeSettingsLabel(strings.languageCode, ReadingMode.PAGE_LTR)) } ) MrComicFilterChip( @@ -458,6 +462,10 @@ internal fun ReaderModeCard( } } +/** A grouped "pages" chip must be idempotent for both LTR and RTL modes. */ +internal fun shouldApplyPagedReadingMode(currentMode: ReadingMode): Boolean = + currentMode != ReadingMode.PAGE_LTR && currentMode != ReadingMode.PAGE_RTL + /* ──── ReaderImageLayoutCard ──── */ @Composable internal fun ReaderImageLayoutCard( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt index ca2949a26..c27c7a799 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt @@ -81,12 +81,13 @@ internal fun ReaderTextAppearancePreviewCard( verticalArrangement = Arrangement.spacedBy(10.dp) ) { MrComicCardSurface( + modifier = Modifier.heightIn(max = 196.dp), shape = MaterialTheme.shapes.large, containerColor = previewBackground ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) ) { Text( text = strings.readerTextPreviewTitle, @@ -100,23 +101,29 @@ internal fun ReaderTextAppearancePreviewCard( ) Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = align - ) { - Text( - text = strings.readerTextPreviewDescription, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = uiState.textFontSize.sp, - lineHeight = (uiState.textFontSize * uiState.textLineHeight).sp, - fontWeight = if (uiState.textBold) FontWeight.SemiBold else FontWeight.Normal, - fontFamily = previewFontFamily, - letterSpacing = uiState.textLetterSpacing.em, - color = previewText - ), - color = previewText, - textAlign = textAlign, - modifier = Modifier.fillMaxWidth(), - maxLines = 8 + horizontalAlignment = align, + verticalArrangement = Arrangement.spacedBy( + uiState.textParagraphSpacing.coerceIn(0f, 32f).dp ) + ) { + repeat(2) { + Text( + text = strings.readerTextPreviewDescription, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = uiState.textFontSize.sp, + lineHeight = (uiState.textFontSize * uiState.textLineHeight).sp, + fontWeight = if (uiState.textBold) FontWeight.SemiBold else FontWeight.Normal, + fontFamily = previewFontFamily, + letterSpacing = uiState.textLetterSpacing.em, + color = previewText + ), + color = previewText, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } } @@ -147,13 +154,14 @@ internal fun ReaderPageLayoutPreviewCard( val previewShape = MaterialTheme.shapes.large MrComicCardSurface( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .heightIn(max = 172.dp), shape = previewShape, containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f) ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -240,7 +248,9 @@ internal fun ReaderHeaderFooterPreviewCard( } ) { MrComicCardSurface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 172.dp), shape = MaterialTheme.shapes.large, containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f) ) { @@ -277,9 +287,9 @@ internal fun ReaderHeaderFooterPreviewCard( overflow = TextOverflow.Ellipsis ) } - Spacer(Modifier.height(30.dp)) + Spacer(Modifier.height(18.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(6.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Text( readerInfoSlotPreviewValue(language, uiState.readerFooterLeftSlot), diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt index 978cb98ab..0a2971635 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt @@ -103,11 +103,13 @@ internal fun ReaderSection( } } ReaderSettingsPage.TEXT_APPEARANCE -> { - item { - ReaderTextAppearancePreviewCard( - uiState = uiState, - strings = strings - ) + stickyHeader(key = "reader_text_appearance_preview") { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + ReaderTextAppearancePreviewCard( + uiState = uiState, + strings = strings + ) + } } item { ReaderTextStyleCard( @@ -124,8 +126,10 @@ internal fun ReaderSection( } } ReaderSettingsPage.PAGE_LAYOUT -> { - item { - ReaderPageLayoutPreviewCard(uiState = uiState, strings = strings) + stickyHeader(key = "reader_page_layout_preview") { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + ReaderPageLayoutPreviewCard(uiState = uiState, strings = strings) + } } item { ReaderModeCard(uiState = uiState, strings = strings, viewModel = viewModel) @@ -155,8 +159,10 @@ internal fun ReaderSection( } } ReaderSettingsPage.PAGING -> { - item { - ReaderPagingPreviewCard(uiState = uiState, language = uiState.appLanguage) + stickyHeader(key = "reader_paging_preview") { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + ReaderPagingPreviewCard(uiState = uiState, language = uiState.appLanguage) + } } item { ReaderPagingSettingsCard( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt index ee72a6abb..301702aef 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt @@ -174,7 +174,7 @@ internal fun ReaderTextStyleCard( modifier = Modifier.fillMaxWidth(), variant = MrComicButtonVariant.Outlined ) { - Text(if (strings.languageCode == "ru") "Сохранить текущий стиль как новый" else "Save current style as new") + Text(styleText.savedStyleSave) } Spacer(Modifier.height(8.dp)) Column( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt index e2610f94f..0063f09fe 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt @@ -20,7 +20,8 @@ import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicButtonVariant import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing import io.leostrange.mrcomic.core.ui.locale.AppStrings import io.leostrange.mrcomic.core.ui.locale.ocrSourceLanguageOptions import io.leostrange.mrcomic.core.ui.locale.translationLanguageOptions @@ -344,19 +345,21 @@ internal fun OcrFiltersCard( ) { SettingsCard(title = sectionText.comicFiltersCard) { LabelText(sectionText.comicFiltersHint) - Spacer(Modifier.height(8.dp)) - MrComicSwitchRow( + MrComicListItem( title = sectionText.dialoguesOnlyTitle, subtitle = sectionText.dialoguesOnlySubtitle, - checked = uiState.ocrDialoguesOnly, - onCheckedChange = viewModel::setOcrDialoguesOnly + trailing = MrComicListItemTrailing.Switch( + checked = uiState.ocrDialoguesOnly, + onCheckedChange = viewModel::setOcrDialoguesOnly, + ), ) - Spacer(Modifier.height(12.dp)) - MrComicSwitchRow( + MrComicListItem( title = sectionText.includeSfxTitle, subtitle = sectionText.includeSfxSubtitle, - checked = uiState.ocrIncludeSfx, - onCheckedChange = viewModel::setOcrIncludeSfx + trailing = MrComicListItemTrailing.Switch( + checked = uiState.ocrIncludeSfx, + onCheckedChange = viewModel::setOcrIncludeSfx + ), ) } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt index 4b8fc14ef..975c6dc6c 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt @@ -23,6 +23,7 @@ internal fun SettingsSettersController.setReaderPreset(presetName: String) { preferences.set(PreferencesKeys.READER_PAGE_ANIMATION, style.pageAnimation) preferences.set(PreferencesKeys.READER_PAGE_SOUND, false) preferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, style.textColorScheme) + preferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, style.textColorScheme) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, null) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, null) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_ACCENT_COLOR, null) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt index a37075eb6..4562aff02 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt @@ -113,6 +113,7 @@ internal suspend fun SettingsSettersController.importReaderTypographyFromJson(ra preferences.set(PreferencesKeys.READER_PRESET, imported.readerPreset.name) preferences.set(PreferencesKeys.TEXT_FONT_SIZE, imported.textFontSize) preferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, imported.textColorScheme) + preferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, imported.textColorScheme) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, imported.textCustomTextColor) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, imported.textCustomBackgroundColor) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_ACCENT_COLOR, imported.textCustomAccentColor) diff --git a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt new file mode 100644 index 000000000..805020ace --- /dev/null +++ b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt @@ -0,0 +1,20 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import io.leostrange.mrcomic.core.model.ReadingMode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderModeSelectionPolicyTest { + + @Test + fun repeatedPagesSelectionDoesNotChangeLtrOrRtl() { + assertFalse(shouldApplyPagedReadingMode(ReadingMode.PAGE_LTR)) + assertFalse(shouldApplyPagedReadingMode(ReadingMode.PAGE_RTL)) + } + + @Test + fun pagesSelectionCanLeaveWebtoonMode() { + assertTrue(shouldApplyPagedReadingMode(ReadingMode.WEBTOON)) + } +} diff --git a/docs/bug-analysis-report.md b/docs/bug-analysis-report.md new file mode 100644 index 000000000..0a3c71aee --- /dev/null +++ b/docs/bug-analysis-report.md @@ -0,0 +1,584 @@ +# Mr.Comic Bug Analysis Report + +**Date:** 2026-08-19 +**Scope:** 17 active bugs from video analysis, bug tracker, and APK static analysis +**Excluded:** Autoscroller viewport defect (fixed), black bar during autoscroller (fixed), Settings → Translation → Dictionaries crash (excluded by owner) + +--- + +## Executive Summary + +After thorough codebase analysis, I've identified root causes and current implementation state for all 17 bugs. The most critical issues are: + +1. **P0/P1 bugs (7):** Position persistence, progress synchronization, TOC navigation, footnotes vs gestures, quote navigation +2. **P2 bugs (7):** Text selection, padding consistency, reading mode/theme coupling, UI consistency, preview component +3. **P3 bugs (2):** HTML title overflow, seekbar desync + +The codebase shows a well-structured reader architecture with separate rendering paths for raster/vertical/text content, but several state synchronization issues remain unresolved. + +--- + +## Detailed Bug Analysis + +### Reader / Vertical Mode + +#### BUG-VERTICAL-01 — Seekbar/Position Desync +**Priority:** P1 | **Area:** Reader State / Scroll / Progress + +**Root Cause Analysis:** +The seekbar (Slider) in `ReaderBottomBar.kt` uses `currentPage` from `_uiState`, while the actual scroll position in vertical mode is tracked separately by: +- `freeScrollProgression` (0..1) for text webtoon +- `freeScrollCharacterOffset` for character-based positioning +- `sectionCharacterOffset` for paged text + +The seekbar value is NOT derived from the scroll state directly—it's a separate `currentPage` integer that gets updated via `navigationController` callbacks. In vertical mode, the WebView's scroll position may update without properly syncing back to the seekbar. + +**Current Implementation:** +```kotlin +// ReaderBottomBar.kt:142 +Slider( + value = freeScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { onProgressionChange?.invoke(it) }, + ... +) +``` + +The seekbar has two paths: +1. Text webtoon: uses `freeScrollProgression` (continuous 0..1) +2. Paged mode: uses `currentPage` integer + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/components/ReaderBottomBar.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderChromeBottomPanel.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderViewModel.kt` (onFreeScrollPositionChanged) + +**Suggested Fix:** +Unify position state: DocumentPosition → ScrollPosition → ReadingProgress → SeekBar. The seekbar should derive its value from a single source of truth (either the scroll fraction or the normalized progress), not maintain independent state. + +--- + +### Reader / Paged Mode + +#### BUG-PAGED-01 — Random Text Selection on Swipe +**Priority:** P2 | **Area:** Gesture / Text Selection + +**Root Cause Analysis:** +`PagedGesturePolicy.classifyPagedGesture()` prioritizes selection over page turns when `hasActiveSelection` is true. However, the WebView may initiate selection on a slight finger movement before the gesture is classified as a swipe. + +The policy checks: +```kotlin +if (hasActiveSelection) return PagedGestureAction.PASS_THROUGH +``` + +This means once selection starts, ALL subsequent events pass through to WebView, preventing page turns. + +**Current Implementation:** +- Selection suppression on move: `shouldSuppressSelectionOnMove` checks `hasMoved && !hasActiveSelection` +- Move interception: `shouldInterceptMove` has different thresholds for vertical (8px) and horizontal (48px) + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/PagedGesturePolicy.kt` +- WebView JavaScript bridge (selection detection) + +**Suggested Fix:** +Add a selection initiation delay or require long-press before allowing selection. The gesture policy should have a "selection lockout" period after touch start (e.g., 300ms) to differentiate tap/swipe from intentional selection. + +--- + +#### BUG-PAGED-02 — Uneven Top/Bottom Padding +**Priority:** P2 | **Area:** Layout / Pagination / Insets + +**Root Cause Analysis:** +The viewport calculation for paged text uses: +``` +Screen height − System Insets − Reader Insets − Reader Padding = Page Viewport +``` + +However, `ChromeInsetsPlan.kt` and `ReaderViewportGeometry.kt` may calculate insets differently depending on: +1. Whether system bars are visible +2. Whether the reader chrome is shown +3. Display cutout handling + +The padding is not centralized—it's applied at multiple levels (Compose layout, WebView padding, CSS margins). + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ChromeInsetsPlan.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderViewportGeometry.kt` +- `android/feature-reader/src/main/java/.../ui/PagedLayoutParams.kt` +- `android/feature-reader/src/main/java/.../ui/PagedViewportContract.kt` + +**Suggested Fix:** +Centralize viewport calculation in a single `ViewportCalculator` that takes all inputs (screen, system insets, reader insets, padding) and returns a consistent viewport rect. Apply this rect uniformly across all rendering paths. + +--- + +#### BUG-PAGED-03 — Footnotes Conflict with Page Gesture Zones +**Priority:** P1 | **Area:** Gesture / Hit Testing / Footnotes + +**Root Cause Analysis:** +The footnote controller (`ReaderFootnoteController.kt`) handles anchor clicks via JavaScript bridge: +```kotlin +fun onAnchorClick(href: String) { ... } +``` + +However, the paged gesture policy intercepts touches BEFORE the WebView can process them as clicks. The policy's priority is: +1. Active selection → PASS_THROUGH +2. Touch on link → PASS_THROUGH +3. Edge tap → TAP_LEFT/TAP_RIGHT + +The issue is that footnotes near screen edges fall into the "edge tap" zone (12% from each side), so they get consumed as page turns instead of footnote clicks. + +**Current Implementation:** +```kotlin +// ReaderWebViewJavaScript.kt:195 +// checks this flag to avoid consuming footnote clicks as page turns. +``` + +The WebView JavaScript sets a flag when a footnote is detected, but this happens AFTER the touch event is already consumed by the gesture policy. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/PagedGesturePolicy.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderFootnoteController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderWebViewJavaScript.kt` + +**Suggested Fix:** +Implement a hit-test priority system: +1. Footnote/Link detection (via JavaScript hit test) +2. Interactive content +3. Selection +4. Page navigation + +The gesture policy should query the WebView for link/footnote presence BEFORE classifying the gesture as a page turn. + +--- + +### Reader State / Pagination / Navigation + +#### BUG-READER-01 — Incorrect Page Count +**Priority:** P1 | **Area:** Pagination Engine + +**Root Cause Analysis:** +Page counts are tracked at multiple levels: +1. `_uiState.totalPages` — raw page count from format reader +2. `sectionPageCounts` — EPUB section page counts (`EpubSectionPageCountStore`) +3. `epubAccumulatedTotalPages` — estimated total visual pages +4. `progressController.totalBookSections` — spine section count + +These values can diverge because: +- EPUB pages are calculated dynamically as sections are paginated +- The deferred page count policy (`DeferredPageCountPolicy`) uses provisional values +- Raster formats count images, text formats count visual pages + +**Current Implementation:** +```kotlin +// ReaderProgressController.kt +fun accumulatedTotalPagesForEpub(): Int { + return EpubProgressCalculator.estimatedTotalPages( + sectionPageCounts = sectionPageCounts.snapshot(), + totalSections = totalBookSections + ) +} +``` + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../domain/progress/EpubSectionPageCountStore.kt` +- `android/feature-reader/src/main/java/.../domain/progress/EpubProgressCalculator.kt` +- `android/feature-reader/src/main/java/.../ui/DeferredPageCountPolicy.kt` + +**Suggested Fix:** +Separate logical position from visual page count. Create a unified `PaginationState` that: +1. Tracks spine sections (stable) +2. Tracks visual pages per section (dynamic) +3. Computes progress as a fraction (0..1) +4. Never mixes chapter/section/document page counts + +--- + +#### BUG-READER-02 — Reading Mode Not Persisted +**Priority:** P1 | **Area:** Persistence / Reader Preferences + +**Root Cause Analysis:** +The reading mode is saved in `setReadingMode()`: +```kotlin +viewModelScope.launch { + readerPreferences.set(PreferencesKeys.READING_MODE, mode.name) +} +``` + +But the restore logic in `configureOpening()` prefers the mode from the structured position: +```kotlin +val openingMode = restoredPosition?.mode?.takeIf { mode -> + !readerRendersHtmlContent || mode != ReadingMode.DUAL_PAGE +} ?: configuredOpeningMode +``` + +If the structured position has a different mode than the user's preference, the position's mode wins. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderReadingModeController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderBookOpeningController.kt` +- `android/core-data/src/main/java/.../preferences/UserPreferences.kt` + +**Suggested Fix:** +Persist reading mode per-book (not globally). When opening a book: +1. Check per-book saved mode +2. If no per-book mode, use the structured position's mode +3. If neither, use the global preference +4. Always save the mode back to per-book storage on change + +--- + +#### BUG-READER-03 — Position Not Restored (P0) +**Priority:** P0 | **Area:** Persistence / Reading Position + +**Root Cause Analysis:** +The position saving has a dedup guard: +```kotlin +if (pending == pendingProgressSave || + isSamePersistedPosition(lastPersistedPositionJson, positionJson) +) return +``` + +During rapid exit, the WebView may not have reported its latest scroll position, so the snapshot appears identical to the last persisted value. The fix (`forceSavePositionOnClose`) bypasses this check: +```kotlin +// BUG-READER-03: use forceSavePositionOnClose instead of savePositionSnapshot +private fun forceSavePositionOnClose() { ... } +``` + +**Current State:** The fix is IMPLEMENTED but may not cover all edge cases: +- Process kill without proper close +- WebView not reporting scroll position before close +- Race condition between scroll callback and close + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../domain/progress/ReaderPosition.kt` +- `android/feature-reader/src/main/java/.../domain/progress/ReaderPositionCodec.kt` + +**Suggested Fix:** +1. Add periodic position snapshots (every 5 seconds during active reading) +2. Save position on every page turn (not just on close) +3. Use `onPause` lifecycle callback to force save +4. Consider using WorkManager for reliable persistence + +--- + +#### BUG-READER-04 — Global Progress Desync (P0/P1) +**Priority:** P0/P1 | **Area:** Reader State / Progress + +**Root Cause Analysis:** +Progress is displayed in multiple places: +1. Chrome toolbar (page counter) +2. Bottom bar (slider + percentage) +3. File info sheet +4. Library card (reading progress badge) + +Each uses different calculation: +- Chrome: `currentPage / totalPages` +- Bottom bar: `effectiveCurrentPage / effectiveTotalPages` (with EPUB accumulation) +- Library: `comic.readingProgress` (0..1 float from database) + +These diverge because: +- EPUB pages are calculated dynamically +- The database stores a snapshot that may be stale +- Different components read from different state sources + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderBottomBar.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderChromeComponents.kt` +- `android/core-data/src/main/java/.../repository/LibraryRepository.kt` + +**Suggested Fix:** +Create a unified `ReadingProgressModel` that: +1. Computes progress from the same source (DocumentPosition) +2. Updates all consumers atomically +3. Separates display progress from persistence progress +4. Never mixes different page count sources + +--- + +#### BUG-READER-05 — Mode Change Resets Theme Preset +**Priority:** P2 | **Area:** State Isolation / Theme + +**Root Cause Analysis:** +The reading mode controller and theme preset controller are separate, but they share state through `_uiState`. When switching modes, the `applyReadingMode()` function updates multiple state fields, and the theme preset may get reset if: +1. The preset depends on the reading mode +2. The state update triggers a recomposition that resets the preset + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderReadingModeController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetStorage.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderSettingsController.kt` + +**Suggested Fix:** +Ensure ReadingMode and ReaderTheme are completely independent state. The theme preset should be persisted separately and restored independently of the reading mode. + +--- + +#### BUG-READER-06 — HTML Title Overflow +**Priority:** P3 | **Area:** HTML Reader / Layout + +**Root Cause Analysis:** +The title in `ReaderMinimalBar` and `ReaderExpandedBar` uses: +```kotlin +Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ... +) +``` + +This should handle overflow, but the issue may be: +1. The title is set before the layout is measured +2. The parent container doesn't constrain width properly +3. HTML titles may contain special characters that break layout + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderChromeComponents.kt` + +**Suggested Fix:** +Verify the parent container constrains width. Add `fillMaxWidth()` modifier and ensure the title text is truncated properly. + +--- + +#### BUG-READER-07 — TOC Not Working in Some Formats +**Priority:** P1 | **Area:** TOC / Document Navigation + +**Root Cause Analysis:** +TOC entries are loaded via `pageCacheController.loadToc()` and stored in `_uiState.tableOfContents`. Navigation uses: +```kotlin +navigateTo(pageIdx, ReaderNavigationProgressSource.JUMP) +``` + +The issue is that `formatReader()?.resolveHrefToPage()` may return null or incorrect page indices for: +- EPUB files with complex spine structures +- FB2 files with non-linear chapter organization +- HTML files with fragment-based navigation + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderPageCacheController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderFootnoteController.kt` +- `android/engine-api/src/main/java/.../FormatReader.kt` (interface) +- `android/engine-formats/src/main/kotlin/.../formats/` (implementations) + +**Suggested Fix:** +Unify TOC resolution across formats: +1. EPUB: Use Readium's locator API +2. FB2: Map chapter IDs to page indices +3. HTML: Resolve fragment anchors to scroll positions +4. All: Support both page-based and anchor-based navigation + +--- + +### Library / Visual System + +#### BUG-UI-01 — Inconsistent Library Card Badges +**Priority:** P2 | **Area:** Design System / Library + +**Root Cause Analysis:** +Format badges use `MrComicFormatBadge` from `core-ui`, but the implementation may: +1. Use different corner radius for different formats +2. Apply different alpha values for contrast +3. Not account for cover image brightness + +**Files Involved:** +- `android/core-ui/src/main/java/.../designsystem/MrComicFormatBadge.kt` +- `android/feature-library/src/main/java/.../components/LibraryContentDecor.kt` +- `android/feature-library/src/main/java/.../components/ComicGridItem.kt` + +**Suggested Fix:** +Standardize badge design tokens: +- Fixed corner radius (e.g., 4.dp) +- Guaranteed contrast ratio (4.5:1 minimum) +- Consistent padding and typography +- Background blur for readability over covers + +--- + +#### BUG-UI-02 — Day Preset Incorrect in Graphic Reader +**Priority:** P2 | **Area:** Graphic Reader / Theme + +**Root Cause Analysis:** +The `ReaderColorScheme.paletteForPreset()` function maps presets to colors: +```kotlin +scheme == "DAY" && readerPreset == ReadingPreset.NEWSPAPER -> "#f1eee7" to "#202020" +scheme == "DAY" && readerPreset == ReadingPreset.PAPER -> "#f6f1e7" to "#2b2118" +``` + +The default "DAY" falls through to: +```kotlin +else -> palette(scheme) // returns "#fafafa" to "#1a1a1a" +``` + +If the preset isn't properly saved or restored, the Day preset may show as default white instead of the intended warm tone. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/ReaderColorScheme.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetStorage.kt` + +**Suggested Fix:** +Verify preset persistence and ensure the Day preset maps to the correct color palette. Add logging to track which preset is being applied. + +--- + +#### BUG-UI-04 — Inconsistent Background/Surface Colors +**Priority:** P1 | **Area:** Theme / Library / Contrast + +**Root Cause Analysis:** +The theme token pipeline is fragmented: +1. Reader uses `ReaderColorScheme` for content +2. Library uses Material 3 `colorScheme` +3. Chrome surfaces use custom colors + +When the user changes the background, not all surfaces update because they use different color sources. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderScreen.kt` +- `android/feature-library/src/main/java/.../ui/LibraryScreen.kt` +- `android/core-ui/src/main/java/.../theme/` + +**Suggested Fix:** +Create a unified theme token pipeline: +1. Define all surface colors in one place +2. Propagate changes through MaterialTheme +3. Ensure reader, library, and chrome all use the same source + +--- + +#### BUG-UI-05 — Broken Customization Preview +**Priority:** P2 | **Area:** Customization / Preview + +**Root Cause Analysis:** +The preview component may show a warning icon instead of the actual component when: +1. The component fails to render +2. The preview uses placeholder data that doesn't match the real component +3. The theme tokens aren't applied to the preview + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderStyleTab.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetUiStateMapper.kt` + +**Suggested Fix:** +Ensure the preview component uses the same rendering logic as the actual component. Apply theme tokens consistently. + +--- + +### Additional Bugs from Video + +#### BUG-CANDIDATE-01 — Quote Navigation Broken +**Priority:** P1 | **Area:** Quotes / Document Location / Navigation + +**Root Cause Analysis:** +Quotes are saved with: +```kotlin +data class SavedQuote( + val comicId: String, + val page: Int, // This is the ONLY position data + val text: String, + ... +) +``` + +When navigating to a quote: +```kotlin +onClick = { onQuoteClick(quote.comicId, quote.page) } +``` + +The issue is that `page` is a raw integer that may not correspond to the actual document location: +- EPUB pages change as font/layout changes +- The page number may be from a different reading mode +- No anchor/offset data is stored + +**Files Involved:** +- `android/core-data/src/main/java/.../db/entity/SavedQuote.kt` +- `android/core-data/src/main/java/.../repository/QuoteRepository.kt` +- `android/feature-library/src/main/java/.../LibraryScreenContent.kt` + +**Suggested Fix:** +Store structured location with quotes: +1. Add `positionJson` field (using ReaderPositionCodec) +2. Add `characterOffset` for text-based relocation +3. Add `domAnchor` for fragment-based navigation +4. Use page number only as legacy fallback + +--- + +#### BUG-CANDIDATE-02 — CBR Displayed as RAR +**Priority:** P2 | **Area:** Format Detection / Library Metadata + +**Root Cause Analysis:** +The format detection has TWO separate detectors: +1. `FormatDetector` (engine-api): Maps "cbr" → `ComicFormat.CBR` +2. `ComicFormatDetector` (core-data): Maps "cbr" → `ComicFormat.RAR` + +```kotlin +// ComicFormatDetector.kt:83 +"cbr" -> ComicFormat.RAR // BUG: Should be CBR +``` + +This means files imported via different paths get different format labels. + +**Files Involved:** +- `android/engine-api/src/main/java/.../FormatDetector.kt` +- `android/core-data/src/main/java/.../repository/ComicFormatDetector.kt` +- `android/core-data/src/main/java/.../repository/ComicSourceResolver.kt` + +**Suggested Fix:** +Unify format detection: +1. Use `FormatDetector` as the single source of truth +2. Remove duplicate detection logic from `ComicFormatDetector` +3. Ensure CBR files always map to `ComicFormat.CBR` +4. Update UI to show "Comic Book" instead of "RAR" + +--- + +## Summary Table + +| Bug ID | Priority | Status | Root Cause | Fix Complexity | +|--------|----------|--------|------------|----------------| +| BUG-VERTICAL-01 | P1 | Open | Seekbar uses independent state | Medium | +| BUG-PAGED-01 | P2 | Open | Selection starts before gesture classification | Medium | +| BUG-PAGED-02 | P2 | Open | Viewport calculation not centralized | High | +| BUG-PAGED-03 | P1 | Open | Gesture policy doesn't check for footnotes | Medium | +| BUG-READER-01 | P1 | Open | Multiple page count sources | High | +| BUG-READER-02 | P1 | Open | Position mode overrides preference | Low | +| BUG-READER-03 | P0 | Partial | Dedup guard loses position on rapid exit | Medium | +| BUG-READER-04 | P0/P1 | Open | Progress computed differently per component | High | +| BUG-READER-05 | P2 | Open | State coupling between mode and theme | Low | +| BUG-READER-06 | P3 | Open | Title layout constraint issue | Low | +| BUG-READER-07 | P1 | Open | TOC resolution varies by format | High | +| BUG-UI-01 | P2 | Open | Badge design tokens inconsistent | Medium | +| BUG-UI-02 | P2 | Open | Preset not properly restored | Low | +| BUG-UI-04 | P1 | Open | Theme token pipeline fragmented | High | +| BUG-UI-05 | P2 | Open | Preview doesn't match real component | Medium | +| BUG-CANDIDATE-01 | P1 | Open | Quote stores only page number | Medium | +| BUG-CANDIDATE-02 | P2 | Open | Two format detectors disagree | Low | + +--- + +## Recommendations + +### Immediate Actions (P0/P1) +1. **BUG-READER-03:** Verify `forceSavePositionOnClose` covers all exit scenarios +2. **BUG-READER-04:** Create unified `ReadingProgressModel` +3. **BUG-PAGED-03:** Add footnote hit-test before gesture classification +4. **BUG-CANDIDATE-01:** Add structured position to quote storage + +### Short-term (P1/P2) +1. **BUG-READER-01:** Centralize pagination state +2. **BUG-READER-07:** Unify TOC resolution across formats +3. **BUG-UI-04:** Unify theme token pipeline +4. **BUG-CANDIDATE-02:** Fix format detection inconsistency + +### Long-term (P2/P3) +1. **BUG-VERTICAL-01:** Unify seekbar position source +2. **BUG-PAGED-01:** Add selection initiation delay +3. **BUG-PAGED-02:** Centralize viewport calculation +4. **BUG-UI-01:** Standardize badge design tokens + +--- + +*Report generated by codebase analysis on 2026-08-19T18:54:45.537Z* diff --git a/docs/bug-analysis-summary.md b/docs/bug-analysis-summary.md new file mode 100644 index 000000000..f91bc988d --- /dev/null +++ b/docs/bug-analysis-summary.md @@ -0,0 +1,78 @@ + +# Key Findings Summary + +## Critical Issues (P0/P1) + +### 1. Position Persistence (BUG-READER-03) +**Status:** Partially fixed +**Issue:** Dedup guard loses position on rapid exit +**Fix:** `forceSavePositionOnClose` bypasses the check, but may not cover all edge cases +**Recommendation:** Add periodic snapshots and lifecycle-based saves + +### 2. Progress Desync (BUG-READER-04) +**Status:** Open +**Issue:** Chrome, toolbar, file info, and library show different progress values +**Root Cause:** Different calculation methods for EPUB vs raster formats +**Recommendation:** Create unified ReadingProgressModel + +### 3. Footnote Gesture Conflict (BUG-PAGED-03) +**Status:** Open +**Issue:** Footnotes near screen edges get consumed as page turns +**Root Cause:** Gesture policy doesn't check for footnote presence before classifying +**Recommendation:** Add hit-test priority: Footnote → Interactive → Selection → Navigation + +### 4. Quote Navigation (BUG-CANDIDATE-01) +**Status:** Open +**Issue:** Clicking a quote doesn't return to the exact location +**Root Cause:** Only page number is stored, no anchor/offset data +**Recommendation:** Store structured ReaderPosition with quotes + +## Moderate Issues (P2) + +### 5. Text Selection on Swipe (BUG-PAGED-01) +**Issue:** Swipe gestures sometimes trigger text selection +**Root Cause:** Selection starts before gesture is classified as swipe +**Fix:** Add selection initiation delay (300ms lockout) + +### 6. Reading Mode Not Persisted (BUG-READER-02) +**Issue:** Mode resets to default after reopening book +**Root Cause:** Position's mode overrides user preference +**Fix:** Save mode per-book, prioritize over position mode + +### 7. CBR Format Detection (BUG-CANDIDATE-02) +**Issue:** CBR files shown as RAR +**Root Cause:** Two detectors disagree: FormatDetector→CBR, ComicFormatDetector→RAR +**Fix:** Unify to use FormatDetector as single source of truth + +## Architecture Issues + +### State Fragmentation +The reader has multiple state sources: +- `_uiState` (MutableStateFlow) +- `progressController` (persistent state) +- `sectionPageCounts` (dynamic pagination) +- `lastPersistedProgress` (database snapshot) + +These can diverge, causing the desync issues. + +### Missing Centralization +Several calculations are duplicated: +- Viewport geometry (ChromeInsetsPlan, ReaderViewportGeometry, PagedLayoutParams) +- Page count (EpubSectionPageCountStore, EpubProgressCalculator, DeferredPageCountPolicy) +- Theme colors (ReaderColorScheme, MaterialTheme, custom surfaces) + +## Files to Watch + +1. `ReaderProgressController.kt` — Position saving/loading +2. `PagedGesturePolicy.kt` — Gesture classification +3. `ReaderBookOpeningController.kt` — Position restoration +4. `ReaderBottomBar.kt` — Seekbar implementation +5. `ComicFormatDetector.kt` — Format detection + +## Next Steps + +1. Verify BUG-READER-03 fix covers all scenarios +2. Create unified ReadingProgressModel +3. Add footnote hit-test to gesture policy +4. Fix format detection inconsistency +5. Add structured position to quote storage From 1404a5b77646ae980b0c6eed00bcf2f2b856d340 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 15:49:23 +0700 Subject: [PATCH 08/17] fix: remove duplicate library import prompts --- .../feature/library/LibraryEmptyStates.kt | 28 +++++++------------ .../mrcomic/feature/library/LibraryFolders.kt | 17 ++++++----- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt index 2b0b75ca4..0eeb414d5 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.CheckCircle @@ -62,23 +61,16 @@ internal fun EmptyLibraryPlaceholder( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(24.dp)) - MrComicButton( - onClick = onAddFile, - variant = MrComicButtonVariant.Filled - ) { - Icon(Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(strings.libraryOpenFile) - } - Spacer(Modifier.height(8.dp)) - MrComicButton( - onClick = onAddFolder, - variant = MrComicButtonVariant.Outlined - ) { - Icon(Icons.Default.FolderOpen, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(strings.libraryOpenFolder) - } + Text( + text = if (strings.languageCode == "en") { + "Use the top menu to add a file or folder." + } else { + "Файлы и папки добавляются через верхнее меню." + }, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt index 1eb80e87c..52bfbe618 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt @@ -350,16 +350,19 @@ internal fun FolderCover( title = title, kind = LibraryFallbackCoverKind.FOLDER, shape = RoundedCornerShape(12.dp), + showIcon = false, modifier = Modifier.fillMaxSize(), ) } - FolderCoverTreatment( - title = title, - hasCover = hasCover, - fileCount = fileCount, - subfolderCount = subfolderCount, - modifier = Modifier.fillMaxSize() - ) + if (hasCover) { + FolderCoverTreatment( + title = title, + hasCover = true, + fileCount = fileCount, + subfolderCount = subfolderCount, + modifier = Modifier.fillMaxSize() + ) + } if (showTitleOverlay) { Box( modifier = Modifier From 67b920422328597cd94b3b29089493a8b8fd41ed Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 18:19:18 +0700 Subject: [PATCH 09/17] fix: compact reader chrome controls --- .../ui/ReaderAutoScrollChromeControls.kt | 34 ++++++++++--------- .../reader/ui/ReaderChromeBottomPanel.kt | 29 +++++++++++----- .../reader/ui/components/ReaderBottomBar.kt | 5 ++- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt index 026b25a27..186a08e3a 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt @@ -11,8 +11,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.BorderStroke import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -25,7 +25,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -105,17 +107,12 @@ internal fun ReaderAutoScrollChromeControls( onSpeedCommit: (Float) -> Unit, modifier: Modifier = Modifier, ) { + var expanded by rememberSaveable { mutableStateOf(true) } var draftSpeed by remember(speed) { mutableFloatStateOf(ReaderAutoScrollPrecision.normalize(speed)) } val normalizedProgress = countdownProgress.coerceIn(0f, 1f) val canCountDown = autoScrollEnabled && !isTemporarilyPaused && readingMode != ReadingMode.WEBTOON - val buttonDescription = if (autoScrollEnabled) { - "Остановить автопрокрутку" - } else { - "Запустить автопрокрутку" - } - Column( modifier = modifier .fillMaxWidth() @@ -127,17 +124,19 @@ internal fun ReaderAutoScrollChromeControls( verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = onToggleAutoScroll, - modifier = Modifier.semantics { contentDescription = buttonDescription }, + onClick = { expanded = !expanded }, + modifier = Modifier.semantics { + contentDescription = if (expanded) { + "Свернуть настройки автопрокрутки" + } else { + "Развернуть настройки автопрокрутки" + } + }, ) { Icon( - imageVector = if (autoScrollEnabled) Icons.Filled.Pause else Icons.Filled.PlayArrow, + imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null, - tint = if (autoScrollEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -166,6 +165,8 @@ internal fun ReaderAutoScrollChromeControls( ) } + if (expanded) { + Text( text = if (readingMode == ReadingMode.WEBTOON) { "Скорость плавной ленты" @@ -262,5 +263,6 @@ internal fun ReaderAutoScrollChromeControls( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt index 10ef38563..1047ec1c6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt @@ -38,7 +38,7 @@ fun ReaderExpandedBottomPanel( ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) - val showReadingPresets = uiState.currentHtmlContent != null + val showReadingPresets = true val useCompactLandscapeImagePanel = isLandscape && !showReadingPresets if (useCompactLandscapeImagePanel) { @@ -63,13 +63,23 @@ fun ReaderExpandedBottomPanel( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - ReaderPanelChip( - selected = uiState.currentPage in uiState.bookmarkedPages, - onClick = onToggleBookmark, - label = { - Text(if (uiState.currentPage in uiState.bookmarkedPages) strings.readerBookmarked else strings.readerBookmark) - } - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + ReaderPanelChip( + selected = uiState.currentPage in uiState.bookmarkedPages, + onClick = onToggleBookmark, + label = { Text(if (uiState.currentPage in uiState.bookmarkedPages) strings.readerBookmarked else strings.readerBookmark) } + ) + ReaderPanelChip( + selected = uiState.readingMode != ReadingMode.WEBTOON, + onClick = { onReadingModeChange(ReadingMode.PAGE_LTR) }, + label = { Text(strings.readerPages) } + ) + ReaderPanelChip( + selected = uiState.readingMode == ReadingMode.WEBTOON, + onClick = { onReadingModeChange(ReadingMode.WEBTOON) }, + label = { Text(strings.readingModeWebtoon) } + ) + } if (showReadingPresets) { Text( text = strings.readerReadingPresets, @@ -113,7 +123,8 @@ fun ReaderExpandedBottomPanel( freeScrollProgression = uiState.freeScrollProgression, rasterWebtoonScrollProgression = uiState.rasterWebtoonScrollProgression, onReadingModeChange = onReadingModeChange, - onPageChange = onPageChange + onPageChange = onPageChange, + showReadingModeControls = false ) } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt index 642585f07..0cbcbe3ff 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt @@ -40,6 +40,7 @@ fun ReaderBottomBar( onReadingModeChange: (ReadingMode) -> Unit, onPageChange: (Int) -> Unit, onProgressionChange: ((Float) -> Unit)? = null, + showReadingModeControls: Boolean = true, modifier: Modifier = Modifier ) { val strings = LocalStrings.current @@ -104,7 +105,9 @@ fun ReaderBottomBar( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) { - if (isLandscape && !isTextBook) { + if (!showReadingModeControls) { + Spacer(Modifier.weight(1f)) + } else if (isLandscape && !isTextBook) { ReaderPanelChip( selected = true, onClick = {}, From c35a8eb557bc892da3df2c9a60cfe1e83f6f19f4 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 18:42:11 +0700 Subject: [PATCH 10/17] fix: stabilize reader viewport and pagination --- .../mrcomic/feature/reader/ui/HtmlPageView.kt | 24 +++++++++++++++++++ .../feature/reader/ui/ReaderPagedLayoutJs.kt | 5 +++- .../mrcomic/feature/reader/ui/ReaderScreen.kt | 4 +--- .../reader/ui/ReaderTextChromeLayoutPolicy.kt | 12 +++++++--- .../feature/reader/ui/ReaderHtmlCssJsTest.kt | 14 +++++++++++ .../ui/ReaderTextChromeLayoutPolicyTest.kt | 8 ++++--- 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt index e2681cd96..f554ce943 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt @@ -3,6 +3,7 @@ package io.leostrange.mrcomic.feature.reader.ui import android.os.Build import android.webkit.WebSettings import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,8 +14,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebViewAssetLoader +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.style @@ -161,6 +165,8 @@ internal fun HtmlPageView( FreeScrollPositionHolder(null) } val runtimeOwner = remember { ReaderWebViewRuntimeOwner() } + val lifecycleOwner = LocalLifecycleOwner.current + val webViewReference = remember { mutableStateOf(null) } val loadController = runtimeOwner.loadController val pageSource = rememberReaderHtmlPageSource( controller = loadController, @@ -194,6 +200,22 @@ internal fun HtmlPageView( val currentCharOffset = rememberUpdatedState(sectionCharacterOffset) val onSelectionModeChange = rememberUpdatedState(onSelectionActionModeChange) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + webViewReference.value?.let { webView -> + webView.onResume() + webView.resumeTimers() + webView.invalidate() + webView.requestLayout() + webView.postDelayed({ webView.verifyVisibleContentOrFallback() }, 120L) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // Auto-scroll state — must be before AndroidView so the factory can capture it. val autoScrollPaused = remember { mutableStateOf(false) } val autoScrollScrollLambda = remember { mutableStateOf<((Int) -> Unit)?>(null) } @@ -230,6 +252,7 @@ internal fun HtmlPageView( factory = { ctx -> ReaderWebView(ctx).apply { val readerWebView = this + webViewReference.value = this // ARC-11 slice 2b: bridge ReaderWebView.markLoadCommitted's token // into the load controller so shouldRestoreScroll() follows the // WebView's own commit lifecycle. @@ -462,6 +485,7 @@ internal fun HtmlPageView( webView.applyHighlightsIfChanged(highlightsJs) }, onRelease = { webView -> + if (webViewReference.value === webView) webViewReference.value = null autoScrollScrollLambda.value = null if (!webView.pagedModeScrollLock) { webView.currentFreeScrollRestoreTarget()?.let { position -> diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt index 51da75563..53d07573a 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt @@ -327,7 +327,10 @@ internal fun readerPagedCoreJs( while(currentcurrent+lineHeight*2){ var nextStartAfterMedia=contentHeight; for(var frontIdx=0;frontIdx= 0) require(measuredBottomCssPx >= 0) - return ReaderTextChromeLayoutInsets(topCssPx = 0, bottomCssPx = 0) + require(persistentGutterCssPx >= 0) + val gutter = persistentGutterCssPx.coerceAtLeast(0) + return ReaderTextChromeLayoutInsets( + topCssPx = maxOf(measuredTopCssPx, gutter), + bottomCssPx = maxOf(measuredBottomCssPx, gutter), + ) } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt index ba3087906..37cffcad5 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt @@ -377,6 +377,20 @@ class ReaderHtmlCssJsTest { ) } + @Test + fun readerPagedLayoutJs_usesLineQuantizedUsableHeightForEveryPageBudget() { + val js = readerPagedLayoutJs(targetPage = 0) + + assertTrue( + "page boundaries must use the same quantized height as the reported usable viewport", + js.contains("var pageBudget=Math.max(lineHeight*3,usableHeight);") + ) + assertFalse( + "page boundaries must not use a second independently rounded clip-height formula", + js.contains("clipHeight-pageTopInset-pageBottomInset-bodyPaddingBottom") + ) + } + @Test fun readerPagedLayoutJs_masksFractionalBoundaryBeforeTheNextLine() { val js = readerPagedLayoutJs(targetPage = 0) diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt index 0eafdc833..5816727d1 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt @@ -5,17 +5,19 @@ import org.junit.Test class ReaderTextChromeLayoutPolicyTest { @Test - fun visibleChrome_doesNotChangeTextLayoutInsets() { + fun textKeepsSymmetricPersistentGutterWhenChromeIsHidden() { val hidden = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 0, measuredBottomCssPx = 0, + persistentGutterCssPx = 18, ) val visible = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 196, measuredBottomCssPx = 236, + persistentGutterCssPx = 18, ) - assertEquals(ReaderTextChromeLayoutInsets(0, 0), hidden) - assertEquals(hidden, visible) + assertEquals(ReaderTextChromeLayoutInsets(18, 18), hidden) + assertEquals(ReaderTextChromeLayoutInsets(196, 236), visible) } } From e45b110c12123297ed5e729954c227082648f4c1 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 19:26:49 +0700 Subject: [PATCH 11/17] fix: correct rtf progress and compact auto-scroll chrome --- .../java/io/leostrange/mrcomic/core/model/Comic.kt | 13 ++++++++++++- .../reader/ui/ReaderAutoScrollChromeControls.kt | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt index 37bcde9e2..3c96e07b5 100644 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt @@ -115,7 +115,17 @@ fun readingProgressForPage(currentPage: Int, pageCount: Int): Float { } /** Progress suitable for display, including protection from legacy placeholder 100% values. */ -fun Comic.displayReadingProgress(): Float = when (readingStatus()) { +fun Comic.displayReadingProgress(): Float { + // RTF files frequently arrive from legacy imports with a stale 1.0 value. + // Do not expose that placeholder until the reader has persisted a real + // position or the title was explicitly completed. + if (format == ComicFormat.RTF && !isCompleted && + storedReaderLocator()?.progression == null && readingProgress >= 0.999f && + !(pageCount > 1 && currentPage >= pageCount - 1) + ) { + return 0f + } + return when (readingStatus()) { ComicReadingStatus.NEW -> 0f ComicReadingStatus.COMPLETED -> 1f ComicReadingStatus.READING -> { @@ -137,6 +147,7 @@ fun Comic.displayReadingProgress(): Float = when (readingStatus()) { } } } +} fun Comic.storedReaderLocator(): ReaderLocator? { val href = readerLocatorHref?.takeIf { it.isNotBlank() } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt index 186a08e3a..1fca547f4 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt @@ -107,7 +107,9 @@ internal fun ReaderAutoScrollChromeControls( onSpeedCommit: (Float) -> Unit, modifier: Modifier = Modifier, ) { - var expanded by rememberSaveable { mutableStateOf(true) } + // Keep the chrome compact on every fresh reader open. The detailed controls + // are still available behind the explicit expand action. + var expanded by rememberSaveable { mutableStateOf(false) } var draftSpeed by remember(speed) { mutableFloatStateOf(ReaderAutoScrollPrecision.normalize(speed)) } From d2d1d26746c7ab66c9c433976c6ab7b53e8e9745 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Thu, 27 Aug 2026 19:57:27 +0700 Subject: [PATCH 12/17] fix: keep reader chrome as overlay --- .../feature/reader/ui/ReaderTextChromeLayoutPolicy.kt | 11 +++++------ .../reader/ui/ReaderTextChromeLayoutPolicyTest.kt | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt index f09371172..907259d31 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt @@ -7,8 +7,8 @@ internal data class ReaderTextChromeLayoutInsets( /** * Reader chrome is an overlay and must not change text wrapping or page - * boundaries. The persistent text gutter is kept symmetrically in CSS; only - * measured chrome insets are added when they are larger than that gutter. + * boundaries. The persistent text gutter is kept symmetrically in CSS. The + * measured chrome bounds are intentionally ignored because chrome is overlay. */ internal fun resolveReaderTextChromeLayoutInsets( measuredTopCssPx: Int, @@ -19,8 +19,7 @@ internal fun resolveReaderTextChromeLayoutInsets( require(measuredBottomCssPx >= 0) require(persistentGutterCssPx >= 0) val gutter = persistentGutterCssPx.coerceAtLeast(0) - return ReaderTextChromeLayoutInsets( - topCssPx = maxOf(measuredTopCssPx, gutter), - bottomCssPx = maxOf(measuredBottomCssPx, gutter), - ) + // Keep parameters validated for the geometry contract, but never feed the + // panel height into reflow: opening chrome must not move the current page. + return ReaderTextChromeLayoutInsets(topCssPx = gutter, bottomCssPx = gutter) } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt index 5816727d1..45027c0a4 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt @@ -18,6 +18,6 @@ class ReaderTextChromeLayoutPolicyTest { ) assertEquals(ReaderTextChromeLayoutInsets(18, 18), hidden) - assertEquals(ReaderTextChromeLayoutInsets(196, 236), visible) + assertEquals(ReaderTextChromeLayoutInsets(18, 18), visible) } } From 8f21929af0683a8f1464db7bf54f22f1879ce0f8 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Fri, 28 Aug 2026 15:33:45 +0700 Subject: [PATCH 13/17] fix: stabilize reader viewport and controls --- .../mrcomic/mrcomic/home/ContinueViewModel.kt | 11 ++- .../home/ContinueStartupWarmStateTest.kt | 19 ++++ .../io/leostrange/mrcomic/core/model/Comic.kt | 5 +- .../engine/formats/fb2/Fb2FormatReader.kt | 13 ++- .../engine/formats/fb2/Fb2FrontMatterTest.kt | 38 ++++++++ .../feature/reader/ui/ReaderBottomSheets.kt | 9 +- .../reader/ui/ReaderChromeBottomPanel.kt | 2 +- .../reader/ui/ReaderChromeComponents.kt | 2 +- .../reader/ui/ReaderControlCenterSheet.kt | 14 +-- .../feature/reader/ui/ReaderHeaderFooterUi.kt | 15 +-- .../feature/reader/ui/ReaderPagedLayoutJs.kt | 8 +- .../feature/reader/ui/ReaderReadingTab.kt | 82 ++++++++-------- .../mrcomic/feature/reader/ui/ReaderScreen.kt | 21 +++- .../feature/reader/ui/ReaderServicesTab.kt | 18 +--- .../feature/reader/ui/ReaderStyleTab.kt | 97 +++++++++++++++++++ .../reader/ui/ReaderTextChromeLayoutPolicy.kt | 28 +++++- .../feature/reader/ui/components/PageView.kt | 15 ++- .../ui/components/ReaderGraphicPageOffset.kt | 21 ++++ .../reader/ui/gesture/PagedLayoutParams.kt | 16 ++- .../feature/reader/ui/ReaderHtmlCssJsTest.kt | 20 +++- .../feature/reader/ui/ReaderReadingTabTest.kt | 17 +++- .../ui/ReaderTextChromeLayoutPolicyTest.kt | 53 +++++++++- .../components/ReaderGraphicPageOffsetTest.kt | 25 +++++ .../ui/gesture/PagedLayoutParamsTest.kt | 64 ++++++++---- .../settings/ui/SettingsReaderPreviews.kt | 90 ++++++++++++++++- 25 files changed, 553 insertions(+), 150 deletions(-) create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt create mode 100644 android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt index f87844abc..ba3483f69 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt @@ -69,9 +69,15 @@ internal enum class ContinueReturnSupportTone { internal fun resolveContinueStartupData( liveComics: List, liveTrail: List, - warmState: ContinueWarmState + warmState: ContinueWarmState, + liveDataReady: Boolean = false ): ContinueResolvedStartupData { return when { + liveDataReady -> ContinueResolvedStartupData( + comics = liveComics, + trail = liveTrail, + isLoading = false + ) liveComics.isNotEmpty() -> ContinueResolvedStartupData( comics = liveComics, trail = liveTrail, @@ -146,7 +152,8 @@ class ContinueViewModel @Inject constructor( val startupData = resolveContinueStartupData( liveComics = inputs.comics, liveTrail = inputs.trail, - warmState = warmState + warmState = warmState, + liveDataReady = true ) val comics = startupData.comics val trail = startupData.trail diff --git a/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt b/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt index 15735cb0a..a80170002 100644 --- a/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt +++ b/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt @@ -64,6 +64,25 @@ class ContinueStartupWarmStateTest { assertEquals("live-1", resolved.trail.first().comicId) } + @Test + fun doesNotRestoreWarmSnapshotAfterLiveLibraryBecomesEmpty() { + val resolved = resolveContinueStartupData( + liveComics = emptyList(), + liveTrail = emptyList(), + warmState = ContinueWarmState.Ready( + ContinueWarmSnapshot( + comics = listOf(sampleComic("deleted")), + trail = listOf(sampleCheckpoint("deleted")) + ) + ), + liveDataReady = true + ) + + assertFalse(resolved.isLoading) + assertTrue(resolved.comics.isEmpty()) + assertTrue(resolved.trail.isEmpty()) + } + private fun sampleComic(id: String): Comic = Comic( id = id, title = "Sample", diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt index 3c96e07b5..e3de6c44b 100644 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt @@ -119,9 +119,10 @@ fun Comic.displayReadingProgress(): Float { // RTF files frequently arrive from legacy imports with a stale 1.0 value. // Do not expose that placeholder until the reader has persisted a real // position or the title was explicitly completed. - if (format == ComicFormat.RTF && !isCompleted && + if (format == ComicFormat.RTF && storedReaderLocator()?.progression == null && readingProgress >= 0.999f && - !(pageCount > 1 && currentPage >= pageCount - 1) + !(pageCount > 1 && currentPage >= pageCount - 1) && + pageCount <= 1 ) { return 0f } diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt index 6e28d275c..19b327977 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt @@ -52,6 +52,10 @@ class Fb2FormatReader( """<(?:h2|h3|p|blockquote)\b[^>]*>.*?||
)?""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) ) + private val SEMANTIC_PAGE_START_RE = Regex( + """^\s*(?:]*mrcomic-cover-image)""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) private const val FB2_READER_CSS = """ a.fn,a[href*="FbAutId_"],a[href^="fbanchor://"]{font-size:0.75em;vertical-align:super;line-height:1; font-weight:bold;text-decoration:none;cursor:pointer} @@ -672,8 +676,9 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left /** * Groups consecutive short raw sections (e.g. individual footnotes) into reader pages * until the combined text reaches [CHARS_PER_PAGE], and maps every raw section index - * to its merged reader page. Sections flagged as explicit section starts always begin - * a new merged page so front-matter (cover/TOC) stays separate from chapter text. + * to its merged reader page. A top-level section starts a new page only when it has + * semantic page content (a title or cover). Untitled FB2 fragments are allowed to flow + * together, avoiding pages with only a few lines while preserving real chapter breaks. */ private fun mergeRawSections( rawSections: List, @@ -686,7 +691,9 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left var pendingChars = 0 for ((rawIdx, section) in rawSections.withIndex()) { val sectionChars = HTML_TAG_RE.replace(section, "").length - if (pendingChars > 0 && rawSectionStarts.getOrNull(rawIdx) == true) { + val isSemanticSectionStart = rawSectionStarts.getOrNull(rawIdx) == true && + SEMANTIC_PAGE_START_RE.containsMatchIn(section) + if (pendingChars > 0 && isSemanticSectionStart) { mergedSections.add(pendingMerge.toString()) pendingMerge.clear() pendingChars = 0 diff --git a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt index 4af30e04a..767a7abcf 100644 --- a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt +++ b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt @@ -15,6 +15,44 @@ import java.io.File @Config(sdk = [35]) class Fb2FrontMatterTest { + @Test + fun untitledTopLevelFragmentsDoNotCreateAlmostEmptyPages() = runBlocking { + val sample = File.createTempFile("mrcomic-untitled-fragments", ".fb2") + sample.writeText( + """ + + + Fragmentsru + +

Короткий остаток первой части.

+

Продолжение того же текста без заголовка.

+
+ <p>Глава вторая</p> +

Новая глава должна начинаться отдельно.

+
+ +
+ """.trimIndent() + ) + + val reader = Fb2FormatReader(ContextWrapper(null), sample.absolutePath) + try { + val pages = (0 until reader.getPageCount()).mapNotNull { reader.getHtmlPage(it) } + + assertEquals("Untitled fragments should share one reader page", 2, pages.size) + assertTrue(pages[0].contains("Короткий остаток")) + assertTrue(pages[0].contains("Продолжение того же текста")) + assertFalse(pages[0].contains("Глава вторая")) + assertTrue(pages[1].contains("Глава вторая")) + assertEquals(0, reader.resolveHrefToPage("#fragment-a")) + assertEquals(0, reader.resolveHrefToPage("#fragment-b")) + assertEquals(1, reader.resolveHrefToPage("#chapter-two")) + } finally { + reader.close() + sample.delete() + } + } + @Test fun coverSynopsisTocAndFirstChapterRemainSeparateAndLinked() = runBlocking { val sample = File.createTempFile("mrcomic-front-matter", ".fb2") diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt index 42c68b46b..d135d5221 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt @@ -233,14 +233,7 @@ internal fun ReaderBottomSheets( onTtsSpeedChange = viewModel.settingsController::setTtsSpeed, onTtsPitchChange = viewModel.settingsController::setTtsPitch, onTtsVolumeChange = viewModel.settingsController::setTtsVolume, - onTtsSleepTimerChange = viewModel.settingsController::setTtsSleepTimerMode, - autoScrollActions = ReaderAutoScrollActions( - toggle = viewModel.autoScrollSettingsController::toggle, - previewSpeed = viewModel.autoScrollSettingsController::previewSpeed, - commitSpeed = { speed -> - viewModel.autoScrollSettingsController.commitSpeed(uiState.readingMode, speed) - } - ) + onTtsSleepTimerChange = viewModel.settingsController::setTtsSleepTimerMode ) } pendingCustomFontDeletion?.let { fontName -> diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt index 1047ec1c6..8ed398531 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt @@ -187,7 +187,7 @@ private fun ReaderCompactLandscapeBottomPanel( Spacer(Modifier.weight(1f)) Text( - text = "${currentPage + 1} / $totalPages", + text = if (totalPages > 1) "${currentPage.coerceIn(0, totalPages - 1) + 1} / $totalPages" else "1 / …", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt index 5fb781da5..baef0017f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt @@ -485,7 +485,7 @@ fun ReaderProgressPill( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "${currentPage + 1} / $totalPages", + text = if (totalPages > 1) "${currentPage.coerceIn(0, totalPages - 1) + 1} / $totalPages" else "1 / …", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt index 1d7071a85..ef8623842 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterSheet.kt @@ -34,12 +34,6 @@ internal enum class ReaderChromeEditorTab { ORDER } -internal data class ReaderAutoScrollActions( - val toggle: () -> Unit, - val previewSpeed: (Float) -> Unit, - val commitSpeed: (Float) -> Unit -) - @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ReaderControlCenterSheet( @@ -104,8 +98,7 @@ internal fun ReaderControlCenterSheet( onTtsSpeedChange: (Float) -> Unit, onTtsPitchChange: (Float) -> Unit, onTtsVolumeChange: (Float) -> Unit, - onTtsSleepTimerChange: (String) -> Unit, - autoScrollActions: ReaderAutoScrollActions + onTtsSleepTimerChange: (String) -> Unit ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -254,10 +247,7 @@ internal fun ReaderControlCenterSheet( onTtsSpeedChange = onTtsSpeedChange, onTtsPitchChange = onTtsPitchChange, onTtsVolumeChange = onTtsVolumeChange, - onTtsSleepTimerChange = onTtsSleepTimerChange, - onAutoScrollToggle = autoScrollActions.toggle, - onAutoScrollSpeedPreview = autoScrollActions.previewSpeed, - onAutoScrollSpeedCommit = autoScrollActions.commitSpeed + onTtsSleepTimerChange = onTtsSleepTimerChange ) } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt index b8079cd9d..4af7059c0 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt @@ -112,13 +112,14 @@ internal fun resolveReaderInfoOverlayLine( clockText: String, currentPage: Int, totalPages: Int, - readingMode: ReadingMode + readingMode: ReadingMode, + canonicalProgressPercent: Int? = null ): ReaderInfoOverlayLine { val visiblePages = resolveReaderVisiblePages(currentPage, totalPages, readingMode) return ReaderInfoOverlayLine( - start = resolveReaderInfoSlotValue(startSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages), - center = resolveReaderInfoSlotValue(centerSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages), - end = resolveReaderInfoSlotValue(endSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages) + start = resolveReaderInfoSlotValue(startSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent), + center = resolveReaderInfoSlotValue(centerSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent), + end = resolveReaderInfoSlotValue(endSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent) ) } @@ -147,13 +148,15 @@ private fun resolveReaderInfoSlotValue( chapterTitle: String?, clockText: String, visiblePages: List, - totalPages: Int + totalPages: Int, + canonicalProgressPercent: Int? ): String = when (ReaderInfoSlot.fromStored(slot)) { ReaderInfoSlot.NONE -> "" ReaderInfoSlot.BOOK_TITLE -> comicTitle.orEmpty() ReaderInfoSlot.CHAPTER_TITLE -> chapterTitle.orEmpty() ReaderInfoSlot.TIME -> clockText - ReaderInfoSlot.PROGRESS -> resolveReaderProgressLabel(visiblePages, totalPages) + ReaderInfoSlot.PROGRESS -> canonicalProgressPercent?.coerceIn(0, 100)?.let { "$it%" } + ?: resolveReaderProgressLabel(visiblePages, totalPages) ReaderInfoSlot.PAGE -> resolveReaderPageLabel(visiblePages, totalPages) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt index 53d07573a..20e7f3ee6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt @@ -118,9 +118,11 @@ internal fun readerPagedCoreJs( viewport.style.boxSizing='border-box'; viewport.style.paddingTop='0px'; viewport.style.paddingBottom='0px'; - var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom-Math.max(6,Math.ceil(lineHeight*0.25))); - var usableLineCount=Math.max(3,Math.floor(rawUsableHeight/lineHeight)); - var usableHeight=Math.max(lineHeight*3,usableLineCount*lineHeight); + // The Compose reader container already owns the complete outer gutter. + // Keep the viewport remainder in the page budget. Quantizing this value + // down by lineHeight turns that remainder into a variable bottom gutter. + var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom); + var usableHeight=rawUsableHeight; root.style.setProperty('--mrcomic-page-visible-height',usableHeight+'px'); root.style.setProperty('--mrcomic-page-inset-top',pageInsetTop+'px'); root.style.setProperty('--mrcomic-page-inset-bottom',pageInsetBottom+'px'); diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt index 6f9a8e654..854f7544e 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt @@ -223,45 +223,47 @@ internal fun ReaderReadingTab( ) ) } - item { - ReaderSliderRow( - title = readerHeaderFooterFontSizeTitle(strings.languageCode), - valueText = "${uiState.headerFooterFontSize}sp", - value = uiState.headerFooterFontSize.toFloat(), - valueRange = 10f..20f, - steps = 9, - onValueChange = { onHeaderFooterFontSizeChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterVerticalPaddingTitle(strings.languageCode), - valueText = "${uiState.headerFooterVerticalPadding}dp", - value = uiState.headerFooterVerticalPadding.toFloat(), - valueRange = 4f..20f, - steps = 15, - onValueChange = { onHeaderFooterVerticalPaddingChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterLeftInsetTitle(strings.languageCode), - valueText = "${uiState.headerFooterLeftPadding}dp", - value = uiState.headerFooterLeftPadding.toFloat(), - valueRange = 8f..32f, - steps = 23, - onValueChange = { onHeaderFooterLeftPaddingChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterRightInsetTitle(strings.languageCode), - valueText = "${uiState.headerFooterRightPadding}dp", - value = uiState.headerFooterRightPadding.toFloat(), - valueRange = 8f..32f, - steps = 23, - onValueChange = { onHeaderFooterRightPaddingChange(it.toInt()) } - ) + if (isTextReader) { + item { + ReaderSliderRow( + title = readerHeaderFooterFontSizeTitle(strings.languageCode), + valueText = "${uiState.headerFooterFontSize}sp", + value = uiState.headerFooterFontSize.toFloat(), + valueRange = 10f..20f, + steps = 9, + onValueChange = { onHeaderFooterFontSizeChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterVerticalPaddingTitle(strings.languageCode), + valueText = "${uiState.headerFooterVerticalPadding}dp", + value = uiState.headerFooterVerticalPadding.toFloat(), + valueRange = 4f..20f, + steps = 15, + onValueChange = { onHeaderFooterVerticalPaddingChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterLeftInsetTitle(strings.languageCode), + valueText = "${uiState.headerFooterLeftPadding}dp", + value = uiState.headerFooterLeftPadding.toFloat(), + valueRange = 8f..32f, + steps = 23, + onValueChange = { onHeaderFooterLeftPaddingChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterRightInsetTitle(strings.languageCode), + valueText = "${uiState.headerFooterRightPadding}dp", + value = uiState.headerFooterRightPadding.toFloat(), + valueRange = 8f..32f, + steps = 23, + onValueChange = { onHeaderFooterRightPaddingChange(it.toInt()) } + ) + } } item { ReaderSwitchRow( @@ -529,4 +531,4 @@ internal fun ReaderHeaderFooterSlotStrip( } } -// Localization strings extracted to ReaderControlCenterStrings.kt \ No newline at end of file +// Localization strings extracted to ReaderControlCenterStrings.kt diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt index 53328db23..e7f6daf92 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt @@ -207,7 +207,8 @@ fun ReaderScreen( startSlot = left, centerSlot = center, endSlot = right, comicTitle = uiState.comic?.title, chapterTitle = currentChapterTitle, clockText = clockText, currentPage = effectiveCurrentPage, - totalPages = effectiveTotalPages, readingMode = uiState.readingMode + totalPages = effectiveTotalPages, readingMode = uiState.readingMode, + canonicalProgressPercent = uiState.effectiveProgressPercent ) val headerOverlayLine = remember( uiState.headerLeftSlot, uiState.headerCenterSlot, uiState.headerRightSlot, @@ -545,12 +546,24 @@ fun ReaderScreen( } val textReaderModifier = Modifier .fillMaxSize() - .then(textSystemInsetsModifier) val textChromeLayoutInsets = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = viewportGeometry.chromeTopInsetCssPx, measuredBottomCssPx = viewportGeometry.chromeBottomInsetCssPx, - persistentGutterCssPx = (textSentenceInsetPx / density.density).roundToInt(), + // Keep one full line of air at each edge and one equal + // line-sized safety step between the text and an + // overlaid chrome bar. This reserve is constant, so + // opening/closing chrome cannot reflow the document. + persistentGutterCssPx = ( + readerTextTwoLineGutterPx(textSentenceInsetPx) / density.density + ).roundToInt(), + persistentGutterPx = readerTextTwoLineGutterPx(textSentenceInsetPx), ) + val stableTextReaderModifier = textReaderModifier + .then(textSystemInsetsModifier) + .padding( + top = with(density) { textChromeLayoutInsets.outerTopPx.toDp() }, + bottom = with(density) { textChromeLayoutInsets.outerBottomPx.toDp() }, + ) val imageReaderModifier = Modifier .fillMaxSize() .then( @@ -576,7 +589,7 @@ fun ReaderScreen( effectiveMarginCropHorizontal = effectiveMarginCropHorizontal, effectiveMarginCropVertical = effectiveMarginCropVertical, effectivePageImageScaleMode = effectivePageImageScaleMode, - textReaderModifier = textReaderModifier, + textReaderModifier = stableTextReaderModifier, imageReaderModifier = imageReaderModifier, textChromeTopInsetCssPx = textChromeLayoutInsets.topCssPx, textChromeBottomInsetCssPx = textChromeLayoutInsets.bottomCssPx, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt index c87ce04bc..d78119284 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderServicesTab.kt @@ -31,10 +31,7 @@ internal fun ReaderServicesTab( onTtsSpeedChange: (Float) -> Unit, onTtsPitchChange: (Float) -> Unit, onTtsVolumeChange: (Float) -> Unit, - onTtsSleepTimerChange: (String) -> Unit, - onAutoScrollToggle: () -> Unit, - onAutoScrollSpeedPreview: (Float) -> Unit, - onAutoScrollSpeedCommit: (Float) -> Unit + onTtsSleepTimerChange: (String) -> Unit ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -56,19 +53,6 @@ internal fun ReaderServicesTab( verticalArrangement = Arrangement.spacedBy(6.dp) ) { item { ReaderSectionTitle(readerText.servicesQuickActionsTitle) } - item { ReaderSectionTitle("Авточтение") } - item { - ReaderAutoScrollChromeControls( - speed = uiState.autoScrollSpeed, - readingMode = uiState.readingMode, - autoScrollEnabled = uiState.autoScrollEnabled, - isTemporarilyPaused = uiState.isAutoScrollTemporarilyPaused, - countdownProgress = uiState.autoScrollCountdownProgress, - onToggleAutoScroll = onAutoScrollToggle, - onSpeedPreview = onAutoScrollSpeedPreview, - onSpeedCommit = onAutoScrollSpeedCommit - ) - } item { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { item("ocr") { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index cd1ba849a..f6467977c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.reader.ui import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow @@ -208,6 +210,13 @@ internal fun ReaderStyleTab( } } if (supportsMarginCrop) { + item { + ReaderGraphicCropPreview( + horizontalCrop = uiState.imageMarginCropHorizontal, + verticalCrop = uiState.imageMarginCropVertical, + language = strings.languageCode + ) + } item { Text( text = readerMarginCropHint(strings.languageCode), @@ -531,6 +540,94 @@ internal fun ReaderStyleTab( } } +/** + * A deliberately simple crop preview: the muted bands are the part removed + * from the page, so vertical cropping stays visible while its slider is being + * adjusted in the bottom sheet. + */ +@Composable +private fun ReaderGraphicCropPreview( + horizontalCrop: Float, + verticalCrop: Float, + language: String +) { + val horizontal = horizontalCrop.coerceIn(0f, 0.18f) + val vertical = verticalCrop.coerceIn(0f, 0.18f) + val removedLabel = when (language) { + "en" -> "Removed margins" + "ja" -> "トリミング範囲" + "zh" -> "已裁剪边缘" + "ko" -> "잘린 여백" + else -> "Подрезаемые поля" + } + ReaderSettingsCard { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(removedLabel, style = MaterialTheme.typography.labelLarge) + Text( + text = "${(vertical * 100f).toInt()}% · ${(horizontal * 100f).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(112.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = (horizontal * 90f).dp, + vertical = (vertical * 90f).dp + ) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + repeat(4) { index -> + Box( + modifier = Modifier + .fillMaxWidth(if (index == 1) 0.78f else 1f) + .height(6.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + RoundedCornerShape(50) + ) + ) + } + } + } + if (vertical > 0f) { + val bandHeight = (vertical * 180f).dp + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.TopCenter) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.BottomCenter) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + ) + } + } + } + } +} + @Composable internal fun ReaderStylePresetListItem( slot: ReaderStylePresetSlot, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt index 907259d31..cb7555518 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt @@ -3,23 +3,43 @@ package io.leostrange.mrcomic.feature.reader.ui internal data class ReaderTextChromeLayoutInsets( val topCssPx: Int, val bottomCssPx: Int, + /** Fixed physical gutter kept outside the WebView content viewport. */ + val outerTopPx: Int, + val outerBottomPx: Int, ) +/** + * Returns the stable physical reserve for paged text. Two line-height steps + * are kept outside the WebView so a chrome overlay cannot change pagination. + */ +internal fun readerTextTwoLineGutterPx(lineHeightPx: Int): Int = + (lineHeightPx.coerceAtLeast(8) * 2).coerceAtLeast(16) + /** * Reader chrome is an overlay and must not change text wrapping or page - * boundaries. The persistent text gutter is kept symmetrically in CSS. The - * measured chrome bounds are intentionally ignored because chrome is overlay. + * boundaries. The persistent text gutter is kept symmetrically outside the + * WebView. The measured chrome bounds are intentionally ignored because + * chrome is overlay. */ internal fun resolveReaderTextChromeLayoutInsets( measuredTopCssPx: Int, measuredBottomCssPx: Int, persistentGutterCssPx: Int = 0, + persistentGutterPx: Int = persistentGutterCssPx, ): ReaderTextChromeLayoutInsets { require(measuredTopCssPx >= 0) require(measuredBottomCssPx >= 0) require(persistentGutterCssPx >= 0) - val gutter = persistentGutterCssPx.coerceAtLeast(0) + require(persistentGutterPx >= 0) + val gutterPx = persistentGutterPx.coerceAtLeast(0) // Keep parameters validated for the geometry contract, but never feed the // panel height into reflow: opening chrome must not move the current page. - return ReaderTextChromeLayoutInsets(topCssPx = gutter, bottomCssPx = gutter) + // The gutter belongs to the Compose viewport, not the document. This keeps + // the WebView's page budget identical with chrome hidden and shown. + return ReaderTextChromeLayoutInsets( + topCssPx = 0, + bottomCssPx = 0, + outerTopPx = gutterPx, + outerBottomPx = gutterPx, + ) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt index 082f33252..80e2c7bf2 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode -import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.ReaderImageScaleMode import io.leostrange.mrcomic.core.ui.eink.LocalEInkMode import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState @@ -61,7 +60,7 @@ fun PageView( ) { val isEInk = LocalEInkMode.current val isDualPage = uiState.readingMode == ReadingMode.DUAL_PAGE - val isDjvu = uiState.comic?.format == ComicFormat.DJVU + val graphicPageOffset = readerGraphicPageOffset(uiState.comic?.format) val leftPage = uiState.currentPage val imageCrop = remember(marginCropHorizontal, marginCropVertical) { ReaderImageCrop( @@ -126,8 +125,8 @@ fun PageView( alignment = Alignment.CenterEnd, imageScaleMode = imageScaleMode, crop = imageCrop, - pageOffsetX = if (isDjvu) (-8).dp else 0.dp, - pageOffsetY = if (isDjvu) 20.dp else 0.dp, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.weight(1f) ) if (rightPage != null) { @@ -137,8 +136,8 @@ fun PageView( alignment = Alignment.CenterStart, imageScaleMode = imageScaleMode, crop = imageCrop, - pageOffsetX = if (isDjvu) (-8).dp else 0.dp, - pageOffsetY = if (isDjvu) 20.dp else 0.dp, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.weight(1f) ) } else { @@ -171,8 +170,8 @@ fun PageView( contentDescription = "Page ${page + 1}", imageScaleMode = imageScaleMode, crop = imageCrop, - pageOffsetX = if (isDjvu) (-8).dp else 0.dp, - pageOffsetY = if (isDjvu) 20.dp else 0.dp, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.fillMaxSize() ) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt new file mode 100644 index 000000000..0dd981927 --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt @@ -0,0 +1,21 @@ +package io.leostrange.mrcomic.feature.reader.ui.components + +import io.leostrange.mrcomic.core.model.ComicFormat + +internal data class ReaderGraphicPageOffset( + val xDp: Int, + val yDp: Int, +) + +/** + * PDF and DJVU pages use the same vertical baseline as the original document + * viewport. Horizontal placement remains neutral because [PagePane] already + * centers the page in its viewport. Other raster formats keep the neutral + * centered placement as well. + */ +internal fun readerGraphicPageOffset(format: ComicFormat?): ReaderGraphicPageOffset = + if (format == ComicFormat.PDF || format == ComicFormat.DJVU) { + ReaderGraphicPageOffset(xDp = 0, yDp = 20) + } else { + ReaderGraphicPageOffset(xDp = 0, yDp = 0) + } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt index a0f384f11..1990f535b 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt @@ -1,6 +1,5 @@ package io.leostrange.mrcomic.feature.reader.ui.gesture -import kotlin.math.floor import kotlin.math.max /** @@ -16,16 +15,15 @@ object PagedLayoutParams { * * This mirrors the JS logic: * ``` - * var rawUsableHeight = Math.max(lineHeight*3, clipHeight - pageInsetTop - pageInsetBottom - Math.max(2, lineHeight*0.12)); - * var usableLineCount = Math.max(3, Math.floor(rawUsableHeight / lineHeight)); - * var usableHeight = Math.max(lineHeight*3, usableLineCount * lineHeight); + * var rawUsableHeight = Math.max(lineHeight*3, clipHeight - pageInsetTop - pageInsetBottom); + * var usableHeight = rawUsableHeight; * ``` * * @param viewportHeightPx Physical viewport height in pixels. * @param topInsetPx Top padding (status bar + chrome) in pixels. * @param bottomInsetPx Bottom padding (navigation bar + chrome) in pixels. * @param lineHeightPx Computed line height in pixels. If 0, defaults to 27 (18sp * 1.5). - * @return Usable page content height in pixels, aligned to line boundaries. + * @return The complete usable page content height in pixels. */ fun calculateUsablePageHeight( viewportHeightPx: Int, @@ -35,10 +33,10 @@ object PagedLayoutParams { ): Int { val lineHeight = if (lineHeightPx > 0f) lineHeightPx else 27f // 18sp * 1.5 val clipHeight = max(lineHeight * 3, viewportHeightPx.toFloat()) - val safetyMargin = max(6f, kotlin.math.ceil(lineHeight * 0.25f)) - val rawUsableHeight = max(lineHeight * 3, clipHeight - topInsetPx - bottomInsetPx - safetyMargin) - val usableLineCount = max(3, floor(rawUsableHeight / lineHeight).toInt()) - return max(lineHeight * 3, usableLineCount * lineHeight).toInt() + // The outer reader already owns the complete two-line gutter. Keep + // every remaining pixel in the page budget: rounding down to a whole + // number of lines turns the remainder into a variable extra gutter. + return max(lineHeight * 3, clipHeight - topInsetPx - bottomInsetPx).toInt() } /** diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt index 37cffcad5..5765db289 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt @@ -378,19 +378,33 @@ class ReaderHtmlCssJsTest { } @Test - fun readerPagedLayoutJs_usesLineQuantizedUsableHeightForEveryPageBudget() { + fun readerPagedLayoutJs_usesSingleUsableHeightForEveryPageBudget() { val js = readerPagedLayoutJs(targetPage = 0) assertTrue( - "page boundaries must use the same quantized height as the reported usable viewport", + "page boundaries must use the same height as the reported usable viewport", js.contains("var pageBudget=Math.max(lineHeight*3,usableHeight);") ) assertFalse( - "page boundaries must not use a second independently rounded clip-height formula", + "page boundaries must not use a second independent clip-height formula", js.contains("clipHeight-pageTopInset-pageBottomInset-bodyPaddingBottom") ) } + @Test + fun readerPagedLayoutJs_doesNotConvertViewportRemainderIntoBottomGutter() { + val js = readerPagedLayoutJs(targetPage = 0) + + assertTrue( + "the complete available viewport must be the page budget", + js.contains("var usableHeight=rawUsableHeight;") + ) + assertFalse( + "flooring by line height creates a variable extra bottom gutter", + js.contains("Math.floor(rawUsableHeight/lineHeight)") + ) + } + @Test fun readerPagedLayoutJs_masksFractionalBoundaryBeforeTheNextLine() { val js = readerPagedLayoutJs(targetPage = 0) diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt index 75004948c..848465e0d 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt @@ -48,13 +48,14 @@ class ReaderReadingTabTest { private fun setTab( uiState: ReaderUiState = ReaderUiState(), - callbacks: Callbacks = Callbacks() + callbacks: Callbacks = Callbacks(), + isTextReader: Boolean = false ) { composeRule.setContent { CompositionLocalProvider(LocalStrings provides English) { ReaderReadingTab( uiState = uiState, - isTextReader = false, + isTextReader = isTextReader, onReadingModeChange = { callbacks.readingMode = it }, onKeepScreenOnChange = { callbacks.keepScreenOn = it }, onScreenTimeoutChange = {}, @@ -143,7 +144,7 @@ class ReaderReadingTabTest { @Test fun `header and footer sliders render`() { - setTab() + setTab(isTextReader = true) scrollTo("Font size") composeRule.onNodeWithText("Font size").assertIsDisplayed() @@ -155,6 +156,16 @@ class ReaderReadingTabTest { composeRule.onNodeWithText("Right inset").assertIsDisplayed() } + @Test + fun `header and footer typography sliders are hidden for graphic readers`() { + setTab(isTextReader = false) + + composeRule.onAllNodesWithText("Font size").assertCountEquals(0) + composeRule.onAllNodesWithText("Vertical padding").assertCountEquals(0) + composeRule.onAllNodesWithText("Left inset").assertCountEquals(0) + composeRule.onAllNodesWithText("Right inset").assertCountEquals(0) + } + @Test fun `webtoon mode disables page animation chips`() { setTab(uiState = ReaderUiState(readingMode = ReadingMode.WEBTOON)) diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt index 45027c0a4..bd228ef79 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt @@ -4,20 +4,69 @@ import org.junit.Assert.assertEquals import org.junit.Test class ReaderTextChromeLayoutPolicyTest { + @Test + fun twoLineGutterIsTheSingleSymmetricReserve() { + assertEquals(108, readerTextTwoLineGutterPx(54)) + assertEquals(16, readerTextTwoLineGutterPx(1)) + } + @Test fun textKeepsSymmetricPersistentGutterWhenChromeIsHidden() { val hidden = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 0, measuredBottomCssPx = 0, persistentGutterCssPx = 18, + persistentGutterPx = 54, ) val visible = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 196, measuredBottomCssPx = 236, persistentGutterCssPx = 18, + persistentGutterPx = 54, + ) + + assertEquals(ReaderTextChromeLayoutInsets(0, 0, 54, 54), hidden) + assertEquals(ReaderTextChromeLayoutInsets(0, 0, 54, 54), visible) + } + + @Test + fun chromeVisibilityCannotChangeOuterReaderGutter() { + val hidden = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 0, + measuredBottomCssPx = 0, + persistentGutterCssPx = 30, + persistentGutterPx = 90, + ) + val expanded = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 260, + measuredBottomCssPx = 320, + persistentGutterCssPx = 30, + persistentGutterPx = 90, + ) + + assertEquals(hidden.outerTopPx, expanded.outerTopPx) + assertEquals(hidden.outerBottomPx, expanded.outerBottomPx) + assertEquals(hidden.topCssPx, expanded.topCssPx) + assertEquals(hidden.bottomCssPx, expanded.bottomCssPx) + } + + @Test + fun twoStepGutterRemainsSymmetricAndIndependentOfChromeMeasurements() { + val hidden = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 0, + measuredBottomCssPx = 0, + persistentGutterCssPx = 36, + persistentGutterPx = 108, + ) + val visible = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 220, + measuredBottomCssPx = 280, + persistentGutterCssPx = 36, + persistentGutterPx = 108, ) - assertEquals(ReaderTextChromeLayoutInsets(18, 18), hidden) - assertEquals(ReaderTextChromeLayoutInsets(18, 18), visible) + assertEquals(108, hidden.outerTopPx) + assertEquals(108, hidden.outerBottomPx) + assertEquals(hidden, visible) } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt new file mode 100644 index 000000000..1e0630636 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt @@ -0,0 +1,25 @@ +package io.leostrange.mrcomic.feature.reader.ui.components + +import io.leostrange.mrcomic.core.model.ComicFormat +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderGraphicPageOffsetTest { + + @Test + fun pdfAndDjvuUseTheSameDocumentAlignment() { + val expected = ReaderGraphicPageOffset(xDp = 0, yDp = 20) + + assertEquals(expected, readerGraphicPageOffset(ComicFormat.PDF)) + assertEquals(expected, readerGraphicPageOffset(ComicFormat.DJVU)) + } + + @Test + fun otherGraphicFormatsRemainCentered() { + val expected = ReaderGraphicPageOffset(xDp = 0, yDp = 0) + + assertEquals(expected, readerGraphicPageOffset(ComicFormat.CBZ)) + assertEquals(expected, readerGraphicPageOffset(ComicFormat.CBR)) + assertEquals(expected, readerGraphicPageOffset(null)) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt index 0971dd5b3..e5ca3b8f0 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt @@ -15,7 +15,7 @@ class PagedLayoutParamsTest { // ── Usable page height ───────────────────────────────────────────────── @Test - fun calculateUsablePageHeight_typicalPhone_returnsLineAligned() { + fun calculateUsablePageHeight_typicalPhone_returnsCompleteBudget() { // 800px viewport, 24px top, 48px bottom, 27px line height val result = PagedLayoutParams.calculateUsablePageHeight( viewportHeightPx = 800, @@ -24,10 +24,8 @@ class PagedLayoutParamsTest { lineHeightPx = 27f ) // clipHeight = max(81, 800) = 800 - // rawUsable = max(81, 800 - 24 - 48 - max(2, 3.24)) = max(81, 724.76) = 724.76 - // usableLineCount = max(3, floor(724.76/27)) = max(3, 26) = 26 - // usableHeight = max(81, 26*27) = max(81, 702) = 702 - assertEquals(702, result) + // rawUsable = max(81, 800 - 24 - 48) = 728 + assertEquals(728, result) } @Test @@ -40,10 +38,8 @@ class PagedLayoutParamsTest { lineHeightPx = 27f ) // clipHeight = max(81, 200) = 200 - // rawUsable = max(81, 200 - 24 - 48 - 3.24) = max(81, 124.76) = 124.76 - // usableLineCount = max(3, floor(124.76/27)) = max(3, 4) = 4 - // usableHeight = max(81, 4*27) = max(81, 108) = 108 - assertEquals(108, result) + // rawUsable = max(81, 200 - 24 - 48) = 128 + assertEquals(128, result) } @Test @@ -55,10 +51,8 @@ class PagedLayoutParamsTest { lineHeightPx = 24f ) // clipHeight = max(72, 900) = 900 - // rawUsable = max(72, 900 - 0 - 0 - max(2, 2.88)) = max(72, 897.12) = 897.12 - // usableLineCount = max(3, floor(897.12/24)) = max(3, 37) = 37 - // usableHeight = max(72, 37*24) = max(72, 888) = 888 - assertEquals(888, result) + // rawUsable = max(72, 900 - 0 - 0) = 900 + assertEquals(900, result) } @Test @@ -71,20 +65,48 @@ class PagedLayoutParamsTest { lineHeightPx = 0f ) // Same as typical phone test with lineHeight=27 - assertEquals(702, result) + assertEquals(728, result) } @Test - fun calculateUsablePageHeight_resultIsMultipleOfLineHeight() { - // Result should always be a multiple of lineHeight (line-aligned) - val lineHeight = 30f + fun calculateUsablePageHeight_preservesNonLineAlignedViewportRemainder() { + val result = PagedLayoutParams.calculateUsablePageHeight( + viewportHeightPx = 997, + topInsetPx = 41, + bottomInsetPx = 42, + lineHeightPx = 30f, + ) + assertEquals(914, result) + } + + @Test + fun calculateUsablePageHeight_doesNotDropAnExtraLineForSafetyMargin() { val result = PagedLayoutParams.calculateUsablePageHeight( viewportHeightPx = 1000, - topInsetPx = 50, - bottomInsetPx = 50, - lineHeightPx = lineHeight + topInsetPx = 40, + bottomInsetPx = 40, + lineHeightPx = 40f ) - assertEquals(0, result % lineHeight.toInt()) + + // The two 40px insets are the complete top/bottom gutter contract. + // A second safety subtraction would floor 850/40 to 21 lines and + // visibly create a third line of empty space at the bottom. + assertEquals(920, result) + } + + @Test + fun calculateUsablePageHeight_keepsFractionalLineRemainderInsidePageBudget() { + val result = PagedLayoutParams.calculateUsablePageHeight( + viewportHeightPx = 800, + topInsetPx = 24, + bottomInsetPx = 48, + lineHeightPx = 27f, + ) + + // The complete viewport budget is 728px. Flooring it to 26 lines + // returns 702px and turns the remaining 26px into a visible third + // bottom gutter even though the outer container already owns two. + assertEquals(728, result) } @Test diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt index c27c7a799..5027e67cc 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt @@ -155,7 +155,7 @@ internal fun ReaderPageLayoutPreviewCard( MrComicCardSurface( modifier = Modifier .fillMaxWidth() - .heightIn(max = 172.dp), + .heightIn(max = 274.dp), shape = previewShape, containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f) ) { @@ -227,6 +227,94 @@ internal fun ReaderPageLayoutPreviewCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(Modifier.height(4.dp)) + ReaderImageCropPreview( + horizontalCrop = uiState.readerImageMarginCropHorizontal, + verticalCrop = uiState.readerImageMarginCropVertical, + language = strings.languageCode + ) + } + } + } +} + +/** Shows the removed top/bottom bands instead of hiding vertical crop in a slider. */ +@Composable +private fun ReaderImageCropPreview( + horizontalCrop: Float, + verticalCrop: Float, + language: String +) { + val horizontal = horizontalCrop.coerceIn(0f, 0.22f) + val vertical = verticalCrop.coerceIn(0f, 0.22f) + val title = when (language) { + "en" -> "PDF / DJVU crop preview" + "ja" -> "PDF / DJVU トリミングプレビュー" + "zh" -> "PDF / DJVU 裁剪预览" + "ko" -> "PDF / DJVU 자르기 미리보기" + else -> "Предпросмотр подрезки PDF / DJVU" + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.labelMedium) + Text( + text = "${(vertical * 100f).toInt()}% · ${(horizontal * 100f).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = (horizontal * 70f).dp, + vertical = (vertical * 70f).dp + ) + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 10.dp, vertical = 7.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(4) { index -> + Box( + modifier = Modifier + .fillMaxWidth(if (index == 1) 0.76f else 1f) + .height(5.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + MaterialTheme.shapes.small + ) + ) + } + } + } + if (vertical > 0f) { + val bandHeight = (vertical * 140f).dp + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.TopCenter) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.BottomCenter) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + ) } } } From fadde0b407762f5a26fbd6e5eb18c2621e217f8e Mon Sep 17 00:00:00 2001 From: Leostrange Date: Fri, 28 Aug 2026 16:18:10 +0700 Subject: [PATCH 14/17] fix: expose crop controls for vertical image strips --- .../feature/reader/ui/ReaderContentPolicy.kt | 11 +++ .../mrcomic/feature/reader/ui/ReaderScreen.kt | 6 +- .../feature/reader/ui/ReaderStyleTab.kt | 72 +++++++++++-------- .../reader/ui/ReaderContentPolicyTest.kt | 27 +++++++ 4 files changed, 82 insertions(+), 34 deletions(-) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt index 82ad65f2e..f5d85f680 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt @@ -6,6 +6,17 @@ import io.leostrange.mrcomic.core.model.isArchiveFormat import io.leostrange.mrcomic.core.model.isGraphicReaderFormat import io.leostrange.mrcomic.core.model.isTextReadingFormat +/** Image margin controls are available only for raster content. */ +fun supportsImageMarginCrop( + containerKind: ReaderContainerKind, + format: ComicFormat? +): Boolean { + if (containerKind.isTextContainer()) return false + return format == ComicFormat.PDF || + format == ComicFormat.DJVU || + containerKind == ReaderContainerKind.RASTER_WEBTOON +} + enum class ReaderContainerKind { TEXT_PAGE, TEXT_WEBTOON, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt index e7f6daf92..6e4395ca4 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt @@ -81,9 +81,9 @@ fun ReaderScreen( val ttsRuntimeState by ttsController.state.collectAsState() val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val isTextReader = uiState.readerContainerKind.isTextContainer() - val supportsDocumentMarginCrop = uiState.comic?.format == ComicFormat.PDF || uiState.comic?.format == ComicFormat.DJVU - val effectiveMarginCropHorizontal = if (supportsDocumentMarginCrop) uiState.imageMarginCropHorizontal else 0f - val effectiveMarginCropVertical = if (supportsDocumentMarginCrop) uiState.imageMarginCropVertical else 0f + val supportsImageMarginCrop = supportsImageMarginCrop(uiState.readerContainerKind, uiState.comic?.format) + val effectiveMarginCropHorizontal = if (supportsImageMarginCrop) uiState.imageMarginCropHorizontal else 0f + val effectiveMarginCropVertical = if (supportsImageMarginCrop) uiState.imageMarginCropVertical else 0f val effectivePageImageScaleMode = if ( uiState.comic?.format == ComicFormat.DJVU && diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index f6467977c..39e57d85c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -77,8 +77,8 @@ internal fun ReaderStyleTab( // Text-reader controls keep the style entry in their own tab. .filterNot { isTextReader && it == ReaderChromeButton.STYLE } } - val supportsMarginCrop = remember(uiState.comic?.format, isTextReader) { - !isTextReader && (uiState.comic?.format == ComicFormat.PDF || uiState.comic?.format == ComicFormat.DJVU) + val supportsMarginCrop = remember(uiState.readerContainerKind, uiState.comic?.format, isTextReader) { + supportsImageMarginCrop(uiState.readerContainerKind, uiState.comic?.format) } val availableFonts = remember(context, fontCatalogVersion) { ReaderTextFontCatalog.availableFontFamilies(context) @@ -540,11 +540,7 @@ internal fun ReaderStyleTab( } } -/** - * A deliberately simple crop preview: the muted bands are the part removed - * from the page, so vertical cropping stays visible while its slider is being - * adjusted in the bottom sheet. - */ +/** Shows the retained image area and the exact horizontal/vertical crop zones. */ @Composable private fun ReaderGraphicCropPreview( horizontalCrop: Float, @@ -553,23 +549,23 @@ private fun ReaderGraphicCropPreview( ) { val horizontal = horizontalCrop.coerceIn(0f, 0.18f) val vertical = verticalCrop.coerceIn(0f, 0.18f) - val removedLabel = when (language) { - "en" -> "Removed margins" - "ja" -> "トリミング範囲" - "zh" -> "已裁剪边缘" - "ko" -> "잘린 여백" - else -> "Подрезаемые поля" + val previewLabel = when (language) { + "en" -> "Preview · retained image area" + "ja" -> "プレビュー・表示領域" + "zh" -> "预览 · 保留图像区域" + "ko" -> "미리보기 · 표시 영역" + else -> "Предпросмотр · видимая область" } ReaderSettingsCard { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(removedLabel, style = MaterialTheme.typography.labelLarge) + Text(previewLabel, style = MaterialTheme.typography.labelLarge) Text( - text = "${(vertical * 100f).toInt()}% · ${(horizontal * 100f).toInt()}%", + text = "↔ ${(horizontal * 100f).toInt()}% ↕ ${(vertical * 100f).toInt()}%", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary ) @@ -577,23 +573,21 @@ private fun ReaderGraphicCropPreview( Box( modifier = Modifier .fillMaxWidth() - .height(112.dp) + .height(128.dp) .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)), + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)), contentAlignment = Alignment.Center ) { Box( modifier = Modifier - .fillMaxSize() - .padding( - horizontal = (horizontal * 90f).dp, - vertical = (vertical * 90f).dp - ) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxHeight() + .fillMaxWidth(0.72f) + .padding(horizontal = 12.dp, vertical = 10.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp) ) { Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { - repeat(4) { index -> + repeat(5) { index -> Box( modifier = Modifier .fillMaxWidth(if (index == 1) 0.78f else 1f) @@ -606,21 +600,37 @@ private fun ReaderGraphicCropPreview( } } } + val overlay = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + if (horizontal > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(horizontal / 0.22f) + .align(Alignment.CenterStart) + .background(overlay) + ) + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(horizontal / 0.22f) + .align(Alignment.CenterEnd) + .background(overlay) + ) + } if (vertical > 0f) { - val bandHeight = (vertical * 180f).dp Box( modifier = Modifier .fillMaxWidth() - .height(bandHeight) + .fillMaxHeight(vertical / 0.22f) .align(Alignment.TopCenter) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + .background(overlay) ) Box( modifier = Modifier .fillMaxWidth() - .height(bandHeight) + .fillMaxHeight(vertical / 0.22f) .align(Alignment.BottomCenter) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) + .background(overlay) ) } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt index 449c0bcae..b27ff7ec2 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt @@ -4,10 +4,37 @@ import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.model.isTextReadingFormat import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ReaderContentPolicyTest { + @Test + fun graphicVerticalStripSupportsImageMarginCrop() { + assertTrue( + supportsImageMarginCrop(ReaderContainerKind.RASTER_WEBTOON, ComicFormat.CBZ) + ) + } + + @Test + fun textVerticalStripDoesNotExposeImageMarginCrop() { + assertFalse( + supportsImageMarginCrop(ReaderContainerKind.TEXT_WEBTOON, ComicFormat.TXT) + ) + } + + @Test + fun documentPagesKeepPdfAndDjvuMarginCrop() { + assertTrue(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.PDF)) + assertTrue(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.DJVU)) + } + + @Test + fun ordinaryRasterPagesDoNotGetDocumentMarginCropControls() { + assertFalse(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.CBZ)) + } + @Test fun textFormatsInPageModesResolveToTextPage() { val textFormats = listOf( From 977ce5cd1979a874325425726dce990501f4e879 Mon Sep 17 00:00:00 2001 From: Leostrange Date: Fri, 28 Aug 2026 16:24:10 +0700 Subject: [PATCH 15/17] chore: publish debug APK to snapshot branch --- .gitattributes | 1 + Mr.Comic-debug.apk | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 Mr.Comic-debug.apk diff --git a/.gitattributes b/.gitattributes index 6fdb58e86..8d0a3e9c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ android/app/src/main/assets/databases/*.dbpack filter=lfs diff=lfs merge=lfs -text +Mr.Comic-debug.apk filter=lfs diff=lfs merge=lfs -text diff --git a/Mr.Comic-debug.apk b/Mr.Comic-debug.apk new file mode 100644 index 000000000..df347b89c --- /dev/null +++ b/Mr.Comic-debug.apk @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e036c03efc4dc6afa6e64745dbcbe4a4229ca4f9cb350d9138e8381a936d8ecd +size 299336554 From 9b9fe29b9728228d02b25e535b57055dd205e83f Mon Sep 17 00:00:00 2001 From: Freebuff Date: Sat, 29 Aug 2026 21:45:32 +0700 Subject: [PATCH 16/17] WIP: freebuff/task-883c9899-3868-49b0-8620-620c9ed27d65 --- .githooks/post-checkout | 3 + .githooks/post-commit | 3 + .githooks/post-merge | 3 + .githooks/pre-push | 3 + .../core/data/preferences/PreferencesKeys.kt | 11 + .../ui/designsystem/MrComicPreviewBackdrop.kt | 64 +++ .../domain/crop/MarginCropAutoDetector.kt | 146 +++++++ .../reader/domain/crop/ReaderMarginCrop.kt | 132 ++++++ .../feature/reader/ui/ReaderChromeBars.kt | 2 + .../feature/reader/ui/ReaderChromeButton.kt | 6 +- .../reader/ui/ReaderChromeComponents.kt | 56 ++- .../feature/reader/ui/ReaderChromeOverlays.kt | 10 +- .../feature/reader/ui/ReaderContainerHost.kt | 16 +- .../feature/reader/ui/ReaderContentPolicy.kt | 34 ++ .../reader/ui/ReaderControlCenterStrings.kt | 8 + .../reader/ui/ReaderMarginCropDialog.kt | 375 ++++++++++++++++++ .../reader/ui/ReaderMarginCropStrings.kt | 118 ++++++ .../reader/ui/ReaderPreferenceRestorer.kt | 52 ++- .../feature/reader/ui/ReaderReadingTab.kt | 3 + .../mrcomic/feature/reader/ui/ReaderScreen.kt | 29 +- .../reader/ui/ReaderSettingsActions.kt | 12 + .../reader/ui/ReaderSettingsController.kt | 134 ++++++- .../feature/reader/ui/ReaderStyleTab.kt | 54 ++- .../feature/reader/ui/ReaderUiState.kt | 23 +- .../feature/reader/ui/components/PageView.kt | 26 +- .../reader/ui/components/ReaderImageCrop.kt | 57 ++- .../reader/ui/components/WebtoonView.kt | 26 +- .../domain/crop/MarginCropAutoDetectorTest.kt | 104 +++++ .../domain/crop/ReaderMarginCropTest.kt | 110 +++++ .../reader/ui/ReaderContentPolicyTest.kt | 52 +++ .../settings/ui/SettingsAppearanceTheme.kt | 3 + .../settings/ui/SettingsLibraryPreviews.kt | 5 + .../settings/ui/SettingsReaderPreviews.kt | 11 + .../settings/ui/SettingsViewModelFlows.kt | 30 +- .../ui/SettingsViewModelReaderSetters.kt | 22 +- 35 files changed, 1635 insertions(+), 108 deletions(-) create mode 100644 .githooks/post-checkout create mode 100644 .githooks/post-commit create mode 100644 .githooks/post-merge create mode 100644 .githooks/pre-push create mode 100644 android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt create mode 100644 android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt create mode 100644 android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100644 index 000000000..ca7fcb400 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-checkout "$@" diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100644 index 000000000..52b339cb3 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-commit "$@" diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100644 index 000000000..a912e667a --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-merge "$@" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 000000000..0f0089bc2 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs pre-push "$@" diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt index 0afa9e3f3..f9c575280 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt @@ -27,6 +27,16 @@ object PreferencesKeys { val READER_IMAGE_SCALE_MODE = stringPreferencesKey("reader_image_scale_mode") val READER_PAGE_MARGIN_CROP_HORIZONTAL = floatPreferencesKey("reader_page_margin_crop_horizontal") val READER_PAGE_MARGIN_CROP_VERTICAL = floatPreferencesKey("reader_page_margin_crop_vertical") + // Обрезка пустых полей документа: посекционные значения (0..0.22) + флаги. + // Посекционные ключи — источник истины; H/V ключи остаются для миграции + // старых симметричных настроек (используются только при первом чтении). + val READER_PAGE_MARGIN_CROP_ENABLED = booleanPreferencesKey("reader_page_margin_crop_enabled") + val READER_PAGE_MARGIN_CROP_LEFT = floatPreferencesKey("reader_page_margin_crop_left") + val READER_PAGE_MARGIN_CROP_TOP = floatPreferencesKey("reader_page_margin_crop_top") + val READER_PAGE_MARGIN_CROP_RIGHT = floatPreferencesKey("reader_page_margin_crop_right") + val READER_PAGE_MARGIN_CROP_BOTTOM = floatPreferencesKey("reader_page_margin_crop_bottom") + val READER_PAGE_MARGIN_CROP_SYMMETRIC = booleanPreferencesKey("reader_page_margin_crop_symmetric") + val READER_PAGE_MARGIN_CROP_SHOW_WARNING = booleanPreferencesKey("reader_page_margin_crop_show_warning") val APP_NAV_TRANSITION_STYLE = stringPreferencesKey("app_nav_transition_style") // NONE/FADE/SLIDE/LIFT val READER_PAGE_ANIMATION = stringPreferencesKey("reader_page_animation") // NONE/SLIDE/FADE val READER_PAGE_SOUND = booleanPreferencesKey("reader_page_sound") // page-flip sound @@ -187,6 +197,7 @@ object PreferencesKeys { val READER_CHROME_SHOW_TRANSLATE = booleanPreferencesKey("reader_chrome_show_translate") val READER_CHROME_SHOW_BRIGHTNESS = booleanPreferencesKey("reader_chrome_show_brightness") val READER_CHROME_SHOW_AUTO_SCROLL = booleanPreferencesKey("reader_chrome_show_auto_scroll") + val READER_CHROME_SHOW_CROP = booleanPreferencesKey("reader_chrome_show_crop") // Последняя секция библиотеки (FILES/AUDIOBOOKS/BOOKMARKS/QUOTES/ACHIEVEMENTS) val LIBRARY_CONTENT_SECTION = stringPreferencesKey("library_content_section") diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt new file mode 100644 index 000000000..2f90d7045 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt @@ -0,0 +1,64 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +/** + * Soft bed placed behind preview surfaces ("Предпросмотр …"). + * + * Preview panels often draw paper-like colors that match the surrounding + * settings background or the reader page behind a bottom sheet, so their + * edges visually merge with real content. The backdrop renders a slightly + * oversized, lightly blurred contrast bed plus a hairline border under the + * preview, keeping the preview surface readable on any background. + * + * Usage: wrap the preview composable's content — the backdrop matches the + * content size. + */ +@Composable +fun MrComicPreviewBackdrop( + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.large, + content: @Composable BoxScope.() -> Unit +) { + val bedColor = MaterialTheme.colorScheme.surfaceContainerHighest + val outlineColor = MaterialTheme.colorScheme.outlineVariant + Box(modifier = modifier) { + // Lightly blurred, slightly inflated contrast bed — the "light blur" + // that separates the preview from the page/text behind it. + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer { + scaleX = PREVIEW_BED_OVERSCALE + scaleY = PREVIEW_BED_OVERSCALE_TALL + alpha = PREVIEW_BED_ALPHA + } + .blur(PREVIEW_BED_BLUR, BlurredEdgeTreatment.Unbounded) + .background(bedColor, shape) + ) + Box( + modifier = Modifier + .matchParentSize() + .border(HairlineBorder, outlineColor.copy(alpha = HairlineBorderAlpha), shape) + ) + content() + } +} + +private const val PREVIEW_BED_OVERSCALE = 1.04f +private const val PREVIEW_BED_OVERSCALE_TALL = 1.06f +private const val PREVIEW_BED_ALPHA = 0.92f +private val PREVIEW_BED_BLUR = 12.dp +private val HairlineBorder = 1.dp +private const val HairlineBorderAlpha = 0.55f diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt new file mode 100644 index 000000000..fb401a95a --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt @@ -0,0 +1,146 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Automatic detection of empty page margins ("Автоматическая" preset). + * + * The core works on a small luminance sampling grid so it stays pure Kotlin + * and unit-testable; the Android Bitmap adapter lives in the UI layer. + */ +object MarginCropAutoDetector { + + /** + * @param width sample grid width + * @param height sample grid height + * @param luminance luminance sample in 0..255 at grid position (x, y) + */ + fun detect( + width: Int, + height: Int, + luminance: (Int, Int) -> Int + ): ReaderMarginCropSides { + if (width < MIN_GRID || height < MIN_GRID) return ReaderMarginCropSides() + + val background = backgroundLuminance(width, height, luminance) + + val columnHasContent: (Int) -> Boolean = { x -> + lineHasContent(height, background) { y -> luminance(x, y) } + } + val rowHasContent: (Int) -> Boolean = { y -> + lineHasContent(width, background) { x -> luminance(x, y) } + } + + val left = firstContentLine(width, columnHasContent) + val right = width - 1 - lastContentLine(width, columnHasContent) + val top = firstContentLine(height, rowHasContent) + val bottom = height - 1 - lastContentLine(height, rowHasContent) + + return ReaderMarginCropSides( + left = lineFraction(left, width), + right = lineFraction(right, width), + top = lineFraction(top, height), + bottom = lineFraction(bottom, height) + ) + } + + /** + * Background estimate: the median of the four corner patches. Page scans + * have uniform paper near the borders, so corners reliably represent the + * empty margin color even when the content block is dark. + */ + private fun backgroundLuminance( + width: Int, + height: Int, + luminance: (Int, Int) -> Int + ): Int { + val patch = min(PATCH, min(width, height) / 2) + val cornerOffsets = listOf( + 0 to 0, + width - patch to 0, + 0 to height - patch, + width - patch to height - patch + ) + val cornerMedians = cornerOffsets.map { (ox, oy) -> + val samples = buildList { + for (y in oy until oy + patch) { + for (x in ox until ox + patch) add(luminance(x, y)) + } + } + median(samples) + } + return median(cornerMedians) + } + + /** + * A border line counts as content when at least [CONTENT_LINE_SHARE]% + * of its samples differ from the paper luminance by more than + * [LUMINANCE_TOLERANCE]. The line-share test ignores specks and dust. + */ + private inline fun lineHasContent( + length: Int, + background: Int, + sample: (Int) -> Int + ): Boolean { + var contentSamples = 0 + val threshold = max(1, length * CONTENT_LINE_SHARE / 100) + for (i in 0 until length) { + if (abs(sample(i) - background) > LUMINANCE_TOLERANCE) { + contentSamples++ + if (contentSamples >= threshold) return true + } + } + return false + } + + private inline fun firstContentLine( + dimension: Int, + hasContent: (Int) -> Boolean + ): Int { + val capIndex = min(dimension - 1, (dimension * ReaderMarginCrop.MAX_SIDE_FRACTION).toInt()) + for (index in 0..capIndex) { + if (hasContent(index)) return index + } + // Content starts beyond the cap (extreme margins): crop up to the cap… + for (index in capIndex + 1 until dimension) { + if (hasContent(index)) return capIndex + } + // …but a fully blank page keeps its full size (never over-crop). + return 0 + } + + private inline fun lastContentLine( + dimension: Int, + hasContent: (Int) -> Boolean + ): Int { + val capIndex = max(0, dimension - 1 - (dimension * ReaderMarginCrop.MAX_SIDE_FRACTION).toInt()) + for (index in dimension - 1 downTo capIndex) { + if (hasContent(index)) return index + } + for (index in capIndex - 1 downTo 0) { + if (hasContent(index)) return capIndex + } + return dimension - 1 + } + + private fun lineFraction(lineIndex: Int, dimension: Int): Float = + (lineIndex.toFloat() / dimension).coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION) + + private fun median(values: List): Int { + if (values.isEmpty()) return 0 + val sorted = values.sorted() + return sorted[sorted.size / 2] + } + + /** Corner background patches are sampled at this size on the grid. */ + private const val PATCH = 6 + private const val MIN_GRID = 12 + + /** A grid line counts as content when ≥5% of its samples differ from paper. */ + private const val CONTENT_LINE_SHARE = 5 + + /** Luminance delta that separates printed content from paper/scan noise. */ + private const val LUMINANCE_TOLERANCE = 28 +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt new file mode 100644 index 000000000..1619ecb5b --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt @@ -0,0 +1,132 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +/** + * Per-side document margin crop ("обрезка пустых полей"). + * + * All values are fractions of the page size (0..[MAX_SIDE_FRACTION]) and are + * stored per side so PDF/DJVU pages with asymmetric scans can be cropped + * precisely. Pure Kotlin: no Android dependencies, unit-testable. + */ +data class ReaderMarginCrop( + val enabled: Boolean = false, + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f, + val symmetric: Boolean = true, + val showWarning: Boolean = true +) { + /** Crop is visually active only when enabled and at least one side is non-zero. */ + val hasVisibleCrop: Boolean + get() = left > 0f || top > 0f || right > 0f || bottom > 0f + + val isActive: Boolean + get() = enabled && hasVisibleCrop + + fun clamped(): ReaderMarginCrop = copy( + left = coerceSide(left), + top = coerceSide(top), + right = coerceSide(right), + bottom = coerceSide(bottom) + ) + + /** + * Enforces the symmetric invariant (left == right, top == bottom). + * Keeps the larger of each pair so enabling symmetric never silently + * reduces an already configured crop. + */ + fun withSymmetricEnforced(): ReaderMarginCrop { + val base = clamped() + if (!base.symmetric) return base + return base.copy( + left = maxOf(base.left, base.right), + right = maxOf(base.left, base.right), + top = maxOf(base.top, base.bottom), + bottom = maxOf(base.top, base.bottom) + ) + } + + /** Averages each axis pair; used when the user turns symmetric mode on. */ + fun withPairsAveraged(): ReaderMarginCrop { + val base = clamped() + val horizontal = (base.left + base.right) / 2f + val vertical = (base.top + base.bottom) / 2f + return base.copy( + left = horizontal, + right = horizontal, + top = vertical, + bottom = vertical + ) + } + + /** + * Sets one side. In symmetric mode the opposite side of the same axis + * follows the new value. + */ + fun withSide(side: ReaderMarginCropSide, value: Float): ReaderMarginCrop { + val safe = coerceSide(value) + val base = clamped() + return when (side) { + ReaderMarginCropSide.LEFT -> + if (base.symmetric) base.copy(left = safe, right = safe) else base.copy(left = safe) + ReaderMarginCropSide.RIGHT -> + if (base.symmetric) base.copy(left = safe, right = safe) else base.copy(right = safe) + ReaderMarginCropSide.TOP -> + if (base.symmetric) base.copy(top = safe, bottom = safe) else base.copy(top = safe) + ReaderMarginCropSide.BOTTOM -> + if (base.symmetric) base.copy(top = safe, bottom = safe) else base.copy(bottom = safe) + } + // NOTE: `enabled` is intentionally untouched here — the enable toggle is + // a separate control (matches the reference dialog where sides can be + // tuned while crop is disabled). + } + + /** Seeds per-side values from the legacy symmetric H/V preferences. */ + fun seededFromLegacy(horizontal: Float, vertical: Float): ReaderMarginCrop { + val h = coerceSide(horizontal) + val v = coerceSide(vertical) + return copy(left = h, right = h, top = v, bottom = v) + } + + companion object { + /** Hard cap for one side, shared with the rendering crop model. */ + const val MAX_SIDE_FRACTION = 0.22f + + fun coerceSide(value: Float): Float = value.coerceIn(0f, MAX_SIDE_FRACTION) + + /** Default disabled state used when no preferences exist yet. */ + val Default = ReaderMarginCrop() + } +} + +enum class ReaderMarginCropSide(val storedValue: String) { + LEFT("left"), + TOP("top"), + RIGHT("right"), + BOTTOM("bottom"); + + companion object { + fun fromStored(value: String?): ReaderMarginCropSide? = + entries.firstOrNull { it.storedValue.equals(value?.trim(), ignoreCase = true) } + } +} + +/** + * The crop sides that are actually applied to a rendered page: zeros when the + * format does not support cropping or the user disabled the feature. + */ +data class ReaderMarginCropSides( + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f +) { + val isZero: Boolean + get() = left <= 0f && top <= 0f && right <= 0f && bottom <= 0f +} + +fun ReaderMarginCrop.effectiveSides(supported: Boolean): ReaderMarginCropSides { + val base = clamped() + if (!supported || !base.isActive) return ReaderMarginCropSides() + return ReaderMarginCropSides(left = base.left, top = base.top, right = base.right, bottom = base.bottom) +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt index db410f208..69385fac6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt @@ -50,6 +50,7 @@ internal fun BoxScope.ReaderChromeBars( onDismissFootnote: () -> Unit, onExpandFootnote: () -> Unit, onCollapseFootnote: () -> Unit, + onToggleMarginCrop: () -> Unit = {}, ) { ReaderTopChromeBar( uiState = uiState, @@ -76,6 +77,7 @@ internal fun BoxScope.ReaderChromeBars( onBrightnessChange = onBrightnessChange, onAutoScrollSpeedPreview = onAutoScrollSpeedPreview, onAutoScrollSpeedCommit = onAutoScrollSpeedCommit, + onToggleMarginCrop = onToggleMarginCrop, ) ReaderBottomChromePanel( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt index 2512b9fb9..ae68de881 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt @@ -7,7 +7,8 @@ internal enum class ReaderChromeButton(val storedValue: String) { DIRECTION("direction"), TRANSLATE("translate"), BRIGHTNESS("brightness"), - AUTO_SCROLL("auto_scroll"); + AUTO_SCROLL("auto_scroll"), + CROP("crop"); companion object { val defaultOrder: List = listOf( @@ -17,7 +18,8 @@ internal enum class ReaderChromeButton(val storedValue: String) { DIRECTION, TRANSLATE, BRIGHTNESS, - AUTO_SCROLL + AUTO_SCROLL, + CROP ) val defaultStoredOrder: String = defaultOrder.joinToString(",") { it.storedValue } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt index baef0017f..3a472be92 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -94,11 +95,13 @@ private fun ReaderChromeIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, buttonSize: Dp = 42.dp, + enabled: Boolean = true, content: @Composable () -> Unit ) { IconButton( onClick = onClick, - modifier = modifier.size(buttonSize) + modifier = modifier.size(buttonSize), + enabled = enabled ) { content() } @@ -145,6 +148,8 @@ fun ReaderExpandedBar( showTranslateIcon: Boolean = true, showBrightnessIcon: Boolean = true, showAutoScrollIcon: Boolean = true, + showCropIcon: Boolean = false, + marginCropAvailable: Boolean = false, autoScrollActive: Boolean = false, onNavigateBack: () -> Unit, onToggleToc: () -> Unit, @@ -153,7 +158,8 @@ fun ReaderExpandedBar( onRequestOcr: () -> Unit, onToggleBrightness: () -> Unit, onToggleTtsControls: () -> Unit = {}, - onAutoScrollToggle: () -> Unit = {} + onAutoScrollToggle: () -> Unit = {}, + onToggleMarginCrop: () -> Unit = {} ) { val strings = LocalStrings.current val chromeIconTint = MaterialTheme.colorScheme.onSurface @@ -200,6 +206,8 @@ fun ReaderExpandedBar( showTranslateIcon = showTranslateIcon, showBrightnessIcon = showBrightnessIcon, showAutoScrollIcon = showAutoScrollIcon, + showCropIcon = showCropIcon, + marginCropAvailable = marginCropAvailable, autoScrollActive = autoScrollActive, chromeIconTint = chromeIconTint, onToggleToc = onToggleToc, @@ -208,7 +216,8 @@ fun ReaderExpandedBar( onRequestOcr = onRequestOcr, onToggleBrightness = onToggleBrightness, onToggleTtsControls = onToggleTtsControls, - onAutoScrollToggle = onAutoScrollToggle + onAutoScrollToggle = onAutoScrollToggle, + onToggleMarginCrop = onToggleMarginCrop ) } } @@ -245,6 +254,8 @@ private fun ReaderExpandedActionButtons( showTranslateIcon: Boolean, showBrightnessIcon: Boolean, showAutoScrollIcon: Boolean = true, + showCropIcon: Boolean = false, + marginCropAvailable: Boolean = false, autoScrollActive: Boolean = false, chromeIconTint: Color, onToggleToc: () -> Unit, @@ -253,7 +264,8 @@ private fun ReaderExpandedActionButtons( onRequestOcr: () -> Unit, onToggleBrightness: () -> Unit, onToggleTtsControls: () -> Unit = {}, - onAutoScrollToggle: () -> Unit = {} + onAutoScrollToggle: () -> Unit = {}, + onToggleMarginCrop: () -> Unit = {} ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -393,6 +405,39 @@ private fun ReaderExpandedActionButtons( ) ) } + + ReaderChromeButton.CROP -> + // Visible for raster readers, locked (disabled) for comics/manga; + // fully active only for document formats (PDF / DjVu). + if (showCropIcon) { + add( + ReaderChromeActionSpec( + key = action.storedValue, + content = { buttonSize -> + ReaderChromeIconButton( + onClick = onToggleMarginCrop, + buttonSize = buttonSize, + enabled = marginCropAvailable + ) { + val cropHint = if (marginCropAvailable) { + readerMarginCropDialogTitle(strings.languageCode) + } else { + readerMarginCropLockedHint(strings.languageCode) + } + Icon( + Icons.Default.Crop, + contentDescription = cropHint, + tint = if (marginCropAvailable) { + chromeIconTint + } else { + chromeIconTint.copy(alpha = LockedIconAlpha) + } + ) + } + } + ) + ) + } } } } @@ -419,6 +464,9 @@ private data class ReaderChromeActionSpec( val content: @Composable (Dp) -> Unit ) +/** Dimmed tint for chrome icons that are visible but locked. */ +private const val LockedIconAlpha = 0.38f + @Composable fun ReaderBrightnessRow( brightness: Float, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt index f62d97e79..7b5eaf1fa 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.core.model.supportsDocumentMarginCrop import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.feature.reader.domain.enums.FootnotePresentation import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState @@ -206,7 +207,8 @@ internal fun BoxScope.ReaderTopChromeBar( onAutoScrollToggle: () -> Unit, onBrightnessChange: (Float) -> Unit, onAutoScrollSpeedPreview: (Float) -> Unit, - onAutoScrollSpeedCommit: (Float) -> Unit + onAutoScrollSpeedCommit: (Float) -> Unit, + onToggleMarginCrop: () -> Unit = {} ) { if (showHeaderFooterOverlay && headerOverlayLine.hasVisibleContent) { Surface( @@ -327,6 +329,9 @@ internal fun BoxScope.ReaderTopChromeBar( showTranslateIcon = uiState.chromeShowTranslateIcon, showBrightnessIcon = uiState.chromeShowBrightnessIcon, showAutoScrollIcon = uiState.chromeShowAutoScrollIcon, + // Margin crop: raster readers only; document formats (PDF/DjVu) unlock it. + showCropIcon = uiState.chromeShowCropIcon && !isTextReader, + marginCropAvailable = uiState.comic?.format?.supportsDocumentMarginCrop() == true, autoScrollActive = uiState.autoScrollEnabled, onNavigateBack = onNavigateBack, onToggleToc = onToggleToc, @@ -335,7 +340,8 @@ internal fun BoxScope.ReaderTopChromeBar( onRequestOcr = onRequestOcr, onToggleBrightness = onToggleBrightness, onToggleTtsControls = onToggleTtsControls, - onAutoScrollToggle = onAutoScrollToggle + onAutoScrollToggle = onAutoScrollToggle, + onToggleMarginCrop = onToggleMarginCrop ) if (showBrightnessRow) { ReaderBrightnessRow( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt index fc3ce9b56..4055bd6db 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt @@ -13,6 +13,7 @@ import io.leostrange.mrcomic.core.model.ReaderTapZoneLayout import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.feature.reader.ui.components.PageView +import io.leostrange.mrcomic.feature.reader.ui.components.ReaderImageCrop import io.leostrange.mrcomic.feature.reader.ui.components.ReadiumEpubView import io.leostrange.mrcomic.feature.reader.ui.components.TextContainer import io.leostrange.mrcomic.feature.reader.ui.components.WebtoonView @@ -27,8 +28,7 @@ internal fun ReaderContainerHost( readerText: ReaderUiText, languageCode: String, tapZoneLayout: ReaderTapZoneLayout, - effectiveMarginCropHorizontal: Float, - effectiveMarginCropVertical: Float, + effectiveMarginCrop: ReaderImageCrop, effectivePageImageScaleMode: String, textReaderModifier: Modifier, imageReaderModifier: Modifier, @@ -221,8 +221,10 @@ internal fun ReaderContainerHost( viewModel = viewModel, uiState = uiState, imageScaleMode = uiState.imageScaleMode, - marginCropHorizontal = effectiveMarginCropHorizontal, - marginCropVertical = effectiveMarginCropVertical, + marginCropLeft = effectiveMarginCrop.normalizedLeft, + marginCropTop = effectiveMarginCrop.normalizedTop, + marginCropRight = effectiveMarginCrop.normalizedRight, + marginCropBottom = effectiveMarginCrop.normalizedBottom, onLeftTap = {}, onRightTap = {}, onCenterTap = { handleTapZoneAction(tapZoneLayout.center) }, @@ -234,8 +236,10 @@ internal fun ReaderContainerHost( viewModel = viewModel, uiState = uiState, imageScaleMode = effectivePageImageScaleMode, - marginCropHorizontal = effectiveMarginCropHorizontal, - marginCropVertical = effectiveMarginCropVertical, + marginCropLeft = effectiveMarginCrop.normalizedLeft, + marginCropTop = effectiveMarginCrop.normalizedTop, + marginCropRight = effectiveMarginCrop.normalizedRight, + marginCropBottom = effectiveMarginCrop.normalizedBottom, onLeftTap = { handleTapZoneAction(tapZoneLayout.left) }, onRightTap = { handleTapZoneAction(tapZoneLayout.right) }, onCenterTap = { handleTapZoneAction(tapZoneLayout.center) }, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt index f5d85f680..773837363 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt @@ -5,6 +5,9 @@ import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.model.isArchiveFormat import io.leostrange.mrcomic.core.model.isGraphicReaderFormat import io.leostrange.mrcomic.core.model.isTextReadingFormat +import io.leostrange.mrcomic.core.model.supportsDocumentMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.ui.components.ReaderImageCrop /** Image margin controls are available only for raster content. */ fun supportsImageMarginCrop( @@ -17,6 +20,37 @@ fun supportsImageMarginCrop( containerKind == ReaderContainerKind.RASTER_WEBTOON } +/** + * The margin-crop chrome button is fully active only for document formats + * (PDF / DjVu). Graphic formats (comics/manga) show the button locked, so + * document comics in PDF still get crop while CBR/CBZ do not pretend to. + */ +fun supportsDocumentMarginCropButton(format: ComicFormat?): Boolean = + format != null && format.supportsDocumentMarginCrop() + +/** + * Resolves the crop actually applied to rendered pages: zeros unless the + * container supports cropping and the user enabled the feature. + */ +internal fun resolveEffectiveImageMarginCrop( + containerKind: ReaderContainerKind, + format: ComicFormat?, + enabled: Boolean, + left: Float, + top: Float, + right: Float, + bottom: Float +): ReaderImageCrop { + val supported = supportsImageMarginCrop(containerKind, format) + if (!supported || !enabled) return ReaderImageCrop.None + return ReaderImageCrop( + leftFraction = left.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + topFraction = top.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + rightFraction = right.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + bottomFraction = bottom.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION) + ) +} + enum class ReaderContainerKind { TEXT_PAGE, TEXT_WEBTOON, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt index 68dd0db46..c5746c330 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt @@ -357,6 +357,13 @@ internal fun readerChromeButtonLabel( "ko" -> "자동 스크롤" else -> "Auto scroll" } + ReaderChromeButton.CROP -> when (language) { + "ru" -> "Обрезка полей" + "ja" -> "余白トリミング" + "zh" -> "页边裁剪" + "ko" -> "여백 자르기" + else -> "Margin crop" + } } internal fun readerChromeButtonVisible( @@ -370,6 +377,7 @@ internal fun readerChromeButtonVisible( ReaderChromeButton.TRANSLATE -> uiState.chromeShowTranslateIcon ReaderChromeButton.BRIGHTNESS -> uiState.chromeShowBrightnessIcon ReaderChromeButton.AUTO_SCROLL -> uiState.chromeShowAutoScrollIcon + ReaderChromeButton.CROP -> uiState.chromeShowCropIcon } internal fun readerTtsSleepTimerLabel(mode: ReaderTtsSleepTimerMode, language: String): String = when (mode) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt new file mode 100644 index 000000000..9d40d51c8 --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt @@ -0,0 +1,375 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Crop +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.leostrange.mrcomic.core.ui.locale.LocalStrings +import io.leostrange.mrcomic.feature.reader.domain.crop.MarginCropAutoDetector +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSide +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSides +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Minimal margin-crop dialog ("Обрезка пустых полей"). + * + * Opens over the reader with chrome hidden; every change is applied to the + * page live through [ReaderSettingsController] and persisted to DataStore. + */ +@Composable +internal fun ReaderMarginCropDialog( + uiState: ReaderUiState, + viewModel: ReaderViewModel, + onDismiss: () -> Unit +) { + val strings = LocalStrings.current + val language = strings.languageCode + val controller = viewModel.settingsController + val scope = rememberCoroutineScope() + var autoRunning by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .widthIn(max = 420.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp)) { + // ── Header ──────────────────────────────────────────────── + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Crop, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp) + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = readerMarginCropDialogTitle(language), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = strings.back, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(6.dp)) + + // ── Enable ──────────────────────────────────────────────── + SwitchRow( + title = readerMarginCropEnable(language), + checked = uiState.marginCropEnabled, + onCheckedChange = controller::setMarginCropEnabled + ) + + // ── Presets ─────────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val preset = ReaderMarginCrop.coerceSide(0.05f) + PresetChip( + selected = autoRunning, + label = if (autoRunning) readerMarginCropAutoRunning(language) + else readerMarginCropPresetAuto(language), + onClick = { + if (autoRunning) return@PresetChip + val bitmap = viewModel.pageLoader.getPage(uiState.currentPage) + if (bitmap == null) { + controller.applyMarginCropSides(preset, preset, preset, preset) + } else { + autoRunning = true + scope.launch { + val sides = withContext(Dispatchers.Default) { + scanBitmapMargins(bitmap) + } + autoRunning = false + if (sides != null) { + controller.applyMarginCropSides( + sides.left, sides.top, sides.right, sides.bottom + ) + } + } + } + } + ) + listOf(0.05f, 0.10f, 0.15f).forEach { value -> + PresetChip( + selected = sidesEqualPreset(uiState, value), + label = "${(value * 100).toInt()}%", + onClick = { + controller.applyMarginCropSides(value, value, value, value) + } + ) + } + } + + // ── Sides ───────────────────────────────────────────────── + Column( + modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha) + ) { + MarginCropSideRow( + title = readerMarginCropSideTop(language), + value = uiState.marginCropTop, + onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.TOP.storedValue, it) } + ) + MarginCropSideRow( + title = readerMarginCropSideBottom(language), + value = uiState.marginCropBottom, + onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.BOTTOM.storedValue, it) } + ) + MarginCropSideRow( + title = readerMarginCropSideLeft(language), + value = uiState.marginCropLeft, + onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.LEFT.storedValue, it) } + ) + MarginCropSideRow( + title = readerMarginCropSideRight(language), + value = uiState.marginCropRight, + onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.RIGHT.storedValue, it) } + ) + } + + // ── Options ─────────────────────────────────────────────── + SwitchRow( + title = readerMarginCropSymmetric(language), + checked = uiState.marginCropSymmetric, + onCheckedChange = controller::setMarginCropSymmetric + ) + SwitchRow( + title = readerMarginCropShowWarning(language), + checked = uiState.marginCropShowWarning, + onCheckedChange = controller::setMarginCropShowWarning + ) + + AnimatedVisibility(visible = uiState.marginCropEnabled && uiState.marginCropShowWarning) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(16.dp) + ) + Text( + text = readerMarginCropWarningText(language), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(10.dp)) + + // ── Footer ──────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { + controller.setMarginCropEnabled(false) + controller.applyMarginCropSides(0f, 0f, 0f, 0f) + }) { + Text(readerMarginCropReset(language)) + } + Spacer(Modifier.weight(1f)) + Button(onClick = onDismiss) { + Text(readerMarginCropDone(language)) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PresetChip( + selected: Boolean, + label: String, + onClick: () -> Unit +) { + FilterChip( + selected = selected, + onClick = onClick, + shape = RoundedCornerShape(999.dp), + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onSurface, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + label = { Text(label, style = MaterialTheme.typography.labelMedium) } + ) +} + +@Composable +private fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedTrackColor = MaterialTheme.colorScheme.primary + ) + ) + } +} + +@Composable +private fun MarginCropSideRow( + title: String, + value: Float, + onValueChange: (Float) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(76.dp) + ) + Text( + text = "${(value * 100).toInt()}%", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(44.dp) + ) + Slider( + value = value.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + onValueChange = onValueChange, + valueRange = 0f..ReaderMarginCrop.MAX_SIDE_FRACTION, + steps = SideSteps, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + } +} + +private fun sidesEqualPreset(uiState: ReaderUiState, value: Float): Boolean = + uiState.marginCropLeft == value && + uiState.marginCropTop == value && + uiState.marginCropRight == value && + uiState.marginCropBottom == value + +/** + * Android Bitmap → luminance grid adapter for [MarginCropAutoDetector]. + * Downsampling keeps the scan in the sub-millisecond range. + */ +private fun scanBitmapMargins(source: Bitmap): ReaderMarginCropSides? { + if (source.isRecycled || source.width < 8 || source.height < 8) return null + val gridWidth = SCAN_GRID_WIDTH + val gridHeight = (gridWidth.toLong() * source.height / source.width) + .toInt() + .coerceIn(MIN_GRID_HEIGHT, MAX_GRID_HEIGHT) + val scaled = Bitmap.createScaledBitmap(source, gridWidth, gridHeight, true) + val pixels = IntArray(gridWidth * gridHeight) + scaled.getPixels(pixels, 0, gridWidth, 0, 0, gridWidth, gridHeight) + if (scaled !== source) scaled.recycle() + return MarginCropAutoDetector.detect(gridWidth, gridHeight) { x, y -> + val pixel = pixels[y * gridWidth + x] + ((pixel shr 16 and 0xFF) * 299 + (pixel shr 8 and 0xFF) * 587 + (pixel and 0xFF) * 114) / 1000 + } +} + +/** 0..0.22 in 1% steps → 22 intervals between endpoints. */ +private const val SideSteps = 21 +private const val DisabledAlpha = 0.45f +private const val SCAN_GRID_WIDTH = 96 +private const val MIN_GRID_HEIGHT = 24 +private const val MAX_GRID_HEIGHT = 220 diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt new file mode 100644 index 000000000..7854be22d --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt @@ -0,0 +1,118 @@ +package io.leostrange.mrcomic.feature.reader.ui + +/** + * Margin-crop dialog localization ("Обрезка пустых полей"). + * Pure functions mapping language → UI strings. + */ +internal fun readerMarginCropDialogTitle(language: String): String = when (language) { + "ru" -> "Обрезка пустых полей" + "ja" -> "余白のトリミング" + "zh" -> "裁剪空白页边" + "ko" -> "빈 여백 자르기" + else -> "Margin crop" +} + +internal fun readerMarginCropEnable(language: String): String = when (language) { + "ru" -> "Включить" + "ja" -> "有効にする" + "zh" -> "启用" + "ko" -> "사용" + else -> "Enable" +} + +internal fun readerMarginCropPresetAuto(language: String): String = when (language) { + "ru" -> "Авто" + "ja" -> "自動" + "zh" -> "自动" + "ko" -> "자동" + else -> "Auto" +} + +internal fun readerMarginCropSideTop(language: String): String = when (language) { + "ru" -> "Сверху" + "ja" -> "上" + "zh" -> "上边" + "ko" -> "위" + else -> "Top" +} + +internal fun readerMarginCropSideBottom(language: String): String = when (language) { + "ru" -> "Снизу" + "ja" -> "下" + "zh" -> "下边" + "ko" -> "아래" + else -> "Bottom" +} + +internal fun readerMarginCropSideLeft(language: String): String = when (language) { + "ru" -> "Слева" + "ja" -> "左" + "zh" -> "左边" + "ko" -> "왼쪽" + else -> "Left" +} + +internal fun readerMarginCropSideRight(language: String): String = when (language) { + "ru" -> "Справа" + "ja" -> "右" + "zh" -> "右边" + "ko" -> "오른쪽" + else -> "Right" +} + +internal fun readerMarginCropSymmetric(language: String): String = when (language) { + "ru" -> "Симметричная обрезка" + "ja" -> "対称トリミング" + "zh" -> "对称裁剪" + "ko" -> "대칭 자르기" + else -> "Symmetric crop" +} + +internal fun readerMarginCropShowWarning(language: String): String = when (language) { + "ru" -> "Показывать предупреждение при обрезке" + "ja" -> "トリミング中は警告を表示" + "zh" -> "裁剪时显示警告" + "ko" -> "자르기 사용 시 경고 표시" + else -> "Show warning while crop is on" +} + +internal fun readerMarginCropWarningText(language: String): String = when (language) { + "ru" -> "Часть изображения по краям страниц скрыта" + "ja" -> "ページの端の一部が非表示になっています" + "zh" -> "页面边缘的部分内容已被隐藏" + "ko" -> "페이지 가장자리의 일부가 숨겨져 있습니다" + else -> "Part of the page edges is hidden" +} + +internal fun readerMarginCropReset(language: String): String = when (language) { + "ru" -> "Сбросить" + "ja" -> "リセット" + "zh" -> "重置" + "ko" -> "초기화" + else -> "Reset" +} + +internal fun readerMarginCropDone(language: String): String = when (language) { + "ru" -> "Готово" + "ja" -> "完了" + "zh" -> "完成" + "ko" -> "완료" + else -> "Done" +} + +internal fun readerMarginCropAutoRunning(language: String): String = when (language) { + "ru" -> "Анализ страницы…" + "ja" -> "ページを解析中…" + "zh" -> "正在分析页面…" + "ko" -> "페이지 분석 중…" + else -> "Analyzing page…" +} + +/** Content description for the locked crop button (comics/manga formats). */ +internal fun readerMarginCropLockedHint(language: String): String = when (language) { + "ru" -> "Обрезка полей доступна для PDF и DjVu" + "ja" -> "余白トリミングは PDF と DjVu で利用できます" + "zh" -> "页边裁剪仅适用于 PDF 和 DjVu" + "ko" -> "여백 자르기는 PDF 및 DjVu에서 사용할 수 있습니다" + else -> "Margin crop is available for PDF and DjVu" +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt index 7b6ae0c8f..db7ed1499 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPreferenceRestorer.kt @@ -53,8 +53,7 @@ internal object ReaderPreferenceRestorer { val bottomToolbarOpacity: Float, val toolbarBlur: Float, val imageScaleMode: ReaderImageScaleMode, - val imageMarginCropHorizontal: Float, - val imageMarginCropVertical: Float, + val marginCrop: io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop, val preload: Int, val fontSize: Int, val colorScheme: String, @@ -102,6 +101,7 @@ internal object ReaderPreferenceRestorer { val chromeShowTranslateIcon: Boolean, val chromeShowBrightnessIcon: Boolean, val chromeShowAutoScrollIcon: Boolean, + val chromeShowCropIcon: Boolean, val readerStylePresetEntries: List, val readerStylePresetSlots: List, val savedReaderStylePresetEntries: List, @@ -135,12 +135,31 @@ internal object ReaderPreferenceRestorer { val imageScaleMode = ReaderImageScaleMode.fromStored( pref(PreferencesKeys.READER_IMAGE_SCALE_MODE, ReaderImageScaleMode.FIT_WIDTH.storedValue) ) - val imageMarginCropHorizontal = pref( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0.0f - ).coerceIn(0f, 0.22f) - val imageMarginCropVertical = pref( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0.0f - ).coerceIn(0f, 0.22f) + // Per-side crop values; -1f marks "key absent" so the legacy symmetric + // H/V preferences can seed them on the first run after the upgrade. + val storedLeft = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, -1f) + val storedTop = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, -1f) + val storedRight = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, -1f) + val storedBottom = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, -1f) + val legacyHorizontal = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0f) + .coerceIn(0f, io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop.MAX_SIDE_FRACTION) + val legacyVertical = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0f) + .coerceIn(0f, io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop.MAX_SIDE_FRACTION) + val seededCrop = io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop() + .seededFromLegacy(legacyHorizontal, legacyVertical) + val marginCrop = io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop( + // A legacy user with non-zero H/V crop keeps it enabled after the upgrade. + enabled = pref( + PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, + legacyHorizontal > 0f || legacyVertical > 0f + ), + left = if (storedLeft >= 0f) storedLeft else seededCrop.left, + top = if (storedTop >= 0f) storedTop else seededCrop.top, + right = if (storedRight >= 0f) storedRight else seededCrop.right, + bottom = if (storedBottom >= 0f) storedBottom else seededCrop.bottom, + symmetric = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_SYMMETRIC, true), + showWarning = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_SHOW_WARNING, true) + ).withSymmetricEnforced() val preload = pref(PreferencesKeys.READER_PRELOAD_PAGES, renderProfile.defaultPreloadPages) .coerceIn(2, 8) .coerceAtMost(renderProfile.maxPreloadPages) @@ -215,6 +234,7 @@ internal object ReaderPreferenceRestorer { val chromeShowTranslateIcon = pref(PreferencesKeys.READER_CHROME_SHOW_TRANSLATE, true) val chromeShowBrightnessIcon = pref(PreferencesKeys.READER_CHROME_SHOW_BRIGHTNESS, true) val chromeShowAutoScrollIcon = pref(PreferencesKeys.READER_CHROME_SHOW_AUTO_SCROLL, true) + val chromeShowCropIcon = pref(PreferencesKeys.READER_CHROME_SHOW_CROP, true) val legacyReaderStylePresetSlots = listOf( ReaderStylePresetSlot(1, pref(PreferencesKeys.READER_STYLE_PRESET_1, "").ifBlank { null }), @@ -251,8 +271,7 @@ internal object ReaderPreferenceRestorer { bottomToolbarOpacity = bottomToolbarOpacity, toolbarBlur = toolbarBlur, imageScaleMode = imageScaleMode, - imageMarginCropHorizontal = imageMarginCropHorizontal, - imageMarginCropVertical = imageMarginCropVertical, + marginCrop = marginCrop, preload = preload, fontSize = fontSize, colorScheme = colorScheme, @@ -300,6 +319,7 @@ internal object ReaderPreferenceRestorer { chromeShowTranslateIcon = chromeShowTranslateIcon, chromeShowBrightnessIcon = chromeShowBrightnessIcon, chromeShowAutoScrollIcon = chromeShowAutoScrollIcon, + chromeShowCropIcon = chromeShowCropIcon, readerStylePresetEntries = readerStylePresetEntries, readerStylePresetSlots = readerStylePresetSlots, savedReaderStylePresetEntries = savedReaderStylePresetEntries, @@ -344,8 +364,13 @@ internal object ReaderPreferenceRestorer { bottomToolbarOpacity = p.bottomToolbarOpacity, toolbarBlur = p.toolbarBlur, imageScaleMode = p.imageScaleMode.storedValue, - imageMarginCropHorizontal = p.imageMarginCropHorizontal, - imageMarginCropVertical = p.imageMarginCropVertical, + marginCropEnabled = p.marginCrop.enabled, + marginCropLeft = p.marginCrop.left, + marginCropTop = p.marginCrop.top, + marginCropRight = p.marginCrop.right, + marginCropBottom = p.marginCrop.bottom, + marginCropSymmetric = p.marginCrop.symmetric, + marginCropShowWarning = p.marginCrop.showWarning, preloadPages = p.preload, textFontSize = p.fontSize, textColorScheme = p.colorScheme, @@ -395,7 +420,8 @@ internal object ReaderPreferenceRestorer { chromeShowDirectionIcon = p.chromeShowDirectionIcon, chromeShowTranslateIcon = p.chromeShowTranslateIcon, chromeShowBrightnessIcon = p.chromeShowBrightnessIcon, - chromeShowAutoScrollIcon = p.chromeShowAutoScrollIcon + chromeShowAutoScrollIcon = p.chromeShowAutoScrollIcon, + chromeShowCropIcon = p.chromeShowCropIcon ) } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt index 854f7544e..ec6846dda 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.theme.style +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop @OptIn(ExperimentalLayoutApi::class) @Composable @@ -394,6 +395,7 @@ internal fun ReaderHeaderFooterPreview( eink = false ) } + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.large) { Surface( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, @@ -427,6 +429,7 @@ internal fun ReaderHeaderFooterPreview( } } } + } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt index 6e4395ca4..eb35bc622 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt @@ -82,8 +82,15 @@ fun ReaderScreen( val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val isTextReader = uiState.readerContainerKind.isTextContainer() val supportsImageMarginCrop = supportsImageMarginCrop(uiState.readerContainerKind, uiState.comic?.format) - val effectiveMarginCropHorizontal = if (supportsImageMarginCrop) uiState.imageMarginCropHorizontal else 0f - val effectiveMarginCropVertical = if (supportsImageMarginCrop) uiState.imageMarginCropVertical else 0f + val effectiveMarginCrop = resolveEffectiveImageMarginCrop( + containerKind = uiState.readerContainerKind, + format = uiState.comic?.format, + enabled = uiState.marginCropEnabled, + left = uiState.marginCropLeft, + top = uiState.marginCropTop, + right = uiState.marginCropRight, + bottom = uiState.marginCropBottom + ) val effectivePageImageScaleMode = if ( uiState.comic?.format == ComicFormat.DJVU && @@ -483,6 +490,7 @@ fun ReaderScreen( // pixel-scroll cannot advance the document underneath an open dialog. val anyBottomSheetOpen = uiState.showTocSheet || uiState.showTextSettings || + uiState.showMarginCropDialog || showReaderAudioSheet || showTextTranslationPageSheet || showRsvpOverlay || @@ -586,8 +594,7 @@ fun ReaderScreen( readerText = readerText, languageCode = strings.languageCode, tapZoneLayout = tapZoneLayout, - effectiveMarginCropHorizontal = effectiveMarginCropHorizontal, - effectiveMarginCropVertical = effectiveMarginCropVertical, + effectiveMarginCrop = effectiveMarginCrop, effectivePageImageScaleMode = effectivePageImageScaleMode, textReaderModifier = stableTextReaderModifier, imageReaderModifier = imageReaderModifier, @@ -654,11 +661,25 @@ fun ReaderScreen( onDismissFootnote = { viewModel.footnoteController.dismissFootnote() }, onExpandFootnote = { viewModel.footnoteController.expandFootnote() }, onCollapseFootnote = { viewModel.footnoteController.collapseFootnote() }, + onToggleMarginCrop = { + // Close the chrome panels and show the crop dialog over + // the page, like the classic document readers do. + viewModel.chromeController.hideChrome() + viewModel.settingsController.setMarginCropDialogVisible(true) + }, ) } } } + if (uiState.showMarginCropDialog && supportsImageMarginCrop) { + ReaderMarginCropDialog( + uiState = uiState, + viewModel = viewModel, + onDismiss = { viewModel.settingsController.setMarginCropDialogVisible(false) } + ) + } + ReaderBottomSheets( host = rememberReaderBottomSheetHost( uiState = uiState, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt index 45e8e2ef5..46a597583 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt @@ -72,9 +72,21 @@ interface ReaderSettingsActions { // ── Image ───────────────────────────────────────────────────────────── fun setImageScaleMode(value: String) + + /** Symmetric pair writes kept for the style tab / settings sliders. */ fun setImageMarginCropHorizontal(value: Float) fun setImageMarginCropVertical(value: Float) + /** Per-side crop write used by the margin-crop dialog. */ + fun setMarginCropSide(side: String, value: Float) + + /** Applies all four sides at once (used by the auto-detect preset). */ + fun applyMarginCropSides(left: Float, top: Float, right: Float, bottom: Float) + fun setMarginCropEnabled(enabled: Boolean) + fun setMarginCropSymmetric(symmetric: Boolean) + fun setMarginCropShowWarning(show: Boolean) + fun setMarginCropDialogVisible(visible: Boolean) + // ── TTS ─────────────────────────────────────────────────────────────── fun setTtsSpeed(value: Float) fun setTtsProvider(value: String) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt index 7f798e6c1..c031a26dd 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt @@ -4,6 +4,8 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import io.leostrange.mrcomic.core.data.preferences.PreferencesKeys import io.leostrange.mrcomic.core.data.preferences.UserPreferences +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSide import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.ui.preset.toReaderStylePresetSnapshot import io.leostrange.mrcomic.core.model.ReaderImageScaleMode @@ -554,15 +556,131 @@ class ReaderSettingsController( } override fun setImageMarginCropHorizontal(value: Float) { - val safe = value.coerceIn(0f, 0.22f) - _uiState.update { it.copy(imageMarginCropHorizontal = safe) } - viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, safe) } + val safe = ReaderMarginCrop.coerceSide(value) + _uiState.update { + it.copy( + marginCropLeft = safe, + marginCropRight = safe, + marginCropEnabled = it.marginCropEnabled || safe > 0f + ) + } + persistMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = safe, + top = _uiState.value.marginCropTop, + right = safe, + bottom = _uiState.value.marginCropBottom + ) } override fun setImageMarginCropVertical(value: Float) { - val safe = value.coerceIn(0f, 0.22f) - _uiState.update { it.copy(imageMarginCropVertical = safe) } - viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, safe) } + val safe = ReaderMarginCrop.coerceSide(value) + _uiState.update { + it.copy( + marginCropTop = safe, + marginCropBottom = safe, + marginCropEnabled = it.marginCropEnabled || safe > 0f + ) + } + persistMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = _uiState.value.marginCropLeft, + top = safe, + right = _uiState.value.marginCropRight, + bottom = safe + ) + } + + override fun setMarginCropSide(side: String, value: Float) { + val cropSide = ReaderMarginCropSide.fromStored(side) ?: return + val next = currentMarginCrop().withSide(cropSide, value) + updateMarginCropState(next) + } + + override fun applyMarginCropSides(left: Float, top: Float, right: Float, bottom: Float) { + val next = currentMarginCrop().copy( + left = ReaderMarginCrop.coerceSide(left), + top = ReaderMarginCrop.coerceSide(top), + right = ReaderMarginCrop.coerceSide(right), + bottom = ReaderMarginCrop.coerceSide(bottom), + enabled = true + ) + updateMarginCropState(next) + } + + override fun setMarginCropEnabled(enabled: Boolean) { + updateMarginCropState(currentMarginCrop().copy(enabled = enabled)) + } + + override fun setMarginCropSymmetric(symmetric: Boolean) { + val next = if (symmetric) { + currentMarginCrop().copy(symmetric = true).withPairsAveraged() + } else { + currentMarginCrop().copy(symmetric = false) + } + updateMarginCropState(next) + } + + override fun setMarginCropShowWarning(show: Boolean) { + updateMarginCropState(currentMarginCrop().copy(showWarning = show)) + } + + override fun setMarginCropDialogVisible(visible: Boolean) { + _uiState.update { it.copy(showMarginCropDialog = visible) } + } + + private fun currentMarginCrop(): ReaderMarginCrop = ReaderMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = _uiState.value.marginCropLeft, + top = _uiState.value.marginCropTop, + right = _uiState.value.marginCropRight, + bottom = _uiState.value.marginCropBottom, + symmetric = _uiState.value.marginCropSymmetric, + showWarning = _uiState.value.marginCropShowWarning + ) + + private fun updateMarginCropState(crop: ReaderMarginCrop) { + val normalized = crop.withSymmetricEnforced() + _uiState.update { + it.copy( + marginCropEnabled = normalized.enabled, + marginCropLeft = normalized.left, + marginCropTop = normalized.top, + marginCropRight = normalized.right, + marginCropBottom = normalized.bottom, + marginCropSymmetric = normalized.symmetric, + marginCropShowWarning = normalized.showWarning + ) + } + persistMarginCrop( + enabled = normalized.enabled, + left = normalized.left, + top = normalized.top, + right = normalized.right, + bottom = normalized.bottom, + symmetric = normalized.symmetric, + showWarning = normalized.showWarning + ) + } + + private fun persistMarginCrop( + enabled: Boolean, + left: Float, + top: Float, + right: Float, + bottom: Float, + symmetric: Boolean = _uiState.value.marginCropSymmetric, + showWarning: Boolean = _uiState.value.marginCropShowWarning + ) { + viewModelScope.launch { + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, enabled) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, left) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, top) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, right) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, bottom) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_SYMMETRIC, symmetric) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_SHOW_WARNING, showWarning) + } } // ── TTS settings ────────────────────────────────────────────────────── @@ -634,6 +752,10 @@ class ReaderSettingsController( _uiState.update { it.copy(chromeShowAutoScrollIcon = visible) } viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_CHROME_SHOW_AUTO_SCROLL, visible) } } + ReaderChromeButton.CROP -> { + _uiState.update { it.copy(chromeShowCropIcon = visible) } + viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_CHROME_SHOW_CROP, visible) } + } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index 39e57d85c..e770d0b04 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.text.font.FontWeight import io.leostrange.mrcomic.core.ui.theme.style +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetEntry import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSlot import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot @@ -212,8 +213,10 @@ internal fun ReaderStyleTab( if (supportsMarginCrop) { item { ReaderGraphicCropPreview( - horizontalCrop = uiState.imageMarginCropHorizontal, - verticalCrop = uiState.imageMarginCropVertical, + leftCrop = uiState.marginCropLeft, + topCrop = uiState.marginCropTop, + rightCrop = uiState.marginCropRight, + bottomCrop = uiState.marginCropBottom, language = strings.languageCode ) } @@ -225,20 +228,22 @@ internal fun ReaderStyleTab( ) } item { + val horizontalCrop = (uiState.marginCropLeft + uiState.marginCropRight) / 2f ReaderSliderRow( - title = readerMarginCropHorizontalLabel(uiState.imageMarginCropHorizontal, strings.languageCode), - valueText = "${(uiState.imageMarginCropHorizontal * 100f).toInt()}%", - value = uiState.imageMarginCropHorizontal, + title = readerMarginCropHorizontalLabel(horizontalCrop, strings.languageCode), + valueText = "${(horizontalCrop * 100f).toInt()}%", + value = horizontalCrop, valueRange = 0f..0.18f, steps = 17, onValueChange = onImageMarginCropHorizontalChange ) } item { + val verticalCrop = (uiState.marginCropTop + uiState.marginCropBottom) / 2f ReaderSliderRow( - title = readerMarginCropVerticalLabel(uiState.imageMarginCropVertical, strings.languageCode), - valueText = "${(uiState.imageMarginCropVertical * 100f).toInt()}%", - value = uiState.imageMarginCropVertical, + title = readerMarginCropVerticalLabel(verticalCrop, strings.languageCode), + valueText = "${(verticalCrop * 100f).toInt()}%", + value = verticalCrop, valueRange = 0f..0.18f, steps = 17, onValueChange = onImageMarginCropVerticalChange @@ -543,12 +548,17 @@ internal fun ReaderStyleTab( /** Shows the retained image area and the exact horizontal/vertical crop zones. */ @Composable private fun ReaderGraphicCropPreview( - horizontalCrop: Float, - verticalCrop: Float, + leftCrop: Float, + topCrop: Float, + rightCrop: Float, + bottomCrop: Float, language: String ) { - val horizontal = horizontalCrop.coerceIn(0f, 0.18f) - val vertical = verticalCrop.coerceIn(0f, 0.18f) + val maxFraction = 0.22f + val left = leftCrop.coerceIn(0f, maxFraction) + val top = topCrop.coerceIn(0f, maxFraction) + val right = rightCrop.coerceIn(0f, maxFraction) + val bottom = bottomCrop.coerceIn(0f, maxFraction) val previewLabel = when (language) { "en" -> "Preview · retained image area" "ja" -> "プレビュー・表示領域" @@ -565,11 +575,12 @@ private fun ReaderGraphicCropPreview( ) { Text(previewLabel, style = MaterialTheme.typography.labelLarge) Text( - text = "↔ ${(horizontal * 100f).toInt()}% ↕ ${(vertical * 100f).toInt()}%", + text = "←${(left * 100f).toInt()}% ↑${(top * 100f).toInt()}% ↓${(bottom * 100f).toInt()}% →${(right * 100f).toInt()}%", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary ) } + MrComicPreviewBackdrop(shape = RoundedCornerShape(12.dp)) { Box( modifier = Modifier .fillMaxWidth() @@ -601,39 +612,44 @@ private fun ReaderGraphicCropPreview( } } val overlay = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - if (horizontal > 0f) { + if (left > 0f) { Box( modifier = Modifier .fillMaxHeight() - .fillMaxWidth(horizontal / 0.22f) + .fillMaxWidth(left / maxFraction) .align(Alignment.CenterStart) .background(overlay) ) + } + if (right > 0f) { Box( modifier = Modifier .fillMaxHeight() - .fillMaxWidth(horizontal / 0.22f) + .fillMaxWidth(right / maxFraction) .align(Alignment.CenterEnd) .background(overlay) ) } - if (vertical > 0f) { + if (top > 0f) { Box( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(vertical / 0.22f) + .fillMaxHeight(top / maxFraction) .align(Alignment.TopCenter) .background(overlay) ) + } + if (bottom > 0f) { Box( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(vertical / 0.22f) + .fillMaxHeight(bottom / maxFraction) .align(Alignment.BottomCenter) .background(overlay) ) } } + } } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt index 1c22d2682..7f64f4e3a 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt @@ -36,8 +36,7 @@ internal const val DEFAULT_TEXT_WORD_SPACING = 0f internal const val DEFAULT_TEXT_PARAGRAPH_SPACING = 0.2f internal const val DEFAULT_TEXT_ALIGNMENT = "left" internal const val DEFAULT_TEXT_BOLD = false -internal const val DEFAULT_IMAGE_MARGIN_CROP_HORIZONTAL = 0f -internal const val DEFAULT_IMAGE_MARGIN_CROP_VERTICAL = 0f +internal const val DEFAULT_IMAGE_MARGIN_CROP = 0f internal const val DEFERRED_PAGE_COUNT_START_DELAY_MILLIS = 1_500L internal const val TAG = "ReaderViewModel" internal val FOOTNOTE_MARKER_RE = Regex( @@ -93,10 +92,18 @@ data class ReaderUiState( val autoScrollPauseReasons: Set = emptySet(), /** How graphic pages should be fitted on the reader canvas. */ val imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - /** Symmetric left/right crop for document page margins. */ - val imageMarginCropHorizontal: Float = DEFAULT_IMAGE_MARGIN_CROP_HORIZONTAL, - /** Symmetric top/bottom crop for document page margins. */ - val imageMarginCropVertical: Float = DEFAULT_IMAGE_MARGIN_CROP_VERTICAL, + /** Per-side document margin crop ("обрезка пустых полей"). */ + val marginCropEnabled: Boolean = false, + val marginCropLeft: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropTop: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropRight: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropBottom: Float = DEFAULT_IMAGE_MARGIN_CROP, + /** Symmetric mode keeps left == right and top == bottom while editing. */ + val marginCropSymmetric: Boolean = true, + /** Show the on-page warning while crop is active. */ + val marginCropShowWarning: Boolean = true, + /** Whether the margin-crop dialog is currently open. */ + val showMarginCropDialog: Boolean = false, /** Number of pages to preload ahead of the current page */ val preloadPages: Int = 3, /** @@ -253,7 +260,9 @@ data class ReaderUiState( val chromeShowDirectionIcon: Boolean = true, val chromeShowTranslateIcon: Boolean = true, val chromeShowBrightnessIcon: Boolean = true, - val chromeShowAutoScrollIcon: Boolean = true + val chromeShowAutoScrollIcon: Boolean = true, + /** Whether the margin-crop button appears in the top chrome bar. */ + val chromeShowCropIcon: Boolean = true ) { /** * BUG-READER-01: Unified effective total pages. diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt index 80e2c7bf2..cfdb604aa 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt @@ -54,20 +54,22 @@ fun PageView( onCenterTap: () -> Unit, onUserTouchChange: (Boolean) -> Unit = {}, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - marginCropHorizontal: Float = 0f, - marginCropVertical: Float = 0f, + marginCropLeft: Float = 0f, + marginCropTop: Float = 0f, + marginCropRight: Float = 0f, + marginCropBottom: Float = 0f, modifier: Modifier = Modifier ) { val isEInk = LocalEInkMode.current val isDualPage = uiState.readingMode == ReadingMode.DUAL_PAGE val graphicPageOffset = readerGraphicPageOffset(uiState.comic?.format) val leftPage = uiState.currentPage - val imageCrop = remember(marginCropHorizontal, marginCropVertical) { - ReaderImageCrop( - horizontalFraction = marginCropHorizontal, - verticalFraction = marginCropVertical - ) - } + val imageCrop = ReaderImageCrop( + leftFraction = marginCropLeft, + topFraction = marginCropTop, + rightFraction = marginCropRight, + bottomFraction = marginCropBottom + ) AnimatedContent( targetState = Pair(leftPage, isDualPage), @@ -201,7 +203,13 @@ private fun PagePane( val density = LocalDensity.current val containerWidthPx = with(density) { maxWidth.toPx().coerceAtLeast(1f) } val containerHeightPx = with(density) { maxHeight.toPx().coerceAtLeast(1f) } - val (sourceWidthPx, sourceHeightPx) = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val (sourceWidthPx, sourceHeightPx) = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceDimensions(bitmap, crop) } val (baseWidthPx, baseHeightPx) = remember( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt index 1a9a2f439..0208a23c1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt @@ -16,12 +16,37 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt +/** + * Per-side page crop as fractions of the page size. The legacy symmetric + * constructor (horizontalFraction/verticalFraction) is kept for call sites + * that crop both sides of an axis equally. + */ internal data class ReaderImageCrop( - val horizontalFraction: Float = 0f, - val verticalFraction: Float = 0f + val leftFraction: Float, + val topFraction: Float, + val rightFraction: Float, + val bottomFraction: Float ) { - val normalizedHorizontal: Float = horizontalFraction.coerceIn(0f, 0.22f) - val normalizedVertical: Float = verticalFraction.coerceIn(0f, 0.22f) + constructor(horizontalFraction: Float = 0f, verticalFraction: Float = 0f) : this( + leftFraction = horizontalFraction, + topFraction = verticalFraction, + rightFraction = horizontalFraction, + bottomFraction = verticalFraction + ) + + val normalizedLeft: Float = leftFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedTop: Float = topFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedRight: Float = rightFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedBottom: Float = bottomFraction.coerceIn(0f, MAX_CROP_FRACTION) + + val isZero: Boolean + get() = normalizedLeft <= 0f && normalizedTop <= 0f && + normalizedRight <= 0f && normalizedBottom <= 0f + + companion object { + const val MAX_CROP_FRACTION = 0.22f + val None = ReaderImageCrop(0f, 0f, 0f, 0f) + } } internal fun croppedSourceDimensions( @@ -44,7 +69,13 @@ internal fun CroppedBitmapImage( ) { val layoutDirection = LocalLayoutDirection.current val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() } - val sourceRect = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val sourceRect = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceRect(bitmap, crop) } Canvas( @@ -89,15 +120,19 @@ private fun croppedSourceRect( bitmap: Bitmap, crop: ReaderImageCrop ): CroppedSourceRect { - val horizontalInset = (bitmap.width * crop.normalizedHorizontal).roundToInt() + val leftInset = (bitmap.width * crop.normalizedLeft).roundToInt() .coerceIn(0, (bitmap.width - 1) / 2) - val verticalInset = (bitmap.height * crop.normalizedVertical).roundToInt() + val rightInset = (bitmap.width * crop.normalizedRight).roundToInt() + .coerceIn(0, (bitmap.width - 1) / 2) + val topInset = (bitmap.height * crop.normalizedTop).roundToInt() + .coerceIn(0, (bitmap.height - 1) / 2) + val bottomInset = (bitmap.height * crop.normalizedBottom).roundToInt() .coerceIn(0, (bitmap.height - 1) / 2) - val width = (bitmap.width - horizontalInset * 2).coerceAtLeast(1) - val height = (bitmap.height - verticalInset * 2).coerceAtLeast(1) + val width = (bitmap.width - leftInset - rightInset).coerceAtLeast(1) + val height = (bitmap.height - topInset - bottomInset).coerceAtLeast(1) return CroppedSourceRect( - left = horizontalInset, - top = verticalInset, + left = leftInset, + top = topInset, width = width, height = height ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt index 0edc1f0aa..a8e095035 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt @@ -54,8 +54,10 @@ fun WebtoonView( onRightTap: () -> Unit, onCenterTap: () -> Unit, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - marginCropHorizontal: Float = 0f, - marginCropVertical: Float = 0f, + marginCropLeft: Float = 0f, + marginCropTop: Float = 0f, + marginCropRight: Float = 0f, + marginCropBottom: Float = 0f, modifier: Modifier = Modifier ) { val isEInk = LocalEInkMode.current @@ -80,12 +82,12 @@ fun WebtoonView( onStop = { viewModel.autoScrollRuntimeController.stop() } ) - val imageCrop = remember(marginCropHorizontal, marginCropVertical) { - ReaderImageCrop( - horizontalFraction = marginCropHorizontal, - verticalFraction = marginCropVertical - ) - } + val imageCrop = ReaderImageCrop( + leftFraction = marginCropLeft, + topFraction = marginCropTop, + rightFraction = marginCropRight, + bottomFraction = marginCropBottom + ) val webtoonPreloadBehind = 1 val webtoonPreloadAhead = 3 @@ -365,7 +367,13 @@ private fun ZoomableFillWidthImage( } else { Float.POSITIVE_INFINITY } - val (sourceWidthPx, sourceHeightPx) = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val (sourceWidthPx, sourceHeightPx) = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceDimensions(bitmap, crop) } val imageSize = remember( diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt new file mode 100644 index 000000000..57a041927 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt @@ -0,0 +1,104 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarginCropAutoDetectorTest { + + /** Solid page: uniform paper with a dark content block at the given insets. */ + private fun grid( + width: Int, + height: Int, + paper: Int = 240, + ink: Int = 30, + insets: ReaderMarginCropSides + ): (Int, Int) -> Int { + val leftPx = (width * insets.left).toInt() + val topPx = (height * insets.top).toInt() + val rightPx = width - (width * insets.right).toInt() + val bottomPx = height - (height * insets.bottom).toInt() + return { x, y -> + if (x in leftPx until rightPx && y in topPx until bottomPx) ink else paper + } + } + + @Test + fun tinyGridsReturnNoCrop() { + assertTrue(MarginCropAutoDetector.detect(8, 8) { _, _ -> 0 }.isZero) + assertTrue(MarginCropAutoDetector.detect(0, 0) { _, _ -> 0 }.isZero) + } + + @Test + fun blankPageIsNotCropped() { + val result = MarginCropAutoDetector.detect(100, 140) { _, _ -> 230 } + assertTrue("Blank page must stay uncropped: $result", result.isZero) + } + + @Test + fun symmetricMarginsAreDetected() { + val width = 200 + val height = 280 + // 10% margins around a text block. + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid(width, height, insets = ReaderMarginCropSides(0.10f, 0.10f, 0.10f, 0.10f)) + ) + assertEquals(0.10f, result.left, 0.02f) + assertEquals(0.10f, result.right, 0.02f) + assertEquals(0.10f, result.top, 0.02f) + assertEquals(0.10f, result.bottom, 0.02f) + } + + @Test + fun asymmetricMarginsAreDetectedIndependently() { + val width = 200 + val height = 280 + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid( + width, + height, + insets = ReaderMarginCropSides(left = 0.04f, top = 0.12f, right = 0.16f, bottom = 0.02f) + ) + ) + assertEquals(0.04f, result.left, 0.02f) + assertEquals(0.16f, result.right, 0.02f) + assertEquals(0.12f, result.top, 0.02f) + assertEquals(0.02f, result.bottom, 0.02f) + } + + @Test + fun contentBeyondTheCapIsNeverOverCropped() { + val width = 200 + val height = 280 + // Content occupies only the middle third: real insets are ~0.33, but the + // detector must cap at MAX_SIDE_FRACTION and never crop past it. + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid(width, height, insets = ReaderMarginCropSides(0.33f, 0.33f, 0.33f, 0.33f)) + ) + assertTrue(result.left <= ReaderMarginCrop.MAX_SIDE_FRACTION) + assertTrue(result.top <= ReaderMarginCrop.MAX_SIDE_FRACTION) + assertTrue(result.left >= 0.20f) + assertTrue(result.top >= 0.20f) + } + + @Test + fun dustSpecksNearTheBorderDoNotBlockCropping() { + val width = 200 + val height = 280 + val base = grid(width, height, insets = ReaderMarginCropSides(0.10f, 0.10f, 0.10f, 0.10f)) + val result = MarginCropAutoDetector.detect(width, height) { x, y -> + // A few isolated dark pixels 2% from the edges (dust/speckle). + val nearLeftSpeck = x == 4 && y == 140 + val nearTopSpeck = y == 5 && x == 100 + if (nearLeftSpeck || nearTopSpeck) 20 else base(x, y) + } + // The left line at x=4 has 1 dark sample out of 280 (<5% share). + assertTrue("Specks must be ignored: $result", result.left >= 0.08f) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt new file mode 100644 index 000000000..6095637c5 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt @@ -0,0 +1,110 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderMarginCropTest { + + @Test + fun clampCapsEachSideAtMaxFraction() { + val crop = ReaderMarginCrop(enabled = true, left = 0.5f, top = -0.4f, right = 0.1f, bottom = 0f) + val clamped = crop.clamped() + assertEquals(ReaderMarginCrop.MAX_SIDE_FRACTION, clamped.left, 1e-6f) + assertEquals(0f, clamped.top, 1e-6f) + assertEquals(0.1f, clamped.right, 1e-6f) + assertEquals(0f, clamped.bottom, 1e-6f) + } + + @Test + fun symmetricEnforceKeepsLargerSideOfEachAxis() { + val crop = ReaderMarginCrop( + enabled = true, + left = 0.10f, + right = 0.04f, + top = 0.02f, + bottom = 0.08f, + symmetric = true + ) + val enforced = crop.withSymmetricEnforced() + assertEquals(0.10f, enforced.left, 1e-6f) + assertEquals(0.10f, enforced.right, 1e-6f) + assertEquals(0.08f, enforced.top, 1e-6f) + assertEquals(0.08f, enforced.bottom, 1e-6f) + } + + @Test + fun symmetricEnforceKeepsAsymmetricValuesWhenDisabled() { + val crop = ReaderMarginCrop(left = 0.10f, right = 0.04f, symmetric = false) + val enforced = crop.withSymmetricEnforced() + assertEquals(0.10f, enforced.left, 1e-6f) + assertEquals(0.04f, enforced.right, 1e-6f) + } + + @Test + fun pairsAveragedProducesEqualAxes() { + val averaged = ReaderMarginCrop(left = 0.10f, right = 0.02f, top = 0f, bottom = 0.06f) + .withPairsAveraged() + assertEquals(0.06f, averaged.left, 1e-6f) + assertEquals(0.06f, averaged.right, 1e-6f) + assertEquals(0.03f, averaged.top, 1e-6f) + assertEquals(0.03f, averaged.bottom, 1e-6f) + } + + @Test + fun setSideMirrorsOppositeSideInSymmetricMode() { + val updated = ReaderMarginCrop(enabled = true, symmetric = true) + .withSide(ReaderMarginCropSide.LEFT, 0.07f) + assertEquals(0.07f, updated.left, 1e-6f) + assertEquals(0.07f, updated.right, 1e-6f) + assertEquals(0f, updated.top, 1e-6f) + } + + @Test + fun setSideKeepsOppositeSideIndependentWhenAsymmetric() { + val updated = ReaderMarginCrop(symmetric = false, right = 0.05f) + .withSide(ReaderMarginCropSide.LEFT, 0.09f) + assertEquals(0.09f, updated.left, 1e-6f) + assertEquals(0.05f, updated.right, 1e-6f) + } + + @Test + fun setSideDoesNotImplicitlyEnableCrop() { + val updated = ReaderMarginCrop(enabled = false, symmetric = false) + .withSide(ReaderMarginCropSide.TOP, 0.10f) + assertFalse(updated.enabled) + assertTrue(updated.hasVisibleCrop) + assertFalse(updated.isActive) + } + + @Test + fun seededFromLegacyFillsAllSides() { + val seeded = ReaderMarginCrop().seededFromLegacy(horizontal = 0.10f, vertical = 0.05f) + assertEquals(0.10f, seeded.left, 1e-6f) + assertEquals(0.10f, seeded.right, 1e-6f) + assertEquals(0.05f, seeded.top, 1e-6f) + assertEquals(0.05f, seeded.bottom, 1e-6f) + } + + @Test + fun effectiveSidesAreZeroWhenUnsupportedOrDisabled() { + val crop = ReaderMarginCrop(enabled = true, left = 0.1f, right = 0.1f, top = 0.05f, bottom = 0.05f) + assertTrue(crop.effectiveSides(supported = false).isZero) + assertTrue(crop.copy(enabled = false).effectiveSides(supported = true).isZero) + val effective = crop.effectiveSides(supported = true) + assertFalse(effective.isZero) + assertEquals(0.1f, effective.left, 1e-6f) + assertEquals(0.05f, effective.top, 1e-6f) + assertEquals(0.1f, effective.right, 1e-6f) + assertEquals(0.05f, effective.bottom, 1e-6f) + } + + @Test + fun sideEnumRoundTripsStoredValues() { + ReaderMarginCropSide.entries.forEach { side -> + assertEquals(side, ReaderMarginCropSide.fromStored(side.storedValue)) + } + assertEquals(null, ReaderMarginCropSide.fromStored("diagonal")) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt index b27ff7ec2..508358558 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt @@ -35,6 +35,58 @@ class ReaderContentPolicyTest { assertFalse(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.CBZ)) } + @Test + fun cropButtonUnlocksOnlyForDocumentFormats() { + assertTrue(supportsDocumentMarginCropButton(ComicFormat.PDF)) + assertTrue(supportsDocumentMarginCropButton(ComicFormat.DJVU)) + // Comics stay locked even though they are raster too. + assertFalse(supportsDocumentMarginCropButton(ComicFormat.CBZ)) + assertFalse(supportsDocumentMarginCropButton(ComicFormat.CBR)) + assertFalse(supportsDocumentMarginCropButton(null)) + } + + @Test + fun effectiveMarginCropRespectsSupportAndEnableFlags() { + // Disabled → no crop even for PDF. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.PDF, + enabled = false, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + // Comics never receive crop even when values are stored. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.CBZ, + enabled = true, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + // Enabled PDF gets the per-side values clamped. + val crop = resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.PDF, + enabled = true, + left = 0.04f, top = 0.5f, right = 0.16f, bottom = 0.02f + ) + assertEquals(0.04f, crop.normalizedLeft, 1e-6f) + assertEquals(0.22f, crop.normalizedTop, 1e-6f) + assertEquals(0.16f, crop.normalizedRight, 1e-6f) + assertEquals(0.02f, crop.normalizedBottom, 1e-6f) + // Text containers never crop. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.TEXT_PAGE, + format = ComicFormat.EPUB, + enabled = true, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + } + @Test fun textFormatsInPageModesResolveToTextPage() { val textFormats = listOf( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt index 16c09f8ad..fc90fac93 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.core.ui.theme.ThemeMode import io.leostrange.mrcomic.core.ui.theme.argbLongToThemeColor import io.leostrange.mrcomic.core.ui.theme.previewColors @@ -117,6 +118,7 @@ internal fun ThemePreviewCard( } val previewCardShape = RoundedCornerShape(20.dp) + MrComicPreviewBackdrop(shape = previewCardShape) { MrComicCardSurface( modifier = Modifier .fillMaxWidth() @@ -241,6 +243,7 @@ internal fun ThemePreviewCard( } } } + } } // ──────────── Theme preset card ──────────── diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt index 0a2d22497..8cc3ebcc8 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.core.ui.library.LibraryBackdropLayer import io.leostrange.mrcomic.core.ui.library.LibraryShelfBar import io.leostrange.mrcomic.core.ui.library.libraryCardElevation @@ -64,6 +65,7 @@ internal fun SelectedLibraryBackgroundPreview( ?: imageUri } + MrComicPreviewBackdrop(shape = RoundedCornerShape(16.dp)) { MrComicCardSurface( modifier = modifier.fillMaxWidth(), cornerRadius = 16.dp, @@ -109,6 +111,7 @@ internal fun SelectedLibraryBackgroundPreview( ) } } + } } } @@ -131,6 +134,7 @@ internal fun LibraryStylePreview( val styleLabel = libraryBackgroundStyleLabel(uiState.libraryBackgroundStyle, uiState.appLanguage) val shelfLabel = libraryShelfStyleLabel(uiState.libraryShelfStyle, uiState.appLanguage) val shape = RoundedCornerShape(22.dp) + MrComicPreviewBackdrop(shape = shape) { MrComicCardSurface( modifier = modifier, fillMaxWidth = false, @@ -241,6 +245,7 @@ internal fun LibraryStylePreview( } } } + } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt index 5027e67cc..3a316a932 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderPreviews.kt @@ -28,6 +28,7 @@ import io.leostrange.mrcomic.core.model.ReaderTapZoneMode import io.leostrange.mrcomic.core.model.resolveReaderTapZoneLayout import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.core.ui.designsystem.MrComicProgressLine import io.leostrange.mrcomic.core.ui.locale.AppStrings import io.leostrange.mrcomic.core.ui.theme.style @@ -80,6 +81,7 @@ internal fun ReaderTextAppearancePreviewCard( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.large) { MrComicCardSurface( modifier = Modifier.heightIn(max = 196.dp), shape = MaterialTheme.shapes.large, @@ -127,6 +129,7 @@ internal fun ReaderTextAppearancePreviewCard( } } } + } Text( text = "${uiState.textFontFamily} · ${readerTextSchemeLabel(strings.languageCode, uiState.textColorScheme)} · ${readerTextLineHeightLabel((uiState.textLineHeight * 100).toInt(), strings.languageCode)}", style = MaterialTheme.typography.bodySmall, @@ -152,6 +155,7 @@ internal fun ReaderPageLayoutPreviewCard( } ) { val previewShape = MaterialTheme.shapes.large + MrComicPreviewBackdrop(shape = previewShape) { MrComicCardSurface( modifier = Modifier .fillMaxWidth() @@ -234,6 +238,7 @@ internal fun ReaderPageLayoutPreviewCard( language = strings.languageCode ) } + } } } } @@ -267,6 +272,7 @@ private fun ReaderImageCropPreview( color = MaterialTheme.colorScheme.primary ) } + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.medium) { Box( modifier = Modifier .fillMaxWidth() @@ -317,6 +323,7 @@ private fun ReaderImageCropPreview( ) } } + } } } @@ -335,6 +342,7 @@ internal fun ReaderHeaderFooterPreviewCard( else -> "Компактный preview колонтитулов" } ) { + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.large) { MrComicCardSurface( modifier = Modifier .fillMaxWidth() @@ -405,6 +413,7 @@ internal fun ReaderHeaderFooterPreviewCard( } } } + } } } @@ -431,6 +440,7 @@ internal fun ReaderPagingPreviewCard( else -> "Preview зон листания" } ) { + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.large) { Row( modifier = Modifier .fillMaxWidth() @@ -463,6 +473,7 @@ internal fun ReaderPagingPreviewCard( } } } + } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt index 82dfd7724..2566d2dd8 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt @@ -493,17 +493,19 @@ internal fun SettingsUiStateFlowBuilder.createCombinedSettingsUiState(): Flow state.copy(readerImageScaleMode = imageScaleMode) }.combine( - preferences.get( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, - 0f - ).map { it.coerceIn(0f, 0.22f) } + combine( + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0f) + ) { left, right, legacy -> resolveMarginCropDisplay(left, right, legacy) } ) { state: SettingsUiState, horizontalCrop: Float -> state.copy(readerImageMarginCropHorizontal = horizontalCrop) }.combine( - preferences.get( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, - 0f - ).map { it.coerceIn(0f, 0.22f) } + combine( + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0f) + ) { top, bottom, legacy -> resolveMarginCropDisplay(top, bottom, legacy) } ) { state: SettingsUiState, verticalCrop: Float -> state.copy(readerImageMarginCropVertical = verticalCrop) }.combine( @@ -561,4 +563,16 @@ internal fun SettingsUiStateFlowBuilder.createCombinedSettingsUiState(): Flow= 0f && second >= 0f -> ((first + second) / 2f).coerceIn(0f, 0.22f) + first >= 0f -> first.coerceIn(0f, 0.22f) + second >= 0f -> second.coerceIn(0f, 0.22f) + else -> legacy.coerceIn(0f, 0.22f) +} + diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt index 975c6dc6c..3f7d69dbc 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt @@ -92,19 +92,25 @@ internal fun SettingsSettersController.setReaderImageScaleMode(mode: String) { internal fun SettingsSettersController.setReaderImageMarginCropHorizontal(value: Float) { setSlider("readerImageMarginCropHorizontal") { - preferences.set( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, - value.coerceIn(0f, 0.22f) - ) + val safe = value.coerceIn(0f, 0.22f) + // Symmetric pair write: the reader stores per-side values. + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, safe) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, safe) + // Moving a crop slider in settings implies wanting the crop active. + if (safe > 0f) { + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, true) + } } } internal fun SettingsSettersController.setReaderImageMarginCropVertical(value: Float) { setSlider("readerImageMarginCropVertical") { - preferences.set( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, - value.coerceIn(0f, 0.22f) - ) + val safe = value.coerceIn(0f, 0.22f) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, safe) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, safe) + if (safe > 0f) { + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, true) + } } } From d16d8a8e372910f714fd5f9f065ce8901b5a88dd Mon Sep 17 00:00:00 2001 From: Leostrange Date: Sun, 30 Aug 2026 18:40:20 +0700 Subject: [PATCH 17/17] fix: restore reader geometry and compact crop controls --- Mr.Comic-debug.apk | 4 +- .../reader/ui/ReaderMarginCropDialog.kt | 83 ++++++++++--------- .../reader/ui/ReaderMarginCropLayoutPolicy.kt | 14 ++++ .../reader/ui/ReaderPageImageScalePolicy.kt | 20 +++++ .../mrcomic/feature/reader/ui/ReaderScreen.kt | 14 ++-- .../feature/reader/ui/ReaderStyleTab.kt | 43 ---------- .../ui/ReaderMarginCropLayoutPolicyTest.kt | 24 ++++++ .../ui/ReaderPageImageScalePolicyTest.kt | 33 ++++++++ .../settings/ui/SettingsReaderLayoutCards.kt | 27 +----- .../settings/ui/SettingsReaderSection.kt | 5 -- 10 files changed, 145 insertions(+), 122 deletions(-) create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt create mode 100644 android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt create mode 100644 android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt create mode 100644 android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt diff --git a/Mr.Comic-debug.apk b/Mr.Comic-debug.apk index df347b89c..0e06de401 100644 --- a/Mr.Comic-debug.apk +++ b/Mr.Comic-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e036c03efc4dc6afa6e64745dbcbe4a4229ca4f9cb350d9138e8381a936d8ecd -size 299336554 +oid sha256:cb153f05cd2fa2c5a6fdbb3139acfb9ead81c1d2e062875bce1631c22f1b152b +size 298314813 diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt index 9d40d51c8..7ed7f8655 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.reader.ui import android.graphics.Bitmap +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -44,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import io.leostrange.mrcomic.core.ui.locale.LocalStrings @@ -72,6 +74,8 @@ internal fun ReaderMarginCropDialog( val controller = viewModel.settingsController val scope = rememberCoroutineScope() var autoRunning by remember { mutableStateOf(false) } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val layout = readerMarginCropLayout(isLandscape) Dialog( onDismissRequest = onDismiss, @@ -80,13 +84,13 @@ internal fun ReaderMarginCropDialog( Surface( modifier = Modifier .padding(horizontal = 20.dp) - .fillMaxWidth() - .widthIn(max = 420.dp), + .fillMaxWidth(layout.widthFraction) + .widthIn(max = if (isLandscape) 820.dp else 420.dp), shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), tonalElevation = 3.dp ) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp)) { + Column(modifier = Modifier.padding(horizontal = 18.dp, vertical = layout.verticalPaddingDp.dp)) { // ── Header ──────────────────────────────────────────────── Row(verticalAlignment = Alignment.CenterVertically) { Box( @@ -175,42 +179,47 @@ internal fun ReaderMarginCropDialog( } // ── Sides ───────────────────────────────────────────────── - Column( - modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha) - ) { - MarginCropSideRow( - title = readerMarginCropSideTop(language), - value = uiState.marginCropTop, - onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.TOP.storedValue, it) } - ) - MarginCropSideRow( - title = readerMarginCropSideBottom(language), - value = uiState.marginCropBottom, - onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.BOTTOM.storedValue, it) } - ) - MarginCropSideRow( - title = readerMarginCropSideLeft(language), - value = uiState.marginCropLeft, - onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.LEFT.storedValue, it) } - ) - MarginCropSideRow( - title = readerMarginCropSideRight(language), - value = uiState.marginCropRight, - onValueChange = { controller.setMarginCropSide(ReaderMarginCropSide.RIGHT.storedValue, it) } - ) + val sides = listOf( + Triple(readerMarginCropSideTop(language), uiState.marginCropTop, ReaderMarginCropSide.TOP), + Triple(readerMarginCropSideBottom(language), uiState.marginCropBottom, ReaderMarginCropSide.BOTTOM), + Triple(readerMarginCropSideLeft(language), uiState.marginCropLeft, ReaderMarginCropSide.LEFT), + Triple(readerMarginCropSideRight(language), uiState.marginCropRight, ReaderMarginCropSide.RIGHT), + ) + if (layout.sideColumns == 2) { + Row( + modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + listOf(sides.take(2), sides.drop(2)).forEach { columnSides -> + Column(modifier = Modifier.weight(1f)) { + columnSides.forEach { (title, value, side) -> + MarginCropSideRow(title, value, { controller.setMarginCropSide(side.storedValue, it) }) + } + } + } + } + } else { + Column(modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha)) { + sides.forEach { (title, value, side) -> + MarginCropSideRow(title, value, { controller.setMarginCropSide(side.storedValue, it) }) + } + } } // ── Options ─────────────────────────────────────────────── - SwitchRow( - title = readerMarginCropSymmetric(language), - checked = uiState.marginCropSymmetric, - onCheckedChange = controller::setMarginCropSymmetric - ) - SwitchRow( - title = readerMarginCropShowWarning(language), - checked = uiState.marginCropShowWarning, - onCheckedChange = controller::setMarginCropShowWarning - ) + if (isLandscape) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.weight(1f)) { + SwitchRow(readerMarginCropSymmetric(language), uiState.marginCropSymmetric, controller::setMarginCropSymmetric) + } + Box(Modifier.weight(1f)) { + SwitchRow(readerMarginCropShowWarning(language), uiState.marginCropShowWarning, controller::setMarginCropShowWarning) + } + } + } else { + SwitchRow(readerMarginCropSymmetric(language), uiState.marginCropSymmetric, controller::setMarginCropSymmetric) + SwitchRow(readerMarginCropShowWarning(language), uiState.marginCropShowWarning, controller::setMarginCropShowWarning) + } AnimatedVisibility(visible = uiState.marginCropEnabled && uiState.marginCropShowWarning) { Row( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt new file mode 100644 index 000000000..c4de6e78f --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt @@ -0,0 +1,14 @@ +package io.leostrange.mrcomic.feature.reader.ui + +internal data class ReaderMarginCropLayout( + val sideColumns: Int, + val widthFraction: Float, + val verticalPaddingDp: Float, +) + +internal fun readerMarginCropLayout(isLandscape: Boolean): ReaderMarginCropLayout = + if (isLandscape) { + ReaderMarginCropLayout(sideColumns = 2, widthFraction = 0.66f, verticalPaddingDp = 8f) + } else { + ReaderMarginCropLayout(sideColumns = 1, widthFraction = 0.86f, verticalPaddingDp = 12f) + } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt new file mode 100644 index 000000000..c43ac00ae --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt @@ -0,0 +1,20 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.ReaderImageScaleMode +import io.leostrange.mrcomic.core.model.ReadingMode + +internal fun resolvePageImageScaleMode( + format: ComicFormat?, + readingMode: ReadingMode, + requestedMode: String, +): String { + val isDocument = format == ComicFormat.PDF || format == ComicFormat.DJVU + if (isDocument && readingMode == ReadingMode.DUAL_PAGE) { + return ReaderImageScaleMode.FIT_HEIGHT.storedValue + } + if (format == ComicFormat.DJVU && requestedMode == ReaderImageScaleMode.FIT_WIDTH.storedValue) { + return ReaderImageScaleMode.FIT_HEIGHT.storedValue + } + return requestedMode +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt index eb35bc622..90c1df127 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt @@ -91,15 +91,11 @@ fun ReaderScreen( right = uiState.marginCropRight, bottom = uiState.marginCropBottom ) - val effectivePageImageScaleMode = - if ( - uiState.comic?.format == ComicFormat.DJVU && - uiState.imageScaleMode == ReaderImageScaleMode.FIT_WIDTH.storedValue - ) { - ReaderImageScaleMode.FIT_HEIGHT.storedValue - } else { - uiState.imageScaleMode - } + val effectivePageImageScaleMode = resolvePageImageScaleMode( + format = uiState.comic?.format, + readingMode = uiState.readingMode, + requestedMode = uiState.imageScaleMode, + ) val supportsLandscapeSpread = !isTextReader && isLandscape && configuration.screenWidthDp >= 600 val activeReaderPreset = remember(uiState.readerPreset) { ReadingPreset.fromStored(uiState.readerPreset) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index e770d0b04..246e4a02c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -78,9 +78,6 @@ internal fun ReaderStyleTab( // Text-reader controls keep the style entry in their own tab. .filterNot { isTextReader && it == ReaderChromeButton.STYLE } } - val supportsMarginCrop = remember(uiState.readerContainerKind, uiState.comic?.format, isTextReader) { - supportsImageMarginCrop(uiState.readerContainerKind, uiState.comic?.format) - } val availableFonts = remember(context, fontCatalogVersion) { ReaderTextFontCatalog.availableFontFamilies(context) } @@ -210,46 +207,6 @@ internal fun ReaderStyleTab( } } } - if (supportsMarginCrop) { - item { - ReaderGraphicCropPreview( - leftCrop = uiState.marginCropLeft, - topCrop = uiState.marginCropTop, - rightCrop = uiState.marginCropRight, - bottomCrop = uiState.marginCropBottom, - language = strings.languageCode - ) - } - item { - Text( - text = readerMarginCropHint(strings.languageCode), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - item { - val horizontalCrop = (uiState.marginCropLeft + uiState.marginCropRight) / 2f - ReaderSliderRow( - title = readerMarginCropHorizontalLabel(horizontalCrop, strings.languageCode), - valueText = "${(horizontalCrop * 100f).toInt()}%", - value = horizontalCrop, - valueRange = 0f..0.18f, - steps = 17, - onValueChange = onImageMarginCropHorizontalChange - ) - } - item { - val verticalCrop = (uiState.marginCropTop + uiState.marginCropBottom) / 2f - ReaderSliderRow( - title = readerMarginCropVerticalLabel(verticalCrop, strings.languageCode), - valueText = "${(verticalCrop * 100f).toInt()}%", - value = verticalCrop, - valueRange = 0f..0.18f, - steps = 17, - onValueChange = onImageMarginCropVerticalChange - ) - } - } item { ReaderSectionTitle(readerText.colorSchemeTitle) } item { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt new file mode 100644 index 000000000..3398e2203 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt @@ -0,0 +1,24 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderMarginCropLayoutPolicyTest { + @Test + fun landscapeUsesTwoColumnsAndKeepsWidePageMargins() { + val layout = readerMarginCropLayout(isLandscape = true) + + assertEquals(2, layout.sideColumns) + assertEquals(0.66f, layout.widthFraction, 0.001f) + assertEquals(8f, layout.verticalPaddingDp, 0.001f) + } + + @Test + fun portraitRemainsSingleColumnButMoreCompact() { + val layout = readerMarginCropLayout(isLandscape = false) + + assertEquals(1, layout.sideColumns) + assertEquals(0.86f, layout.widthFraction, 0.001f) + assertEquals(12f, layout.verticalPaddingDp, 0.001f) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt new file mode 100644 index 000000000..28ae1f007 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt @@ -0,0 +1,33 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.ReaderImageScaleMode +import io.leostrange.mrcomic.core.model.ReadingMode +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderPageImageScalePolicyTest { + @Test + fun documentSpreadAlwaysFitsAvailableHeight() { + assertEquals( + ReaderImageScaleMode.FIT_HEIGHT.storedValue, + resolvePageImageScaleMode( + format = ComicFormat.PDF, + readingMode = ReadingMode.DUAL_PAGE, + requestedMode = ReaderImageScaleMode.FIT_WIDTH.storedValue, + ) + ) + } + + @Test + fun singlePdfPageKeepsRequestedScale() { + assertEquals( + ReaderImageScaleMode.FIT_WIDTH.storedValue, + resolvePageImageScaleMode( + format = ComicFormat.PDF, + readingMode = ReadingMode.PAGE_LTR, + requestedMode = ReaderImageScaleMode.FIT_WIDTH.storedValue, + ) + ) + } +} diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt index 7cd80abc2..b234ad407 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt @@ -473,14 +473,7 @@ internal fun ReaderImageLayoutCard( language: String, viewModel: SettingsViewModel ) { - SettingsCard(title = readerImageLayoutCardTitle(language)) { - Text( - readerImageLayoutCardHint(language), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) - LabelText(readerImageScaleModeTitle(language)) + SettingsCard(title = readerImageScaleModeTitle(language)) { ChipRow { ReaderImageScaleMode.entries.forEach { mode -> MrComicFilterChip( @@ -490,24 +483,6 @@ internal fun ReaderImageLayoutCard( ) } } - Spacer(Modifier.height(6.dp)) - SettingsSliderTile( - title = readerMarginCropHorizontalTitle(language), - valueLabel = readerMarginCropPercentLabel(uiState.readerImageMarginCropHorizontal), - value = uiState.readerImageMarginCropHorizontal, - onValueChange = viewModel::setReaderImageMarginCropHorizontal, - valueRange = 0f..0.22f, - steps = 10 - ) - Spacer(Modifier.height(8.dp)) - SettingsSliderTile( - title = readerMarginCropVerticalTitle(language), - valueLabel = readerMarginCropPercentLabel(uiState.readerImageMarginCropVertical), - value = uiState.readerImageMarginCropVertical, - onValueChange = viewModel::setReaderImageMarginCropVertical, - valueRange = 0f..0.22f, - steps = 10 - ) } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt index 0a2971635..e9f5ee4eb 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt @@ -126,11 +126,6 @@ internal fun ReaderSection( } } ReaderSettingsPage.PAGE_LAYOUT -> { - stickyHeader(key = "reader_page_layout_preview") { - Box(modifier = Modifier.padding(bottom = 10.dp)) { - ReaderPageLayoutPreviewCard(uiState = uiState, strings = strings) - } - } item { ReaderModeCard(uiState = uiState, strings = strings, viewModel = viewModel) }