Skip to content

refactor: load what's-new content from per-version JSON files#497

Merged
rainxchzed merged 8 commits into
mainfrom
feat/whats-new-localized-files
May 3, 2026
Merged

refactor: load what's-new content from per-version JSON files#497
rainxchzed merged 8 commits into
mainfrom
feat/whats-new-localized-files

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 3, 2026

Follow-up to #496.

Why

The hardcoded WhatsNewEntries object grew with every release and offered no path for community translators to contribute. With one entry per release, the file would balloon over time; localization required either Kotlin churn from translators or shipping English-only forever.

This PR moves the content out of code and into per-version JSON files under Compose Resources, with locale-qualified directories that let translators contribute incrementally without ever touching Kotlin.

What changed

  • WhatsNewLoader interface in core/domain + WhatsNewLoaderImpl in composeApp/.../whatsnew/ reads files/whatsnew/<versionCode>.json via Res.readBytes. Compose Resources resolves the locale-qualified variant first (files-zh-rCN/whatsnew/<vc>.json) and falls back to the default English file when a translation is missing.
  • WhatsNewEntryDto + toDomain() mapper in core/data keep the domain model framework-free.
  • KnownWhatsNewVersionCodes.ALL in WhatsNewLoaderImpl.kt enumerates the known versions β€” one line touched per release. Picked over an index.json for type safety + zero extra parsing.
  • 1.8.0 (15) and 1.8.1 (16) content ported verbatim into core/presentation/.../composeResources/files/whatsnew/15.json and 16.json.
  • WhatsNewViewModel now consumes the loader; exposes historyEntries: StateFlow<List<WhatsNewEntry>> and hasHistory: StateFlow<Boolean> so consumers can observe load state without blocking on suspend calls.
  • WhatsNewHistoryScreen takes entries: List<WhatsNewEntry> as a parameter β€” the AppNavigation route collects from the activity-scoped WhatsNewViewModel and passes them in, with the existing empty-state covering the brief loading window.
  • The hardcoded WhatsNewEntries object is deleted.
  • Per-release authoring + translator workflow documented in core/presentation/.../composeResources/files/whatsnew/README.md.

Per-release author workflow (after this PR)

  1. Add core/presentation/src/commonMain/composeResources/files/whatsnew/<versionCode>.json.
  2. Append the new versionCode to KnownWhatsNewVersionCodes.ALL.

Translator workflow

  1. Copy files/whatsnew/<versionCode>.json to files-<locale>/whatsnew/<versionCode>.json.
  2. Translate bullet text; leave structural fields (versionCode, versionName, releaseDate, showAsSheet, section type) untouched.
  3. PR. English remains the fallback for any version the locale has not translated yet.

Test plan

  • Cold start with no DataStore record: loader resolves 16.json, evaluator hides sheet on fresh install (existing behavior preserved).
  • Long-press Profile β†’ "What's new" β†’ sheet preview renders 1.8.1 content from 16.json.
  • Tap Profile β†’ "What's new" β†’ history screen renders 1.8.1 then 1.8.0, both loaded from JSON via the loader.
  • Drop a malformed whatsnew/<vc>.json β†’ loader logs the parse failure and silently skips that version, app does not crash.
  • Switch device locale to one without a translation override β†’ English fallback content renders unchanged.

Summary by CodeRabbit

Release Notes

  • New Features
    • Introduced a "What's New" feature that displays detailed release notes and changelogs for each app version. The feature includes full localization support across multiple languages, ensuring release notes are always available in your preferred language. Users can browse current version updates or review previous release notes through the dedicated history screen.

rainxchzed added 8 commits May 3, 2026 20:33
…nager

