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|hr)>)""",
+ """<(?:mbp:pagebreak|pagebreak)\b[^>]*(?:/?>|>.*?(?:mbp:pagebreak|pagebreak)>)|
]*(?: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 "]*(?:/?>|>.*?(?:mbp:pagebreak|pagebreak)>)"""),
- """
"""
+ ""
)
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 = """
@@ -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 @@
-
-
+
+
@@ -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
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%0A_=a#T64x>QXKCd^D!
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