Skip to content

feat: in-app announcements feed for cross-version user communication#498

Merged
rainxchzed merged 25 commits into
mainfrom
feat/announcements-feed
May 3, 2026
Merged

feat: in-app announcements feed for cross-version user communication#498
rainxchzed merged 25 commits into
mainfrom
feat/announcements-feed

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 3, 2026

Adds an in-app announcements channel for messages that don't fit a release: privacy-policy changes, surveys, initiative endorsements, security advisories, backend-status notices.

Built privacy-first: no per-user identifier sent, single anonymous endpoint, dismissal/acknowledgment recorded only on device. Same payload returned to every caller.

Client (this PR)

  • Domain layer. Announcement, AnnouncementSeverity (INFO / IMPORTANT / CRITICAL), AnnouncementCategory (NEWS / PRIVACY / SURVEY / SECURITY / STATUS), AnnouncementsRepository interface, AnnouncementsFeedSnapshot value type.
  • Data layer. AnnouncementDto + AnnouncementsResponseDto + locale variants (AnnouncementLocaleDto). Mapper handles locale resolution chain (full → primary → English fallback). AnnouncementsRepositoryImpl fetches from backend, caches in DataStore, applies client-side filtering (expired items, version range, platform). Backend endpoint added to existing BackendApiClient as getAnnouncements().
  • Persistence. New DataStore keys: announcements_dismissed_ids, announcements_acknowledged_ids, announcements_muted_categories, announcements_cached_payload, announcements_last_fetched_at. Acknowledged-ID set kept locally only, never transmitted.
  • UI.
    • AnnouncementsRoot — inbox screen reachable from Profile. LazyColumn of cards, mute-settings overflow.
    • AnnouncementCard — severity-coded border bar, category chip, expandable body, CTA + dismiss/acknowledge actions.
    • CriticalAnnouncementModalAlertDialog for severity = CRITICAL + requiresAcknowledgment, non-dismissible until tap.
    • MuteSettingsBottomSheet — toggle categories on/off; PRIVACY and SECURITY locked on.
  • Wiring. AnnouncementsScreen route in GithubStoreGraph. Critical modal hosted in Main.kt next to existing what's-new sheet, using the same gating (only on Home, AuthState settled, no rate-limit dialog active, 600ms debounce). Profile gets a new "Announcements" entry below "What's new".
  • i18n. Embedded i18n: Map<locale, variant> per item — single fetch covers all locales; switching app language re-resolves without refetch. English is mandatory fallback.

Backend (separate work — implementation spec attached)

docs/backend/announcements-endpoint.md is the full contract for the backend agent: endpoint shape, item schema, validation rules, storage recommendation, privacy guardrails, example payloads, and a per-announcement authoring rubric. Self-contained; no need to read the rest of this PR to implement.

Until the backend endpoint exists, the client gracefully no-ops: refresh fails silently → cached payload (empty initially) renders → inbox shows the empty state.

What's deliberately NOT in this PR (deferred to phase 2)

  • Bottom-nav Profile-tab dot for unread count. The existing Apps-tab dot is already a union of "updates available" + "import candidates"; adding announcements to that union would fire the dot on the wrong tab. Per-tab badge support is a separate, larger refactor — deferred so the existing dot semantic stays clean.
  • Pull-to-refresh on the inbox.
  • Aggregate impression counters (server-side only when added; never per-user).
  • Long-press preview of an announcement on the Profile entry.

Test plan

Without the backend live:

  • Open app → Profile → tap "Announcements" → empty-state renders without crash.
  • Toggle categories in the mute sheet → persists across restart.
  • Force-stop the app, then reopen — empty state still renders, no crash from the failed background refresh.

Once backend ships an empty items: [] payload:

  • Inbox renders empty state, "Last refreshed" caption updates.

Once backend ships at least one INFO item:

  • Item renders in inbox with category chip + severity icon.
  • Dismiss works and persists across restart.
  • CTA button opens external URL.

Once backend ships an IMPORTANT item with requiresAcknowledgment: true:

  • Item shows "I've read this" instead of "Dismiss".
  • After acknowledgment, item shows the "Acknowledged" checkmark.

Once backend ships a CRITICAL item with requiresAcknowledgment: true:

  • Modal auto-rises after Home settles + 600ms debounce.
  • Modal does NOT show on top of OAuth flow / rate-limit dialog / what's-new sheet.
  • After tapping "I've read this", modal disappears and does not re-show on next launch.
  • Backdrop tap and back-press do not dismiss the critical modal.

Files of note

  • docs/backend/announcements-endpoint.md — backend contract.
  • core/domain/.../model/Announcement.kt, AnnouncementSeverity.kt, AnnouncementCategory.kt — domain.
  • core/domain/.../repository/AnnouncementsRepository.kt — interface + AnnouncementsFeedSnapshot.
  • core/data/.../dto/AnnouncementDto.kt, mappers/AnnouncementMapper.kt — wire format + locale resolution.
  • core/data/.../repository/AnnouncementsRepositoryImpl.kt — fetch, cache, filter.
  • core/data/.../network/BackendApiClient.kt — added getAnnouncements().
  • core/presentation/.../components/announcements/ — all composables.
  • composeApp/.../app/announcements/AnnouncementsViewModel.kt — state + actions.
  • composeApp/.../Main.kt — critical modal host.
  • feature/profile/.../components/sections/Options.kt — Profile entry.

