Skip to content

feat(app): catalogo offline (Room) + cache copertine persistente + refresh all'apertura#9

Open
fabiodalez-dev wants to merge 1 commit into
mainfrom
feat/offline-catalog-cache
Open

feat(app): catalogo offline (Room) + cache copertine persistente + refresh all'apertura#9
fabiodalez-dev wants to merge 1 commit into
mainfrom
feat/offline-catalog-cache

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Implementa una cache locale per non sovraccaricare il server e far funzionare il catalogo offline (richiesta esplicita).

Cosa fa

  • DB locale Room (data/local/): CachedBook + CatalogDao (Flow) + AppDatabase salvano uno snapshot del catalogo. CatalogRepository espone observeCachedCatalog() (offline-first) e refreshCatalog() (scarica la prima pagina e rimpiazza lo snapshot in modo atomico; se la rete fallisce, la cache resta).
  • Home offline-first: lo scaffale "Disponibili ora" si renderizza subito dalla cache (anche senza rete) e si aggiorna in background.
  • Copertine: ImageLoader Coil persistente da 256 MB con respectCacheHeaders(false) → le copertine si scaricano una volta e si riusano, invece di rifarle ogni volta.
  • Refresh all'apertura: ProcessLifecycle ON_START aggiorna la cache quando sei loggato → il catalogo offline resta aggiornato senza martellare il server.