Compose Resources doesn't extend qualifier-folder resolution to its raw
'files' directory, so localized variants now live as plain subfolders
(files/whatsnew/<locale>/<vc>.json) and the loader walks the candidates
itself: full BCP-47 code, primary language code, then the English file
at files/whatsnew/<vc>.json. The current locale comes from
LocalizationManager so the in-app language override picks the right
translation, not just the OS locale.
Adds files/whatsnew/<locale>/{15,16}.json for ar, bn, es, fr, hi, it,
ja, ko, pl, ru, tr, zh-CN. Brand and platform names (GitHub, APK,
Shizuku, OAuth, Linux, macOS, Compose Multiplatform, deb, rpm,
AppImage, Personal Access Token, SHA-256) are preserved untranslated.
Native speakers are welcome to refine any locale via follow-up PRs;
English remains the fallback for any locale that has not translated a
given release.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Walkthrough

This PR refactors the "What's New" feature from hardcoded data to a resource-driven system. It replaces a static WhatsNewEntries object with a new WhatsNewLoader interface and WhatsNewLoaderImpl that loads localized JSON release notes from resource files. The ViewModel, navigation, DI, and UI are updated to use this new loader, and the feature now supports multi-language release notes.

Changes

What's New: Resource-Driven Architecture

Layer / File(s) Summary
Dependencies & Data Shape
composeApp/build.gradle.kts, core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/WhatsNewEntryDto.kt
Added kotlinx.serialization.json dependency. Introduced WhatsNewEntryDto and WhatsNewSectionDto data classes with @Serializable and @SerialName annotations for JSON deserialization.
Data Mapping
core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/WhatsNewEntryMapper.kt
Added extension mappers WhatsNewEntryDto.toDomain() and WhatsNewSectionDto.toDomain() to convert data-layer DTOs to domain models, including section type normalization.
Domain Interface
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt
Introduced WhatsNewLoader interface with suspend fun loadAll() and suspend fun forVersionCode(versionCode: Int) methods.
Core Loader Implementation
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt
Implemented WhatsNewLoaderImpl with localization-aware JSON resource loading. Includes candidatePaths() for locale fallback (full locale, primary locale, English), readEntry() for JSON parsing with error handling, and KnownWhatsNewVersionCodes.ALL = [16, 15].
Localized Resource Files
core/presentation/src/commonMain/composeResources/files/whatsnew/*/*.json, core/presentation/src/commonMain/composeResources/files/whatsnew/README.md
Added versioned JSON release notes for versions 15 (1.8.0) and 16 (1.8.1) across 10 locales (en, ar, bn, es, fr, hi, it, ja, ko, pl, ru, tr, zh-CN). Included documentation on file structure, locale resolution, and contributor workflows.
ViewModel Integration
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt
Updated constructor to accept WhatsNewLoader. Replaced hardcoded data with eager loading during init via whatsNewLoader.loadAll(). Published historyEntries and hasHistory as StateFlows. Updated evaluate() and forceShowLatest() to use loader instead of static helpers.
Navigation & UI Wiring
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt, core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt
Updated WhatsNewHistoryScreen to accept entries: List<WhatsNewEntry> parameter instead of loading internally. Modified AppNavigation to collect and pass historyEntries from ViewModel to the screen.
Main Composition
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
Updated WhatsNewSheet to collect hasHistory as a state instead of calling ViewModel property directly.
Dependency Injection
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/WhatsNewModule.kt, composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt
Created new whatsNewModule that binds WhatsNewLoader as a singleton with WhatsNewLoaderImpl, injecting localizationManager and logger. Added module to initKoin() configuration.
Cleanup
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/WhatsNewEntries.kt
Removed hardcoded WhatsNewEntries object containing static release data for versions 15 and 16.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 From static stones to flowing streams,
The release notes dance in JSON dreams,
Each tongue finds its tale to share,
Locale by locale, with careful care,
The loader hops through fallback paths with flair! ✨

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title 'refactor: load what's-new content from per-version JSON files' is concise, specific, and directly describes the primary structural changeβ€”migrating What's New content from hardcoded Kotlin objects to external per-version JSON files.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/whats-new-localized-files

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt (1)

79-91: ⚑ Quick win

forceShowLatest() redundantly re-reads all JSON files when historyEntries is already cached.

_historyEntries.value is populated during init from the same loadAll() call. When forceShowLatest() is invoked from the UI, _historyEntries will almost certainly be populated, making the fallback loadAll() call unnecessary file I/O. A small guard handles the rare early-call race window:

♻️ Proposed refactor
-                    ?: whatsNewLoader.loadAll().firstOrNull()
+                    ?: _historyEntries.value.firstOrNull()
+                    ?: whatsNewLoader.loadAll().firstOrNull()
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`
around lines 79 - 91, forceShowLatest() currently calls whatsNewLoader.loadAll()
even though the cache _historyEntries.value is populated in init; change the
logic in the viewModelScope.launch block to first consult _historyEntries.value
(or its backed property) and use whatsNewLoader.forVersionCode(current) against
that cached list, falling back to calling whatsNewLoader.loadAll() only if the
cached _historyEntries is null or empty to avoid redundant file I/O; update
references in the try block around whatsNewLoader.forVersionCode(current),
_historyEntries, and _pendingEntry to use the cached entries first and keep the
existing catch behavior.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/WhatsNewEntryMapper.kt`:
- Around line 18-22: The current WhatsNewSectionDto.toDomain() uses
WhatsNewSectionType.valueOf(...) which throws on unknown names and can cause
entire entries to be dropped; change WhatsNewSectionDto.toDomain() to return a
nullable WhatsNewSection? and perform a safe lookup like
WhatsNewSectionType.entries.find { it.name.equals(type.trim(), ignoreCase =
true) } (or similar) to resolve the enum; if the lookup returns null, return
null from toDomain() so callers can filter out only the malformed section
instead of the whole entry; update any call sites of
WhatsNewSectionDto.toDomain() to filterNulls()/remove nulls accordingly when
building WhatsNewSection lists.

---

Nitpick comments:
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`:
- Around line 79-91: forceShowLatest() currently calls whatsNewLoader.loadAll()
even though the cache _historyEntries.value is populated in init; change the
logic in the viewModelScope.launch block to first consult _historyEntries.value
(or its backed property) and use whatsNewLoader.forVersionCode(current) against
that cached list, falling back to calling whatsNewLoader.loadAll() only if the
cached _historyEntries is null or empty to avoid redundant file I/O; update
references in the try block around whatsNewLoader.forVersionCode(current),
_historyEntries, and _pendingEntry to use the cached entries first and keep the
existing catch behavior.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d77e59ea-9693-48de-8e9b-4d7cc6ba599e

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 8f3c6da and 1bc5520.

πŸ“’ Files selected for processing (39)
  • composeApp/build.gradle.kts
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/WhatsNewModule.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/WhatsNewEntryDto.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/WhatsNewEntryMapper.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/WhatsNewEntries.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/README.md
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/15.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt
πŸ’€ Files with no reviewable changes (1)
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/WhatsNewEntries.kt

Comment on lines +18 to +22
fun WhatsNewSectionDto.toDomain(): WhatsNewSection =
WhatsNewSection(
type = WhatsNewSectionType.valueOf(type.trim().uppercase()),
bullets = bullets,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the file exists and check its package declaration
find . -name "WhatsNewEntryMapper.kt" -type f

Repository: OpenHub-Store/GitHub-Store

Length of output: 157


🏁 Script executed:

# Also look for the WhatsNewSectionType enum
find . -name "*.kt" -type f | xargs grep -l "enum.*WhatsNewSectionType"

Repository: OpenHub-Store/GitHub-Store

Length of output: 153


🏁 Script executed:

# Check the directory structure to verify source set organization
find . -path "*/core/data/src/*" -type d | head -20

Repository: OpenHub-Store/GitHub-Store

Length of output: 1155


🏁 Script executed:

cat -n ./core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/WhatsNewEntryMapper.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 969


🏁 Script executed:

cat -n ./core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/WhatsNewEntry.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 627


🏁 Script executed:

# Search for all calls to toDomain() involving WhatsNewSection
rg "toDomain\(\)" -B2 -A2 core/data/src/

Repository: OpenHub-Store/GitHub-Store

Length of output: 15884


🏁 Script executed:

# Search for WhatsNewLoaderImpl and loadOrNull
rg -l "WhatsNewLoaderImpl|loadOrNull" --type kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 97


🏁 Script executed:

# Find JSON files related to WhatsNew
find . -name "*.json" -type f | xargs grep -l "WhatsNew\|whatsnew" 2>/dev/null | head -10

Repository: OpenHub-Store/GitHub-Store

Length of output: 52


🏁 Script executed:

# Search for test files related to WhatsNew
find . -path "*/test*" -name "*WhatsNew*.kt" -type f

