REDI-101: replace em/en dashes with hyphens in prose & comments
Em dashes are a common AI-writing tell; swap them (and en dashes) for plain hyphens across the README and all KDoc/comment prose so the repo reads as hand-authored. Byte-level replace of U+2014/U+2013 -> '-'; arrows and the ellipsis are left untouched. The two functional em dashes are intentionally kept: the `DASH = "—"` blank-field UI placeholder in CharacterDetailUi and the preview sample that mirrors it -- those are deliberate UX, not prose.
This commit is contained in:
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
|
||||
data object AboutRoute
|
||||
|
||||
/**
|
||||
* The About feature nav graph. It only needs a "go back" callback — `:app` wires it to the shared
|
||||
* The About feature nav graph. It only needs a "go back" callback - `:app` wires it to the shared
|
||||
* NavController, keeping this feature decoupled from how it is reached.
|
||||
*/
|
||||
fun NavGraphBuilder.aboutGraph(
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.example.architecture.feature.about.presentation.model.AboutLink
|
||||
|
||||
/**
|
||||
* Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects
|
||||
* [AboutState] and passes the ViewModel's **method reference** straight through — there is no
|
||||
* [AboutState] and passes the ViewModel's **method reference** straight through - there is no
|
||||
* `onAction` funnel and no event observation, because this screen has neither.
|
||||
*/
|
||||
@Composable
|
||||
@@ -88,7 +88,7 @@ fun AboutScreen(
|
||||
Text(text = "• $highlight", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
// The expandable card is driven entirely by the VM's plain method — the MVVM contrast.
|
||||
// The expandable card is driven entirely by the VM's plain method - the MVVM contrast.
|
||||
AppCard(
|
||||
onClick = onToggleMvvmNote,
|
||||
header = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* **MVVM — the deliberate contrast to the app's MVI screens.**
|
||||
* **MVVM - the deliberate contrast to the app's MVI screens.**
|
||||
*
|
||||
* There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and
|
||||
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
|
||||
@@ -37,7 +37,7 @@ class AboutViewModel : ViewModel() {
|
||||
),
|
||||
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " +
|
||||
"and emits one-time effects (navigation, snackbars) through an Event channel. That " +
|
||||
"structure pays off when state is complex and interacting — like the paginated, " +
|
||||
"structure pays off when state is complex and interacting - like the paginated, " +
|
||||
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
|
||||
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " +
|
||||
"no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
|
||||
@@ -56,7 +56,7 @@ class AboutViewModel : ViewModel() {
|
||||
)
|
||||
val state: StateFlow<AboutState> = _state.asStateFlow()
|
||||
|
||||
/** MVVM: a plain public method mutates state directly — no Action object, no reducer funnel. */
|
||||
/** MVVM: a plain public method mutates state directly - no Action object, no reducer funnel. */
|
||||
fun onToggleMvvmNote() {
|
||||
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.example.architecture.feature.characters.data.dto.CharactersResponseDt
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
/**
|
||||
* Remote data source for characters. Returns raw DTOs (no mapping here — the repository maps via
|
||||
* Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
|
||||
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
|
||||
*/
|
||||
internal class KtorCharacterDataSource(
|
||||
|
||||
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real
|
||||
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) — so the
|
||||
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) - so the
|
||||
* full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping →
|
||||
* domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a
|
||||
* malformed-body → SERIALIZATION case.
|
||||
|
||||
@@ -9,14 +9,14 @@ import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
* Loads one page of characters.
|
||||
*
|
||||
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
|
||||
* logic that does NOT belong in the ViewModel — non-trivial rules, or *composition* of several
|
||||
* logic that does NOT belong in the ViewModel - non-trivial rules, or *composition* of several
|
||||
* repositories/sources into one domain operation. When the ViewModel would merely forward a single
|
||||
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
|
||||
*
|
||||
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
|
||||
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
|
||||
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
|
||||
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) — or
|
||||
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) - or
|
||||
* delete it and let the ViewModel call the repository.
|
||||
*/
|
||||
class GetCharactersPageUseCase(
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
|
||||
* the repository and return its result verbatim — success and error alike. Pure JVM test on the
|
||||
* the repository and return its result verbatim - success and error alike. Pure JVM test on the
|
||||
* JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
|
||||
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.junit.Assert.assertTrue
|
||||
/**
|
||||
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
|
||||
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
|
||||
* the interaction vocabulary; the test owns the assertions' intent — keeping tests readable and
|
||||
* the interaction vocabulary; the test owns the assertions' intent - keeping tests readable and
|
||||
* resilient to UI structure changes.
|
||||
*/
|
||||
class CharacterListRobot(
|
||||
|
||||
@@ -68,7 +68,7 @@ fun CharacterDetailRoot(
|
||||
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
|
||||
}
|
||||
|
||||
/** Pure, stateless screen — previewable without a ViewModel. */
|
||||
/** Pure, stateless screen - previewable without a ViewModel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CharacterDetailScreen(
|
||||
|
||||
@@ -101,7 +101,7 @@ fun CharacterListRoot(
|
||||
)
|
||||
}
|
||||
|
||||
/** Pure, stateless screen — previewable without a ViewModel. */
|
||||
/** Pure, stateless screen - previewable without a ViewModel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CharacterListScreen(
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data object CharacterListRoute
|
||||
|
||||
/** Type-safe route for the character detail screen — carries only the typed id, never an object. */
|
||||
/** Type-safe route for the character detail screen - carries only the typed id, never an object. */
|
||||
@Serializable
|
||||
data class CharacterDetailRoute(val characterId: Int)
|
||||
|
||||
@@ -39,7 +39,7 @@ fun NavGraphBuilder.charactersGraph(
|
||||
}
|
||||
composable<CharacterDetailRoute> {
|
||||
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
|
||||
// which Navigation copies into the ViewModel's SavedStateHandle — that is where
|
||||
// which Navigation copies into the ViewModel's SavedStateHandle - that is where
|
||||
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
|
||||
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ fun ErrorDemoRoot(
|
||||
ErrorDemoScreen(state = state, onAction = viewModel::onAction)
|
||||
}
|
||||
|
||||
/** Pure, stateless screen — previewable without a ViewModel. */
|
||||
/** Pure, stateless screen - previewable without a ViewModel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorDemoScreen(
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
/**
|
||||
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
|
||||
* the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly
|
||||
* the Compose screen - proving the presentation logic (State/Action/Event/UI-model) is truly
|
||||
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
|
||||
*
|
||||
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/**
|
||||
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
|
||||
* renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer
|
||||
* renderer's helpers but return platform types (a string-res id and an ARGB Int) - each renderer
|
||||
* owns its own resources, so the small label duplication across modules is expected.
|
||||
*/
|
||||
@StringRes
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
// Stable collection for state — makes the list Compose-stable WITHOUT a Compose dependency,
|
||||
// Stable collection for state - makes the list Compose-stable WITHOUT a Compose dependency,
|
||||
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
|
||||
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
|
||||
api(libs.kotlinx.collections.immutable)
|
||||
|
||||
@@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
|
||||
* UI-agnostic MVI ViewModel for the character detail screen.
|
||||
*
|
||||
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
|
||||
* field name. Reading that raw key — instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` —
|
||||
* field name. Reading that raw key - instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` -
|
||||
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
|
||||
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
/**
|
||||
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
|
||||
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
|
||||
* Compose already treats as stable — so this module needs no Compose dependency. Navigation and
|
||||
* Compose already treats as stable - so this module needs no Compose dependency. Navigation and
|
||||
* snackbars are one-time Events, never state.
|
||||
*/
|
||||
data class CharacterListState(
|
||||
|
||||
@@ -77,7 +77,7 @@ class CharacterListViewModel(
|
||||
page++
|
||||
}
|
||||
|
||||
// Always surface a failure — even a partial one where earlier pages loaded.
|
||||
// Always surface a failure - even a partial one where earlier pages loaded.
|
||||
if (error != null) {
|
||||
_events.send(CharacterListEvent.ShowSnackbar(error))
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class CharacterListViewModel(
|
||||
|
||||
private fun loadPage(page: Int) {
|
||||
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
|
||||
// guarded out before its coroutine starts — otherwise the same page loads twice and items
|
||||
// guarded out before its coroutine starts - otherwise the same page loads twice and items
|
||||
// get appended twice.
|
||||
_state.update { it.copy(isLoadingNextPage = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -4,7 +4,7 @@ sealed interface ErrorDemoAction {
|
||||
/** Force a load that fails with the given [ErrorScenario]. */
|
||||
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
|
||||
|
||||
/** Force a load that succeeds — clears any current error. */
|
||||
/** Force a load that succeeds - clears any current error. */
|
||||
data object OnLoadSuccess : ErrorDemoAction
|
||||
|
||||
/** Re-issue the most recent load (the design-system retry button). */
|
||||
|
||||
@@ -4,8 +4,8 @@ import com.example.architecture.core.presentation.UiText
|
||||
|
||||
/**
|
||||
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
|
||||
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` — exactly what the real
|
||||
* screens hold — so the renderer resolves and shows it the same way.
|
||||
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` - exactly what the real
|
||||
* screens hold - so the renderer resolves and shows it the same way.
|
||||
*/
|
||||
data class ErrorDemoState(
|
||||
val isLoading: Boolean = false,
|
||||
|
||||
@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-agnostic MVI ViewModel for the **error-handling demo** — a runnable walk-through of the whole
|
||||
* UI-agnostic MVI ViewModel for the **error-handling demo** - a runnable walk-through of the whole
|
||||
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
|
||||
* through the *same* steps a genuine network call uses:
|
||||
*
|
||||
@@ -24,8 +24,8 @@ import kotlinx.coroutines.launch
|
||||
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
|
||||
* ```
|
||||
*
|
||||
* The outcome is *simulated* (no real request) only so every case — including NO_INTERNET, which you
|
||||
* can't reliably trigger on demand — is reachable deterministically. [OnRetry] re-issues the last
|
||||
* The outcome is *simulated* (no real request) only so every case - including NO_INTERNET, which you
|
||||
* can't reliably trigger on demand - is reachable deterministically. [OnRetry] re-issues the last
|
||||
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
|
||||
* success).
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.koin.dsl.module
|
||||
|
||||
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
|
||||
val charactersPresentationModule = module {
|
||||
// Stateless domain UseCase — `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
|
||||
// Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
|
||||
// its CharacterRepository from charactersDataModule.
|
||||
factoryOf(::GetCharactersPageUseCase)
|
||||
viewModelOf(::CharacterListViewModel)
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.example.architecture.feature.characters.presentation.model
|
||||
import com.example.architecture.feature.characters.domain.model.Character
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||
|
||||
/** Presentation model for a character list item — decouples the UI from the domain [Character]. */
|
||||
/** Presentation model for a character list item - decouples the UI from the domain [Character]. */
|
||||
data class CharacterUi(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.junit.jupiter.api.assertThrows
|
||||
|
||||
/**
|
||||
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
|
||||
* by type-safe navigation), which is constructed directly here — proving the VM needs no navigation
|
||||
* by type-safe navigation), which is constructed directly here - proving the VM needs no navigation
|
||||
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
|
||||
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
|
||||
* assertions use AssertK; the back event is observed with Turbine.
|
||||
@@ -85,7 +85,7 @@ class CharacterDetailViewModelTest {
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value.error).isNotNull()
|
||||
|
||||
// Same call, new answer — the latest `coEvery` wins, so the retry attempt succeeds.
|
||||
// Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
|
||||
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
|
||||
viewModel.onAction(CharacterDetailAction.OnRetry)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [CharacterListViewModel] — driven entirely through its public MVI surface
|
||||
* Unit tests for [CharacterListViewModel] - driven entirely through its public MVI surface
|
||||
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
|
||||
*
|
||||
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
|
||||
@@ -159,7 +159,7 @@ class CharacterListViewModelTest {
|
||||
viewModel.onAction(CharacterListAction.OnLoadNextPage)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Only the single initial load ran — the guarded next-page request never fired.
|
||||
// Only the single initial load ran - the guarded next-page request never fired.
|
||||
coVerify(exactly = 1) { repository.getCharacters(1) }
|
||||
coVerify(exactly = 0) { repository.getCharacters(2) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user