Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>, 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
}
Original file line number Diff line number Diff line change
@@ -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,
}
18 changes: 18 additions & 0 deletions model/src/main/kotlin/io/github/solcott/countries/model/Outcome.kt
Original file line number Diff line number Diff line change
@@ -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<out T> {
data class Data<out T>(val data: T, val origin: Origin) : Outcome<T>

data class Error(val cause: DataError, val origin: Origin) : Outcome<Nothing>
}

This file was deleted.

1 change: 1 addition & 0 deletions network/src/main/graphql/Countries.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ query Countries($filter: CountryFilterInput) {
emoji
capital
continent {
code
name
}
}
Expand Down
1 change: 1 addition & 0 deletions network/src/main/graphql/CountryDetail.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ query CountryDetail($code: ID!) {
currency
phone
continent {
code
name
}
languages {
Expand Down
15 changes: 15 additions & 0 deletions network/src/main/graphql/extra.graphqls
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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<T>(
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 <T> ContentState<T>.applyEmission(outcome: Outcome<T>): ContentState<T> =
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 <T> ContentState<T>.settled(): ContentState<T> =
if (status is LoadStatus.Loading) copy(status = LoadStatus.Idle) else this

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ 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
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
Expand All @@ -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<CountryDetail?>(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) {
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CountryDetail?> = 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<CountryDetail?>.isNotFound: Boolean
get() = status is LoadStatus.Idle && data == null
Loading