Summary by CodeRabbit

  • New Features

    • Announcements system to deliver updates, security notices, and surveys.
    • Home-screen critical announcement modal (non-dismissible until acknowledged).
    • Announcements screen accessible from Profile (tap to open; long-press previews sample items).
    • Announcement cards with severity visuals, CTAs, dismiss/acknowledge actions, and unread counts.
    • Pull-to-refresh with refresh-failed indicator; Mute Settings sheet with locked categories.
    • Localized strings and platform persistence for announcements.
  • Documentation

    • Backend announcements endpoint spec and authoring guidelines added.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a complete Announcements feature: domain models, DTOs, mappers, repository + cache, tweaks persistence, network fetch, view-model, UI (list, critical modal, mute sheet), Profile navigation/wiring, DI and platform datastores, and backend endpoint documentation.

Changes

Announcements Feature

Layer / File(s) Summary
Domain Models
core/domain/.../model/Announcement.kt, .../AnnouncementCategory.kt, .../AnnouncementSeverity.kt
Adds Announcement data class, AnnouncementIconHint, AnnouncementCategory (with isMutable) and AnnouncementSeverity.
Repository Contract
core/domain/.../repository/AnnouncementsRepository.kt, .../TweaksRepository.kt
Adds AnnouncementsRepository and AnnouncementsFeedSnapshot; extends TweaksRepository with flows/setters for dismissed/acknowledged IDs, muted categories, and last-fetched timestamp.
Cache Store Interface
core/domain/.../repository/AnnouncementsCacheStore.kt
Introduces AnnouncementsCacheStore interface for cached payload observation and update.
Network DTOs
core/data/.../dto/AnnouncementDto.kt, core/data/.../network/BackendApiClient.kt
Adds AnnouncementsResponseDto, AnnouncementDto, AnnouncementLocaleDto and BackendApiClient.getAnnouncements() to fetch the announcements envelope.
Mappers
core/data/.../mappers/AnnouncementMapper.kt
Adds AnnouncementDto.toDomain(fullLocale, primaryLocale) with locale fallback, instant parsing, and parsing helpers.
Repository Implementation
core/data/.../repository/AnnouncementsRepositoryImpl.kt, .../AnnouncementsCacheStoreImpl.kt, .../TweaksRepositoryImpl.kt
Implements feed observation combining cached payload + tweaks state, parsing/filtering (expiry, version bounds, platform/installer), refresh/caching, and tweak-backed persistence (dismiss/ack/muted IDs, lastFetchedAt).
Platform Datastores
core/data/.../local/data_store/createAnnouncementsDataStore.*, .../PlatformModule.*, .../announcementsDataStoreFile.kt
Adds per-platform createAnnouncementsDataStore() factories and registers a qualified DataStore<Preferences> named \"announcements\".
DI Registrations
core/data/.../di/SharedModule.kt, composeApp/.../di/ViewModelsModule.kt
Registers AnnouncementsCacheStoreImpl, AnnouncementsRepositoryImpl in core DI and AnnouncementsViewModel in app view-model module.
ViewModel
composeApp/.../announcements/AnnouncementsViewModel.kt
Adds AnnouncementsViewModel exposing feed, displayedItems, unreadCount, pendingCriticalAcknowledgment, preview APIs, and action methods (refresh, dismiss, acknowledge, openCta, setMuted).
Navigation & Wiring
composeApp/.../navigation/GithubStoreGraph.kt, NavigationUtils.kt, AppNavigation.kt
Adds AnnouncementsScreen route, backstack recognition, wires AnnouncementsViewModel in AppNavigation, and composes AnnouncementsRoot with action handlers and profile-triggered preview navigation. Also conditionally shows CriticalAnnouncementModal from App when gating passes.
Profile Integration
feature/profile/.../ProfileAction.kt, ProfileRoot.kt, ProfileViewModel.kt, components/sections/Options.kt
Adds OnAnnouncementsClick action, profile callbacks onNavigateToAnnouncements/onPreviewAnnouncements, updates ProfileRoot/ViewModel wiring, and an Announcements OptionCard.
UI Components
core/presentation/.../AnnouncementCard.kt, AnnouncementsRoot.kt, CriticalAnnouncementModal.kt, MuteSettingsBottomSheet.kt, AnnouncementUiHelpers.kt
Adds AnnouncementCard (expandable, severity styling), AnnouncementsRoot (pull-to-refresh, refreshFailed handling), CriticalAnnouncementModal (assertive non-dismissible dialog), MuteSettingsBottomSheet, and category/severity label helpers.
Strings & Docs
core/presentation/.../values/strings.xml, docs/backend/announcements-endpoint.md
Adds localized strings for announcements UI and a backend contract doc for GET /v1/announcements including schema, validation, examples, and checklist.

Sequence Diagram