Repository: OpenHub-Store/GitHub-Store

Length of output: 52


🏁 Script executed:

# Search for WhatsNewLoaderImpl without type filter
rg "WhatsNewLoaderImpl|WhatsNewLoader" -l

Repository: OpenHub-Store/GitHub-Store

Length of output: 497


🏁 Script executed:

# Find where DTOs are loaded/parsed
rg "WhatsNewSectionDto|WhatsNewEntryDto" -B3 -A3 | head -80

Repository: OpenHub-Store/GitHub-Store

Length of output: 6029


🏁 Script executed:

# Look for exception handling around valueOf or enum parsing
rg "catch|try.*valueOf" core/data/src/

Repository: OpenHub-Store/GitHub-Store

Length of output: 19237


🏁 Script executed:

# Read the WhatsNew README
cat ./core/presentation/src/commonMain/composeResources/files/whatsnew/README.md

Repository: OpenHub-Store/GitHub-Store

Length of output: 2821


🏁 Script executed:

# Look at WhatsNewLoaderImpl more thoroughly
cat -n ./composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 2810


🏁 Script executed:

# Check if there are actual JSON files for whatsnew
find . -path "*/whatsnew/*" -name "*.json" -type f

Repository: OpenHub-Store/GitHub-Store

Length of output: 2098


