diff --git a/README.md b/README.md index 314315c..a26040a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The full documentation is available [here](https://docs.uandcode.com/container). Add the following line to your `build.gradle` file: ``` -implementation "com.elveum:container:3.3.1" +implementation "com.elveum:container:3.3.2" ``` ## Core Concepts diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_keyed/master_details/details/CatDetailsRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_keyed/master_details/details/CatDetailsRepository.kt index 9614846..3d62f95 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_keyed/master_details/details/CatDetailsRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_keyed/master_details/details/CatDetailsRepository.kt @@ -41,10 +41,6 @@ class CatDetailsRepository @Inject constructor( return catEvents } - fun invalidate(catId: Long) { - store.invalidateAsync(catId) - } - private suspend fun optimisticUpdate(id: Long, name: String) { store.optimisticUpdate(id) { old -> val entity = old.copy(cat = old.cat.copy(name = name)) diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsScreen.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsScreen.kt index b2fc212..5306630 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsScreen.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsScreen.kt @@ -41,6 +41,7 @@ import com.elveum.container.subject.paging.PageState import com.elveum.store.load.StoreResult import com.elveum.store.load.isBackgroundLoading import com.elveum.store.load.nextPageState +import com.elveum.store.load.onItemRendered @Composable fun PaginationArgsScreen() = DemoScaffold( @@ -82,7 +83,7 @@ fun PaginationArgsScreen() = DemoScaffold( key = { _, photo -> photo.id }, ) { index, photo -> LaunchedEffect(index) { - viewModel.onItemRendered(index) + finalResult.onItemRendered(index) } PhotoCard(photo = photo) } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsViewModel.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsViewModel.kt index 50edbda..33845f6 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsViewModel.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PaginationArgsViewModel.kt @@ -24,8 +24,4 @@ class PaginationArgsViewModel @Inject constructor( repository.toggleCategory(category) } - fun onItemRendered(index: Int) { - repository.onItemRendered(index) - } - } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PhotoRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PhotoRepository.kt index 0e49319..209cb7a 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PhotoRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_args/PhotoRepository.kt @@ -32,10 +32,6 @@ class PhotoRepository @Inject constructor( store.submitQueryAsync(newFilter) } - fun onItemRendered(index: Int) { - store.onItemRendered(index) - } - data class Photo( val id: Int, val title: String, diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationScreen.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationScreen.kt index 212887a..fcfd6ec 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationScreen.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationScreen.kt @@ -29,13 +29,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil.compose.AsyncImage +import com.elveum.container.subject.paging.PageState import com.elveum.store.demo.feature.examples.store_paged.pagination_basic.PhotoRepository.Photo import com.elveum.store.demo.ui.components.DemoScaffold import com.elveum.store.demo.ui.theme.Dimens -import com.elveum.container.subject.paging.PageState -import com.elveum.container.subject.paging.onItemRendered import com.elveum.store.load.StoreResult import com.elveum.store.load.nextPageState +import com.elveum.store.load.onItemRendered @Composable fun BasicPaginationScreen() = DemoScaffold( @@ -74,7 +74,7 @@ fun BasicPaginationScreen() = DemoScaffold( key = { _, photo -> photo.id }, ) { index, photo -> LaunchedEffect(index) { - viewModel.onItemRendered(index) + finalResult.onItemRendered(index) } PhotoCard(photo = photo) } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationViewModel.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationViewModel.kt index 294a012..378c6ab 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationViewModel.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/BasicPaginationViewModel.kt @@ -17,7 +17,4 @@ class BasicPaginationViewModel @Inject constructor( .getPhotos() .stateIn(StoreResult.Loading) - fun onItemRendered(index: Int) { - repository.onItemRendered(index) - } } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/PhotoRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/PhotoRepository.kt index 989d3b4..c64dfa9 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/PhotoRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_basic/PhotoRepository.kt @@ -16,10 +16,6 @@ class PhotoRepository @Inject constructor( fun getPhotos(): Flow>> = store.observe() - fun onItemRendered(index: Int) { - store.onItemRendered(index) - } - data class Photo( val id: Int, val title: String, diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/BookRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/BookRepository.kt index 041e2b4..6efd298 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/BookRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/BookRepository.kt @@ -1,7 +1,6 @@ package com.elveum.store.demo.feature.examples.store_paged.pagination_statuses import com.elveum.store.StoreFactory -import com.elveum.store.load.LoadRequest import com.elveum.store.load.StoreResult import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -17,10 +16,6 @@ class BookRepository @Inject constructor( fun getBooks(): Flow>> = store.observe() - fun onItemRendered(index: Int) { - store.onItemRendered(index) - } - data class Book( val id: Int, val title: String, diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesScreen.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesScreen.kt index a448d9d..8334483 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesScreen.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesScreen.kt @@ -38,6 +38,7 @@ import com.elveum.store.load.StoreResult import com.elveum.store.load.invalidate import com.elveum.store.load.isBackgroundLoading import com.elveum.store.load.nextPageState +import com.elveum.store.load.onItemRendered @Composable fun PaginationStatusesScreen() { @@ -116,7 +117,7 @@ fun PaginationStatusesScreen() { key = { _, book -> book.id }, ) { index, book -> LaunchedEffect(index) { - viewModel.onItemRendered(index) + finalResult.onItemRendered(index) } BookCard(book = book) } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesViewModel.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesViewModel.kt index 88c56f0..c080c16 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesViewModel.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_paged/pagination_statuses/PaginationStatusesViewModel.kt @@ -1,8 +1,8 @@ package com.elveum.store.demo.feature.examples.store_paged.pagination_statuses +import com.elveum.container.reducer.stateIn import com.elveum.store.demo.errors.ErrorFlagRepository import com.elveum.store.demo.ui.AbstractViewModel -import com.elveum.container.reducer.stateIn import com.elveum.store.load.StoreResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow @@ -24,8 +24,4 @@ class PaginationStatusesViewModel @Inject constructor( errorFlagRepository.toggleErrorFlag() } - fun onItemRendered(index: Int) { - bookRepository.onItemRendered(index) - } - } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsScreen.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsScreen.kt index d970bed..9449333 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsScreen.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsScreen.kt @@ -3,38 +3,23 @@ package com.elveum.store.demo.feature.examples.store_simple.basic import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.elveum.store.demo.ui.components.DemoScaffold import com.elveum.store.demo.ui.components.EditableField -import com.elveum.store.demo.ui.components.Heading import com.elveum.store.load.StoreResult +import com.elveum.store.load.invalidate @Composable fun SimpleStoreBasicsScreen() = DemoScaffold( @@ -93,7 +78,7 @@ fun SimpleStoreBasicsScreen() = DemoScaffold( onNewValue = viewModel::updateBio, ) - Button(onClick = viewModel::reload) { + Button(onClick = userProfile::invalidate) { Text("Reload") } Spacer(Modifier.weight(1f)) @@ -102,7 +87,7 @@ fun SimpleStoreBasicsScreen() = DemoScaffold( Spacer(Modifier.weight(1f)) Text("${userProfile.exception.message}") Button( - onClick = viewModel::reload, + onClick = userProfile::invalidate, ) { Text("Try Again") } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsViewModel.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsViewModel.kt index 658c906..f2a629c 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsViewModel.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/SimpleStoreBasicsViewModel.kt @@ -31,10 +31,6 @@ class SimpleStoreBasicsViewModel @Inject constructor( errorFlagRepository.toggleErrorFlag() } - fun reload() { - userProfileRepository.reload() - } - fun updateAge(age: Int) { currentUserProfile?.copy(age = age)?.let(::updateProfile) } diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/UserProfileRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/UserProfileRepository.kt index 3e15ad0..251cc09 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/UserProfileRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/basic/UserProfileRepository.kt @@ -17,10 +17,6 @@ class UserProfileRepository @Inject constructor( return store.observe() } - fun reload() { - store.invalidateAsync() - } - suspend fun update(newProfile: UserProfile) { store.optimisticUpdate { emit(newProfile) diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/CombinedSimpleStoreScreen.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/CombinedSimpleStoreScreen.kt index 335e299..ff8ba73 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/CombinedSimpleStoreScreen.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/CombinedSimpleStoreScreen.kt @@ -69,7 +69,7 @@ fun CombinedSimpleStoreScreen() { DemoAction( icon = Icons.Default.Refresh, state = if (isRefreshInProgress) DemoActionState.Loading else DemoActionState.Default, - onClick = { state.images.invalidate(LoadConfig.SilentLoading) }, + onClick = { state.images.invalidate() }, ) ) diff --git a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/GalleryRepository.kt b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/GalleryRepository.kt index 1e44bb7..b5e5e06 100644 --- a/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/GalleryRepository.kt +++ b/app-store-demo/src/main/java/com/elveum/store/demo/feature/examples/store_simple/combined/GalleryRepository.kt @@ -20,9 +20,9 @@ class GalleryRepository @Inject constructor( .map {isKeepContentOnError -> LoadRequest.builder().run { if (isKeepContentOnError) { - keepContentOnLoadAndError() + keepContentOnLoadAndError().keepContentOnQueryAndError() } else { - keepContentOnLoad() + keepContentOnLoad().keepContentOnQuery() } }.build() } diff --git a/container/src/main/java/com/elveum/container/subject/paging/internal/PageRecordsState.kt b/container/src/main/java/com/elveum/container/subject/paging/internal/PageRecordsState.kt index 68e68aa..c795c21 100644 --- a/container/src/main/java/com/elveum/container/subject/paging/internal/PageRecordsState.kt +++ b/container/src/main/java/com/elveum/container/subject/paging/internal/PageRecordsState.kt @@ -1,6 +1,7 @@ package com.elveum.container.subject.paging.internal import com.elveum.container.BackgroundLoadMetadata +import com.elveum.container.BackgroundLoadState import com.elveum.container.Container import com.elveum.container.ContainerMetadata import com.elveum.container.EmptyMetadata @@ -164,6 +165,7 @@ internal class PageRecordsState( val outputList = outputListSnapshot() val bgLoadMetadata = containers.firstOrNull()?.metadata?.get() + ?: BackgroundLoadMetadata(BackgroundLoadState.Idle) val finalMetadata = if (config.emitMetadata) { OnItemRenderedCallbackMetadata(onItemRenderedRef) + NextPageStateMetadata(pageState) + diff --git a/gradle-public.properties b/gradle-public.properties index b352136..b5c43f6 100644 --- a/gradle-public.properties +++ b/gradle-public.properties @@ -6,7 +6,7 @@ android.nonTransitiveRClass=true # Maven Central publishing coordinates GROUP=com.elveum -VERSION_NAME=3.3.1 +VERSION_NAME=3.3.2 # POM metadata POM_DESCRIPTION=A library for simplifying state management and data loading in Android applications diff --git a/skills/container-store/README.md b/skills/container-store/README.md index 371ce2d..5cd178f 100644 --- a/skills/container-store/README.md +++ b/skills/container-store/README.md @@ -43,4 +43,4 @@ discovers skills (e.g. `~/.agents/skills/` for Codex), or paste | `references/api.md` | Every public import, `StoreResult` / `LoadRequest` semantics, typical operations | | `references/patterns.md` | Layer-by-layer patterns: data sources, repositories, ViewModels, Compose, DI, scoping | -The skill matches library version `3.3.1`. +The skill matches library version `3.3.2`. diff --git a/skills/container-store/SKILL.md b/skills/container-store/SKILL.md index 2dec518..0a36c14 100644 --- a/skills/container-store/SKILL.md +++ b/skills/container-store/SKILL.md @@ -1,6 +1,8 @@ --- name: container-store description: Use when writing, updating, or reviewing any code that references Store-related symbols (StoreFactory, SimpleStore, KeyedStore, KeyedQueryStore, PagedStore, PagedKeyedStore, PagedQueryStore, SimpleQueryStore, StoreResult, LoadRequest, StoreResultReducer) or when integrating the com.elveum:store Kotlin/Android library. Do NOT inspect or decompile JAR/AAR files to understand this library - all API and usage patterns are documented in references/api.md and references/patterns.md. +metadata: + version: 3.3.2 --- # Container Store Library @@ -22,13 +24,13 @@ directly - no decompilation or dependency tree inspection needed. ## Dependency Setup -Maven coordinates: `com.elveum:store:3.3.1` (transitively brings +Maven coordinates: `com.elveum:store:3.3.2` (transitively brings `com.elveum:container`, whose types are part of the public API). ```toml # gradle/libs.versions.toml [versions] -store = "3.3.1" +store = "3.3.2" [libraries] store = { module = "com.elveum:store", version.ref = "store" } ``` @@ -105,6 +107,7 @@ still appropriate. | Reload from the UI (try-again / pull-to-refresh) — PREFERRED | `result.invalidate()` on the rendered `StoreResult`; reloads the origin store with no ViewModel/repository plumbing | | Pull-to-refresh (keep content visible) | Observe with `LoadRequest.Silent`, then `result.invalidate()`; progress shows via `result.isBackgroundLoading()` | | Try-again (reload showing `Loading`) | `result.invalidate()` on the failed result (default request re-shows `Loading`) | +| Keep content per reload trigger (invalidate vs query change) | `LoadRequest.builder().keepContentOnLoad()` (invalidate) and/or `.keepContentOnQuery()` (query change), then `.build()`; each unset trigger shows `Loading`. `LoadRequest.Silent` = both (see api.md) | | Reload without a result at hand (old way, still valid) | `store.invalidateAsync()` / `store.invalidate()` (key variants for keyed) | | Replace cached result outright | `store.updateWith(StoreResult.Loaded(new))` / `store.updateWith(key, ...)` | | Read-modify-write loaded value | `store.updateIfSuccess { old -> new }` / `store.updateIfSuccess(key) { old -> new }` (no-op unless `Loaded`) | @@ -117,6 +120,7 @@ still appropriate. | Pagination: next-page status | `result.nextPageState` (`Idle`/`Pending`/`Error(retry)`) | | React while store is observed (connect to other stores/events) | `.whenActive { ... }` chained after `build`; `this` is the store, block runs while observed & is cancelled on cache release (see patterns.md "Relations between stores") | | Attach/read custom result flags (metadata) | `PagedList(items, nextKey, metadata = MyMeta(...))` / `emit(v, metadata = MyMeta(...))`; read `result.metadata.get()` (see api.md) | +| Strip metadata for tests / equality (`assertEquals`) | `result.raw()` → same `Loading`/`Loaded`/`Failed` with all metadata dropped, so results compare by value/exception only | Cache behavior (all stores): lazy first fetch, one shared in-memory cache, released after the last observer unsubscribes plus diff --git a/skills/container-store/references/api.md b/skills/container-store/references/api.md index 993fefe..491fc9b 100644 --- a/skills/container-store/references/api.md +++ b/skills/container-store/references/api.md @@ -52,6 +52,7 @@ import com.elveum.store.load.filterLoaded // Flow>.fi import com.elveum.store.load.toStoreResult // Container.toStoreResult(): StoreResult (interop) import com.elveum.store.load.toContainer // StoreResult.toContainer(): Container (interop) import com.elveum.store.load.withMetadataFrom // StoreResult.withMetadataFrom(origin): merge metadata +import com.elveum.store.load.raw // StoreResult.raw(): strip ALL metadata (for testing/equality) import com.elveum.store.load.isLoaded // smart-cast contract import com.elveum.store.load.isFailed // smart-cast contract import com.elveum.store.load.isCompleted @@ -235,6 +236,17 @@ val flags = result.metadata.get() // PagingFlagsMetadata? if (result.metadata.hasNextPage) { /* ... */ } // via the typed accessor ``` +**Stripping metadata for tests** — because metadata rides along on every +`StoreResult`, two results with the same value/exception but different metadata +are **not** equal. When asserting in tests, call `result.raw()` to drop all +metadata and compare only the value/exception: + +```kotlin +assertEquals(StoreResult.Loaded(expectedUser), store.get().raw()) +``` + +`raw()` returns `Loading` unchanged, and `Loaded`/`Failed` with `EmptyMetadata`. + Combine several entries with `+` (a same-typed entry on the right replaces the one on the left): `PagingFlagsMetadata(true) + EtagMetadata(tag)`. Metadata is propagated from the loader/`PagedList` to the emitted `StoreResult`, so it is the @@ -257,8 +269,8 @@ of always meaning `LoadRequest.Default`: | Request | Effect | |---------|--------| | `LoadRequest.Default` | Fetch only if not cached; observers see `Loading` during the load | -| `LoadRequest.Silent` | Keep currently shown content while reloading (pull-to-refresh); progress visible via `isBackgroundLoading()` | -| `LoadRequest.builder()` | Custom: `freshMode()` (skip caches), `offlineMode()` (cache only; emits `NoCachedDataException` if empty), `keepContentOnLoad(replaceErrorsOnReload = true)`, `keepContentOnLoadAndError(replaceErrorsOnReload = true)`, then `build()`. `replaceErrorsOnReload = false` keeps even a current error visible while reloading | +| `LoadRequest.Silent` | Keep currently shown content while reloading, both on invalidate **and** on query change (pull-to-refresh); progress visible via `isBackgroundLoading()`. `= builder().keepContentOnLoad().keepContentOnQuery().build()` | +| `LoadRequest.builder()` | Custom: `freshMode()` (skip caches), `offlineMode()` (cache only; emits `NoCachedDataException` if empty), then `build()`. Keep-content is set **per reload trigger**: `keepContentOnLoad` / `keepContentOnLoadAndError` for invalidation (`invalidate`/`result.invalidate()`), `keepContentOnQuery` / `keepContentOnQueryAndError` for query changes (`submitQuery`/query flow). Each defaults to showing `Loading` if its option is not set, so `keepContentOnLoad()` alone reloads silently but still shows `Loading` on a query change. Each option takes `replaceErrorsOnReload`/`replaceErrorsOnQuery = true`; pass `false` to also keep a current error visible while reloading. Each family may be set at most once (enforced by the fluent builder type) | ### Store behaviour (all types) diff --git a/store/README.md b/store/README.md index 4b60827..3826cf3 100644 --- a/store/README.md +++ b/store/README.md @@ -38,7 +38,7 @@ the hood. Add the following line to your `build.gradle` file: ``` -implementation "com.elveum:store:3.3.1" +implementation "com.elveum:store:3.3.2" ``` The `store` artifact depends on `com.elveum:container`, so the Container @@ -412,7 +412,12 @@ reload, and every observer keeps receiving data according to the request **it** subscribed with via `observe(...)` (or the builder default). The `LoadRequest.builder()` also supports `freshMode()` (skip caches, force -a remote fetch) and `offlineMode()` (use only cached data). See +a remote fetch) and `offlineMode()` (use only cached data). Keep-content +behaviour is configured per reload trigger: `keepContentOnLoad` / +`keepContentOnLoadAndError` apply to invalidation, while `keepContentOnQuery` +/ `keepContentOnQueryAndError` apply to query changes - so a single request can +reload silently on `invalidate()` yet show a `Loading` state when the query +changes. `LoadRequest.Silent` keeps content on both. See [Load Requests](docs/load-requests.md). ## Consuming Stores in ViewModels diff --git a/store/docs/load-requests.md b/store/docs/load-requests.md index 43fe382..9549679 100644 --- a/store/docs/load-requests.md +++ b/store/docs/load-requests.md @@ -9,6 +9,20 @@ Mutating and query calls - `invalidate` / `invalidateAsync` and trigger a reload, and every observer keeps receiving data according to the request it subscribed with. +A single request can, however, react **differently** depending on *what* +triggered the reload. It carries two independent keep-content policies: + +- one for reloads triggered by **invalidation** (`invalidate` / + `invalidateAsync` / `result.invalidate()`), configured with + `keepContentOnLoad` / `keepContentOnLoadAndError`; +- one for reloads triggered by a **query change** (`submitQuery` / + `submitQueryAsync`, or a reactive query flow), configured with + `keepContentOnQuery` / `keepContentOnQueryAndError`. + +This makes it possible, for example, to reload silently in the background on +`invalidate()` while showing a fresh `Loading` state whenever the query +changes - see [Keeping Content](#keeping-content). + ## Table of Contents - [Predefined Requests](#predefined-requests) @@ -23,7 +37,7 @@ request it subscribed with. | Request | Behaviour | |-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `LoadRequest.Default` | Fetch only on demand, when the value is not in the in-memory cache. While loading, observers see `StoreResult.Loading`. Two identical concurrent requests fetch data only once. | -| `LoadRequest.Silent` | The same, but existing content stays visible while new content is being loaded (equivalent to `builder().keepContentOnLoad().build()`). | +| `LoadRequest.Silent` | The same, but existing content stays visible while new content is being loaded - both on invalidation and on query changes (equivalent to `builder().keepContentOnLoad().keepContentOnQuery().build()`). | `LoadRequest.Silent` is the natural choice for pull-to-refresh - configure it as the observer's request so that a reload keeps the existing content @@ -98,37 +112,72 @@ The source mode determines where data is loaded from ### Keeping Content The keep-content options control what observers see while a new value is -being loaded: +being loaded. They come in two families, one per reload trigger: + +| Trigger | Keep loaded content | Keep content even on failure | +|------------------------------------------------------------|---------------------|------------------------------| +| Invalidation (`invalidate` / `invalidateAsync` / `result.invalidate()`) | `keepContentOnLoad()` | `keepContentOnLoadAndError()` | +| Query change (`submitQuery` / `submitQueryAsync` / query flow) | `keepContentOnQuery()` | `keepContentOnQueryAndError()` | + +- **`keepContentOnLoad()` / `keepContentOnQuery()`** - the previously loaded + content stays in all observed flows while the new content is loading. If the + load fails, the failure replaces the content. +- **`keepContentOnLoadAndError()` / `keepContentOnQueryAndError()`** - the + previous content also survives a failed load; the UI keeps showing the old + data instead of an error screen. + +Each trigger is configured independently. Without a matching option for a +given trigger, observers see `StoreResult.Loading` for the duration of that +load. So `keepContentOnLoad()` alone keeps content silently on invalidation +but still shows `Loading` when the query changes, and vice versa. Call one +option from each family to cover both triggers (that is what +`LoadRequest.Silent` does): -- **`keepContentOnLoad()`** - the previously loaded content stays in all - observed flows while the new content is loading. If the load fails, the - failure replaces the content. -- **`keepContentOnLoadAndError()`** - the previous content also survives a - failed load; the UI keeps showing the old data instead of an error - screen. +```kotlin +// silent background reload on invalidate(), but a fresh Loading state on query change +val request = LoadRequest.builder() + .keepContentOnLoad() + .build() -Without either option, observers see `StoreResult.Loading` for the -duration of the load. +// keep content on both triggers (same as LoadRequest.Silent) +val request = LoadRequest.builder() + .keepContentOnLoad() + .keepContentOnQuery() + .build() -Both keep-content options accept an optional -`replaceErrorsOnReload: Boolean = true` parameter that controls what happens -when the **current** state is an *error* and a reload starts: +// keep content only while the query changes; show Loading on an explicit invalidate() +val request = LoadRequest.builder() + .keepContentOnQuery() + .build() +``` -- **`replaceErrorsOnReload = true`** (default) - a current error is replaced - by a loading state on reload, i.e. content is kept silently only while it is - actually loaded; a stale error is not. -- **`replaceErrorsOnReload = false`** - a current error is also kept silently - during the reload (the previous error stays visible with a background-load - indicator instead of switching to `StoreResult.Loading`). +Each option accepts an optional `Boolean` flag (`replaceErrorsOnReload` for +the load family, `replaceErrorsOnQuery` for the query family, both defaulting +to `true`) that controls what happens when the **current** state is an *error* +and that reload starts: -```kotlin -// keep loaded content silently, but show a loading state when reloading after an error -val request = LoadRequest.builder().keepContentOnLoad().build() +- **`true`** (default) - a current error is replaced by a loading state on + reload, i.e. content is kept silently only while it is actually loaded; a + stale error is not. +- **`false`** - a current error is also kept silently during the reload (the + previous error stays visible with a background-load indicator instead of + switching to `StoreResult.Loading`). -// keep even a previous error visible while reloading +```kotlin +// keep even a previous error visible while reloading after an invalidate() val request = LoadRequest.builder().keepContentOnLoad(replaceErrorsOnReload = false).build() + +// keep a previous error visible while the query changes +val request = LoadRequest.builder().keepContentOnQuery(replaceErrorsOnQuery = false).build() ``` +> **Note.** The keep-content and source-mode options are order-independent and +> each family may be set at most once - the builder's fluent type guides you so +> that, for example, `keepContentOnLoad()` cannot be followed by another +> `keepContentOnLoad*()` call. `keepContentOnQuery*()` only affects stores that +> actually have a query (`SimpleQueryStore`, `KeyedQueryStore`, +> `PagedQueryStore`); on stores without a query it is simply inert. + ## Observing Background Loads When content is kept on load, the reload happens "behind" the visible diff --git a/store/src/main/java/com/elveum/store/internal/load/LoadRequestBuilderImpl.kt b/store/src/main/java/com/elveum/store/internal/load/LoadRequestBuilderImpl.kt index 6fe4765..b2a3633 100644 --- a/store/src/main/java/com/elveum/store/internal/load/LoadRequestBuilderImpl.kt +++ b/store/src/main/java/com/elveum/store/internal/load/LoadRequestBuilderImpl.kt @@ -3,15 +3,12 @@ package com.elveum.store.internal.load import com.elveum.container.LoadConfig import com.elveum.container.ReplaceErrorsOnReload import com.elveum.store.load.LoadRequestBuilder -import com.elveum.store.load.LoadRequestKeepContentBuilder import com.elveum.store.load.LoadRequestSource -import com.elveum.store.load.LoadRequestSourceBuilder -internal class LoadRequestBuilderImpl : LoadRequestBuilder, - LoadRequestKeepContentBuilder, - LoadRequestSourceBuilder { +internal class LoadRequestBuilderImpl : LoadRequestBuilder { private var config: LoadConfig = LoadConfig.Normal + private var queryConfig: LoadConfig = LoadConfig.Normal private var requestSource: LoadRequestSource = LoadRequestSource.Default override fun offlineMode() = apply { @@ -23,21 +20,36 @@ internal class LoadRequestBuilderImpl : LoadRequestBuilder, } override fun keepContentOnLoad(replaceErrorsOnReload: Boolean) = apply { - config = if (replaceErrorsOnReload) { + config = keepContent(replaceErrorsOnReload) + } + + override fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean) = apply { + config = keepContentAndError(replaceErrorsOnReload) + } + + override fun keepContentOnQuery(replaceErrorsOnQuery: Boolean) = apply { + queryConfig = keepContent(replaceErrorsOnQuery) + } + + override fun keepContentOnQueryAndError(replaceErrorsOnQuery: Boolean) = apply { + queryConfig = keepContentAndError(replaceErrorsOnQuery) + } + + override fun build() = LoadRequestImpl(config, queryConfig ?: config, requestSource) + + private fun keepContent(replaceErrors: Boolean) = run { + if (replaceErrors) { LoadConfig.SilentLoading + ReplaceErrorsOnReload } else { LoadConfig.SilentLoading } } - override fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean) = apply { - config = if (replaceErrorsOnReload) { + private fun keepContentAndError(replaceErrors: Boolean) = run { + if (replaceErrors) { LoadConfig.SilentLoadingAndError + ReplaceErrorsOnReload } else { LoadConfig.SilentLoadingAndError } } - - override fun build() = LoadRequestImpl(config, requestSource) - } diff --git a/store/src/main/java/com/elveum/store/internal/load/LoadRequestImpl.kt b/store/src/main/java/com/elveum/store/internal/load/LoadRequestImpl.kt index 3102c44..5b04aab 100644 --- a/store/src/main/java/com/elveum/store/internal/load/LoadRequestImpl.kt +++ b/store/src/main/java/com/elveum/store/internal/load/LoadRequestImpl.kt @@ -6,7 +6,8 @@ import com.elveum.store.load.LoadRequestSource internal data class LoadRequestImpl( override val config: LoadConfig = LoadConfig.Normal, - override val requestSource: LoadRequestSource = LoadRequestSource.Default + override val queryConfig: LoadConfig = config, + override val requestSource: LoadRequestSource = LoadRequestSource.Default, ) : LoadRequest internal val LoadRequest.metadata get() = LoadRequestSourceMetadata(requestSource) diff --git a/store/src/main/java/com/elveum/store/internal/stores/common/CoreStore.kt b/store/src/main/java/com/elveum/store/internal/stores/common/CoreStore.kt index 5383ab9..245295b 100644 --- a/store/src/main/java/com/elveum/store/internal/stores/common/CoreStore.kt +++ b/store/src/main/java/com/elveum/store/internal/stores/common/CoreStore.kt @@ -2,6 +2,8 @@ package com.elveum.store.internal.stores.common +import com.elveum.container.ReloadFunction +import com.elveum.container.ReloadFunctionMetadata import com.elveum.container.cache.LazyCache import com.elveum.container.cache.listenReloadable import com.elveum.container.cache.reloadAsync @@ -16,7 +18,6 @@ import com.elveum.container.subject.LazyFlowSubject import com.elveum.store.internal.builders.SharedConfig import com.elveum.store.internal.load.metadata import com.elveum.store.internal.processItems -import com.elveum.store.internal.stores.common.KeyRecord import com.elveum.store.load.LoadRequest import com.elveum.store.load.LoadRequestSource import com.elveum.store.load.StoreResult @@ -92,7 +93,7 @@ internal class CoreStore( delay(queryDebounceMillis) val queriesAfterDelay = currentQueriesFlow.value if (queriesAfterDelay[key] == query) { - invalidate(key) + invalidate(key, isByQuery = true) } } @@ -148,28 +149,30 @@ internal class CoreStore( ?.drop(1) ?.debounce(queryDebounceMillis) ?.collect { - invalidateAsync(key) + invalidateAsync(key, isByQuery = true) } } awaitCancellation() } } - suspend fun invalidate(key: Key) { + suspend fun invalidate(key: Key, isByQuery: Boolean = false) { coroutineScope { getKeyRecords(key).forEach { launch { + val config = if (isByQuery) it.loadRequest.queryConfig else it.loadRequest.config cache - .reload(it, it.loadRequest.config, it.loadRequest.metadata) + .reload(it, config, it.loadRequest.metadata) .collect() } } } } - fun invalidateAsync(key: Key) { + fun invalidateAsync(key: Key, isByQuery: Boolean = false) { getKeyRecords(key).forEach { - cache.reloadAsync(it, it.loadRequest.config, it.loadRequest.metadata) + val config = if (isByQuery) it.loadRequest.queryConfig else it.loadRequest.config + cache.reloadAsync(it, config, it.loadRequest.metadata) } } @@ -197,6 +200,7 @@ internal class CoreStore( request: LoadRequest? ): Flow> { val loadRequestFlow = request?.let(::flowOf) ?: config.loadRequestFlow + val reloadFunctionRef: ReloadFunction = { invalidateAsync(key, isByQuery = false) } return loadRequestFlow .flatMapLatest { loadRequest -> val record = KeyRecord(key, loadRequest) @@ -223,7 +227,7 @@ internal class CoreStore( PreparedResult(currentResult, currentLoadRequest) } } - .map { it.result } + .map { it.result + ReloadFunctionMetadata(reloadFunctionRef) } .distinctUntilChanged() } diff --git a/store/src/main/java/com/elveum/store/load/LoadRequest.kt b/store/src/main/java/com/elveum/store/load/LoadRequest.kt index 8174835..44c049a 100644 --- a/store/src/main/java/com/elveum/store/load/LoadRequest.kt +++ b/store/src/main/java/com/elveum/store/load/LoadRequest.kt @@ -14,6 +14,11 @@ public interface LoadRequest { */ public val config: LoadConfig + /** + * How the load state is propagated to collectors when the load is triggered by a query. + */ + public val queryConfig: LoadConfig + /** * Which source should be used when loading data. */ @@ -33,7 +38,10 @@ public interface LoadRequest { * data. The background load state can be read using [StoreResult.backgroundLoadState] * property, or via extension: [StoreResult.isBackgroundLoading]. */ - public val Silent: LoadRequest = builder().keepContentOnLoad().build() + public val Silent: LoadRequest = builder() + .keepContentOnLoad() + .keepContentOnQuery() + .build() /** * Create a custom [LoadRequest] instance. See [LoadRequestBuilder] diff --git a/store/src/main/java/com/elveum/store/load/LoadRequestBuilder.kt b/store/src/main/java/com/elveum/store/load/LoadRequestBuilder.kt index 8afca4f..bd5e5b4 100644 --- a/store/src/main/java/com/elveum/store/load/LoadRequestBuilder.kt +++ b/store/src/main/java/com/elveum/store/load/LoadRequestBuilder.kt @@ -1,20 +1,88 @@ package com.elveum.store.load -public interface LoadRequestSourceBuilder : BaseLoadRequestBuilder { +public interface BaseLoadRequestBuilder { /** - * Use only cached data. If there is no cached values, emit an error. + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. */ - public fun offlineMode(): LoadRequestKeepContentBuilder + public fun build(): LoadRequest + +} + +public interface LoadRequestBuilder : + LoadRequestSourceModeBuilder, + LoadRequestKeepOnLoadBuilder, + LoadRequestKeepOnQueryBuilder, + LoadRequestKeepOnBuilder, + LoadRequestModeAndKeepQueryBuilder, + LoadRequestModeAndKeepLoadBuilder { + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQueryAndError(replaceErrorsOnQuery: Boolean): LoadRequestModeAndKeepLoadBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on query change instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQuery(replaceErrorsOnQuery: Boolean): LoadRequestModeAndKeepLoadBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean): LoadRequestModeAndKeepQueryBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnLoad(replaceErrorsOnReload: Boolean): LoadRequestModeAndKeepQueryBuilder /** * Ignore cached values and force re-fetching from the remote source. */ - public fun freshMode(): LoadRequestKeepContentBuilder + override fun freshMode(): LoadRequestKeepOnBuilder + /** + * Use only cached data. If there is no cached values, emit an error. + */ + override fun offlineMode(): LoadRequestKeepOnBuilder + + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest } -public interface LoadRequestKeepContentBuilder : BaseLoadRequestBuilder { + +public interface LoadRequestKeepOnBuilder : LoadRequestKeepOnLoadBuilder, LoadRequestKeepOnQueryBuilder { /** * Keep existing content in all observed flows while new content is being loaded. @@ -25,11 +93,11 @@ public interface LoadRequestKeepContentBuilder : BaseLoadRequestBuilder { * replaced by a loading state on reload instead of being kept silently; i.e. content is * kept only while it is actually loaded. */ - public fun keepContentOnLoad(replaceErrorsOnReload: Boolean = true): LoadRequestSourceBuilder + override fun keepContentOnLoad(replaceErrorsOnReload: Boolean): LoadRequestKeepOnQueryBuilder /** - * Keep existing content in all observed flows while new content is being loaded and - * if the new content loading has been failed. + * Keep existing content in all observed flows while new content is being loaded + * even if the new content loading has been failed. * * You can still observe the load state using background metadata. * @@ -37,20 +105,201 @@ public interface LoadRequestKeepContentBuilder : BaseLoadRequestBuilder { * replaced by a loading state on reload instead of being kept silently; i.e. content is * kept only while it is actually loaded. */ - public fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean = true): LoadRequestSourceBuilder + override fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean): LoadRequestKeepOnQueryBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on query change instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQuery(replaceErrorsOnQuery: Boolean): LoadRequestKeepOnLoadBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQueryAndError(replaceErrorsOnQuery: Boolean): LoadRequestKeepOnLoadBuilder + + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest } -public interface BaseLoadRequestBuilder { +public interface LoadRequestModeAndKeepQueryBuilder : LoadRequestSourceModeBuilder, LoadRequestKeepOnQueryBuilder { + + /** + * Use only cached data. If there is no cached values, emit an error. + */ + override fun offlineMode(): LoadRequestKeepOnQueryBuilder + + /** + * Ignore cached values and force re-fetching from the remote source. + */ + override fun freshMode(): LoadRequestKeepOnQueryBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on query change instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQuery(replaceErrorsOnQuery: Boolean): LoadRequestSourceModeBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnQueryAndError(replaceErrorsOnQuery: Boolean): LoadRequestSourceModeBuilder /** * Create a [LoadRequest] request instance based on configuration provided * by this builder. */ - public fun build(): LoadRequest + override fun build(): LoadRequest +} + +public interface LoadRequestModeAndKeepLoadBuilder : LoadRequestSourceModeBuilder, LoadRequestKeepOnLoadBuilder { + + /** + * Use only cached data. If there is no cached values, emit an error. + */ + override fun offlineMode(): LoadRequestKeepOnLoadBuilder + + /** + * Ignore cached values and force re-fetching from the remote source. + */ + override fun freshMode(): LoadRequestKeepOnLoadBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean): LoadRequestSourceModeBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + override fun keepContentOnLoad(replaceErrorsOnReload: Boolean): LoadRequestSourceModeBuilder + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest } -public interface LoadRequestBuilder : - LoadRequestSourceBuilder, - LoadRequestKeepContentBuilder, - BaseLoadRequestBuilder +public interface LoadRequestKeepOnQueryBuilder : BaseLoadRequestBuilder { + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on query change instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + public fun keepContentOnQuery(replaceErrorsOnQuery: Boolean = true): BaseLoadRequestBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded by changed query + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnQuery when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + public fun keepContentOnQueryAndError(replaceErrorsOnQuery: Boolean = true): BaseLoadRequestBuilder + + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest +} + +public interface LoadRequestKeepOnLoadBuilder : BaseLoadRequestBuilder { + + /** + * Keep existing content in all observed flows while new content is being loaded. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + public fun keepContentOnLoad(replaceErrorsOnReload: Boolean = true): BaseLoadRequestBuilder + + /** + * Keep existing content in all observed flows while new content is being loaded + * even if the new content loading has been failed. + * + * You can still observe the load state using background metadata. + * + * @param replaceErrorsOnReload when `true` (the default), a current *error* state is + * replaced by a loading state on reload instead of being kept silently; i.e. content is + * kept only while it is actually loaded. + */ + public fun keepContentOnLoadAndError(replaceErrorsOnReload: Boolean = true): BaseLoadRequestBuilder + + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest +} + +public interface LoadRequestSourceModeBuilder : BaseLoadRequestBuilder { + + /** + * Use only cached data. If there is no cached values, emit an error. + */ + public fun offlineMode(): BaseLoadRequestBuilder + + /** + * Ignore cached values and force re-fetching from the remote source. + */ + public fun freshMode(): BaseLoadRequestBuilder + + /** + * Create a [LoadRequest] request instance based on configuration provided + * by this builder. + */ + override fun build(): LoadRequest +} diff --git a/store/src/test/java/com/elveum/store/simple/SimpleQueryStoreTest.kt b/store/src/test/java/com/elveum/store/simple/SimpleQueryStoreTest.kt index 2a2a9d3..c501f87 100644 --- a/store/src/test/java/com/elveum/store/simple/SimpleQueryStoreTest.kt +++ b/store/src/test/java/com/elveum/store/simple/SimpleQueryStoreTest.kt @@ -39,7 +39,7 @@ class SimpleQueryStoreTest : AbstractSimpleStoreTest() { } @Test - fun `GIVEN submitted query with default request WHEN re-fetch is in progress THEN old content is kept`() = runFlowTest { + fun `GIVEN submitted query with silent request WHEN re-fetch is in progress THEN old content is kept`() = runFlowTest { val store = storeBuilder() .withQuery(initialQuery = "q1") .build { query -> @@ -58,6 +58,28 @@ class SimpleQueryStoreTest : AbstractSimpleStoreTest() { assertResult(StoreResult.Loaded("value-q2"), collector.lastItem) } + @Test + fun `GIVEN submitted query with only silent load WHEN re-fetch is in progress THEN old content is not kept`() = runFlowTest { + val store = storeBuilder() + .withQuery(initialQuery = "q1") + .build { query -> + delay(10) + "value-$query" + } + val loadRequest = LoadRequest.builder() + .keepContentOnLoad() + .build() + val collector = store.observe(loadRequest).startCollecting() + advanceTimeBy(11) // first load + + store.submitQueryAsync("q2") // observer keeps content while the new query loads + + advanceTimeBy(10) // almost done second load + assertResult(StoreResult.Loading, collector.lastItem) + advanceTimeBy(1) // now loaded + assertResult(StoreResult.Loaded("value-q2"), collector.lastItem) + } + @Test fun `GIVEN submitted query with non-silent request WHEN re-fetch is in progress THEN loading state is emitted`() = runFlowTest { val store = storeBuilder()