sequenceDiagram
    participant User
    participant App as App/HomeScreen
    participant VM as AnnouncementsViewModel
    participant Repo as AnnouncementsRepository
    participant Tweaks as TweaksRepository
    participant Backend as BackendApiClient
    participant UI as AnnouncementsUI

    rect rgba(100,150,255,0.5)
    Note over User,App: Initial load / refresh
    User->>App: launch/navigate
    App->>VM: collect feed / observe pendingCriticalAcknowledgment
    VM->>Repo: observeFeed() / refresh()
    Repo->>Backend: getAnnouncements()
    Backend-->>Repo: AnnouncementsResponseDto
    Repo->>Repo: cache payload via AnnouncementsCacheStore
    Repo->>Tweaks: setAnnouncementsLastFetchedAt()
    Repo-->>VM: AnnouncementsFeedSnapshot
    VM-->>App: pendingCriticalAcknowledgment emitted
    App->>UI: show CriticalAnnouncementModal (if gating passes)
    end

    rect rgba(150,200,100,0.5)
    Note over User,UI: User interactions
    User->>UI: acknowledge/dismiss/mute/open CTA
    UI->>VM: acknowledge/dismiss/setMuted/openCta
    VM->>Repo: acknowledge/dismiss/setMuted
    Repo->>Tweaks: persist changes
    Tweaks-->>Repo: updated flows
    Repo-->>VM: updated snapshot
    VM-->>UI: UI updates
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped through DTOs, mappers, and views,
I planted previews and critical cues,
I cached the feed, and opened the screen,
muted a category, kept the modal serene,
a little rabbit cheers — announcements are seen!

🚥 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 pull request title clearly and specifically summarizes the main feature addition: an in-app announcements system for cross-version user communication. It directly maps to the changeset's primary purpose.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/announcements-feed

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

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: 7

🧹 Nitpick comments (6)
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt (1)

123-125: ⚡ Quick win

Prefer AnnouncementCategory over String in the muted-categories API.

The domain contract leaks the DataStore serialization detail (Set<String>) instead of the typed domain value. If AnnouncementCategory entries are ever renamed, persisted strings silently diverge from the enum with no compiler warning.

The implementation can handle the name/valueOf mapping internally:

♻️ Proposed refactor
-    fun getAnnouncementsMutedCategories(): Flow<Set<String>>
+    fun getAnnouncementsMutedCategories(): Flow<Set<AnnouncementCategory>>

-    suspend fun setAnnouncementCategoryMuted(categoryName: String, muted: Boolean)
+    suspend fun setAnnouncementCategoryMuted(category: AnnouncementCategory, muted: Boolean)

The TweaksRepositoryImpl converts via AnnouncementCategory.valueOf(name) / category.name before reading/writing DataStore, keeping the serialization concern out of the interface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt`
around lines 123 - 125, Change the API to use the domain type instead of raw
strings: update TweaksRepository's method signatures to return
Flow<Set<AnnouncementCategory>> from getAnnouncementsMutedCategories() and
accept category: AnnouncementCategory in setAnnouncementCategoryMuted(category,
muted). Keep DataStore string serialization inside TweaksRepositoryImpl by
converting persisted names via AnnouncementCategory.valueOf(name) when reading
and using category.name when writing so callers never see raw strings; update
any callers/tests to use AnnouncementCategory accordingly.
core/presentation/src/commonMain/composeResources/values/strings.xml (1)

1030-1030: 💤 Low value

announcements_dismiss is an exact duplicate of the existing dismiss string (line 394).

Both resolve to "Dismiss". Reusing the shared key avoids drift if the copy ever changes.

♻️ Proposed fix
-    <string name="announcements_dismiss">Dismiss</string>

Reference @string/dismiss directly in the composable instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/presentation/src/commonMain/composeResources/values/strings.xml` at line
1030, The string resource announcements_dismiss duplicates the existing dismiss
string ("Dismiss"); remove the duplicate <string name="announcements_dismiss">
and update any usage sites (the composable(s) that used announcements_dismiss)
to reference the shared key dismiss instead (e.g., use
stringResource(R.string.dismiss) or `@string/dismiss` as appropriate) so the code
uses the single canonical resource name dismiss and avoids drift.
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Announcement.kt (1)

5-6: Consider using kotlinx.datetime.Instant for expiresAt rather than String.

