RandomBoxd is a Kotlin Multiplatform (KMP) application built with Compose Multiplatform targeting Android and iOS. It fetches random movies from Letterboxd user watchlists and custom lists via web scraping. The project follows Clean Architecture principles with MVVM in the presentation layer.
RandomBoxd/
├── composeApp/ # Shared KMP application module
│ └── src/
│ ├── commonMain/ # Shared business logic and UI
│ ├── commonTest/ # Shared unit tests
│ ├── androidMain/ # Android platform implementations
│ ├── androidInstrumentedTest/ # Android UI tests
│ ├── iosMain/ # iOS platform implementations
│ └── nativeMain/ # Native-common code (iOS targets)
├── iosApp/ # iOS entry point (Swift)
├── gradle/libs.versions.toml # Version catalog (single source of truth for dependencies)
├── build.gradle.kts # Root build configuration
└── settings.gradle.kts # Module inclusion
There is a single shared module (composeApp). There are no separate feature modules; feature separation is achieved through package structure within commonMain.
com.nacchofer31.randomboxd/
├── app/ # App entry point and navigation
│ └── RandomBoxdApp.kt # NavHost setup, route definitions
│
├── core/
│ ├── data/ # Infrastructure: HTTP client, endpoints, safe call wrapper
│ │ ├── HttpClientFactory.kt # Ktor client builder (timeouts, logging, JSON)
│ │ ├── RandomBoxdEndpoints.kt # URL constants and path builders
│ │ └── SafeCallExtension.kt # Wraps HTTP calls into ResultData with error mapping
│ ├── domain/ # Shared domain types
│ │ ├── DataError.kt # Sealed interface: Remote + Local error enums
│ │ └── ResultData.kt # Result monad: Success | Error
│ └── presentation/ # Design system tokens
│ ├── RandomBoxdColors.kt # Color palette constants
│ └── RandomBoxdTypography.kt # Custom font (Conduit) + Material3 type scale
│
├── di/ # Dependency injection (Koin)
│ ├── Modules.kt # Common Koin module definitions
│ └── InitKoin.kt # Koin initialization entry point
│
├── onboarding/ # Onboarding feature
│ └── presentation/
│ ├── OnboardingScreen.kt # 4-page horizontal pager
│ └── components/ # Onboarding-specific composables
│
└── random_film/ # Main feature
├── data/
│ ├── dto/ # Data Transfer Objects (FilmDto)
│ ├── mappers/ # DTO-to-domain mapping extensions
│ └── repository_impl/ # Repository implementations
│ ├── RandomFilmScrappingRepository.kt # Primary: HTML scraping via Ksoup
│ └── RandomFilmRepositoryImpl.kt # Secondary: API-based (incomplete)
├── domain/
│ ├── model/ # Domain entities (Film, FilmSearchMode)
│ └── repository/ # Repository interfaces (contracts)
└── presentation/
├── viewmodel/
│ └── RandomFilmViewModel.kt # State management, action handling
├── screen/
│ └── RandomFilmScreen.kt # Root screen composable
└── components/ # 20+ reusable UI components
- Pattern: MVVM with unidirectional data flow.
- ViewModels extend
ViewModel(fromlifecycle-viewmodel-compose) and are scoped to navigation graphs. - State is exposed as
StateFlow<State>and collected in Compose viacollectAsStateWithLifecycle(). - Actions are modeled as a sealed interface (
RandomFilmAction) and dispatched from UI to ViewModel via a singleonAction(action)function. - UI is 100% Jetpack Compose Multiplatform. No XML layouts, no SwiftUI. Screens compose smaller components from the
components/package.
State flow:
UI Event → Action (sealed interface) → ViewModel processes → State update → UI recomposition
Key ViewModel conventions:
- Actions are emitted into a
MutableSharedFlowand processed via Flow operators (filterIsInstance,flatMapLatest,onEach). - Sharing strategy:
SharingStarted.WhileSubscribed(5000L). - Loading states use null/non-null patterns on the result field plus an explicit
isLoadingboolean.
- Pure Kotlin — no framework dependencies.
- Contains repository interfaces (contracts), domain models (data classes/enums), and the error/result types.
ResultData<D, E>is the canonical result wrapper — a sealed interface withSuccess(data)andError(error)variants.DataErroris a sealed interface split intoRemoteandLocalenums covering all expected failure modes.- Domain models:
Film(slug, imageUrl, releaseYear, name),UserName(id, username — also a Room entity),FilmSearchMode(UNION, INTERSECTION).
- Repositories implement domain interfaces and coordinate data sources.
- Primary data source: Web scraping of Letterboxd HTML using Ksoup (CSS selector-based parsing).
- Secondary data source: REST API via Ktor client (JSON serialization).
- Local persistence: Room database for saved usernames, with platform-specific
RoomDatabase.Buildervia expect/actual. - DTOs are
@Serializabledata classes; mapping to domain models happens through extension functions inmappers/.
HttpClientFactorybuilds the Ktor client with platform-injected engine, content negotiation (JSON), logging, and 20-second timeouts.SafeCallExtension.safeCall {}wraps suspend blocks and maps exceptions toDataError.Remotevariants.RandomBoxdEndpointsis a static object holding the base URL (https://www.watchlistpicker.com) and URL builder functions.
Framework: Koin 4.1.1
Module organization:
commonMain/di/Modules.kt— shared module registering repositories, ViewModels, HTTP client, dispatchers.androidMain/di/Modules.android.kt— providesOkHttpengine, Room database builder,OnboardingPreferences(SharedPreferences).iosMain/di/Modules.ios.kt— providesDarwinengine, Room database builder,OnboardingPreferences(NSUserDefaults).- Platform modules are composed via
platformModule(expect/actual).
ViewModel registration: Uses viewModelOf(::RandomFilmViewModel) for constructor injection.
Initialization:
- Android:
initKoin()called fromRandomBoxdApplication.onCreate()withandroidLogger()andandroidContext(). - iOS:
initKoin()called from Swift entry point.
Framework: Jetpack Compose Navigation with type-safe routes (Kotlin Serializable).
Route definitions:
sealed interface RandomBoxdRoute {
@Serializable data object Onboarding : RandomBoxdRoute
@Serializable data object Home : RandomBoxdRoute
@Serializable data object RandomFilm : RandomBoxdRoute
}Navigation graph (in RandomBoxdApp.kt):
- Start destination depends on
OnboardingPreferences.isFirstRun(). Onboardingroute →OnboardingScreen(navigates to Home on completion).Homenavigation graph containsRandomFilmas its start destination.- ViewModel is scoped to the
Homenavigation graph.
External navigation: Film clicks open the Letterboxd URL via LocalUriHandler.current.openUri().
The following use the expect/actual mechanism:
| Abstraction | Android (androidMain) |
iOS (iosMain) |
|---|---|---|
OnboardingPreferences |
SharedPreferences |
NSUserDefaults |
UserNameDatabaseConstructor |
Room databaseBuilder with context |
Room with BundledSQLiteDriver, Documents dir |
platformModule (Koin) |
OkHttp engine + Android DB + prefs | Darwin engine + iOS DB + prefs |
Dispatcher abstraction: DispatcherProvider interface with main, io, default properties. Default implementation uses Dispatchers.Main, Dispatchers.IO, Dispatchers.Default. Test implementation uses UnconfinedTestDispatcher.
- Client: Ktor 3.2.3 with platform-specific engines (OkHttp on Android, Darwin on iOS).
- Primary approach: Web scraping Letterboxd HTML pages. The
RandomFilmScrappingRepositoryfetches watchlist/list pages and parses them with Ksoup using CSS selectors likeli.poster-container. - Film data extraction: Parses
data-film-slug,data-film-name,data-film-release-year, and poster imagesrcfrom HTML. - Multi-user support: Fetches each user's list, then applies INTERSECTION (shared films) or UNION (all films) logic, and picks a random result.
- Error handling:
safeCall {}maps exceptions and HTTP status codes toDataError.Remotevariants.
- ORM: Room 2.7.2 with KSP code generation (
ksp.useKSP2=true). - Single table:
UserNameentity (Long id, String username). - DAO operations:
upsert,delete,getAllUserNames()(returnsFlow<List<UserName>>). - Database class:
UsernameDatabase(abstract, extendsRoomDatabase). - Platform constructors:
getUserNameDatabase()in each platform's source set provides theRoomDatabase.Builderwith platform-appropriate storage paths.
Colors (RandomBoxdColors object):
| Token | Hex | Usage |
|---|---|---|
BackgroundDarkColor |
#14181C |
Primary background |
BackgroundLightColor |
#99AABB |
Secondary surfaces |
TextFieldBackgroundColor |
#2C3440 |
Input fields |
CardBackground |
#1C2228 |
Card surfaces |
TextMuted |
#556677 |
Disabled/hint text |
GreenAccent |
#00E054 |
Primary accent, buttons |
OrangeAccent |
#F27405 |
Union mode indicator |
BlueAccent |
#40BCF4 |
Info elements |
Typography: Custom font Conduit (light/regular weights) applied to the full Material3 Typography scale. Sizes range from 12sp to 32sp.
Theme: Dark theme only. No light theme variant exists.
The ViewModel uses a Flow-based reactive pipeline:
- Actions arrive via
MutableSharedFlow<RandomFilmAction>(extra buffer capacity, drop oldest on overflow). - Processing pipelines are set up in
init {}using Flow operators:filterIsInstance<SpecificAction>()to route actions.flatMapLatest {}for cancellable async operations (new search cancels previous).onStart {}andonEach {}for state mutations.
- State is held in
MutableStateFlow<RandomFilmState>and exposed as read-onlyStateFlow. - UI collection:
state.collectAsStateWithLifecycle()in composables.
State data class (RandomFilmState):
userName: String— current input.resultFilm: Film?— last successful result.resultError: DataError.Remote?— last error.isLoading: Boolean— loading indicator.userNameSearchList: Set<String>— multi-user search set.filmSearchMode: FilmSearchMode— INTERSECTION or UNION.
Result type: ResultData<D, E : Error> sealed interface with Success and Error variants. Extension functions: .map(), .onSuccess(), .onError().
Remote errors (DataError.Remote):
REQUEST_TIMEOUT— socket timeout or HTTP 408.TOO_MANY_REQUESTS— HTTP 429.NO_INTERNET— unresolved address.SERVER— HTTP 5xx.SERIALIZATION— parsing failures.NO_RESULTS— scraping returned empty.UNKNOWN— catch-all.
Local errors (DataError.Local): DISK_FULL, UNKNOWN.
Error display: FilmErrorView composable maps each error enum to a user-facing string resource and icon.
Frameworks:
| Tool | Purpose |
|---|---|
| JUnit 4 | Test runner |
| Kotlin Test | Assertions and test utilities |
| Turbine | Flow/StateFlow emission testing |
| AssertK | Fluent assertions |
| Mockmp (Kodein) | Mocking with KSP generation |
| Ktor MockEngine | HTTP request interception |
| Compose UI Test | Instrumented UI testing |
Test utilities (in commonTest):
TestDispatchers: ProvidesUnconfinedTestDispatcherfor all dispatcher types.HttpResponseData: Helper to build mock Ktor responses.
Testing patterns:
- ViewModels are tested with
runTest+ Turbine'sstate.test { awaitItem() }. - Repositories are tested with Ktor
MockEnginereturning canned HTML/JSON. - UI tests use Compose test tags (
Modifier.testTag(...)) for node lookup.
Code coverage: JaCoCo with a 70% minimum line coverage requirement. Reports generated in HTML, CSV, and XML. Exclusions: generated code, app bootstrap classes.
- Gradle: 9.2.1
- Kotlin: 2.2.10
- AGP: 9.0.1
- Compose Multiplatform: 1.10.0
- KSP: Used for Room code generation and Mockmp mock generation.
Android targets: minSdk 24, targetSdk 35, compileSdk 35, JVM target Java 21.
iOS targets: iosArm64, iosX64, iosSimulatorArm64 — grouped via iosMain intermediate source set.
Code formatting: Spotless plugin with KtLint rules. Run via ./gradlew :composeApp:spotlessApply.
Key Gradle tasks:
:composeApp:testDebugUnitTest— run unit tests.:composeApp:jacocoTestReport— generate coverage report.:composeApp:jacocoTestCoverageVerification— enforce 70% threshold.:composeApp:fullCoverageReport— tests + coverage in one task.:composeApp:spotlessApply— format code.:composeApp:spotlessCheck— verify formatting.
- String resources use Compose Multiplatform's
Res.string.*mechanism. - 40+ string keys defined.
- Supported languages: CA, DE, ES, FR, GL, IT, JA, PT (plus default English).
- All user-facing text must go through
stringResource(Res.string.key).
- No feature modules — features are packages, not Gradle modules. All code lives in
composeApp. - Repository pattern — domain layer defines interfaces; data layer provides implementations. Never depend on concrete repository classes from presentation.
- Single ViewModel per feature —
RandomFilmViewModelhandles all state for the main feature, scoped to theHomenavigation graph. - Actions over callbacks — UI communicates with ViewModels exclusively through sealed interface actions, not individual callback lambdas.
- ResultData everywhere — all repository methods return
ResultData<Success, Error>, never throw exceptions to callers. - Expect/actual for platform code — platform-specific implementations live in
androidMain/iosMainwith shared interfaces incommonMain. - Koin for DI — all dependencies are registered in Koin modules. No manual construction of repositories or ViewModels.
- Dark theme only — the app uses a single dark color scheme. All colors are in
RandomBoxdColors. - Custom font — Conduit is the only font. Typography is defined once in
RandomBoxdTypographyand applied globally. - Test coverage gate — JaCoCo enforces 70% line coverage. New code must include tests.
- Code formatting — Spotless/KtLint must pass. Run
spotlessApplybefore committing. - Flow-based reactivity — state management uses Kotlin Flow operators, not LiveData or callbacks.
- Web scraping as primary data source — the app parses Letterboxd HTML, not a stable API. Scraped selectors (e.g.,
li.poster-container,data-film-slug) are brittle and may break if Letterboxd changes their markup. - No network caching layer — there is no HTTP cache or local film cache. Each search hits the network.
- Room for persistence — only used for username history. Film data is not persisted locally.