From e9a446ceeec506c22ad9c628f465fe2d64eee8fc Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Thu, 23 Jul 2026 16:21:17 -0600 Subject: [PATCH 1/3] Replace Response with transport-agnostic Outcome and ContentState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the data-load state model to be more robust and reusable across data sources, and to carry richer error and origin information. model: - Replace Response (Loading/Data/Error) with Outcome, a Data/Error result that drops Loading — an in-flight concern the consumer tracks, not a settled result. - Add DataError, a transport-neutral failure vocabulary (Network, Http, Api, Serialization, Unknown) so any client (Apollo, Ktor, Store) maps its own errors into a shared domain type. - Add Origin (Cache/Network), recorded on every Outcome. repository: - Map Apollo responses to Outcome via mapToOutcome, tagging Origin from isFromCache, categorizing ApolloExceptions into DataError, and dropping cache-miss emissions instead of surfacing them as errors. - Depend on apollo normalized-cache to read per-response cache metadata. - Remove the dead ContinentRepositoryImpl.kt. presenter: - Replace LoadState/CountriesState/ContinentsState with a single generic ContentState plus a LoadStatus (Idle/Loading/Failed) sum type, eliminating the boolean-soup that allowed illegal states. - Derive Loading from the flow lifecycle (onEach/onCompletion), making no assumption about fetch policy; re-assert Loading on refilter via collectLatest while keeping the current list visible. - Move CountryDetailScreen.State onto ContentState with a derived isNotFound. ui: - Resolve DataError to localized strings; show a RefreshingIndicator while reloading over already-visible data. --- .../solcott/countries/model/DataError.kt | 28 ++++ .../github/solcott/countries/model/Origin.kt | 7 + .../github/solcott/countries/model/Outcome.kt | 18 ++ .../solcott/countries/model/Response.kt | 19 --- .../countries/presenter/ContentState.kt | 55 ++++++ .../countries/presenter/ContinentsState.kt | 10 -- .../countries/presenter/CountriesState.kt | 10 -- .../presenter/CountryDetailPresenter.kt | 40 ++--- .../presenter/CountryDetailScreen.kt | 26 ++- .../presenter/CountryListPresenter.kt | 80 +++++---- .../countries/presenter/CountryListScreen.kt | 5 +- .../solcott/countries/presenter/LoadState.kt | 8 - .../presenter/CountryListPresenterTest.kt | 156 ++++++++++-------- repository/build.gradle.kts | 2 + .../repository/ContinentRepository.kt | 8 +- .../repository/ContinentRepositoryImpl.kt | 6 - .../countries/repository/CountryRepository.kt | 32 ++-- .../solcott/countries/repository/Mappers.kt | 74 +++++---- .../solcott/countries/ui/CountryDetailUi.kt | 18 +- .../solcott/countries/ui/CountryListUi.kt | 52 ++++-- .../solcott/countries/ui/DataErrorMessage.kt | 16 ++ ui/src/main/res/values/strings.xml | 5 + 22 files changed, 405 insertions(+), 270 deletions(-) create mode 100644 model/src/main/kotlin/io/github/solcott/countries/model/DataError.kt create mode 100644 model/src/main/kotlin/io/github/solcott/countries/model/Origin.kt create mode 100644 model/src/main/kotlin/io/github/solcott/countries/model/Outcome.kt delete mode 100644 model/src/main/kotlin/io/github/solcott/countries/model/Response.kt create mode 100644 presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContentState.kt delete mode 100644 presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContinentsState.kt delete mode 100644 presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountriesState.kt delete mode 100644 presenter/src/main/kotlin/io/github/solcott/countries/presenter/LoadState.kt delete mode 100644 repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepositoryImpl.kt create mode 100644 ui/src/main/kotlin/io/github/solcott/countries/ui/DataErrorMessage.kt diff --git a/model/src/main/kotlin/io/github/solcott/countries/model/DataError.kt b/model/src/main/kotlin/io/github/solcott/countries/model/DataError.kt new file mode 100644 index 0000000..7558b9d --- /dev/null +++ b/model/src/main/kotlin/io/github/solcott/countries/model/DataError.kt @@ -0,0 +1,28 @@ +package io.github.solcott.countries.model + +/** + * Transport-agnostic failure vocabulary. + * + * Errors are classified by what they mean to a consumer, never by which client produced them. Each + * data source (Apollo, Ktor, Store, …) maps its own errors into these cases at its own boundary, so + * nothing outside that boundary depends on a specific networking library. + */ +sealed interface DataError { + /** No usable response: offline, DNS failure, dropped connection, or timeout. */ + data object Network : DataError + + /** An HTTP response arrived with a non-success status code. */ + data class Http(val code: Int) : DataError + + /** + * The transport succeeded but the backend reported logical errors in the payload — GraphQL + * `errors`, a REST error envelope, and so on. + */ + data class Api(val messages: List, val code: String? = null) : DataError + + /** A response body arrived but could not be decoded, or did not match the expected schema. */ + data object Serialization : DataError + + /** Anything not otherwise classified. [cause] and [message] are retained for logging. */ + data class Unknown(val cause: Throwable? = null, val message: String? = null) : DataError +} diff --git a/model/src/main/kotlin/io/github/solcott/countries/model/Origin.kt b/model/src/main/kotlin/io/github/solcott/countries/model/Origin.kt new file mode 100644 index 0000000..45eaab4 --- /dev/null +++ b/model/src/main/kotlin/io/github/solcott/countries/model/Origin.kt @@ -0,0 +1,7 @@ +package io.github.solcott.countries.model + +/** Where a piece of data came from, or where an in-flight load is being served from. */ +enum class Origin { + Cache, + Network, +} diff --git a/model/src/main/kotlin/io/github/solcott/countries/model/Outcome.kt b/model/src/main/kotlin/io/github/solcott/countries/model/Outcome.kt new file mode 100644 index 0000000..5413ba5 --- /dev/null +++ b/model/src/main/kotlin/io/github/solcott/countries/model/Outcome.kt @@ -0,0 +1,18 @@ +package io.github.solcott.countries.model + +/** + * The result of a single data emission: either [Data] carrying a value, or an [Error] carrying a + * typed [DataError]. + * + * Loading is deliberately *not* modeled here. It is a property of an in-flight request, tracked by + * the consumer, rather than of a settled result — so a source may keep emitting [Outcome]s (cache, + * then network) while the consumer decides when the request is done. + * + * Every case records the [Origin] it came from, letting callers distinguish cached data from fresh + * network data. + */ +sealed interface Outcome { + data class Data(val data: T, val origin: Origin) : Outcome + + data class Error(val cause: DataError, val origin: Origin) : Outcome +} diff --git a/model/src/main/kotlin/io/github/solcott/countries/model/Response.kt b/model/src/main/kotlin/io/github/solcott/countries/model/Response.kt deleted file mode 100644 index 59154ef..0000000 --- a/model/src/main/kotlin/io/github/solcott/countries/model/Response.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.github.solcott.countries.model - -/** - * A sealed class representing the various states of a data request or operation. - * - * This is pretty basic and could be more robust. Add support for better error responses. For example device offline, http errors, no data found, etc - * - * Also, possibly add source of response (network, cache) - */ -sealed class Response { - class Loading : Response() - - data class Data(val data: T) : Response() - - data class Error(val message: String) : Response() - - val isLoading: Boolean = this is Loading - val isError: Boolean = this is Error -} diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContentState.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContentState.kt new file mode 100644 index 0000000..7f06394 --- /dev/null +++ b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContentState.kt @@ -0,0 +1,55 @@ +package io.github.solcott.countries.presenter + +import io.github.solcott.countries.model.DataError +import io.github.solcott.countries.model.Origin +import io.github.solcott.countries.model.Outcome + +/** + * View state for an asynchronously loaded piece of content. + * + * [data] is always present so the UI can keep showing the last known value while a refresh is in + * flight (stale-while-revalidate). [origin] records where that data came from, and [status] tracks + * the current request independently — the two together let the UI show, for example, cached data + * with an "updating" indicator. + */ +data class ContentState( + val data: T, + val origin: Origin? = null, + val status: LoadStatus = LoadStatus.Loading, +) + +/** + * The state of the request backing a [ContentState]. [Loading] means a request is in flight and + * makes no assumption about where it will be served from — that is a fetch-policy detail the + * consumer must not depend on. + */ +sealed interface LoadStatus { + data object Idle : LoadStatus + + data object Loading : LoadStatus + + data class Failed(val error: DataError) : LoadStatus +} + +/** True while a request is in flight. */ +val ContentState<*>.isLoading: Boolean + get() = status is LoadStatus.Loading + +/** The failure of the most recent request, or null if it did not fail. */ +val ContentState<*>.errorOrNull: DataError? + get() = (status as? LoadStatus.Failed)?.error + +/** + * Folds a single [Outcome] into the running state. Data replaces the held value and records its + * origin but leaves [status] as-is — the request is only considered settled once its flow completes + * (see the presenters). An error is recorded while the previously loaded data is kept on screen. + */ +fun ContentState.applyEmission(outcome: Outcome): ContentState = + when (outcome) { + is Outcome.Data -> copy(data = outcome.data, origin = outcome.origin) + is Outcome.Error -> copy(status = LoadStatus.Failed(outcome.cause)) + } + +/** Settles a still-loading request to [LoadStatus.Idle], leaving a failed or idle status untouched. */ +fun ContentState.settled(): ContentState = + if (status is LoadStatus.Loading) copy(status = LoadStatus.Idle) else this diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContinentsState.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContinentsState.kt deleted file mode 100644 index 5422538..0000000 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/ContinentsState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.solcott.countries.presenter - -import io.github.solcott.countries.model.Continent - -data class ContinentsState( - override val loading: Boolean = true, - override val data: List = emptyList(), - override val error: Boolean = false, - override val errorMessage: String? = null, -) : LoadState> \ No newline at end of file diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountriesState.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountriesState.kt deleted file mode 100644 index 9447766..0000000 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountriesState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.solcott.countries.presenter - -import io.github.solcott.countries.model.Country - -data class CountriesState( - override val loading: Boolean = true, - override val data: List = emptyList(), - override val error: Boolean = false, - override val errorMessage: String? = null, -) : LoadState> \ No newline at end of file diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt index 653cc6f..bcbc5a4 100644 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt +++ b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt @@ -3,8 +3,6 @@ package io.github.solcott.countries.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import com.slack.circuit.codegen.annotations.CircuitInject @@ -12,9 +10,12 @@ import com.slack.circuit.retained.produceRetainedState import com.slack.circuit.runtime.Navigator import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.CountryDetail import io.github.solcott.countries.repository.CountryRepository +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach @CircuitInject(CountryDetailScreen::class, AppScope::class) @Inject @@ -25,12 +26,19 @@ fun CountryDetailPresenter( repository: CountryRepository, ): CountryDetailScreen.State { var reloadKey by retain { mutableIntStateOf(0) } - val result by - produceRetainedState(initialValue = Response.Loading(), key1 = screen.code, reloadKey) { - repository.countryAsFlow(screen.code).distinctUntilChanged().collect { - value = it - } - } + val content by + produceRetainedState( + initialValue = ContentState(data = null), + key1 = screen.code, + reloadKey, + ) { + repository + .countryAsFlow(screen.code) + .distinctUntilChanged() + .onEach { value = value.applyEmission(it) } + .onCompletion { cause -> if (cause == null) value = value.settled() } + .collect() + } fun handle(event: CountryDetailScreen.Event) { when (event) { @@ -39,17 +47,5 @@ fun CountryDetailPresenter( } } - val isLoading = result.isLoading - val isError = result.isError - val country = (result as? Response.Data)?.data - val countryNotFound = !isLoading && !isError && country == null - val errorMessage = (result as? Response.Error)?.message - return CountryDetailScreen.State( - isLoading = isLoading, - country = country, - countryNotFound = countryNotFound, - isError = isError, - errorMessage = errorMessage, - eventSink = ::handle, - ) + return CountryDetailScreen.State(content = content, eventSink = ::handle) } diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailScreen.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailScreen.kt index 87996d5..56b251e 100644 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailScreen.kt +++ b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryDetailScreen.kt @@ -4,26 +4,24 @@ import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen import dev.zacsweers.redacted.annotations.Redacted -import io.github.solcott.countries.model.Country import io.github.solcott.countries.model.CountryDetail import kotlinx.parcelize.Parcelize @Parcelize data class CountryDetailScreen(val code: String) : Screen { - data class State( - val isLoading: Boolean = true, - val country: CountryDetail? = null, - val countryNotFound: Boolean = false, - val isError: Boolean = false, - val errorMessage: String? = null, - @Redacted - val eventSink: (Event) -> Unit - ) : CircuitUiState + data class State( + val content: ContentState = ContentState(data = null), + @Redacted val eventSink: (Event) -> Unit, + ) : CircuitUiState - sealed interface Event : CircuitUiEvent { - data object BackClicked : Event + sealed interface Event : CircuitUiEvent { + data object BackClicked : Event - data object Retry : Event - } + data object Retry : Event + } } + +/** A settled request that produced no country — the requested code does not exist. */ +val ContentState.isNotFound: Boolean + get() = status is LoadStatus.Idle && data == null diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt index 857726c..3105a39 100644 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt +++ b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt @@ -16,19 +16,20 @@ import com.slack.circuit.runtime.Navigator import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import io.github.solcott.countries.model.Continent -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.Country import io.github.solcott.countries.repository.ContinentRepository import io.github.solcott.countries.repository.CountryRepository import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@OptIn(FlowPreview::class) @CircuitInject(CountryListScreen::class, AppScope::class) @Inject @Composable @@ -40,60 +41,57 @@ fun CountryListPresenter( var reloadKey by retain { mutableIntStateOf(0) } val nameStartsWithText = rememberTextFieldState() val selectedContinents: SnapshotStateList = rememberSaveable { mutableStateListOf() } - val countriesLoadState by - produceRetainedState(initialValue = CountriesState(loading = true), key1 = reloadKey) { + val countriesState by + produceRetainedState( + initialValue = ContentState>(data = emptyList()), + key1 = reloadKey, + ) { combine( snapshotFlow { nameStartsWithText.text }.debounce(300.milliseconds), snapshotFlow { selectedContinents.toList() }, - ) { nameStartsWithText, continents -> - Pair(nameStartsWithText, continents) + ) { name, continents -> + name to continents } - .transformLatest { latest -> - emitAll( - repository - .countriesAsFlow(latest.first.toString(), latest.second.map { it.code }) - .distinctUntilChanged() - ) - } - .collect { countriesResponse -> - value = - CountriesState( - loading = countriesResponse.isLoading, - data = (countriesResponse as? Response.Data)?.data.orEmpty(), - error = countriesResponse.isError, - errorMessage = (countriesResponse as? Response.Error)?.message, - ) + .collectLatest { (name, continents) -> + // A new filter starts a fresh request: keep the current list visible but flag loading. + value = value.copy(status = LoadStatus.Loading) + repository + .countriesAsFlow(name.toString(), continents.map { it.code }) + .distinctUntilChanged() + .onEach { value = value.applyEmission(it) } + .onCompletion { cause -> if (cause == null) value = value.settled() } + .collect() } } val continentsState by - produceRetainedState(initialValue = ContinentsState(loading = true), key1 = reloadKey) { - continentRepository.continentsAsFlow().distinctUntilChanged().collect { - value = - ContinentsState( - loading = it.isLoading, - data = (it as? Response.Data)?.data.orEmpty(), - error = it.isError, - errorMessage = (it as? Response.Error)?.message, - ) - } + produceRetainedState( + initialValue = ContentState>(data = emptyList()), + key1 = reloadKey, + ) { + continentRepository + .continentsAsFlow() + .distinctUntilChanged() + .onEach { value = value.applyEmission(it) } + .onCompletion { cause -> if (cause == null) value = value.settled() } + .collect() } fun handle(event: CountryListScreen.Event) { when (event) { is CountryListScreen.Event.CountryClicked -> navigator.goTo(CountryDetailScreen(event.code)) CountryListScreen.Event.Retry -> reloadKey++ - is CountryListScreen.Event.ToggleContinentSelection ->{ - if(selectedContinents.contains(event.continent)){ - selectedContinents.remove(event.continent) - } else { - selectedContinents.add(event.continent) - } + is CountryListScreen.Event.ToggleContinentSelection -> { + if (selectedContinents.contains(event.continent)) { + selectedContinents.remove(event.continent) + } else { + selectedContinents.add(event.continent) + } } } } return CountryListScreen.State( nameStartsWithText = nameStartsWithText, - countriesState = countriesLoadState, + countriesState = countriesState, continentsState = continentsState, selectedContinents = selectedContinents, eventSink = ::handle, diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListScreen.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListScreen.kt index e57c842..23ee426 100644 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListScreen.kt +++ b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/CountryListScreen.kt @@ -6,6 +6,7 @@ import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen import dev.zacsweers.redacted.annotations.Redacted import io.github.solcott.countries.model.Continent +import io.github.solcott.countries.model.Country import kotlinx.parcelize.Parcelize @Parcelize @@ -13,8 +14,8 @@ data object CountryListScreen : Screen { data class State( val nameStartsWithText: TextFieldState, - val countriesState: CountriesState, - val continentsState: ContinentsState, + val countriesState: ContentState>, + val continentsState: ContentState>, val selectedContinents: List, @Redacted val eventSink: (Event) -> Unit, ) : CircuitUiState diff --git a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/LoadState.kt b/presenter/src/main/kotlin/io/github/solcott/countries/presenter/LoadState.kt deleted file mode 100644 index aa91083..0000000 --- a/presenter/src/main/kotlin/io/github/solcott/countries/presenter/LoadState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.solcott.countries.presenter - -interface LoadState { - val loading: Boolean - val data: T - val error: Boolean - val errorMessage: String? -} diff --git a/presenter/src/test/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt b/presenter/src/test/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt index 3d48c01..ce6ea50 100644 --- a/presenter/src/test/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt +++ b/presenter/src/test/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt @@ -1,12 +1,15 @@ package io.github.solcott.countries.presenter import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import app.cash.turbine.ReceiveTurbine import com.slack.circuit.test.FakeNavigator import com.slack.circuit.test.presenterTestOf import io.github.solcott.countries.model.Continent import io.github.solcott.countries.model.Country import io.github.solcott.countries.model.CountryDetail -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.DataError +import io.github.solcott.countries.model.Origin +import io.github.solcott.countries.model.Outcome import io.github.solcott.countries.repository.ContinentRepository import io.github.solcott.countries.repository.CountryRepository import kotlin.collections.emptyList @@ -72,18 +75,17 @@ class CountryListPresenterTest { fun `loads countries and emits loaded state`() = runTest { val navigator = FakeNavigator(CountryListScreen) val repository = - FakeCountryRepository( - countriesAsFlow = { _, _ -> flowOf(Response.Loading(), Response.Data(listOf(canada))) } - ) + FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) val continentRepository = FakeContinentRepository() presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - val loadingState = awaitItem() // Loading - assertTrue(loadingState.countriesState.loading) + assertTrue(awaitItem().countriesState.isLoading) - val loaded = awaitItem() - assertEquals(listOf(canada), loaded.countriesState.data) + val loaded = awaitCountriesSettled() + assertEquals(listOf(canada), loaded.data) + assertEquals(Origin.Network, loaded.origin) + cancelAndIgnoreRemainingEvents() } } @@ -94,38 +96,37 @@ class CountryListPresenterTest { FakeCountryRepository( countriesAsFlow = { name, cs -> if (name.startsWith("c") && cs.isEmpty()) { - flowOf(Response.Data(listOf(canada))) + flowOf(data(listOf(canada))) } else if (name.startsWith("c") && cs.contains(europe.code)) { - flowOf(Response.Data(emptyList())) + flowOf(data(emptyList())) } else if (name.startsWith("e") && cs.contains(africa.code)) { - flowOf(Response.Data(listOf(egypt))) + flowOf(data(listOf(egypt))) } else { - flowOf(Response.Data(countries)) + flowOf(data(countries)) } } ) val continentRepository = - FakeContinentRepository(continentsAsFlow = { flowOf(Response.Data(continents)) }) + FakeContinentRepository(continentsAsFlow = { flowOf(data(continents)) }) presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - val loading = awaitItem() - val loading2 = awaitItem() - val loaded = awaitItem() - assertEquals(countries, loaded.countriesState.data) - assertEquals(continents, loaded.continentsState.data) - loaded.nameStartsWithText.setTextAndPlaceCursorAtEnd("c") - val filteredByName = awaitItem() - assertEquals(listOf(canada), filteredByName.countriesState.data) - filteredByName.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) - val addEurope = awaitItem() - assertEquals(emptyList(), addEurope.countriesState.data) - addEurope.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) - val removeEurope = awaitItem() - assertEquals(listOf(canada), removeEurope.countriesState.data) - removeEurope.nameStartsWithText.setTextAndPlaceCursorAtEnd("") - val removeNameC = awaitItem() - assertEquals(countries, removeNameC.countriesState.data) + val initial = awaitFullySettled() + assertEquals(countries, initial.countriesState.data) + assertEquals(continents, initial.continentsState.data) + + initial.nameStartsWithText.setTextAndPlaceCursorAtEnd("c") + assertEquals(listOf(canada), awaitCountriesSettled().data) + + initial.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) + assertEquals(emptyList(), awaitCountriesSettled().data) + + initial.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) + assertEquals(listOf(canada), awaitCountriesSettled().data) + + initial.nameStartsWithText.setTextAndPlaceCursorAtEnd("") + assertEquals(countries, awaitCountriesSettled().data) + cancelAndIgnoreRemainingEvents() } } @@ -133,18 +134,16 @@ class CountryListPresenterTest { fun `clicking a country navigates to detail`() = runTest { val navigator = FakeNavigator(CountryListScreen) val repository = - FakeCountryRepository( - countriesAsFlow = { _, _ -> flowOf(Response.Loading(), Response.Data(listOf(canada))) } - ) + FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) val continentRepository = FakeContinentRepository() presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - awaitItem() // Loading - val loaded = awaitItem() + val loaded = awaitCountriesSettledState() loaded.eventSink(CountryListScreen.Event.CountryClicked("CA")) assertEquals(CountryDetailScreen("CA"), navigator.awaitNextScreen()) + cancelAndIgnoreRemainingEvents() } } @@ -153,17 +152,18 @@ class CountryListPresenterTest { val navigator = FakeNavigator(CountryListScreen) val repository = FakeCountryRepository( - countriesAsFlow = { _, _ -> flowOf(Response.Loading(), Response.Error("network down")) } + countriesAsFlow = { _, _ -> flowOf(Outcome.Error(DataError.Network, Origin.Network)) } ) val continentRepository = FakeContinentRepository() presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - val loadingState = awaitItem() // Loading - assertTrue(loadingState.countriesState.loading) - val errorState = awaitItem() - assertTrue(errorState.countriesState.error) - assertEquals("network down", errorState.countriesState.errorMessage) + assertTrue(awaitItem().countriesState.isLoading) + + val errorState = awaitCountriesSettled() + assertTrue(errorState.status is LoadStatus.Failed) + assertEquals(DataError.Network, errorState.errorOrNull) + cancelAndIgnoreRemainingEvents() } } @@ -173,19 +173,14 @@ class CountryListPresenterTest { val repository = FakeCountryRepository() val continentRepository = - FakeContinentRepository( - continentsAsFlow = { - flowOf(Response.Loading(), Response.Data(listOf(europe))) - } - ) + FakeContinentRepository(continentsAsFlow = { flowOf(data(listOf(europe))) }) presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - val loadingState = awaitItem() // Loading - println(loadingState) - assertTrue(loadingState.continentsState.loading) + assertTrue(awaitItem().continentsState.isLoading) - val loaded = awaitItem() - assertEquals(listOf(europe), loaded.continentsState.data) + val loaded = awaitContinentsSettled() + assertEquals(listOf(europe), loaded.data) + cancelAndIgnoreRemainingEvents() } } @@ -197,41 +192,72 @@ class CountryListPresenterTest { val continentRepository = FakeContinentRepository() presenterTestOf({ CountryListPresenter(navigator, repository, continentRepository) }) { - val state = awaitItem() // Loading + val state = awaitItem() state.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) assertEquals(listOf(europe), state.selectedContinents) state.eventSink(CountryListScreen.Event.ToggleContinentSelection(europe)) assertTrue(state.selectedContinents.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + private fun data(value: T, origin: Origin = Origin.Network): Outcome = + Outcome.Data(value, origin) +} + +/** Drains emissions until the country content has settled (loaded or failed), returning that state. */ +private suspend fun ReceiveTurbine.awaitCountriesSettledState(): + CountryListScreen.State { + while (true) { + val state = awaitItem() + if (state.countriesState.status !is LoadStatus.Loading) return state + } +} + +private suspend fun ReceiveTurbine.awaitCountriesSettled(): + ContentState> = awaitCountriesSettledState().countriesState + +private suspend fun ReceiveTurbine.awaitContinentsSettled(): + ContentState> { + while (true) { + val state = awaitItem() + if (state.continentsState.status !is LoadStatus.Loading) return state.continentsState + } +} + +/** Drains until both the country and continent content have settled. */ +private suspend fun ReceiveTurbine.awaitFullySettled(): + CountryListScreen.State { + while (true) { + val state = awaitItem() + if ( + state.countriesState.status !is LoadStatus.Loading && + state.continentsState.status !is LoadStatus.Loading + ) { + return state } } } class FakeCountryRepository( private val countriesAsFlow: - ( - nameStartsWith: String, - continentCodes: List, - ) -> Flow>> = + (nameStartsWith: String, continentCodes: List) -> Flow>> = { _, _ -> emptyFlow() }, - private val countryAsFlow: (code: String) -> Flow> = { - emptyFlow() - }, + private val countryAsFlow: (code: String) -> Flow> = { emptyFlow() }, ) : CountryRepository { override fun countriesAsFlow( nameStartsWith: String, continentCodes: List, - ): Flow>> = countriesAsFlow.invoke(nameStartsWith, continentCodes) + ): Flow>> = countriesAsFlow.invoke(nameStartsWith, continentCodes) - override fun countryAsFlow(code: String): Flow> = + override fun countryAsFlow(code: String): Flow> = countryAsFlow.invoke(code) } class FakeContinentRepository( - private val continentsAsFlow: () -> Flow>> = { - emptyFlow() - }, + private val continentsAsFlow: () -> Flow>> = { emptyFlow() } ) : ContinentRepository { - override fun continentsAsFlow(): Flow>> = continentsAsFlow.invoke() + override fun continentsAsFlow(): Flow>> = continentsAsFlow.invoke() } diff --git a/repository/build.gradle.kts b/repository/build.gradle.kts index f688d2d..341cf92 100644 --- a/repository/build.gradle.kts +++ b/repository/build.gradle.kts @@ -6,6 +6,8 @@ plugins { dependencies { api(project(":model")) implementation(project(":network")) + // Read-only access to Apollo's per-response cache metadata (isFromCache) for Origin mapping. + implementation(libs.apollo.normalized.cache) testImplementation(libs.junit) } diff --git a/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepository.kt b/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepository.kt index e39f5f2..554f61b 100644 --- a/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepository.kt +++ b/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepository.kt @@ -5,20 +5,20 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.github.solcott.countries.model.Continent -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.Outcome import io.github.solcott.countries.network.ContinentsApi import kotlinx.coroutines.flow.Flow interface ContinentRepository { - fun continentsAsFlow(): Flow>> + fun continentsAsFlow(): Flow>> } @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) @Inject internal class ContinentRepositoryImpl(private val api: ContinentsApi) : ContinentRepository { - override fun continentsAsFlow(): Flow>> { - return api.continentsAsFlow().mapToResponse { continents.map { Continent(it.code, it.name) } } + override fun continentsAsFlow(): Flow>> { + return api.continentsAsFlow().mapToOutcome { continents.map { Continent(it.code, it.name) } } } } diff --git a/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepositoryImpl.kt b/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepositoryImpl.kt deleted file mode 100644 index 42d47a8..0000000 --- a/repository/src/main/kotlin/io/github/solcott/countries/repository/ContinentRepositoryImpl.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.solcott.countries.repository - -import io.github.solcott.countries.model.Continent -import io.github.solcott.countries.model.Response -import kotlinx.coroutines.flow.Flow - diff --git a/repository/src/main/kotlin/io/github/solcott/countries/repository/CountryRepository.kt b/repository/src/main/kotlin/io/github/solcott/countries/repository/CountryRepository.kt index 728c758..59a9571 100644 --- a/repository/src/main/kotlin/io/github/solcott/countries/repository/CountryRepository.kt +++ b/repository/src/main/kotlin/io/github/solcott/countries/repository/CountryRepository.kt @@ -1,23 +1,22 @@ package io.github.solcott.countries.repository -import com.apollographql.apollo.api.ApolloResponse -import com.apollographql.apollo.api.Operation import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.github.solcott.countries.model.Country import io.github.solcott.countries.model.CountryDetail -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.Outcome import io.github.solcott.countries.network.CountriesApi -import kotlin.collections.map import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart interface CountryRepository { - fun countriesAsFlow(nameStartsWith: String, continentCodes: List = emptyList()): Flow>> - fun countryAsFlow(code: String): Flow> + fun countriesAsFlow( + nameStartsWith: String, + continentCodes: List = emptyList(), + ): Flow>> + + fun countryAsFlow(code: String): Flow> } /** @@ -29,11 +28,14 @@ interface CountryRepository { @SingleIn(AppScope::class) internal class CountryRepositoryImpl(private val api: CountriesApi) : CountryRepository { - override fun countriesAsFlow(nameStartsWith:String, continentCodes: List): Flow>> = - api.countriesAsFlow(nameStartsWith, continentCodes).mapToResponse { countries.map { country -> country.toModel() } } - - override fun countryAsFlow(code: String): Flow> = api.countryAsFlow(code).mapToResponse { - country?.toModel() - } + override fun countriesAsFlow( + nameStartsWith: String, + continentCodes: List, + ): Flow>> = + api.countriesAsFlow(nameStartsWith, continentCodes).mapToOutcome { + countries.map { country -> country.toModel() } + } -} \ No newline at end of file + override fun countryAsFlow(code: String): Flow> = + api.countryAsFlow(code).mapToOutcome { country?.toModel() } +} diff --git a/repository/src/main/kotlin/io/github/solcott/countries/repository/Mappers.kt b/repository/src/main/kotlin/io/github/solcott/countries/repository/Mappers.kt index 9e8f992..53f091c 100644 --- a/repository/src/main/kotlin/io/github/solcott/countries/repository/Mappers.kt +++ b/repository/src/main/kotlin/io/github/solcott/countries/repository/Mappers.kt @@ -3,40 +3,32 @@ package io.github.solcott.countries.repository import android.util.Log import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.Operation -import com.apollographql.apollo.exception.ApolloCompositeException -import com.apollographql.apollo.exception.ApolloGraphQLException +import com.apollographql.apollo.exception.ApolloException import com.apollographql.apollo.exception.ApolloHttpException import com.apollographql.apollo.exception.ApolloNetworkException import com.apollographql.apollo.exception.ApolloOfflineException -import com.apollographql.apollo.exception.ApolloParseException -import com.apollographql.apollo.exception.ApolloWebSocketClosedException -import com.apollographql.apollo.exception.ApolloWebSocketForceCloseException -import com.apollographql.apollo.exception.AutoPersistedQueriesNotSupported import com.apollographql.apollo.exception.CacheMissException -import com.apollographql.apollo.exception.DefaultApolloException import com.apollographql.apollo.exception.HttpCacheMissException import com.apollographql.apollo.exception.JsonDataException import com.apollographql.apollo.exception.JsonEncodingException -import com.apollographql.apollo.exception.MissingValueException -import com.apollographql.apollo.exception.NoDataException -import com.apollographql.apollo.exception.NullOrMissingField -import com.apollographql.apollo.exception.RouterError -import com.apollographql.apollo.exception.SubscriptionConnectionException -import com.apollographql.apollo.exception.SubscriptionOperationException +import com.apollographql.cache.normalized.isFromCache import io.github.solcott.countries.model.Country import io.github.solcott.countries.model.CountryDetail +import io.github.solcott.countries.model.DataError import io.github.solcott.countries.model.Language -import io.github.solcott.countries.model.Response +import io.github.solcott.countries.model.Origin +import io.github.solcott.countries.model.Outcome import io.github.solcott.countries.network.graphql.CountriesQuery import io.github.solcott.countries.network.graphql.CountryDetailQuery import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull /** * Mapping from Apollo generated types to `model` types. This file is the only place generated * GraphQL classes are allowed to appear alongside domain types. */ +private const val TAG = "CountriesRepository" + internal fun CountriesQuery.Country.toModel() = Country( code = code, @@ -59,26 +51,36 @@ internal fun CountryDetailQuery.Country.toModel() = languages = languages.map { Language(code = it.code, name = it.name) }, ) -internal fun Flow>.mapToResponse(mapSuccess: T.() -> R) = - mapNotNull { response -> - if (response.hasErrors()) { - // TODO come up with better approach to errors - Response.Error(response.errors!!.first().message) - } else { - val exception = response.exception - if(exception != null){ - Log.e("${this::class.simpleName}", "Error occurred", exception) - when(exception){ - is HttpCacheMissException, is CacheMissException -> null - else -> { - Log.e("${this::class.simpleName}", "Error occurred", exception) - Response.Error(exception.message ?: "An error occurred") // TODO handle better and log - } - } - } - else { - Response.Data(response.dataOrThrow().mapSuccess()) - } - } +/** + * Maps each Apollo response to a transport-agnostic [Outcome], tagged with the [Origin] it was + * served from. Cache-miss responses are dropped rather than surfaced as errors: under a + * cache-then-network policy a network response follows, and under a cache-only lookup the empty + * result is handled upstream. + */ +internal fun Flow>.mapToOutcome( + mapSuccess: T.() -> R +): Flow> = mapNotNull { response -> + val origin = if (response.isFromCache) Origin.Cache else Origin.Network + val exception = response.exception + when { + response.hasErrors() -> + Outcome.Error(DataError.Api(response.errors.orEmpty().map { it.message }), origin) + exception is CacheMissException || exception is HttpCacheMissException -> null + exception != null -> { + Log.e(TAG, "Data request failed", exception) + Outcome.Error(exception.toDataError(), origin) } + else -> Outcome.Data(response.dataOrThrow().mapSuccess(), origin) + } +} +/** Categorizes an [ApolloException] into the transport-agnostic [DataError] vocabulary. */ +private fun ApolloException.toDataError(): DataError = + when (this) { + is ApolloOfflineException, + is ApolloNetworkException -> DataError.Network + is ApolloHttpException -> DataError.Http(statusCode) + is JsonDataException, + is JsonEncodingException -> DataError.Serialization + else -> DataError.Unknown(cause = this, message = message) + } diff --git a/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt b/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt index 7989d14..ba6ab46 100644 --- a/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt +++ b/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.viewinterop.AndroidView import com.slack.circuit.codegen.annotations.CircuitInject import dev.zacsweers.metro.AppScope import io.github.solcott.countries.presenter.CountryDetailScreen +import io.github.solcott.countries.presenter.errorOrNull +import io.github.solcott.countries.presenter.isLoading +import io.github.solcott.countries.presenter.isNotFound import io.github.solcott.countries.ui.databinding.ViewCountryDetailBinding /** @@ -47,15 +50,18 @@ fun CountryDetailUi(state: CountryDetailScreen.State, screen: CountryDetailScree .padding(innerPadding), contentAlignment = Alignment.Center, ) { + val content = state.content + val country = content.data + val error = content.errorOrNull when { - state.isLoading -> CircularProgressIndicator() - state.isError -> + country == null && content.isLoading -> CircularProgressIndicator() + country == null && error != null -> ErrorContent( - message = state.errorMessage ?: stringResource(R.string.unknown_error_occurred), + message = error.toUserMessage(), onRetry = { state.eventSink(CountryDetailScreen.Event.Retry) }, ) - state.countryNotFound -> { - Text(stringResource(R.string.country_with_code_not_found, screen.code)) + content.isNotFound -> { + Text(stringResource(R.string.country_with_code_not_found, screen.code)) } else -> { AndroidView( @@ -65,7 +71,7 @@ fun CountryDetailUi(state: CountryDetailScreen.State, screen: CountryDetailScree }, update = { root -> val context = root.context - val country = checkNotNull(state.country) + val country = checkNotNull(country) with(ViewCountryDetailBinding.bind(root)) { flag.text = country.emoji name.text = country.name diff --git a/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryListUi.kt b/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryListUi.kt index a084864..9c4982d 100644 --- a/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryListUi.kt +++ b/ui/src/main/kotlin/io/github/solcott/countries/ui/CountryListUi.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,9 +40,10 @@ import com.slack.circuit.codegen.annotations.CircuitInject import dev.zacsweers.metro.AppScope import io.github.solcott.countries.model.Continent import io.github.solcott.countries.model.Country -import io.github.solcott.countries.presenter.ContinentsState -import io.github.solcott.countries.presenter.CountriesState +import io.github.solcott.countries.presenter.ContentState import io.github.solcott.countries.presenter.CountryListScreen +import io.github.solcott.countries.presenter.errorOrNull +import io.github.solcott.countries.presenter.isLoading @OptIn(ExperimentalMaterial3Api::class) @CircuitInject(CountryListScreen::class, AppScope::class) @@ -58,12 +60,12 @@ fun CountryListUi(state: CountryListScreen.State, modifier: Modifier = Modifier) contentAlignment = Alignment.Center, ) { val countriesState = state.countriesState + val error = countriesState.errorOrNull when { - countriesState.loading -> CircularProgressIndicator() - countriesState.error -> + countriesState.data.isEmpty() && countriesState.isLoading -> CircularProgressIndicator() + countriesState.data.isEmpty() && error != null -> ErrorContent( - message = - countriesState.errorMessage ?: stringResource(R.string.unknown_error_occurred), + message = error.toUserMessage(), onRetry = { state.eventSink(CountryListScreen.Event.Retry) }, ) else -> CountriesList(state, countriesState, Modifier.fillMaxSize()) @@ -75,10 +77,10 @@ fun CountryListUi(state: CountryListScreen.State, modifier: Modifier = Modifier) @Composable private fun CountriesList( state: CountryListScreen.State, - countriesState: CountriesState, + countriesState: ContentState>, modifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier.imePadding()) { + LazyColumn(modifier = modifier.fillMaxSize().imePadding()) { stickyHeader { SearchAndFilterHeader( state.continentsState, @@ -87,8 +89,16 @@ private fun CountriesList( onToggleContinentSelection = { continent -> state.eventSink(CountryListScreen.Event.ToggleContinentSelection(continent)) }, + modifier = Modifier.animateItem() ) } + // Data is already on screen while a refresh runs (e.g. cache shown, network in flight) — keep + // it visible and surface the in-flight request rather than blanking to a spinner. + if (countriesState.isLoading) { + item("loading_network", "loading_network") { + RefreshingIndicator(modifier = Modifier.animateItem()) + } + } val countries = countriesState.data if (countries.isNotEmpty()) { items(countries, key = Country::code, contentType = { "country" }) { country -> @@ -97,12 +107,13 @@ private fun CountriesList( onClick = { state.eventSink(CountryListScreen.Event.CountryClicked(country.code)) }, + modifier = Modifier.animateItem() ) - HorizontalDivider() + HorizontalDivider(modifier = Modifier.animateItem(), ) } } else { item(key = "empty", "empty") { - Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { + Box(Modifier.fillParentMaxSize().animateItem(), contentAlignment = Alignment.Center) { Text( stringResource(R.string.no_countries_found), ) @@ -112,17 +123,34 @@ private fun CountriesList( } } +@Composable +private fun RefreshingIndicator(modifier: Modifier = Modifier) { + Row( + modifier = + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.updating)) + LinearProgressIndicator(modifier = Modifier.weight(1f)) + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun SearchAndFilterHeader( - continentsState: ContinentsState, + continentsState: ContentState>, nameStartsWithText: TextFieldState, selectedContinents: List, onToggleContinentSelection: (Continent) -> Unit, + modifier: Modifier = Modifier ) { var continentDropdownExpanded by remember { mutableStateOf(false) } Row( - Modifier.fillMaxWidth() + modifier.fillMaxWidth() .background(MaterialTheme.colorScheme.surface) .padding(vertical = 8.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), diff --git a/ui/src/main/kotlin/io/github/solcott/countries/ui/DataErrorMessage.kt b/ui/src/main/kotlin/io/github/solcott/countries/ui/DataErrorMessage.kt new file mode 100644 index 0000000..86755cb --- /dev/null +++ b/ui/src/main/kotlin/io/github/solcott/countries/ui/DataErrorMessage.kt @@ -0,0 +1,16 @@ +package io.github.solcott.countries.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.github.solcott.countries.model.DataError + +/** Resolves a transport-agnostic [DataError] into a localized, user-facing message. */ +@Composable +fun DataError.toUserMessage(): String = + when (this) { + DataError.Network -> stringResource(R.string.error_offline) + is DataError.Http -> stringResource(R.string.error_http, code) + is DataError.Api -> stringResource(R.string.error_api) + DataError.Serialization -> stringResource(R.string.error_data) + is DataError.Unknown -> stringResource(R.string.unknown_error_occurred) + } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 68ed6e6..b753882 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ Calling code: +%1$s Languages: %1$s An unknown error occurred + You appear to be offline. Check your connection and try again. + The server returned an error (%1$d). + The server was unable to complete your request. + Something went wrong reading the response. + Updating… No countries found Countries Clear From 59c64c5c155a58522abbe701aa8abf8bcc7c0437 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Thu, 23 Jul 2026 16:22:26 -0600 Subject: [PATCH 2/3] Update Android Gradle Plugin to 9.3.1. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 337030f..61c2def 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # Build tooling. AGP 9.x ships built-in Kotlin support pinned to an older KGP; the root # buildscript classpath overrides it to the Kotlin version Metro and Compose need. -agp = "9.3.0" +agp = "9.3.1" kotlin = "2.4.10" metro = "1.3.2" apollo = "5.0.1" From 1221a35c897d2b861309869c054c25558c885d83 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Thu, 23 Jul 2026 16:53:03 -0600 Subject: [PATCH 3/3] Configure Apollo normalized caching for core domain entities. This change introduces client-side cache normalization policies to ensure that entity data is shared and kept consistent across different GraphQL queries. * **Cache Configuration**: Added `extra.graphqls` to define `@typePolicy` for `Country`, `Continent`, and `Language` types, using the `code` field as the unique cache key. * **GraphQL Queries**: Updated `Countries.graphql` and `CountryDetail.graphql` to explicitly select the `code` field for nested continent objects, satisfying the normalization requirement that key fields must be present in the selection set. --- network/src/main/graphql/Countries.graphql | 1 + network/src/main/graphql/CountryDetail.graphql | 1 + network/src/main/graphql/extra.graphqls | 15 +++++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 network/src/main/graphql/extra.graphqls diff --git a/network/src/main/graphql/Countries.graphql b/network/src/main/graphql/Countries.graphql index 1581c5e..8039b1f 100644 --- a/network/src/main/graphql/Countries.graphql +++ b/network/src/main/graphql/Countries.graphql @@ -12,6 +12,7 @@ query Countries($filter: CountryFilterInput) { emoji capital continent { + code name } } diff --git a/network/src/main/graphql/CountryDetail.graphql b/network/src/main/graphql/CountryDetail.graphql index 6388625..43fe774 100644 --- a/network/src/main/graphql/CountryDetail.graphql +++ b/network/src/main/graphql/CountryDetail.graphql @@ -8,6 +8,7 @@ query CountryDetail($code: ID!) { currency phone continent { + code name } languages { diff --git a/network/src/main/graphql/extra.graphqls b/network/src/main/graphql/extra.graphqls new file mode 100644 index 0000000..ab04470 --- /dev/null +++ b/network/src/main/graphql/extra.graphqls @@ -0,0 +1,15 @@ +# Client-side cache configuration (not part of the server schema). +# +# Normalize these types by their `code` so every query that returns them shares a single cache +# record per entity instead of storing independent copies. With a shared record, a future cache +# write to one entity is visible to every query that read it. Normalization by key requires `code` +# to be selected in every selection of the type — including the nested `continent { code name }` in +# the Countries and CountryDetail queries. +extend schema +@link(url: "https://specs.apollo.dev/kotlin_labs/v0.3", import: ["@typePolicy"]) + +extend type Country @typePolicy(keyFields: "code") + +extend type Continent @typePolicy(keyFields: "code") + +extend type Language @typePolicy(keyFields: "code")