expiresAt: String? defers date parsing to filter time in isPastIso(), where parsing failures silently allow expired announcements to remain visible (line 117 and 131-134 of AnnouncementsRepositoryImpl.kt). Using kotlinx.datetime.Instant (or Long epoch millis for consistency with getAnnouncementsLastFetchedAt) makes the format contract explicit at the type level and moves parse errors to the mapper boundary where they can be handled deliberately. kotlinx-datetime is already a project dependency (v0.7.1).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Announcement.kt`
around lines 5 - 6, The Announcement data model currently declares expiresAt:
String? which defers parsing and silently permits invalid dates to pass through;
change the type to kotlinx.datetime.Instant? (or Long epoch millis to match
getAnnouncementsLastFetchedAt) on the Announcement class (publishedAt/expiresAt
fields) and update the mapper that constructs Announcement instances so it
parses/raw-converts the incoming string into Instant (handling parse errors
explicitly). Then update consumers such as isPastIso() in
AnnouncementsRepositoryImpl to accept Instant? (or Long) instead of parsing
strings, ensuring parse failures are handled at the mapping boundary rather than
inside isPastIso().
core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt (1)

259-265: ⚡ Quick win

categoryLabel is duplicated verbatim in MuteSettingsBottomSheet.kt.

Both files define an identical private categoryLabel(AnnouncementCategory): StringResource when-expression. If a new AnnouncementCategory entry is added, both copies need updating independently. Extract it to a shared internal file in the package (e.g., AnnouncementUiHelpers.kt).

♻️ Proposed extraction

Create core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementUiHelpers.kt:

package zed.rainxch.core.presentation.components.announcements

import org.jetbrains.compose.resources.StringResource
import zed.rainxch.core.domain.model.AnnouncementCategory
import zed.rainxch.githubstore.core.presentation.res.Res
import zed.rainxch.githubstore.core.presentation.res.announcements_category_news
import zed.rainxch.githubstore.core.presentation.res.announcements_category_privacy
import zed.rainxch.githubstore.core.presentation.res.announcements_category_security
import zed.rainxch.githubstore.core.presentation.res.announcements_category_status
import zed.rainxch.githubstore.core.presentation.res.announcements_category_survey

internal fun categoryLabel(category: AnnouncementCategory): StringResource = when (category) {
    AnnouncementCategory.NEWS -> Res.string.announcements_category_news
    AnnouncementCategory.PRIVACY -> Res.string.announcements_category_privacy
    AnnouncementCategory.SURVEY -> Res.string.announcements_category_survey
    AnnouncementCategory.SECURITY -> Res.string.announcements_category_security
    AnnouncementCategory.STATUS -> Res.string.announcements_category_status
}

Then delete the private copies from both AnnouncementCard.kt and MuteSettingsBottomSheet.kt.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt`
around lines 259 - 265, Duplicate private categoryLabel(category:
AnnouncementCategory) exists in both AnnouncementCard and
MuteSettingsBottomSheet; extract it into a single internal helper function in a
new shared file (e.g., AnnouncementUiHelpers.kt) inside the same package,
implement internal fun categoryLabel(category: AnnouncementCategory):
StringResource = when(...) using the existing mappings
(AnnouncementCategory.NEWS/PRIVACY/SURVEY/SECURITY/STATUS ->
Res.string.announcements_category_*), then replace the private copies in
AnnouncementCard and MuteSettingsBottomSheet with imports/usage of this shared
categoryLabel and remove the duplicate functions; ensure you import
AnnouncementCategory, StringResource and Res.string identifiers in the new
helper.
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt (1)

174-174: 💤 Low value

canShowCritical is an exact duplicate of canShowWhatsNew

Lines 147 and 174 compute the same boolean expression. Reusing the existing variable avoids silent drift if the condition ever changes.

♻️ Proposed refactor
-        val canShowCritical = onHomeScreen && authSettled && rateLimitCleared
+        val canShowCritical = canShowWhatsNew
         val criticalToShow = pendingCritical
🤖 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/Main.kt` at line
174, canShowCritical duplicates the exact boolean expression used for
canShowWhatsNew (onHomeScreen && authSettled && rateLimitCleared); replace the
duplicated expression by reusing canShowWhatsNew (i.e., assign canShowCritical =
canShowWhatsNew or remove canShowCritical and use canShowWhatsNew where needed)
so the logic isn't repeated and can't drift; update references to
canShowCritical accordingly if you choose to remove the variable.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt (1)

37-57: 💤 Low value

lastRefreshFailed is always false — consider tracking actual failure state.

The lastRefreshFailed property in AnnouncementsFeedSnapshot is always set to false, even when refresh() fails. While the PR objectives mention "silent refresh failure," the snapshot never exposes this state to the UI layer.

If you want the UI to optionally show a subtle indicator (e.g., stale-data badge), consider adding a separate flow or state variable to track refresh failures. Otherwise, if silent failure is intentional and the UI should never react to it, you could document this design decision or remove the property from the snapshot.

🤖 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/repository/AnnouncementsRepositoryImpl.kt`
around lines 37 - 57, The snapshot's lastRefreshFailed is hard-coded to false;
update observeFeed to combine in a real "refresh failed" boolean instead of a
literal false by adding a source that reflects refresh() failure (either a new
tweaksRepository.getAnnouncementsLastRefreshFailed() Flow<Boolean> or a
MutableStateFlow<Boolean> updated by the refresh() implementation) and include
that value when constructing AnnouncementsFeedSnapshot in observeFeed; locate
observeFeed and AnnouncementsFeedSnapshot usage and ensure refresh() sets the
failure state on the chosen flow (or resets it to false on success) so the UI
can read the real lastRefreshFailed value.
🤖 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/repository/TweaksRepositoryImpl.kt`:
- Around line 343-356: getAnnouncementsCachedPayload and
setAnnouncementsCachedPayload currently store a large JSON blob under
ANNOUNCEMENTS_CACHED_PAYLOAD_KEY in the shared preferences DataStore
(preferences.data / preferences.edit), which forces full-file rewrites and
deserializations on every preference change; refactor by moving the
announcements cache out of the shared Preferences DataStore into a dedicated
storage (either a separate DataStore<Preferences> instance scoped to
announcements or a file-based cache using okio/FileSystem), update
getAnnouncementsCachedPayload to read from that new store and
setAnnouncementsCachedPayload to write/remove from it, and remove usage of
ANNOUNCEMENTS_CACHED_PAYLOAD_KEY from the global preferences to avoid bloating
preferences.data emissions.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt`:
- Around line 77-88: The severity bar is invisible because the first Box is a
no-op and the second Box collapses to zero height; in the AnnouncementCard
composable remove the dead first Box (the spacer with .height(0.dp)) and make
the colored bar Box span the row's height by replacing its measurement modifiers
with a fixed width (4.dp) and fillMaxHeight() (or matchParentSize equivalent) so
it renders as a vertical bar; ensure the colored Box sits at the start of the
Row and remove any .fillMaxWidth()/.height(0.dp) that cause collapse.
- Around line 176-196: The ExpandableBody composable currently uses
BODY_COLLAPSED_LINES and a character-length check to decide when to show the
"read more" button, causing silent clipping or unnecessary buttons; change
ExpandableBody to track actual visual overflow via Text's onTextLayout (e.g.,
remember a boolean isOverflowing and update it from
layoutResult.hasVisualOverflow), then set Text's maxLines to if (expanded)
Int.MAX_VALUE else BODY_COLLAPSED_LINES and only render the TextButton when
!expanded && isOverflowing; ensure the overflow state is reset/remembered based
on the body parameter so it recalculates when body changes.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt`:
- Around line 63-77: The two IconButton usages in AnnouncementsRoot.kt currently
set contentDescription = null which makes them inaccessible; update the
IconButton for back-navigation (the one invoking onNavigateBack with
Icons.AutoMirrored.Filled.ArrowBack) and the IconButton that sets showMuteSheet
= true (with Icons.Filled.Tune) to provide meaningful contentDescription strings
(e.g., use string resources like "back_button" and "open_mute_settings") and
wire them via stringResource(...) so screen readers can announce them; ensure
the corresponding string keys are added to the localization files if missing.
- Around line 82-94: The empty-state branch currently only checks
items.isEmpty() and shows "No announcements", which swallows refreshFailed;
update the AnnouncementsRoot composable so that when items.isEmpty() &&
refreshFailed is true it shows an error UI (e.g., replace or augment the Box
content with an error Text/message and optional retry affordance) instead of the
silent empty message. Locate the items and refreshFailed usage in
AnnouncementsRoot.kt (the Box/ Text block using innerPadding,
stringResource(Res.string.announcements_empty), MaterialTheme) and add a
conditional to render a failure message (use an appropriate stringResource or
pass through the existing error state) and a retry trigger tied to the existing
refresh logic.

In `@docs/backend/announcements-endpoint.md`:
- Around line 17-19: The fenced code blocks containing the endpoint path "GET
/v1/announcements" and the directory tree starting with "announcements/" lack a
language specifier; update each triple-backtick fence to declare a language (use
"text") so they become ```text and ```text respectively; locate the blocks by
the exact block contents ("GET /v1/announcements" and the "announcements/"
listing) and add the language token to satisfy markdownlint MD040.
- Around line 94-108: Add a new cross-field validation rule that enforces
"severity = CRITICAL → requiresAcknowledgment = true" so critical announcements
always trigger the modal: update the server-side announcements validation (the
same place that enforces "category = SECURITY" and "requiresAcknowledgment =
true → dismissible = false") to reject any announcement where severity ==
CRITICAL and requiresAcknowledgment != true; also update the documentation list
of validation rules to include the new line. Reference the client check in
AnnouncementsRepository.kt (pendingCriticalAcknowledgment) to ensure the
validation matches the modal trigger logic.

---

Nitpick comments:
In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt`:
- Line 174: canShowCritical duplicates the exact boolean expression used for
canShowWhatsNew (onHomeScreen && authSettled && rateLimitCleared); replace the
duplicated expression by reusing canShowWhatsNew (i.e., assign canShowCritical =
canShowWhatsNew or remove canShowCritical and use canShowWhatsNew where needed)
so the logic isn't repeated and can't drift; update references to
canShowCritical accordingly if you choose to remove the variable.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt`:
- Around line 37-57: The snapshot's lastRefreshFailed is hard-coded to false;
update observeFeed to combine in a real "refresh failed" boolean instead of a
literal false by adding a source that reflects refresh() failure (either a new
tweaksRepository.getAnnouncementsLastRefreshFailed() Flow<Boolean> or a
MutableStateFlow<Boolean> updated by the refresh() implementation) and include
that value when constructing AnnouncementsFeedSnapshot in observeFeed; locate
observeFeed and AnnouncementsFeedSnapshot usage and ensure refresh() sets the
failure state on the chosen flow (or resets it to false on success) so the UI
can read the real lastRefreshFailed value.

In
`@core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Announcement.kt`:
- Around line 5-6: The Announcement data model currently declares expiresAt:
String? which defers parsing and silently permits invalid dates to pass through;
change the type to kotlinx.datetime.Instant? (or Long epoch millis to match
getAnnouncementsLastFetchedAt) on the Announcement class (publishedAt/expiresAt
fields) and update the mapper that constructs Announcement instances so it
parses/raw-converts the incoming string into Instant (handling parse errors
explicitly). Then update consumers such as isPastIso() in
AnnouncementsRepositoryImpl to accept Instant? (or Long) instead of parsing
strings, ensuring parse failures are handled at the mapping boundary rather than
inside isPastIso().

In
`@core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt`:
- Around line 123-125: Change the API to use the domain type instead of raw
strings: update TweaksRepository's method signatures to return
Flow<Set<AnnouncementCategory>> from getAnnouncementsMutedCategories() and
accept category: AnnouncementCategory in setAnnouncementCategoryMuted(category,
muted). Keep DataStore string serialization inside TweaksRepositoryImpl by
converting persisted names via AnnouncementCategory.valueOf(name) when reading
and using category.name when writing so callers never see raw strings; update
any callers/tests to use AnnouncementCategory accordingly.

In `@core/presentation/src/commonMain/composeResources/values/strings.xml`:
- Line 1030: The string resource announcements_dismiss duplicates the existing
dismiss string ("Dismiss"); remove the duplicate <string
name="announcements_dismiss"> and update any usage sites (the composable(s) that
used announcements_dismiss) to reference the shared key dismiss instead (e.g.,
use stringResource(R.string.dismiss) or `@string/dismiss` as appropriate) so the
code uses the single canonical resource name dismiss and avoids drift.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt`:
- Around line 259-265: Duplicate private categoryLabel(category:
AnnouncementCategory) exists in both AnnouncementCard and
MuteSettingsBottomSheet; extract it into a single internal helper function in a
new shared file (e.g., AnnouncementUiHelpers.kt) inside the same package,
implement internal fun categoryLabel(category: AnnouncementCategory):
StringResource = when(...) using the existing mappings
(AnnouncementCategory.NEWS/PRIVACY/SURVEY/SECURITY/STATUS ->
Res.string.announcements_category_*), then replace the private copies in
AnnouncementCard and MuteSettingsBottomSheet with imports/usage of this shared
categoryLabel and remove the duplicate functions; ensure you import
AnnouncementCategory, StringResource and Res.string identifiers in the new
helper.
🪄 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: 2248e81a-85e8-4d50-b5b4-70b8eeb70920