🏁 Script executed:

# Quick verification of the exact flow - confirm that a bad section type at line 20 
# would propagate and be caught by WhatsNewLoaderImpl
cat -n ./composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt | grep -A5 "readEntry"

Repository: OpenHub-Store/GitHub-Store

Length of output: 566


valueOf() throws on any unrecognized section type, silently dropping entire entries.

valueOf() throws an IllegalArgumentException on JVM if the enum constant is not found; on JavaScript and Kotlin/Native, the exception type may differ (IllegalStateException or plain Exception), making a broad catch at the loader level unreliable across Compose Multiplatform targets.

Since JSON files are community-translated (12+ locales documented in the README), any typo or mistake in a type field will cause readEntry() to catch the exception and return null, losing the entire release entry instead of just the malformed section.

Replace valueOf with a safe lookup using WhatsNewSectionType.entries.find, and make toDomain() nullable so callers can filter unknown sections without losing the whole entry:

πŸ›‘οΈ Proposed fix
-fun WhatsNewSectionDto.toDomain(): WhatsNewSection =
+fun WhatsNewSectionDto.toDomain(): WhatsNewSection? {
+    val sectionType = WhatsNewSectionType.entries
+        .find { it.name == type.trim().uppercase() }
+        ?: return null
     WhatsNewSection(
-        type = WhatsNewSectionType.valueOf(type.trim().uppercase()),
+        type = sectionType,
         bullets = bullets,
     )
+}

Update the entry mapper to filter out unknown sections:

 sections = sections.map { it.toDomain() },
+sections = sections.mapNotNull { it.toDomain() },
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/WhatsNewEntryMapper.kt`
around lines 18 - 22, The current WhatsNewSectionDto.toDomain() uses
WhatsNewSectionType.valueOf(...) which throws on unknown names and can cause
entire entries to be dropped; change WhatsNewSectionDto.toDomain() to return a
nullable WhatsNewSection? and perform a safe lookup like
WhatsNewSectionType.entries.find { it.name.equals(type.trim(), ignoreCase =
true) } (or similar) to resolve the enum; if the lookup returns null, return
null from toDomain() so callers can filter out only the malformed section
instead of the whole entry; update any call sites of
WhatsNewSectionDto.toDomain() to filterNulls()/remove nulls accordingly when
building WhatsNewSection lists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant