Commit Graph

28 Commits

Author SHA1 Message Date
Adrian Kuta
3f9cf96216 REDI-95: ViewModel unit tests (JUnit5 + Turbine + AssertK + fakes)
Test CharacterListViewModel and CharacterDetailViewModel entirely through their
MVI surface with a FakeCharacterRepository (a fake, not a mock) and a directly
constructed SavedStateHandle, on StandardTestDispatcher. Coverage: happy path,
error -> UiText + snackbar Event, pagination end-reached, the in-flight and
duplicate next-page guards, process-death restore, and both branches of OnRetry.
Also a domain test for GetCharactersPageUseCase (delegation + error
propagation).
2026-06-10 15:00:45 +02:00
Adrian Kuta
7a7ab45a66 test infra: JUnit5 unit tests on Android modules + Compose UI test wiring
Add an architecture.android.unit.test convention plugin that runs local unit
tests on the JUnit5 platform via useJUnitPlatform() (AndroidUnitTest extends
Gradle's Test) + the unit-test bundle. Deliberately NOT using the
de.mannodermaus plugin (targets AGP 8.x; we're on AGP 9). Add
junit-platform-launcher (Gradle 9 dropped the bundled launcher); set the
instrumentation runner; add a compose-ui-test bundle pinning espresso/runner to
current versions (transitive espresso 3.5.0 calls the removed
InputManager.getInstance() on API 34+). CI now runs ./gradlew test and compiles
the instrumented tests. Drop unused testing catalog entries.
2026-06-10 15:00:37 +02:00
Adrian Kuta
cf63095acc REDI-98: error-handling demo screen (DataError -> UiText pipeline)
A runnable MVI screen (reached from the list overflow menu) that forces a real
DataError.Network case and routes it through the same pipeline a genuine call
uses: Result.Error -> onFailure -> DataError.toUiText() -> design-system
ErrorState. Three distinct cases (NO_INTERNET, NOT_FOUND, SERVER_ERROR) each
render their mapped message; Retry re-issues the last attempt via an Action; a
successful load clears the error. Wired as intra-feature navigation
(ErrorDemoRoute) and registered in Koin (incl. the UseCase factoryOf).
2026-06-10 15:00:27 +02:00
Adrian Kuta
0542d4dc1d REDI-94: GetCharactersPageUseCase + inject into list ViewModel
Add a domain UseCase (operator invoke) in :feature:characters:domain delegating
to CharacterRepository, and have CharacterListViewModel depend on it instead of
the repository directly. The UseCase is a deliberate thin pass-through that
documents the 'when to add a UseCase' convention (real logic / multi-source
composition vs. a single forwarded call).
2026-06-10 15:00:17 +02:00
Adrian Kuta
c17a1c163b Merge pull request #3 from AdrianKuta/feat/breadth-contrast
Breadth & Contrast (REDI-90…93): detail screen, MVVM about, Views renderer, Compose↔View interop
2026-06-10 14:14:46 +02:00
Adrian Kuta
3c02096a8b docs: README MVI-vs-MVVM section + two-renderer overview
Add a Presentation patterns (MVI vs MVVM) section with a comparison table and the
'one ViewModel, two renderers' explanation, and update the status/stack (Timber, not
Kermit; Breadth & Contrast complete).
2026-06-10 13:45:05 +02:00
Adrian Kuta
6577a85a15 REDI-93: host the Views list in the Compose NavHost (Compose<->View interop)
:app now owns the cross-toolkit interop. MainActivity becomes a FragmentActivity and
hosts CharacterListFragment inside the Compose NavHost via AndroidFragment at a new
@Serializable CharactersViewsRoute (defined in :app, since :app owns interop — the Views
module stays nav-agnostic). The overflow menu's 'Open as Views' / 'About' entries and the
Views item-click -> detail navigation are all injected as callbacks, keeping the renderers
decoupled. Assemble aboutPresentationModule in Koin; add androidx.fragment:fragment-compose.
The Material3 Activity theme styles both toolkits.
2026-06-10 13:45:00 +02:00
Adrian Kuta
e230aa77d8 REDI-92: Views/XML renderer for the characters list (same MVI ViewModel)
Add :feature:characters:presentation-views — a classic Fragment + ViewBinding +
RecyclerView/DiffUtil renderer driving the SAME CharacterListViewModel as the Compose
screen (obtained via Koin's by viewModel()), proving the presentation logic is truly
UI-agnostic. State is observed with viewLifecycleOwner.repeatOnLifecycle(STARTED),
one-time Events are collected, UiText is resolved via Context, and the binding is nulled
in onDestroyView. Coil loads avatars into ImageView with a circle-crop transform; the
module has no Compose dependency.

Paging scroll listener guards the empty-list case (lastVisible >= 0), uses a safe
layoutManager cast, and is removed in onDestroyView.
2026-06-10 13:44:53 +02:00
Adrian Kuta
5f2792002b REDI-91: MVVM contrast screen (:feature:about:presentation)
Add a deliberately small MVVM About screen: AboutViewModel exposes a StateFlow plus
plain public methods (onToggleMvvmNote) with NO Action sealed type and NO Event channel
— the explicit contrast to the app's MVI screens. AboutScreen collects state and calls
the VM method directly; links open via LocalUriHandler. Static showcase copy lives in
the VM as state to demonstrate the StateFlow-as-content shape. aboutGraph + @Serializable
AboutRoute + aboutPresentationModule wire it in. A code comment and the README explain
why this is MVVM and when each pattern fits.
2026-06-10 13:44:47 +02:00
Adrian Kuta
33de7f5ef8 REDI-90: character detail screen (type-safe nav args + MVI)
Add a CharacterDetail MVI stack (State/Action/Event/ViewModel + CharacterDetailUi)
to the UI-agnostic :feature:characters:presentation. The detail ViewModel reads the
typed characterId from SavedStateHandle (populated by the type-safe CharacterDetailRoute),
so the module keeps zero navigation/Compose deps.

Add CharacterDetailScreen (Root/Screen, image header, attribute rows, loading/error)
and CharacterDetailRoute to :presentation-compose; refactor charactersGraph to drive
list->detail via NavController and expose About / Views entries as callbacks. Extract
shared CharacterStatus label/colour helpers; add an overflow menu to the list app bar.

Add material-icons-core to the compose bundle for the app-bar icons.
2026-06-10 13:44:39 +02:00
Adrian Kuta
843c2fb4ef Merge pull request #2 from AdrianKuta/feat/flagship-mvi
Flagship MVI Feature (REDI-85…89)
2026-06-10 13:13:17 +02:00
Adrian Kuta
38d8f5915b fix(characters:presentation): pagination race + silent restore failure (review)
- Race: isLoadingNextPage was set inside the launched coroutine, so a rapid second
  OnLoadNextPage passed the guard before the flag flipped -> the same page loaded twice and
  items were appended twice. Set the loading flag synchronously before launching.
- Restore: when a middle page failed after earlier pages loaded, the error was swallowed
  (error=null, no event). Now any restore failure emits a ShowSnackbar; partial restores show
  the loaded list + snackbar, full failures show the error state.

Found by the milestone review.
2026-06-10 13:03:09 +02:00
Adrian Kuta
ef50094e3e feat(characters): Koin module + nav graph + wire into :app (REDI-89)
- charactersPresentationModule: viewModelOf(::CharacterListViewModel) (in the UI-agnostic module).
- @Serializable CharacterListRoute + NavGraphBuilder.charactersGraph { composable<CharacterListRoute> }
  in presentation-compose (serialization plugin added for type-safe routes).
- :app registers coreDataModule + charactersDataModule + charactersPresentationModule in startKoin,
  and hosts a NavHost(startDestination = CharacterListRoute) calling charactersGraph.
- core:data manifest declares INTERNET (merges into :app) for live API calls.
2026-06-10 12:52:14 +02:00
Adrian Kuta
dd4576409d feat(characters:presentation-compose): list renderer, Root/Screen split, previews (REDI-88)
- CharacterListRoot: koinViewModel(), ObserveAsEvents, forwards nav + shows snackbar via Context asString.
- CharacterListScreen: pure state+onAction; AppScaffold + LazyColumn (key=id), design-system
  NetworkImage (contentDescription)/AppCard, loading/error/empty states, snapshot-based
  scroll-to-end -> OnLoadNextPage (ViewModel guards duplicates).
- Loaded + error previews wrapped in AppTheme.
- feature:characters:presentation now exposes kotlinx-immutable as api (ImmutableList is in the state API).
2026-06-10 12:48:21 +02:00
Adrian Kuta
2a419df43e feat(characters:presentation): UI-agnostic MVI ViewModel (REDI-87)
- CharacterListState (characters, isLoading, isLoadingNextPage, currentPage, endReached, error: UiText?),
  CharacterListAction (OnCharacterClick/OnRetry/OnLoadNextPage), CharacterListEvent (NavigateToDetail/ShowSnackbar).
- CharacterListViewModel: state via .update, one-time events via Channel, DataError -> UiText on failure,
  pagination persisted in SavedStateHandle (rebuilds list up to the saved page after process death).
- CharacterUi + Character.toCharacterUi().
- NO Compose/Views deps: verified no androidx.compose on the compile classpath. Stability via
  ImmutableList instead of @Stable (which would require compose-runtime) — the only compose-named
  transitive is kotlinx-immutable's annotations-only stub, not the Compose framework.
2026-06-10 12:43:30 +02:00
Adrian Kuta
0bb96baa4d feat(characters:data): DTOs, mappers, data source, repo, Koin module (REDI-86)
- @Serializable CharacterDto/CharactersResponseDto/PageInfoDto in dto/.
- mappers/CharacterMapper.kt: internal, pure toDomain()/toCharacter()/toCharacterDetails();
  nextPage parsed from info.next URL. No mapping inside DTO/data-source classes.
- KtorCharacterDataSource via the typed HttpClient.get helpers (errors -> DataError.Network).
- NetworkCharacterRepository (not *Impl) maps DTO -> domain; DataError.Network widens to DataError.
- charactersDataModule: singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }.
- core:data: expose ktor-client-core as api (public inline helpers are inlined into consumers) and
  move Timber logging into a @PublishedApi internal fn so Timber doesn't leak across modules.
2026-06-10 12:37:40 +02:00
Adrian Kuta
600f12259d feat(characters:domain): models + CharacterRepository interface (REDI-85)
- Character, CharacterStatus, CharactersPage(characters, nextPage), CharacterDetails.
- CharacterRepository interface returning Result<CharactersPage, DataError> and
  Result<CharacterDetails, DataError>. Pure Kotlin, no serialization annotations, no Android.
2026-06-10 12:31:59 +02:00
Adrian Kuta
7af99f91f3 Merge pull request #1 from AdrianKuta/feat/core-infrastructure
Core Infrastructure (REDI-80…84)
2026-06-10 12:29:56 +02:00
Adrian Kuta
6a1842ae96 refactor(logging): use Timber instead of Kermit
Android-only project, so Timber (the de-facto Android logging lib) fits better than the
KMP-oriented Kermit.

- Catalog: kermit -> timber (com.jakewharton.timber:timber 5.0.1).
- core:data: Ktor Logging bridged to Timber; safeCall logs via Timber.
- app: plant Timber.DebugTree() in debug only (buildConfig enabled for BuildConfig.DEBUG).
2026-06-10 12:23:43 +02:00
Adrian Kuta
b7ccf2fefa fix(core:data): catch deserialization errors in safeCall (review)
Deserialization happens in responseToResult via response.body<T>() on the 2xx branch.
That call was outside safeCall's try/catch, so a malformed 2xx body threw an uncaught
SerializationException (crash) instead of mapping to DataError.Network.SERIALIZATION.
Move responseToResult(execute()) inside the try so both transport and parse errors are typed.

Found by the milestone review.
2026-06-10 11:57:08 +02:00
Adrian Kuta
070ffde49c feat(app): Koin bootstrap + AppTheme + Material3 XML theme (REDI-84)
- ArchitectureApp.Application: startKoin { androidLogger; androidContext; modules(coreDataModule) }.
  Modules are assembled only here; feature modules will append to the list.
- MainActivity hosts a themed empty screen via AppTheme + AppScaffold (design-system).
- Activity XML theme upgraded to Theme.Material3.DayNight.NoActionBar (Compose themes via AppTheme;
  the Material3 XML theme lets the later Views renderer inherit Material3 styling).
- :app depends on :core:data + :core:design-system; applies the koin convention.
2026-06-10 11:48:19 +02:00
Adrian Kuta
5f3cc51195 feat(core:data): Ktor network core + coreDataModule (REDI-83)
- HttpClientFactory.create(engine) with the engine injected (MockEngine seam for tests):
  ContentNegotiation JSON (ignoreUnknownKeys), Kermit-backed Ktor logging, default JSON request.
- safeCall / responseToResult (status -> DataError.Network, extended with 400/403/404/503) /
  constructRoute (reads BuildConfig.BASE_URL) and typed HttpClient.get/post/delete.
- BASE_URL BuildConfig field = Rick & Morty API.
- coreDataModule: single<HttpClient> via factory lambda (the one sanctioned lambda-DSL binding).
2026-06-10 11:45:53 +02:00
Adrian Kuta
709c7d6ff5 feat(core:presentation): UiText, ObserveAsEvents, DataError -> UiText (REDI-82)
- UiText sealed interface (DynamicString / StringResource) — Compose-free type so the
  UI-agnostic presentation module can hold UiText? in state without depending on Compose.
- Two resolvers: @Composable UiText.asString() (Compose renderer) and
  UiText.asString(context) (Views renderer).
- ObserveAsEvents: lifecycle-aware one-time event collection on Main.immediate.
- DataError.toUiText() covering all displayed cases with else -> unknown; error strings here.
2026-06-10 11:42:38 +02:00
Adrian Kuta
3a155beb3c feat(core:design-system): AppTheme + reusable composables (REDI-81)
- AppTheme wraps Material3 (color scheme, typography, shapes); all previews use it.
- Slot-API AppCard (header + content slots, optional click); AppScaffold.
- LoadingIndicator, ErrorState (optional retry), Coil-backed NetworkImage.
- Modifier.shimmerEffect() animated placeholder (Modifier extension, not @Composable).
- Add androidx-compose-foundation to the version catalog + compose bundle.
2026-06-10 11:39:51 +02:00
Adrian Kuta
6bc4027cbb feat(core:domain): typed Result / Error / DataError core (REDI-80)
- Error marker interface; Result<D, E: Error> (Success/Error) + EmptyResult typealias.
- Inline chainable helpers: map / onSuccess / onFailure / asEmptyResult.
- DataError sealed interface with full Network + Local case sets.
- Pure Kotlin, zero Android imports.
2026-06-10 11:31:13 +02:00
Adrian Kuta
a3bda3b601 chore: gitignore the whole .idea/ directory
Untrack IDE-generated .idea/ files that a Gradle sync added; ignore the directory
entirely to keep machine-specific config out of the repo.
2026-06-10 11:28:14 +02:00
Adrian Kuta
9ef0719447 chore: use src/main/kotlin source directories in :app
Kotlin-only reference repo — move the app's main/test/androidTest source roots
from src/main/java to src/main/kotlin (the core/feature modules already use kotlin).
Verified: :app:compileDebugKotlin produces MainActivity from src/main/kotlin.
2026-06-10 11:27:32 +02:00
Adrian Kuta
10fa6dc9eb chore: scaffold multi-module project, version catalog, and build-logic
Foundation milestone (REDI-78, REDI-79):

- Multi-module skeleton: :app, :core:{domain,data,presentation,design-system},
  :feature:characters:{domain,data,presentation,presentation-compose,presentation-views},
  :feature:about:presentation, plus the :build-logic composite build.
- gradle/libs.versions.toml as the single source of truth ([versions]/[libraries]/
  [bundles]/[plugins]); no inline versions in any build file.
- Convention plugins: architecture.android.{application,library,feature,feature.views},
  domain.module, compose, koin, ktor, kotlinx.serialization.
- Pure-Kotlin domain modules; presentation-compose uses android.feature;
  presentation-views uses android.feature.views (ViewBinding on, Compose off);
  the UI-agnostic :presentation has neither Compose nor Views deps.
- Toolchain: AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1.0, compileSdk 36, minSdk 24, Java 17.
- Minimal MainActivity placeholder; CI (assembleDebug) via GitHub Actions.

Verified: ./gradlew projects lists the full tree and ./gradlew assemble is green.
2026-06-10 10:52:03 +02:00