📥 Commits

Reviewing files that changed from the base of the PR and between 9ab1e3f and 66051d4.

📒 Files selected for processing (27)
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AnnouncementDto.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AnnouncementMapper.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Announcement.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AnnouncementCategory.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AnnouncementSeverity.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/CriticalAnnouncementModal.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/MuteSettingsBottomSheet.kt
  • docs/backend/announcements-endpoint.md
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt

Comment thread docs/backend/announcements-endpoint.md Outdated
Comment thread docs/backend/announcements-endpoint.md
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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt`:
- Around line 117-119: The preview data in AnnouncementsViewModel
(previewSampleAnnouncements(), _previewItems, SamplePreviewItems) is never
cleared and can leak into real sessions; fix by scoping or clearing the state:
either move SamplePreviewItems out of the shared AnnouncementsViewModel into a
destination-scoped view model, or add a clear method (e.g., clearPreviewItems())
that sets _previewItems back to an empty/neutral value and ensure
clearPreviewItems() is invoked when the preview destination exits (call from the
destination's onLeave/unregister lifecycle or navigation exit handler) so
previewSampleAnnouncements() no longer affects later sessions.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt`:
- Around line 88-97: The refresh indicator is being hidden after a fixed 800ms
delay instead of when the refresh work completes; change the logic in the
PullToRefreshBox onRefresh handler (where isRefreshing is set) to await the
actual refresh completion: make or treat onRefresh as a suspend function or
return a Job/Deferred, launch a coroutine that sets isRefreshing=true,
calls/awaits onRefresh(), and then sets isRefreshing=false in a finally block to
ensure the spinner stops even on errors; update the signature or call sites of
onRefresh if needed so the handler can await completion.
🪄 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: 6143063b-87a8-4d9d-9b8f-76cd29517eee

📥 Commits

Reviewing files that changed from the base of the PR and between 66051d4 and 01fca91.

📒 Files selected for processing (7)
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt
🚧 Files skipped from review as they are similar to previous changes (4)
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt
  • feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt

Comment on lines +117 to +119
fun previewSampleAnnouncements() {
_previewItems.value = SamplePreviewItems
}
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 | 🏗️ Heavy lift

Reset preview data when the preview flow ends.

_previewItems lives in a shared ViewModel and is never cleared, so the sample announcements can bleed into later real sessions and keep affecting the global critical-modal path. Scope this state to the destination or clear it on exit.