Test (prima suite di test dell'app — 29 test, tutti verdi)

  • CatalogDaoTest (Robolectric, Room in-memory): insert/observe/ordinamento/replaceAll atomico/clear.
  • CachedBookMapperTest: round-trip entity↔summary.
  • BookSummaryTest: disponibilità derivata.
  • StatusMappingTest: regole di annullabilità prenotazione.
  • NetworkUrlTest: derivazione URL istanza.

./gradlew testDebugUnitTest → 29/29 verde. assembleDebug builda. versionCode 4 / 1.2.0.

Note

  • Cache = snapshot prima pagina (≤50) per offline-first dell'Home; la ricerca filtrata/paginata resta online. La paginazione offline completa (RemoteMediator) è un possibile follow-up.
  • Robolectric forzato a SDK 34 (max supportato; app compila su 35).

Summary by CodeRabbit

  • Nuove funzionalità

    • Aggiunto il supporto alla cache locale del catalogo con sincronizzazione offline-first.
    • L’app ora aggiorna automaticamente il catalogo quando torna in primo piano, se l’utente è autenticato.
    • Migliorata la gestione delle immagini con caricamento più fluido e cache persistente.
  • Miglioramenti

    • La schermata principale mostra i contenuti disponibili anche dai dati memorizzati in locale.
    • Aggiornata la versione dell’app a 1.2.0.
  • Test

    • Aggiunte nuove coperture per cache, mapping dati, logica URL e stati di prenotazione.

…fresh on open

Reduces server load and lets the catalog work offline.

- Room cache (data/local/): CachedBook entity + CatalogDao (Flow-backed) +
  AppDatabase store the catalog snapshot locally. CatalogRepository exposes
  observeCachedCatalog() (offline-first) and refreshCatalog() (fetch page 1,
  replace the snapshot atomically; a network failure keeps the existing cache).
- Home is offline-first: it renders the "Available now" shelf from the cached
  snapshot immediately (works with no network) and refreshes in the background.
- Covers: a persistent 256 MB Coil ImageLoader with respectCacheHeaders(false),
  so covers are downloaded once and reused instead of re-fetched every time.
- Refresh on app open: ProcessLifecycle ON_START refreshes the cache when logged
  in, so the offline catalog stays current without hammering the server.
- First unit-test suite for the app (29 tests): Room CatalogDao (Robolectric,
  in-memory), entity↔summary mappers, BookSummary availability, reservation
  cancellable rules, and the URL-derivation helpers.
- versionCode 3 -> 4, versionName 1.1.1 -> 1.2.0.

Verified: assembleDebug builds; testDebugUnitTest green (29/29).
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Viene introdotta una cache locale offline-first basata su Room per il catalogo libri. Sono aggiunti l'entità CachedBook, CatalogDao, AppDatabase singleton, e CatalogRepository è aggiornato con API reattive. HomeViewModel passa a observeCache()/refresh(), PinakesApplication aggiunge un refresh in foreground e un ImageLoader Coil persistente. Inclusi test unitari e DAO.

Changes

Cache offline del catalogo e aggiornamento in foreground

Layer / File(s) Riepilogo
Entità Room, DAO e database singleton
app/src/main/java/com/pinakes/app/data/local/CachedBook.kt, app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt, app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt
CachedBook è definita come entità Room con mapping bidirezionale toCached/toSummary; CatalogDao espone observeAll(), count(), insertAll(), clear() e replaceAll() atomico; AppDatabase inizializza il singleton pinakes-cache.db.
CatalogRepository offline-first e wiring ServiceLocator
app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt, app/src/main/java/com/pinakes/app/di/ServiceLocator.kt
CatalogRepository riceve CatalogDao e aggiunge observeCachedCatalog(), hasCachedCatalog() e refreshCatalog(); ServiceLocator introduce la property database e passa database.catalogDao() al repository.
HomeViewModel offline-first
app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt
load() è rimosso; il init avvia observeCache() (Flow dalla cache Room) e refresh() (rete con errore condizionale alla presenza di cache); retry() ora invoca refresh().
PinakesApplication: foreground refresh e Coil ImageLoader
app/src/main/java/com/pinakes/app/PinakesApplication.kt
PinakesApplication implementa ImageLoaderFactory con DiskCache da 256 MB e crossfade; un ProcessLifecycleObserver avvia refreshCatalog() via appScope se la sessione è attiva all'avvio in foreground.
Build config e dipendenze
app/build.gradle.kts, gradle/libs.versions.toml
Aggiunge plugin KAPT, incrementa versionCode/versionName a 4/1.2.0, abilita unitTests.isIncludeAndroidResources, aggiunge dipendenze Room/lifecycle-process/test e relativi alias nel catalogo versioni.
Test unitari e DAO
app/src/test/java/com/pinakes/app/BookSummaryTest.kt, ...CachedBookMapperTest.kt, ...CatalogDaoTest.kt, ...NetworkUrlTest.kt, ...StatusMappingTest.kt
Test per BookSummary.available/authorsLabel, round-trip mapper CachedBook, operazioni CatalogDao in-memory via Robolectric (insert/replace/clear/ordinamento), derivazione URL di rete e StatusMapping.isReservationCancellable.

Sequence Diagram(s)

sequenceDiagram
  participant App as PinakesApplication
  participant PLO as ProcessLifecycleOwner
  participant Repo as CatalogRepository
  participant DAO as CatalogDao
  participant Net as NetworkModule

  rect rgba(33, 150, 243, 0.5)
    note over App,PLO: Avvio in foreground
    PLO->>App: onStart()
    App->>Repo: refreshCatalog()
    Repo->>Net: search(page=1, limit=40)
    Net-->>Repo: ApiResult.Success(books)
    Repo->>DAO: replaceAll(books.toCached())
  end

  rect rgba(76, 175, 80, 0.5)
    note over App,DAO: HomeViewModel osserva cache
    DAO-->>Repo: Flow~List~CachedBook~~
    Repo-->>App: observeCachedCatalog() Flow~List~BookSummary~~
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Hop hop, la cache è pronta ormai,
Room conserva i libri, non si perde mai!
In foreground si aggiorna, fresco il catalogo —
Coil carica le cover, che bel prologo.
Il coniglio festeggia: offline non fa più paura,
la shelf rimane viva, con ogni lettura! 📚

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.69% 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 Il titolo riassume correttamente le tre modifiche principali: catalogo offline con Room, cache copertine persistente e refresh all'apertura.
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.
✨ 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/offline-catalog-cache

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.

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
app/src/main/java/com/pinakes/app/PinakesApplication.kt (1)

30-35: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Refresh duplicato all'avvio con utente loggato.

All'apertura dell'app con sessione attiva, ProcessLifecycleOwner onStart lancia refreshCatalog() e, in parallelo, HomeViewModel.init chiama anch'esso refresh(). Si ottengono due richieste di rete e due replaceAll concorrenti (serializzati da Room, ma comunque ridondanti). Valutare un meccanismo di de-duplicazione/throttle (es. saltare se un refresh recente è già in corso) per evitare traffico e scritture superflue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/pinakes/app/PinakesApplication.kt` around lines 30 -
35, The startup refresh path is triggering duplicate catalog loads when a user
is already logged in: `PinakesApplication`’s `ProcessLifecycleOwner` observer
and `HomeViewModel.init` both call refresh logic. Update the refresh flow around
`services.catalogRepository.refreshCatalog()` and the `HomeViewModel` refresh
entrypoint so only one in-flight or very recent refresh can run, using a shared
de-duplication/throttle guard. Keep the change localized to the lifecycle
observer and the view-model refresh trigger so repeated app foregrounding or
initialization does not issue redundant network requests or `replaceAll` writes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt`:
- Around line 47-54: In HomeViewModel.observeCache(), the state currently sets
loading to false on every cache emission, including Room’s initial empty list,
which hides the initial spinner too early. Update the flow handling so loading
stays true while the cache is still empty and only gets cleared when refresh()
completes or when a non-empty catalog is observed; keep using the existing
observeCache(), refresh(), and _state.update symbols to locate the change.

---

Nitpick comments:
In `@app/src/main/java/com/pinakes/app/PinakesApplication.kt`:
- Around line 30-35: The startup refresh path is triggering duplicate catalog
loads when a user is already logged in: `PinakesApplication`’s
`ProcessLifecycleOwner` observer and `HomeViewModel.init` both call refresh
logic. Update the refresh flow around
`services.catalogRepository.refreshCatalog()` and the `HomeViewModel` refresh
entrypoint so only one in-flight or very recent refresh can run, using a shared
de-duplication/throttle guard. Keep the change localized to the lifecycle
observer and the view-model refresh trigger so repeated app foregrounding or
initialization does not issue redundant network requests or `replaceAll` writes.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a68e91a4-b969-4a10-b00d-d45568fc4cde

📥 Commits

Reviewing files that changed from the base of the PR and between 63a9bd4 and 7923205.

📒 Files selected for processing (14)
  • app/build.gradle.kts
  • app/src/main/java/com/pinakes/app/PinakesApplication.kt
  • app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt
  • app/src/main/java/com/pinakes/app/data/local/CachedBook.kt
  • app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt
  • app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt
  • app/src/main/java/com/pinakes/app/di/ServiceLocator.kt
  • app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt
  • app/src/test/java/com/pinakes/app/BookSummaryTest.kt
  • app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt
  • app/src/test/java/com/pinakes/app/CatalogDaoTest.kt
  • app/src/test/java/com/pinakes/app/NetworkUrlTest.kt
  • app/src/test/java/com/pinakes/app/StatusMappingTest.kt
  • gradle/libs.versions.toml

Comment on lines +47 to +54
private fun observeCache() {
viewModelScope.launch {
// "Available now" shelf: full catalog filtered to currently-loanable copies.
when (val res = catalog.search(SearchFilters(availableOnly = true), limit = 20)) {
is ApiResult.Success -> _state.update {
it.copy(available = res.data.items, loading = false, error = null)
}
is ApiResult.Failure -> _state.update {
it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() })
catalog.observeCachedCatalog().collectLatest { books ->
val available = books.filter { it.available }
_state.update { it.copy(available = available, loading = false, error = null) }
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Lo spinner iniziale viene perso al primo avvio con cache vuota.

observeCache() imposta loading = false ad ogni emissione del Flow, inclusa la prima emissione di lista vuota che Room produce immediatamente. Su un'installazione pulita (cache vuota) lo stato diventa subito isEmpty = true mentre refresh() è ancora in corso, mostrando lo stato "catalogo vuoto" invece dell'indicatore di caricamento. Conviene mantenere loading finché la cache è vuota e lasciare che sia refresh() a chiuderlo, oppure abbassare loading solo quando arriva una lista non vuota.

🩹 Possibile correzione
     private fun observeCache() {
         viewModelScope.launch {
             catalog.observeCachedCatalog().collectLatest { books ->
                 val available = books.filter { it.available }
-                _state.update { it.copy(available = available, loading = false, error = null) }
+                _state.update {
+                    it.copy(
+                        available = available,
+                        loading = if (available.isEmpty()) it.loading else false,
+                        error = null,
+                    )
+                }
             }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun observeCache() {
viewModelScope.launch {
// "Available now" shelf: full catalog filtered to currently-loanable copies.
when (val res = catalog.search(SearchFilters(availableOnly = true), limit = 20)) {
is ApiResult.Success -> _state.update {
it.copy(available = res.data.items, loading = false, error = null)
}
is ApiResult.Failure -> _state.update {
it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() })
catalog.observeCachedCatalog().collectLatest { books ->
val available = books.filter { it.available }
_state.update { it.copy(available = available, loading = false, error = null) }
}
}
}
private fun observeCache() {
viewModelScope.launch {
catalog.observeCachedCatalog().collectLatest { books ->
val available = books.filter { it.available }
_state.update {
it.copy(
available = available,
loading = if (available.isEmpty()) it.loading else false,
error = null,
)
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt` around
lines 47 - 54, In HomeViewModel.observeCache(), the state currently sets loading
to false on every cache emission, including Room’s initial empty list, which
hides the initial spinner too early. Update the flow handling so loading stays
true while the cache is still empty and only gets cleared when refresh()
completes or when a non-empty catalog is observed; keep using the existing
observeCache(), refresh(), and _state.update symbols to locate the change.

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