🤖 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/announcements/AnnouncementsViewModel.kt`
around lines 117 - 119, The preview data in AnnouncementsViewModel
(previewSampleAnnouncements(), _previewItems, SamplePreviewItems) is never
cleared and can leak into real sessions; fix by scoping or clearing the state:
either move SamplePreviewItems out of the shared AnnouncementsViewModel into a
destination-scoped view model, or add a clear method (e.g., clearPreviewItems())
that sets _previewItems back to an empty/neutral value and ensure
clearPreviewItems() is invoked when the preview destination exits (call from the
destination's onLeave/unregister lifecycle or navigation exit handler) so
previewSampleAnnouncements() no longer affects later sessions.

rainxchzed added 8 commits May 3, 2026 22:35
The cached payload used to live next to every other tweak in the shared
preferences DataStore, which meant every refresh rewrote the whole file
and woke every other collector. Move it behind a small
AnnouncementsCacheStore backed by its own DataStore so feature-scoped
writes stay scoped, and drop the cache plumbing from TweaksRepository.
…ndary

Previously expiresAt and publishedAt were String? on the domain model
and parsed lazily inside isPastIso, so an unparsable timestamp would
silently fall through and an item with a malformed expiresAt looked
permanently active. Move parsing into the mapper, drop items whose
publishedAt does not parse, and let the comparison work on Instant
values directly.
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: 6

🤖 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/repository/AnnouncementsCacheStoreImpl.kt`:
- Around line 14-15: getCachedPayload() currently forwards preferences.data
directly so any DataStore I/O exception will break the flow; wrap
preferences.data with a catch that intercepts IOException and recovers by
emitting an empty state (e.g., emit emptyPreferences() or emit a null payload)
so the returned Flow<String?> yields null instead of failing; update the
implementation of getCachedPayload() to use preferences.data.catch { if (it is
IOException) emit(emptyPreferences()) else throw it } and then map { prefs ->
prefs[CACHED_PAYLOAD_KEY] } so observeFeed() degrades gracefully on read errors.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt`:
- Around line 72-88: In AnnouncementsRepositoryImpl.refresh() the onSuccess
branch can still throw during json.encodeToString, cacheStore.setCachedPayload
or tweaksRepository.setAnnouncementsLastFetchedAt which currently escapes the
function; wrap the serialization and persistence steps inside a try/catch within
the onSuccess lambda (cover
json.encodeToString(AnnouncementsResponseDto.serializer(), dto),
cacheStore.setCachedPayload(raw) and
tweaksRepository.setAnnouncementsLastFetchedAt(nowMillis())), on exception log
the error, set lastRefreshFailed.value = true and return
Result.failure(exception), otherwise proceed to set lastRefreshFailed.value =
false and return Result.success(Unit).
- Around line 41-62: The feed currently parses item.installerTypes but never
uses it; update observeFeed to include the current installer type from
tweaksRepository in the combined PersistedFeedState (add a field like
currentInstallerType and include
tweaksRepository.getCurrentInstallerType()/getInstallerType() in the combine
call), then update parseAndFilter(…) to accept the installer type from
PersistedFeedState and reject any announcement whose installerTypes does not
contain the current installer type (or whose installerTypes is non-empty and
does not match DEFAULT/SHIZUKU accordingly). Ensure you update the
PersistedFeedState data class, the combine call in observeFeed, and
parseAndFilter signature/logic to perform this filtering.
- Around line 90-113: The catch blocks in dismiss, acknowledge, and setMuted are
catching Throwable and thus swallowing CancellationException; update each catch
to rethrow CancellationException (or check and throw if t is
CancellationException) before logging and handling persistence errors for
tweaksRepository.addAnnouncementDismissedId,
tweaksRepository.addAnnouncementAcknowledgedId, and
tweaksRepository.setAnnouncementCategoryMuted so cancelled coroutines unwind
normally; keep the existing logging for non-cancellation throwables.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt`:
- Around line 174-187: The isOverflowing flag only flips true currently; in the
Text composable's onTextLayout callback (for the Text that uses body, expanded
and BODY_COLLAPSED_LINES) update the logic so that on every collapsed layout
pass you assign isOverflowing = layout.hasVisualOverflow (rather than only
setting true). Locate the variables expanded and isOverflowing and the Text's
onTextLayout and change the callback to reassign isOverflowing based on
layout.hasVisualOverflow whenever !expanded so resize/font-scale changes
correctly hide or show the “read more” state.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/MuteSettingsBottomSheet.kt`:
- Around line 74-107: CategoryRow currently renders the label as a separate Text
so screen readers can reach the Switch without its label; wrap/convert the whole
row into a single labeled affordance by making the Row merge semantics and be
toggleable: add Modifier.semantics(mergeDescendants = true) and
Modifier.toggleable(checked = enabled, onValueChange = { if (!locked)
onToggle(it) }, role = Role.Switch) to the Row's modifier (keep the visual
Switch but set its onCheckedChange to null or disable its own interaction via
enabled = !locked), so the row and switch are announced together with the
category label; reference: CategoryRow, the Row modifier and the Switch
parameters.
🪄 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: e81167b8-0493-4aae-b07e-927a3ea2decb

📥 Commits

Reviewing files that changed from the base of the PR and between 01fca91 and 4f3c0a9.

📒 Files selected for processing (22)
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/data_store/createAnnouncementsDataStore.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/data_store/announcementsDataStoreFile.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AnnouncementMapper.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/data_store/createAnnouncementsDataStore.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/Announcement.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsCacheStore.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementUiHelpers.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/MuteSettingsBottomSheet.kt
  • docs/backend/announcements-endpoint.md
✅ Files skipped from review due to trivial changes (3)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/data_store/announcementsDataStoreFile.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

rainxchzed added 3 commits May 3, 2026 22:48
- Wrap onSuccess persistence in try/catch so a cache or DataStore
  write failure flips lastRefreshFailed and surfaces a Result.failure
  instead of leaking out of refresh().
- Rethrow CancellationException in dismiss / acknowledge / setMuted
  so cancelled coroutines unwind structured concurrency correctly
  while persistence errors stay logged.
- Filter announcements by the current InstallerType so installer-
  scoped advisories (e.g. Shizuku-only) only reach matching users.
- ExpandableBody now reassigns isOverflowing every collapsed layout
  pass instead of latching true once, so resizes and font-scale
  changes hide the read-more button when the body fits again.
- Mute-settings rows are now toggleable as a single labelled
  affordance via Modifier.toggleable + mergeDescendants, so screen
  readers announce the category and switch state together.
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: 6

🤖 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/repository/AnnouncementsRepositoryImpl.kt`:
- Around line 42-57: The feed combo currently ignores locale changes because
observeFeed() only combines persistedFlow, lastRefreshFailed and installer type;
include a locale signal so parseAndFilter() runs on language switches. Modify
observeFeed() to also combine a locale Flow from localizationManager (e.g.
localizationManager.localeFlow / observeLocale()) or pass locale down into the
downstream mapping and call parseAndFilter(persisted.payload,
installerType.name, locale) (or re-run parseAndFilter inside a map keyed by
locale) so snapshots are re-resolved immediately when the app language changes;
update parseAndFilter signature if necessary to accept the locale or move
localization resolution downstream where localeFlow is observed.
- Around line 146-154: The filter predicate in AnnouncementsRepositoryImpl that
currently checks expiry, version, platform and installer (the lambda using
item.expiresAt, item.minVersionCode, item.maxVersionCode, item.platforms,
item.installerTypes) is missing a check for item.publishedAt, so announcements
with a future publishedAt still pass; update the predicate to compute a
publishedOk boolean (e.g., publishedOk = item.publishedAt?.let { it <= now } ?:
true) and include publishedOk in the final conjunction alongside !expired,
versionFloorOk, versionCeilingOk, platformOk and installerOk so items scheduled
for the future are excluded.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt`:
- Around line 141-150: The Icon in AnnouncementCard is currently using the same
stringResource as the Text which causes duplicate announcement in
TalkBack/VoiceOver; update the Icon call (the Icon(...) inside AnnouncementCard)
to be decorative by setting its contentDescription to null (leave the Text(...)
with text = stringResource(Res.string.announcements_acknowledged) unchanged) so
only the textual label is announced.
- Around line 213-222: The TextButton currently uses announcement.ctaLabel
directly which renders an empty label when ctaLabel is blank; update the label
resolution in AnnouncementCard so it uses the same null-or-blank fallback as the
CTA URL (e.g., treat announcement.ctaLabel with an isNullOrBlank check) and pass
stringResource(Res.string.announcements_read_more) when
announcement.ctaLabel.isNullOrBlank(), leaving the surrounding
TextButton(onClick = onCtaClick) and Icon/Spacer structure unchanged.
- Around line 132-135: The Icon currently sets contentDescription = null which
hides the announcement severity from accessibility services; update the Icon in
AnnouncementCard.kt to provide a meaningful contentDescription derived from the
announcement severity (e.g., use severity.name or a localized display
label/stringResource for severity) and/or include iconHint context so screen
readers convey urgency (for example "Severity: High"). Ensure the description is
used instead of null so TalkBack/VoiceOver can announce severity along with the
visual icon.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/MuteSettingsBottomSheet.kt`:
- Around line 66-69: The toggle row currently sets enabled = category !in
mutedCategories even for locked categories; change the enabled expression so
locked categories (where category.isMutable is false) always have enabled = true
and only use category !in mutedCategories for mutable ones (e.g., enabled = if
(!category.isMutable) true else category !in mutedCategories) while keeping
locked = !category.isMutable and leaving onToggle unchanged or no-op when
locked.
🪄 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: abc161ce-1f84-4a9d-9ace-c79f0016b135

📥 Commits

Reviewing files that changed from the base of the PR and between 4f3c0a9 and ddc3f9c.

📒 Files selected for processing (4)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/MuteSettingsBottomSheet.kt

rainxchzed added 6 commits May 3, 2026 23:02
…tems

Add tweaksRepository.getAppLanguage() to the observeFeed combine so
parseAndFilter re-runs when the in-app language switches; otherwise
the cached payload kept its first-resolved locale strings until
something else triggered a re-emission. Also reject items whose
publishedAt is in the future so a scheduled announcement does not
surface before its release date.
- Severity icon now carries a localized severityLabel content
  description so screen readers announce urgency alongside the
  visual color/shape cue.
- The acknowledged badge icon goes back to decorative since the
  Text right next to it already conveys the same word; TalkBack
  no longer reads 'Acknowledged' twice.
- TextButton CTA falls back to 'Read more' when ctaLabel is blank,
  not just null, so an empty string from the backend never renders
  an empty button.
Items that require an explicit acknowledgment (privacy, security)
still wait for the user to tap their button, so the badge stays
until those are confirmed. Routine items get auto-acknowledged when
the inbox screen mounts, which is what gives the dot a natural way
to clear without forcing the user to dismiss every card.
Mirrors the bottom-nav badge so users who tap into Profile from a
flagged tab can see exactly which row needs attention instead of
hunting. Same error-color dot, same dataset, same auto-clear when
the inbox marks routine items as seen.
@rainxchzed rainxchzed merged commit 24f4561 into main May 3, 2026
1 check passed
@rainxchzed rainxchzed deleted the feat/announcements-feed branch May 3, 2026 18:28
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