diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..292496e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs cat:*)", + "Bash(xargs -I{} bash -c 'echo \"=== {} ===\"; cat \"{}\"')", + "Bash(xargs ls:*)", + "Bash(git checkout:*)", + "Bash(./gradlew assembleDebug)", + "Bash(./gradlew :utils:random:compileDebugKotlin)", + "Bash(./gradlew :utils:random:compileDebugKotlin --info)", + "Bash(./gradlew :core:database:api:compileDebugKotlin :core:database:room:compileDebugKotlin :core:database:noop:compileDebugKotlin :core:model:compileDebugKotlin :core:domain:compileDebugKotlin :core:data:compileDebugKotlin)", + "Bash(./gradlew :core:data:compileDebugKotlin --info)", + "Bash(./gradlew :core:domain:compileDebugKotlin)", + "Bash(./gradlew :feature:movie-catalog:spotlessCheck)", + "Bash(./gradlew :feature:movie-catalog:spotlessApply)", + "Bash(./gradlew :feature:movie-catalog:assembleDebug)", + "Bash(./gradlew :core:domain:spotlessApply :core:data:spotlessApply :feature:movie-catalog:spotlessApply :test:doubles:database:spotlessApply :test:doubles:model:spotlessApply)", + "Bash(./gradlew :core:domain:test :core:data:test :feature:movie-catalog:test)", + "Bash(find /Users/p.staboulis-ag/AndroidStudioProjects/android-template-project/core/domain/build/reports/tests -name \"*.xml\" -exec grep -l \"failures\\\\|errors\" {} \\\\;)", + "Bash(./gradlew :core:domain:testDebugUnitTest --info)", + "Bash(./gradlew :core:domain:testDebugUnitTest)", + "Bash(./gradlew :core:data:testDebugUnitTest)", + "Bash(./gradlew :core:domain:spotlessApply :core:data:spotlessApply)", + "Bash(./gradlew :core:domain:testDebugUnitTest :core:data:testDebugUnitTest :feature:movie-catalog:testDebugUnitTest)", + "Bash(./gradlew :core:domain:testDebugUnitTest :core:data:testDebugUnitTest :feature:movie-catalog:testDebugUnitTest --rerun-tasks)", + "Bash(./gradlew spotlessCheck assembleDebug)", + "Bash(./gradlew :test:konsist:spotlessApply)", + "Bash(./gradlew spotlessCheck)", + "Bash(./gradlew spotlessApply)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b2024f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,191 @@ +# CLAUDE.md — Android Template Project + +## Build & Run + +- **Requires** `TMDB_API_KEY=` in `local.properties` (root). Missing key = empty BuildConfig field, not a build error. +- Build/run via Android Studio or `./gradlew assembleDebug`. +- Run all tests: `./gradlew test` +- Run Konsist architecture tests: `./gradlew :test:konsist:test` +- Run Spotless check: `./gradlew spotlessCheck` — fix: `./gradlew spotlessApply` + +--- + +## Module Structure + +All modules are declared in `settings.gradle.kts`. Key groupings: + +| Path | Role | +|---|---| +| `:app` | Entry point; depends on all feature + core modules | +| `:feature:movie-catalog` | Feature module (UI + ViewModel only) | +| `:core:data` | Repository implementations (single source of truth) | +| `:core:domain` | Use case interfaces/impls + repository interfaces | +| `:core:model` | Domain/business models only | +| `:core:network:api/retrofit/noop` | Network abstraction + Retrofit impl | +| `:core:database:api/room/noop` | Database abstraction + Room impl | +| `:core:presentation:mvi/theme/common-ui/viewmodel` | Shared UI infra | +| `:core:navigation:api/navigation-compose` | Navigation abstraction | +| `:core:bridge-di` | Prevents cyclic Gradle DI dependencies | +| `:architecture:mapper` | Mapper interfaces only | +| `:utils:koin`, `:utils:random` | Shared utilities | +| `:test:konsist` | Architecture rule tests (Konsist) | +| `:test:doubles:*` | Test doubles for DB, network, models | + +**Rule**: Feature modules depend only on core modules, never on each other. +**Rule**: Core modules never depend on feature or app modules. +**Rule**: Only put classes in `:core` if used by 2+ feature modules. + +--- + +## Convention Plugins (build-logic) + +Every module uses one of these in its `build.gradle.kts` — never configure Android/Kotlin directly: + +| Plugin ID | Use for | +|---|---| +| `com.pantelisstampoulis.library.core` | Any Android library module | +| `com.pantelisstampoulis.library.feature` | Feature modules (adds Compose, Serialization, domain/presentation deps) | +| `com.pantelisstampoulis.application.core` | `:app` module | +| `com.pantelisstampoulis.application.compose` | `:app` Compose setup | +| `com.pantelisstampoulis.koin` | Adds Koin BOM + common deps | +| `com.pantelisstampoulis.room` | Adds Room + KSP | +| `com.pantelisstampoulis.testing` | Adds test doubles, mockative, turbine, truth | +| `com.pantelisstampoulis.konsist` | Konsist architecture tests | +| `com.pantelisstampoulis.compose` | Compose compiler + dependencies | +| `com.pantelisstampoulis.kotlin.serialization` | KotlinX Serialization | + +Use `namespaceWithProjectPackage(suffix = "foo.bar")` in `android { namespace }` — do not hardcode the full package. + +SDK/Java version config comes from `gradle.properties` (`configuration.android.*`). Do not hardcode these values. + +--- + +## Architecture: Clean Architecture + MVI + +### Layer dependencies (enforced by Konsist) +``` +network ──┐ +database ──┤──► data ──► domain ──► model + │ │ + └──────────────┤ + ▼ + feature (UI + ViewModel) + │ + presentation +``` + +### MVI pattern +- ViewModels extend `MviViewModel` from `:core:presentation:mvi` +- `Event` = user intent (sealed interface, one per screen) +- `UiState` = immutable data class implementing `UiState` interface +- `SideEffect` = one-time effects (navigation, toasts) via `Channel` +- ViewModels call `setState { copy(...) }`, `setEffect { ... }`, never expose mutable state +- UI collects `viewState` as `StateFlow`, `effect` as `Flow` + +### Use Cases +- Interface + `internal` impl pattern: `interface FooUseCase`, `internal class FooUseCaseImpl` +- Must reside in `..domain..usecase..` package +- Single public method: `override operator fun invoke(input: T): Flow>` +- Impl class uses `onStartCatch()` extension for coroutine error handling +- ViewModels **must not** receive repositories directly — use cases only (Konsist-enforced) + +### Repository +- Interface in `:core:domain` at `..domain..repository` package +- Implementation (`*RepositoryImpl`) in `:core:data` at `..data..repository` package +- Every `RepositoryImpl` must have a corresponding `RepositoryImplTest` (Konsist-enforced) +- Returns `Flow>` — never suspends directly + +### ResultState +```kotlin +// onLoading { }, onSuccess { data -> }, onError { error -> } +``` + +### Mappers +All mappers implement typed interfaces from `:architecture:mapper`: +- `ApiToDomainMapper` → `fromApiToDomain()` +- `DbToDomainMapper` → `fromDbToDomain()` +- `ApiToDbMapper` → `fromApiToDb()` +- `DomainToUiMapper` → `fromDomainToUi()` + +--- + +## Naming Conventions (Konsist-enforced) + +| Class type | Suffix | Package | +|---|---|---| +| Use case interface | `UseCase` | `..domain..usecase..` | +| Use case impl | `UseCaseImpl` | `..domain..usecase..` | +| Repository interface | `Repository` | `..domain..repository` | +| Repository impl | `RepositoryImpl` | `..data..repository` | +| Network models | `ApiModel` | `..network.model` | +| Database models | `DbModel` or `Entity` | `..database.model` | + +- `ApiModel` classes: must be `data class`, `@Serializable`, all `val` with `@SerialName` +- `DbModel`/`Entity` classes: must be `data class`, all `val`, no functions +- `NetworkDataSource` impl: must be `internal`, all non-override properties `private val` +- `DatabaseDataSource` impl: must be `internal`, all non-override properties `private val` +- No `m` prefix on fields (e.g. `mValue` is forbidden) +- `companion object` must be last declaration in a class + +--- + +## Navigation + +- Destinations are `@Serializable` objects/data classes in a `sealed interface` per feature +- Feature exposes a `NavGraphBuilder` extension function (e.g. `movieCatalogGraph(...)`) +- ViewModels are obtained via `koinViewModel()` inside composable lambdas +- Use `collectAsStateWithLifecycle()` for state, `toRoute()` for route args +- Navigation is abstracted: `:core:navigation:api` + `:core:navigation:navigation-compose` + +--- + +## Dependency Injection (Koin) + +- DI modules are `val fooModule: Module = module { ... }` — `factory` for use cases/repos, `single` for data sources +- Impl classes bound to their interface: `} bind FooInterface::class` +- Impl classes are `internal`; Koin wires them via binding +- Use `getWith("tag")` (from `:utils:koin`) for tagged Logger injection +- Use `get(qualifier = named(CoroutinesDispatchers.IO))` for dispatcher injection + +--- + +## Testing + +- Test framework: JUnit4 + Google Truth assertions + Turbine (Flow testing) + Mockative (KSP-based mocking) +- Mockative: annotate fields with `@Mock`, generate via KSP (`kspTest`) +- Repository tests use `KoinTest` with real `testDataModule` + mock overrides +- Test file naming: `FooTest` (suffix `Test`, not `Spec`) +- **No Mockito** — use Mockative only +- Test doubles live in `:test:doubles:*`, not in the module under test +- **Mockative `every` vs `coEvery`**: use `every {}` for non-suspend functions (including Flow-returning ones); use `coEvery {}` only for `suspend` functions. Mixing them causes `InvalidExpectationException` at runtime. +- **Logger in use case tests**: do not mock `Logger` — use an inline no-op object instead. Mockative stubs are not set up for `logger.e(...)`, so an unstubbed call inside `onStartCatch`'s `catch` block will propagate as an uncaught exception that Turbine surfaces as a flow error. +- **Adding tests to a module that has none**: add `id(libs.plugins.custom.testing.get().pluginId)` to the module's `build.gradle.kts` first. Without it, test dependencies (Mockative, Turbine, Truth, test doubles) are not on the classpath. +- **Event and SideEffect files**: each screen puts its `*Event` and `*SideEffect` in separate files (not co-located in the ViewModel file). Follow this convention when creating new screens. + +--- + +## Key Libraries + +- **Kotlin**: 2.2.21, Java 17 +- **Coroutines**: 1.10.2 +- **Compose BOM**: 2025.10.01 +- **Koin**: 4.1.1 +- **Retrofit**: 3.0.0 + OkHttp 5.2.1 +- **Room**: 2.8.3 +- **KSP**: 2.3.0 +- **Navigation Compose**: 2.9.5 +- **kotlinx.collections.immutable**: 0.4.0 — use `ImmutableList` in `UiState` +- **kotlinx-datetime**: 0.7.0 — `Instant` moved to `kotlin.time.Instant` (not `kotlinx.datetime.Instant`); requires `@file:OptIn(kotlin.time.ExperimentalTime::class)` at call sites. +- All versions are in `gradle/libs.versions.toml`. Never hardcode versions in `build.gradle.kts`. + +--- + +## Product Flavors + +Currently **no active flavors**. `FlavorDimension` and `AppFlavor` enums in `build-logic` are placeholders — add flavor entries there if needed. + +--- + +## Base Package + +`com.pantelisstampoulis.androidtemplateproject` (from `gradle.properties: configuration.package.project`) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e34a387 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1313 @@ +# Implementation Plan: Watched Movies Feature + +**Spec**: `SPEC.md` +**Architecture**: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/MODULARIZATION.md` +**Base package**: `com.pantelisstampoulis.androidtemplateproject` + +--- + +## Design Decisions + +1. **Single `MoviesRepository`** — extend the existing repository with watched movie methods. No separate repository. +2. **Keep the Rate button** — stars are interactive for selection, button tap triggers save. No change to existing UX pattern. +3. **Network + local save** — two separate use cases. `RateMovieUseCase` (existing, TMDB API) stays unchanged. New `SaveWatchedMovieUseCase` handles local DB persistence. `MovieDetailsViewModel` orchestrates: network rate on success then local save. +4. **Separate `WatchedMovieListViewModel`** — one ViewModel per tab, matching existing patterns. +5. **Separate `WatchedMovieUiModel`** — the watched tab item has different fields (user rating, no genre) than the discover item. +6. **DB migration v1 to v2** — adds `watched_movies` table. + +--- + +## Phase 1: Data Layer + +This phase builds the full vertical data stack: database abstraction, Room implementation, domain model, repository interface, repository implementation, mappers, and DI wiring. After this phase, all watched-movie data operations are functional end-to-end. + +### Step 1.1 — New file: `WatchedMovieDbModel` + +**Path**: `core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieDbModel.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.database.model + +data class WatchedMovieDbModel( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) +``` + +### Step 1.2 — Modify: `DatabaseDataSource` + +**Path**: `core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/DatabaseDataSource.kt` + +Add 3 methods to the interface (keep existing methods unchanged): + +```kotlin +suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) +fun getWatchedMovies(): Flow> +suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? +``` + +Note: `getWatchedMovies()` is NOT suspend (returns `Flow` directly for continuous observation). + +### Step 1.3 — New file: `WatchedMovieEntity` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieEntity.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "watched_movies") +data class WatchedMovieEntity( + @PrimaryKey val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) +``` + +### Step 1.4 — New file: `WatchedMovieDao` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/dao/WatchedMovieDao.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface WatchedMovieDao { + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertWatchedMovie(entity: WatchedMovieEntity): Long + + @Query("SELECT * FROM watched_movies ORDER BY ratedAt DESC") + fun getWatchedMovieEntities(): Flow> + + @Query("SELECT * FROM watched_movies WHERE movieId = :movieId") + fun getWatchedMovieEntity(movieId: Int): WatchedMovieEntity? +} +``` + +`OnConflictStrategy.ABORT` enforces immutable ratings (spec: ratings cannot be changed once saved). + +### Step 1.5 — New file: `WatchedMovieDbMapper` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/WatchedMovieDbMapper.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.database.mapper + +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity + +class WatchedMovieDbMapper { + + fun toDb(model: WatchedMovieDbModel): WatchedMovieEntity = WatchedMovieEntity( + movieId = model.movieId, + title = model.title, + posterUrl = model.posterUrl, + overview = model.overview, + publicRating = model.publicRating, + releaseDate = model.releaseDate, + userRating = model.userRating, + ratedAt = model.ratedAt, + ) + + fun mapFromDb(entity: WatchedMovieEntity): WatchedMovieDbModel = WatchedMovieDbModel( + movieId = entity.movieId, + title = entity.title, + posterUrl = entity.posterUrl, + overview = entity.overview, + publicRating = entity.publicRating, + releaseDate = entity.releaseDate, + userRating = entity.userRating, + ratedAt = entity.ratedAt, + ) +} +``` + +### Step 1.6 — Modify: `Mappers` (room) + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/Mappers.kt` + +Add field to the existing data class: + +```kotlin +val watchedMovieDbMapper: WatchedMovieDbMapper +``` + +### Step 1.7 — New file: `Migrations.kt` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/migration/Migrations.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `watched_movies` ( + `movieId` INTEGER NOT NULL PRIMARY KEY, + `title` TEXT NOT NULL, + `posterUrl` TEXT, + `overview` TEXT, + `publicRating` REAL NOT NULL, + `releaseDate` TEXT, + `userRating` INTEGER NOT NULL, + `ratedAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + } +} +``` + +### Step 1.8 — Modify: `AppDatabase` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/AppDatabase.kt` + +Changes: +- Add `WatchedMovieEntity::class` to the `entities` array. +- Bump `version` from `1` to `2`. +- Add `abstract fun watchedMovieDao(): WatchedMovieDao`. + +Result: + +```kotlin +@Database( + entities = [ + MovieEntity::class, + WatchedMovieEntity::class, + ], + version = 2, + exportSchema = true, +) +internal abstract class AppDatabase : RoomDatabase() { + abstract fun movieDao(): MovieDao + abstract fun watchedMovieDao(): WatchedMovieDao +} +``` + +### Step 1.9 — Modify: `RoomDataSource` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/RoomDataSource.kt` + +Add implementations for the 3 new `DatabaseDataSource` methods: + +```kotlin +override suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) { + db.watchedMovieDao().insertWatchedMovie(mappers.watchedMovieDbMapper.toDb(movie)) +} + +override fun getWatchedMovies(): Flow> = + db.watchedMovieDao().getWatchedMovieEntities().map { entities -> + entities.map { mappers.watchedMovieDbMapper.mapFromDb(it) } + } + +override suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? = + db.watchedMovieDao().getWatchedMovieEntity(movieId)?.let { + mappers.watchedMovieDbMapper.mapFromDb(it) + } +``` + +### Step 1.10 — Modify: `NoopDatabaseDataSource` + +**Path**: `core/database/noop/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/NoopDatabaseDataSource.kt` + +Add stubs for the 3 new methods: + +```kotlin +override suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) { } +override fun getWatchedMovies(): Flow> = flowOf(emptyList()) +override suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? = null +``` + +### Step 1.11 — Modify: `DatabaseRoomModules.kt` + +**Path**: `core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/di/DatabaseRoomModules.kt` + +Two changes: +1. In `mappersModule`, add `watchedMovieDbMapper = WatchedMovieDbMapper()` to the `Mappers` constructor call. +2. In `databaseModule`, add `.addMigrations(MIGRATION_1_2)` to the `Room.databaseBuilder(...)` chain before `.build()`. + +### Step 1.12 — New file: `WatchedMovie` (domain model) + +**Path**: `core/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/model/movies/WatchedMovie.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.model.movies + +data class WatchedMovie( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) +``` + +### Step 1.13 — Modify: `MoviesRepository` (interface) + +**Path**: `core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/repository/MoviesRepository.kt` + +Add 3 methods (keep all existing methods unchanged): + +```kotlin +fun saveWatchedMovie( + movieId: Int, + title: String, + posterUrl: String?, + overview: String?, + publicRating: Double, + releaseDate: String?, + userRating: Int, +): Flow> + +fun getWatchedMovies(): Flow>> + +fun getWatchedMovie(movieId: Int): Flow> +``` + +Add import for `WatchedMovie`. + +### Step 1.14 — New file: `WatchedMovieDomainMapper` + +**Path**: `core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/WatchedMovieDomainMapper.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.data.mapper + +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel +import com.pantelisstampoulis.androidtemplateproject.mapper.DbToDomainMapper +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie + +internal class WatchedMovieDomainMapper : DbToDomainMapper { + override fun fromDbToDomain(dbModel: WatchedMovieDbModel): WatchedMovie = WatchedMovie( + movieId = dbModel.movieId, + title = dbModel.title, + posterUrl = dbModel.posterUrl, + overview = dbModel.overview, + publicRating = dbModel.publicRating, + releaseDate = dbModel.releaseDate, + userRating = dbModel.userRating, + ratedAt = dbModel.ratedAt, + ) +} +``` + +### Step 1.15 — Modify: `Mappers` (data) + +**Path**: `core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/Mappers.kt` + +Add field: + +```kotlin +val watchedMovieDomainMapper: WatchedMovieDomainMapper +``` + +### Step 1.16 — Modify: `MoviesRepositoryImpl` + +**Path**: `core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImpl.kt` + +Add 3 method implementations (keep all existing methods unchanged): + +```kotlin +override fun saveWatchedMovie( + movieId: Int, + title: String, + posterUrl: String?, + overview: String?, + publicRating: Double, + releaseDate: String?, + userRating: Int, +): Flow> = flow { + val dbModel = WatchedMovieDbModel( + movieId = movieId, + title = title, + posterUrl = posterUrl, + overview = overview, + publicRating = publicRating, + releaseDate = releaseDate, + userRating = userRating, + ratedAt = System.currentTimeMillis(), + ) + databaseDataSource.insertWatchedMovie(dbModel) + emit(ResultState.Success(Unit)) +} + +override fun getWatchedMovies(): Flow>> = flow { + databaseDataSource.getWatchedMovies().collect { dbModels -> + val watchedMovies = dbModels.map(mappers.watchedMovieDomainMapper::fromDbToDomain) + emit(ResultState.Success(watchedMovies)) + } +} + +override fun getWatchedMovie(movieId: Int): Flow> = flow { + val dbModel = databaseDataSource.getWatchedMovie(movieId) + dbModel?.let { + emit(ResultState.Success(mappers.watchedMovieDomainMapper.fromDbToDomain(it))) + } ?: emit(ResultState.Error(ErrorModel.NotFound())) +} +``` + +### Step 1.17 — Modify: `DataModules.kt` + +**Path**: `core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/di/DataModules.kt` + +In `mappersModule`, add `watchedMovieDomainMapper = WatchedMovieDomainMapper()` to the `Mappers` constructor call. + +### Verification + +```bash +./gradlew assembleDebug +``` + +Expected: build succeeds. All new database, model, mapper, and repository code compiles. No runtime verification yet — that comes in Phase 4. + +--- + +## Phase 2: Domain Layer + +This phase adds the three new use cases and wires them into the Koin DI graph. After this phase, all business logic for saving, listing, and retrieving watched movies is available for injection. + +### Step 2.1 — New file: `SaveWatchedMovieUseCase` + +**Path**: `core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCase.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface SaveWatchedMovieUseCase : UseCase + +data class SaveWatchedMovieInput( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, +) + +internal class SaveWatchedMovieUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : SaveWatchedMovieUseCase { + + override operator fun invoke(input: SaveWatchedMovieInput): Flow> = + moviesRepository.saveWatchedMovie( + movieId = input.movieId, + title = input.title, + posterUrl = input.posterUrl, + overview = input.overview, + publicRating = input.publicRating, + releaseDate = input.releaseDate, + userRating = input.userRating, + ).onStartCatch(coroutineContext = coroutineContext, logger = logger) +} +``` + +### Step 2.2 — New file: `GetWatchedMoviesUseCase` + +**Path**: `core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCase.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface GetWatchedMoviesUseCase : UseCase> + +internal class GetWatchedMoviesUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : GetWatchedMoviesUseCase { + + override operator fun invoke(input: Unit): Flow>> = + moviesRepository.getWatchedMovies() + .onStartCatch(coroutineContext = coroutineContext, logger = logger) +} +``` + +### Step 2.3 — New file: `GetWatchedMovieUseCase` + +**Path**: `core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCase.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface GetWatchedMovieUseCase : UseCase + +internal class GetWatchedMovieUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : GetWatchedMovieUseCase { + + override operator fun invoke(input: Int): Flow> = + moviesRepository.getWatchedMovie(input) + .onStartCatch(coroutineContext = coroutineContext, logger = logger) +} +``` + +### Step 2.4 — Modify: `DomainModules.kt` + +**Path**: `core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/di/DomainModules.kt` + +Add 3 new factory bindings inside the existing `domainModule` (keep all existing bindings unchanged): + +```kotlin +factory { + SaveWatchedMovieUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("SaveWatchedMovieUseCase"), + ) +} bind SaveWatchedMovieUseCase::class + +factory { + GetWatchedMoviesUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("GetWatchedMoviesUseCase"), + ) +} bind GetWatchedMoviesUseCase::class + +factory { + GetWatchedMovieUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("GetWatchedMovieUseCase"), + ) +} bind GetWatchedMovieUseCase::class +``` + +### Verification + +```bash +./gradlew assembleDebug +``` + +Expected: build succeeds. Use cases compile and are registered in Koin. No existing code is broken. + +--- + +## Phase 3: UI Layer + +This phase modifies the movie details screen for the rating flow and adds the Watched tab to the movie list screen. After this phase, the full feature is functional end-to-end. + +### Step 3.1 — Modify: `UserRatingBar` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uicomponent/UserRatingBar.kt` + +Add `enabled: Boolean = true` parameter to `UserRatingBar`. Pass it through to `StarIcon`. + +In `StarIcon`, add `enabled: Boolean` parameter. When `enabled = false`, the `pointerInteropFilter` block should not update `ratingState`: + +```kotlin +.pointerInteropFilter { + if (enabled) { + when (it.action) { + MotionEvent.ACTION_DOWN -> { + ratingState.value = ratingValue + } + } + } + true +} +``` + +### Step 3.2 — Modify: `MovieDetailsViewModel` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModel.kt` + +**Constructor** — add two new parameters (keep existing ones): + +```kotlin +class MovieDetailsViewModel( + private val getMovieUseCase: GetMovieUseCase, + private val rateMovieUseCase: RateMovieUseCase, + private val saveWatchedMovieUseCase: SaveWatchedMovieUseCase, + private val getWatchedMovieUseCase: GetWatchedMovieUseCase, + private val mapper: MovieUiMapper, +) : MviViewModel(...) +``` + +**`MovieDetailsUiState`** — add two fields: + +```kotlin +data class MovieDetailsUiState( + val isLoading: Boolean = false, + val errorMessage: String? = null, + val data: MovieUiModel? = null, + val userRating: Int? = null, + val isRatingInProgress: Boolean = false, +) : UiState +``` + +**`MovieDetailsSideEffect`** — rename `ShowToast` to `ShowSnackbar`: + +```kotlin +sealed interface MovieDetailsSideEffect : SideEffect { + data class ShowSnackbar(val message: String) : MovieDetailsSideEffect +} +``` + +**`handleEvents` — `Init` event:** + +After the existing `getMovieUseCase` collection, also launch a second coroutine to check watched status: + +```kotlin +is MovieDetailsEvent.Init -> { + // existing: load movie data + viewModelScope.launch { + getMovieUseCase(input = event.movieId).collect { resultState -> + // ... existing logic unchanged ... + } + } + // new: check if already rated + viewModelScope.launch { + getWatchedMovieUseCase(input = event.movieId).collect { resultState -> + resultState + .onSuccess { watchedMovie -> + setState { copy(userRating = watchedMovie.userRating) } + } + .onError { + // NotFound = not rated yet, leave userRating as null + } + } + } +} +``` + +**`handleEvents` — `RateMovie` event:** + +Replace existing implementation with two-step orchestration: + +```kotlin +is MovieDetailsEvent.RateMovie -> { + setState { copy(isRatingInProgress = true) } + viewModelScope.launch { + // Step 1: Rate on TMDB network API + rateMovieUseCase.invoke( + RateMovieUseCaseInput(event.movieId, event.rating), + ).collect { resultState -> + resultState + .onSuccess { + // Step 2: Save watched movie locally + val movie = viewState.value.data ?: return@onSuccess + saveWatchedMovieUseCase.invoke( + SaveWatchedMovieInput( + movieId = movie.id, + title = movie.title, + posterUrl = movie.posterPath, + overview = movie.overview, + publicRating = movie.voteAverage, + releaseDate = movie.releaseYear, + userRating = event.rating.toInt(), + ), + ).collect { saveResult -> + saveResult + .onSuccess { + setState { + copy( + userRating = event.rating.toInt(), + isRatingInProgress = false, + ) + } + setEffect { MovieDetailsSideEffect.ShowSnackbar("Rating saved") } + } + .onError { + setState { copy(isRatingInProgress = false) } + setEffect { + MovieDetailsSideEffect.ShowSnackbar( + "Something went wrong. Please try again." + ) + } + } + } + } + .onError { + setState { copy(isRatingInProgress = false) } + setEffect { + MovieDetailsSideEffect.ShowSnackbar( + "Something went wrong. Please try again." + ) + } + } + } + } +} +``` + +### Step 3.3 — Modify: `MovieDetailsScreen` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsScreen.kt` + +**Changes:** + +1. Replace `Toast` with `Snackbar`: + - Add `val snackbarHostState = remember { SnackbarHostState() }`. + - Wrap the screen in `Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) })`. + - In `ObserveEffects`, handle `ShowSnackbar` by calling `snackbarHostState.showSnackbar(sideEffect.message)` inside a coroutine scope. + +2. Pass `state.userRating` and `state.isRatingInProgress` to the `RateMovie` composable. + +3. Update `MovieDetails` composable signature to accept `userRating: Int?` and `isRatingInProgress: Boolean`. + +4. Rewrite `RateMovie` composable: + +```kotlin +@Composable +fun RateMovie( + modifier: Modifier = Modifier, + onEvent: (MovieDetailsEvent) -> Unit, + movie: MovieUiModel, + userRating: Int?, + isRatingInProgress: Boolean, +) { + Column( + modifier = modifier.fillMaxWidth().wrapContentSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (userRating != null) { + // Already rated — read-only display + Text( + text = stringResource(id = R.string.label_you_rated_this), + style = MaterialTheme.typography.labelMedium, + ) + val lockedRatingState = remember { mutableIntStateOf(userRating) } + UserRatingBar( + ratingState = lockedRatingState, + size = 28.dp, + enabled = false, + ) + } else { + // Not rated — interactive + val ratingState = rememberSaveable { mutableIntStateOf(0) } + UserRatingBar( + ratingState = ratingState, + size = 28.dp, + enabled = !isRatingInProgress, + ) + Button( + onClick = { + onEvent(MovieDetailsEvent.RateMovie(movie.id, ratingState.intValue.toFloat())) + }, + enabled = ratingState.intValue >= 1 && !isRatingInProgress, + ) { + Text( + text = stringResource(id = R.string.label_rate), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} +``` + +### Step 3.4 — New file: `WatchedMovieUiModel` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uimodel/WatchedMovieUiModel.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel + +import androidx.compose.runtime.Immutable + +@Immutable +data class WatchedMovieUiModel( + val movieId: Int, + val title: String, + val posterPath: String?, + val voteAverage: Double, + val userRating: Int, + val releaseYear: String, +) +``` + +### Step 3.5 — New file: `WatchedMovieUiMapper` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/mapper/WatchedMovieUiMapper.kt` + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper + +import com.pantelisstampoulis.androidtemplateproject.mapper.DomainToUiMapper +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.WatchedMovieUiModel + +class WatchedMovieUiMapper : DomainToUiMapper { + + override fun fromDomainToUi(domainModel: WatchedMovie): WatchedMovieUiModel = + WatchedMovieUiModel( + movieId = domainModel.movieId, + title = domainModel.title, + posterPath = domainModel.posterUrl, + voteAverage = domainModel.publicRating, + userRating = domainModel.userRating, + releaseYear = domainModel.releaseDate + ?.split("-") + ?.firstOrNull() + .orEmpty(), + ) +} +``` + +Uses the same year-extraction logic as `MovieUiMapper`. + +### Step 3.6 — New file: `WatchedMovieListViewModel` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModel.kt` + +This file contains the ViewModel, UiState, Event, and SideEffect classes (same pattern as `MovieListViewModel.kt` and its companion files — check whether events/side effects are in separate files or co-located, and follow the same convention): + +```kotlin +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import com.pantelisstampoulis.androidtemplateproject.domain.onError +import com.pantelisstampoulis.androidtemplateproject.domain.onLoading +import com.pantelisstampoulis.androidtemplateproject.domain.onSuccess +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMoviesUseCase +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.WatchedMovieUiMapper +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.WatchedMovieUiModel +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.Event +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.MviViewModel +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.SideEffect +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +class WatchedMovieListViewModel( + private val getWatchedMoviesUseCase: GetWatchedMoviesUseCase, + private val mapper: WatchedMovieUiMapper, +) : MviViewModel( + initialState = WatchedMovieListUiState(), +) { + + override fun handleEvents(event: WatchedMovieListEvent) { + when (event) { + is WatchedMovieListEvent.GetWatchedMovies -> { + viewModelScope.launch { + getWatchedMoviesUseCase(input = Unit).collect { resultState -> + resultState + .onLoading { + setState { copy(isLoading = true) } + } + .onSuccess { + setState { + copy( + isLoading = false, + errorMessage = null, + data = it.map { mapper.fromDomainToUi(it) } + .toImmutableList(), + ) + } + } + .onError { error -> + setState { + copy( + isLoading = false, + errorMessage = error.message ?: "An error occurred", + ) + } + } + } + } + } + + is WatchedMovieListEvent.ShowMovieDetails -> setEffect { + WatchedMovieListSideEffect.NavigateToMovieDetails(event.movieId) + } + } + } +} + +data class WatchedMovieListUiState( + val isLoading: Boolean = false, + val errorMessage: String? = null, + val data: ImmutableList? = null, +) : UiState + +sealed interface WatchedMovieListEvent : Event { + data object GetWatchedMovies : WatchedMovieListEvent + data class ShowMovieDetails(val movieId: Int) : WatchedMovieListEvent +} + +sealed interface WatchedMovieListSideEffect : SideEffect { + data class NavigateToMovieDetails(val movieId: Int) : WatchedMovieListSideEffect +} +``` + +Note: check if the existing codebase puts events/side effects in separate files per screen. If so, split them out. The exploration shows `MovieListEvent`, `MovieListSideEffect`, `MovieDetailsEvent`, `MovieDetailsSideEffect` — check their file locations and follow the same pattern. + +### Step 3.7 — New file: `WatchedMovieListScreen` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListScreen.kt` + +```kotlin +@Composable +fun WatchedMovieListScreen( + state: WatchedMovieListUiState, + effect: Flow, + onEvent: (WatchedMovieListEvent) -> Unit, + onMovieClicked: (Int) -> Unit, +) +``` + +**Structure:** +- `Box` with `fillMaxSize`, centered content alignment (same pattern as `MovieListScreen`). +- When `state.isLoading`: show `CircularProgressIndicator`. +- When `state.errorMessage != null`: show `Text(state.errorMessage)`. +- When `state.data != null`: + - If `state.data.isEmpty()`: show centered `Text(stringResource(R.string.watched_empty_state))`. + - Else: show `LazyColumn` (NOT `PullToRefreshLazyColumn` — no pull-to-refresh per spec AC-4.4). +- `LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME)`: trigger `GetWatchedMovies` event. Uses `ON_RESUME` (not `ON_CREATE`) so the list refreshes when returning from details screen after rating. +- `ObserveEffects` for side effects — `NavigateToMovieDetails` calls `onMovieClicked(movieId)`. + +**`WatchedMovieRow` composable:** + +```kotlin +@Composable +fun WatchedMovieRow( + movie: WatchedMovieUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +- `Card` with `onClick`, same height as `MovieRow` (150.dp). +- `Row`: poster image (`AsyncImage`) + `Column` with: + - `Text(movie.title)` — `titleSmall`, max 2 lines, `overflow = TextOverflow.Ellipsis`. + - `Row` for release year. + - `Row` at bottom with: + - TMDB rating: `Icon(star) + Text(movie.voteAverage)` — same style as `MovieRow`. + - Spacer. + - User rating: `Icon(star, tint = StarYellow) + Text(movie.userRating.toString())` with `Modifier.semantics { contentDescription = context.getString(R.string.content_description_user_rating, movie.userRating) }`. + +### Step 3.8 — New file: `MovieCatalogTabbedScreen` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/MovieCatalogTabbedScreen.kt` + +```kotlin +@Composable +fun MovieCatalogTabbedScreen( + movieListState: MovieListUiState, + movieListEffect: Flow, + onMovieListEvent: (MovieListEvent) -> Unit, + watchedMovieListState: WatchedMovieListUiState, + watchedMovieListEffect: Flow, + onWatchedMovieListEvent: (WatchedMovieListEvent) -> Unit, + onMovieClicked: (Int) -> Unit, +) +``` + +**Implementation:** + +```kotlin +val tabTitles = listOf( + stringResource(R.string.tab_discover), + stringResource(R.string.tab_watched), +) +var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + +Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = selectedTabIndex) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + text = { Text(title) }, + ) + } + } + + // Use Box with both screens in composition to preserve scroll state (AC-4.1). + // Toggle visibility so the inactive tab's composable stays alive. + Box(modifier = Modifier.fillMaxSize()) { + if (selectedTabIndex == 0) { + MovieListScreen( + state = movieListState, + effect = movieListEffect, + onEvent = onMovieListEvent, + onMovieClicked = onMovieClicked, + ) + } + if (selectedTabIndex == 1) { + WatchedMovieListScreen( + state = watchedMovieListState, + effect = watchedMovieListEffect, + onEvent = onWatchedMovieListEvent, + onMovieClicked = onMovieClicked, + ) + } + } +} +``` + +**Scroll state preservation note (AC-4.1):** The simplest approach uses conditional composition as above. If scroll state is lost on tab switch, upgrade to `HorizontalPager(state = rememberPagerState { 2 })` which keeps both pages in memory. The ViewModel state (via `StateFlow`) persists regardless. + +**Discover reload behavior (AC-4.2):** When `MovieListScreen` leaves composition on tab switch, its `LifecycleEventEffect(ON_CREATE)` coroutines are cancelled. When the user returns to Discover, `ON_CREATE` fires again and reloads data. This matches the spec: "the load is cancelled. When the user returns to Discover, the data reloads from scratch." + +### Step 3.9 — Modify: `MovieCatalogNavigation.kt` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/navigation/MovieCatalogNavigation.kt` + +Replace `addMovieListScreen` body: + +```kotlin +private fun NavGraphBuilder.addMovieListScreen( + onMovieClicked: (Int) -> Unit, +) { + composable { + val movieListViewModel = koinViewModel() + val movieListState by movieListViewModel.viewState.collectAsStateWithLifecycle() + + val watchedMovieListViewModel = koinViewModel() + val watchedMovieListState by watchedMovieListViewModel.viewState.collectAsStateWithLifecycle() + + MovieCatalogTabbedScreen( + movieListState = movieListState, + movieListEffect = movieListViewModel.effect, + onMovieListEvent = movieListViewModel::setEvent, + watchedMovieListState = watchedMovieListState, + watchedMovieListEffect = watchedMovieListViewModel.effect, + onWatchedMovieListEvent = watchedMovieListViewModel::setEvent, + onMovieClicked = onMovieClicked, + ) + } +} +``` + +Add imports for `WatchedMovieListViewModel`, `MovieCatalogTabbedScreen`. + +### Step 3.10 — Modify: `FeatureMovieCatalogPresentationModule.kt` + +**Path**: `feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/di/FeatureMovieCatalogPresentationModule.kt` + +Add: + +```kotlin +single { WatchedMovieUiMapper() } + +viewModel { + WatchedMovieListViewModel( + getWatchedMoviesUseCase = get(), + mapper = get(), + ) +} +``` + +Update existing `MovieDetailsViewModel` factory: + +```kotlin +viewModel { + MovieDetailsViewModel( + getMovieUseCase = get(), + rateMovieUseCase = get(), + saveWatchedMovieUseCase = get(), + getWatchedMovieUseCase = get(), + mapper = get(), + ) +} +``` + +### Step 3.11 — Modify: `strings.xml` + +**Path**: `feature/movie-catalog/src/main/res/values/strings.xml` + +Add these string resources: + +```xml +Discover +Watched +You rated this +Rating saved +Something went wrong. Please try again. +No movies rated yet. Start exploring and rate movies you\'ve watched. +Your rating: %d out of 10 +``` + +### Verification + +```bash +./gradlew assembleDebug +``` + +Expected: full build succeeds. The feature is functional end-to-end. Manual QA can verify: +- Details screen shows interactive stars + Rate button for unrated movies. +- Tapping Rate saves via network then locally, shows "Rating saved" snackbar, locks stars. +- Re-opening a rated movie shows "You rated this" with non-interactive pre-filled stars. +- Movie list screen has Discover/Watched tabs. +- Watched tab shows rated movies with user rating, or empty state. +- Tab switching works, Discover reloads on return. + +--- + +## Phase 4: Testing + +This phase adds test doubles, unit tests for all new code, and verifies architecture rules. After this phase, the feature is fully tested and ready for review. + +### Step 4.1 — Modify: `DatabaseTestDoubleFactory` + +**Path**: `test/doubles/database/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/doubles/database/DatabaseTestDoubleFactory.kt` + +Add factory method: + +```kotlin +fun provideWatchedMovieDbModel() = WatchedMovieDbModel( + movieId = randomInt(from = 1, until = 1000), + title = randomString(), + posterUrl = randomString(), + overview = randomString(), + publicRating = randomFloat(from = 0F, until = 10F).toDouble(), + releaseDate = randomString(), + userRating = randomInt(from = 1, until = 10), + ratedAt = System.currentTimeMillis(), +) +``` + +### Step 4.2 — Modify: `DomainTestDoubleFactory` + +**Path**: `test/doubles/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/doubles/model/DomainTestDoubleFactory.kt` + +Add factory method: + +```kotlin +fun provideWatchedMovieModel() = WatchedMovie( + movieId = randomInt(from = 1, until = 1000), + title = randomString(), + posterUrl = randomString(), + overview = randomString(), + publicRating = randomFloat(from = 0F, until = 10F).toDouble(), + releaseDate = randomString(), + userRating = randomInt(from = 1, until = 10), + ratedAt = System.currentTimeMillis(), +) +``` + +### Step 4.3 — Modify: `MoviesRepositoryImplTest` + +**Path**: `core/data/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImplTest.kt` + +Add test cases for the 3 new repository methods. Follow the existing test patterns in this file (KoinTest + mock overrides + Turbine for Flow testing): + +**`saveWatchedMovie`:** +- Verify `databaseDataSource.insertWatchedMovie()` is called with a `WatchedMovieDbModel` matching all input fields. +- Verify `ratedAt` is set (non-zero Long). +- Verify flow emits `ResultState.Success(Unit)`. + +**`getWatchedMovies`:** +- Mock `databaseDataSource.getWatchedMovies()` to return a flow of `List`. +- Verify result maps correctly to `List` via `watchedMovieDomainMapper`. +- Verify flow emits `ResultState.Success(watchedMovies)`. + +**`getWatchedMovie`:** +- **Found case:** Mock `databaseDataSource.getWatchedMovie(id)` to return a `WatchedMovieDbModel`. Verify `ResultState.Success(watchedMovie)`. +- **Not found case:** Mock to return `null`. Verify `ResultState.Error(ErrorModel.NotFound())`. + +### Step 4.4 — New file: `SaveWatchedMovieUseCaseImplTest` + +**Path**: `core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCaseImplTest.kt` + +Follow existing use case test patterns. Test: +- Delegates to `moviesRepository.saveWatchedMovie(...)` with correct parameters. +- Emits `ResultState.Loading` then `ResultState.Success` (via `onStartCatch`). +- On repository exception, emits `ResultState.Error`. + +### Step 4.5 — New file: `GetWatchedMoviesUseCaseImplTest` + +**Path**: `core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCaseImplTest.kt` + +Test: +- Delegates to `moviesRepository.getWatchedMovies()`. +- Emits `ResultState.Loading` then `ResultState.Success(list)`. +- On repository exception, emits `ResultState.Error`. + +### Step 4.6 — New file: `GetWatchedMovieUseCaseImplTest` + +**Path**: `core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCaseImplTest.kt` + +Test: +- Delegates to `moviesRepository.getWatchedMovie(movieId)`. +- Found: emits `ResultState.Success(watchedMovie)`. +- Not found: emits `ResultState.Error(NotFound)`. + +### Step 4.7 — Modify: `MovieDetailsViewModelTest` + +**Path**: `feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModelTest.kt` + +Update ViewModel instantiation to include the 2 new use case mocks (`saveWatchedMovieUseCase`, `getWatchedMovieUseCase`). + +Add test cases: + +**Init — movie already rated:** +- Mock `getWatchedMovieUseCase(movieId)` to return `Success(watchedMovie)`. +- Verify `state.userRating == watchedMovie.userRating`. + +**Init — movie not rated:** +- Mock `getWatchedMovieUseCase(movieId)` to return `Error(NotFound)`. +- Verify `state.userRating == null`. + +**RateMovie — full success (network + local):** +- Mock `rateMovieUseCase` to return `Success`. +- Mock `saveWatchedMovieUseCase` to return `Success`. +- Verify `state.userRating == rating`, `state.isRatingInProgress == false`. +- Verify `ShowSnackbar("Rating saved")` effect emitted. + +**RateMovie — network failure:** +- Mock `rateMovieUseCase` to return `Error`. +- Verify `state.userRating == null`, `state.isRatingInProgress == false`. +- Verify `ShowSnackbar("Something went wrong. Please try again.")` effect emitted. + +**RateMovie — network success, local save failure:** +- Mock `rateMovieUseCase` to return `Success`. +- Mock `saveWatchedMovieUseCase` to return `Error`. +- Verify `state.userRating == null`, `state.isRatingInProgress == false`. +- Verify error snackbar effect emitted. + +### Step 4.8 — New file: `WatchedMovieListViewModelTest` + +**Path**: `feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModelTest.kt` + +Follow existing `MovieListViewModelTest` patterns. Test cases: + +**GetWatchedMovies — success with data:** +- Mock `getWatchedMoviesUseCase(Unit)` to return `Success(listOf(watchedMovie1, watchedMovie2))`. +- Verify `state.data` contains mapped `WatchedMovieUiModel` items, `state.isLoading == false`. + +**GetWatchedMovies — success with empty list:** +- Mock to return `Success(emptyList())`. +- Verify `state.data` is empty `ImmutableList`, `state.isLoading == false`. + +**GetWatchedMovies — error:** +- Mock to return `Error(ServerError("fail"))`. +- Verify `state.errorMessage != null`, `state.isLoading == false`. + +**ShowMovieDetails:** +- Send `ShowMovieDetails(movieId = 42)` event. +- Verify `NavigateToMovieDetails(movieId = 42)` side effect emitted. + +### Verification + +```bash +# Run all unit tests +./gradlew test + +# Run Konsist architecture tests specifically +./gradlew :test:konsist:test + +# Run Spotless formatting check (fix with spotlessApply if needed) +./gradlew spotlessCheck +``` + +Expected: +- All new and existing unit tests pass. +- Konsist rules pass — all new classes follow naming conventions (`*UseCase`, `*UseCaseImpl`, `*RepositoryImpl`), reside in correct packages (`..domain..usecase..`, `..data..repository`), and respect architectural constraints. +- Spotless formatting passes (run `./gradlew spotlessApply` first if needed). + +--- + +## File Inventory + +### New files (19) + +| # | Phase | Path | +|---|---|---| +| 1 | 1 | `core/database/api/src/main/kotlin/.../database/model/WatchedMovieDbModel.kt` | +| 2 | 1 | `core/database/room/src/main/kotlin/.../database/model/WatchedMovieEntity.kt` | +| 3 | 1 | `core/database/room/src/main/kotlin/.../database/dao/WatchedMovieDao.kt` | +| 4 | 1 | `core/database/room/src/main/kotlin/.../database/mapper/WatchedMovieDbMapper.kt` | +| 5 | 1 | `core/database/room/src/main/kotlin/.../database/migration/Migrations.kt` | +| 6 | 1 | `core/model/src/main/kotlin/.../model/movies/WatchedMovie.kt` | +| 7 | 1 | `core/data/src/main/kotlin/.../data/mapper/WatchedMovieDomainMapper.kt` | +| 8 | 2 | `core/domain/src/main/kotlin/.../domain/usecase/movies/SaveWatchedMovieUseCase.kt` | +| 9 | 2 | `core/domain/src/main/kotlin/.../domain/usecase/movies/GetWatchedMoviesUseCase.kt` | +| 10 | 2 | `core/domain/src/main/kotlin/.../domain/usecase/movies/GetWatchedMovieUseCase.kt` | +| 11 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/uimodel/WatchedMovieUiModel.kt` | +| 12 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/mapper/WatchedMovieUiMapper.kt` | +| 13 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/screen/watchedmovielist/WatchedMovieListViewModel.kt` | +| 14 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/screen/watchedmovielist/WatchedMovieListScreen.kt` | +| 15 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/screen/MovieCatalogTabbedScreen.kt` | +| 16 | 4 | `core/domain/src/test/kotlin/.../domain/usecase/movies/SaveWatchedMovieUseCaseImplTest.kt` | +| 17 | 4 | `core/domain/src/test/kotlin/.../domain/usecase/movies/GetWatchedMoviesUseCaseImplTest.kt` | +| 18 | 4 | `core/domain/src/test/kotlin/.../domain/usecase/movies/GetWatchedMovieUseCaseImplTest.kt` | +| 19 | 4 | `feature/movie-catalog/src/test/kotlin/.../presentation/screen/watchedmovielist/WatchedMovieListViewModelTest.kt` | + +### Modified files (21) + +| # | Phase | Path | +|---|---|---| +| 1 | 1 | `core/database/api/src/main/kotlin/.../database/DatabaseDataSource.kt` | +| 2 | 1 | `core/database/room/src/main/kotlin/.../database/AppDatabase.kt` | +| 3 | 1 | `core/database/room/src/main/kotlin/.../database/RoomDataSource.kt` | +| 4 | 1 | `core/database/room/src/main/kotlin/.../database/mapper/Mappers.kt` | +| 5 | 1 | `core/database/room/src/main/kotlin/.../database/di/DatabaseRoomModules.kt` | +| 6 | 1 | `core/database/noop/src/main/kotlin/.../database/NoopDatabaseDataSource.kt` | +| 7 | 1 | `core/domain/src/main/kotlin/.../domain/repository/MoviesRepository.kt` | +| 8 | 1 | `core/data/src/main/kotlin/.../data/repository/MoviesRepositoryImpl.kt` | +| 9 | 1 | `core/data/src/main/kotlin/.../data/mapper/Mappers.kt` | +| 10 | 1 | `core/data/src/main/kotlin/.../data/di/DataModules.kt` | +| 11 | 2 | `core/domain/src/main/kotlin/.../domain/di/DomainModules.kt` | +| 12 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/uicomponent/UserRatingBar.kt` | +| 13 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/screen/moviedetails/MovieDetailsViewModel.kt` | +| 14 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/screen/moviedetails/MovieDetailsScreen.kt` | +| 15 | 3 | `feature/movie-catalog/src/main/kotlin/.../navigation/MovieCatalogNavigation.kt` | +| 16 | 3 | `feature/movie-catalog/src/main/kotlin/.../presentation/di/FeatureMovieCatalogPresentationModule.kt` | +| 17 | 3 | `feature/movie-catalog/src/main/res/values/strings.xml` | +| 18 | 4 | `test/doubles/database/src/main/kotlin/.../doubles/database/DatabaseTestDoubleFactory.kt` | +| 19 | 4 | `test/doubles/model/src/main/kotlin/.../doubles/model/DomainTestDoubleFactory.kt` | +| 20 | 4 | `core/data/src/test/kotlin/.../data/repository/MoviesRepositoryImplTest.kt` | +| 21 | 4 | `feature/movie-catalog/src/test/kotlin/.../presentation/screen/moviedetails/MovieDetailsViewModelTest.kt` | diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e42c788 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,156 @@ +# Feature Spec: Watched Movies Collection + +**Author**: Pantelis Stampoulis +**Date**: 2026-04-11 +**Status**: Draft + +--- + +## Overview + +Allow users to rate movies (1–10 stars) from the movie details screen. Rated movies are persisted locally and surfaced in a dedicated "Watched" tab on the main movie list screen, showing both the user's personal rating and the public TMDB rating. + +--- + +## User Stories + +### US-1: Rate a movie + +**As a** user on the movie details screen, +**I want to** tap a star to give the movie a rating from 1 to 10, +**so that** my rating is saved and I can find the movie in my Watched collection. + +#### Acceptance Criteria + +- AC-1.1: The details screen shows a row of 10 star icons when the movie has not been rated yet. +- AC-1.2: Tapping the Nth star selects stars 1–N (gold) and leaves stars N+1–10 unselected (gray). +- AC-1.3: Tapping a star saves the rating and a full snapshot of the movie data to the local database. +- AC-1.4: On successful save, a snackbar appears with the message "Rating saved". +- AC-1.5: After the snackbar appears, the star row becomes non-interactive and a label "You rated this" is displayed above the stars showing the saved rating pre-filled. +- AC-1.6: The user remains on the details screen after rating (no automatic navigation). +- AC-1.7: On save failure, a snackbar appears with the message "Something went wrong. Please try again." The UI reverts to its pre-rating state (stars remain interactive, no rating saved). +- AC-1.8: A rating, once saved, cannot be changed. The rating is immutable. + +--- + +### US-2: View an already-rated movie's details + +**As a** user who has previously rated a movie, +**I want to** see my rating clearly when I open that movie's details screen, +**so that** I know I have already rated it and cannot accidentally re-rate it. + +#### Acceptance Criteria + +- AC-2.1: When opening the details screen of a rated movie, the label "You rated this" is shown. +- AC-2.2: Below the label, the star row is pre-filled with the user's rating and is non-interactive. +- AC-2.3: There is no Rate button or interactive rating affordance visible for an already-rated movie. + +--- + +### US-3: Browse rated movies in the Watched tab + +**As a** user who has rated one or more movies, +**I want to** see all my rated movies in a dedicated "Watched" tab, +**so that** I can revisit movies I have seen and recall my personal ratings. + +#### Acceptance Criteria + +- AC-3.1: The main movie list screen has two tabs: "Discover" (default) and "Watched". +- AC-3.2: "Discover" is the selected tab when the screen is first opened. +- AC-3.3: The Watched tab lists all movies the user has rated, sorted by most recently rated first. +- AC-3.4: Each list item shows: movie poster, title, public TMDB rating (existing style), and the user's personal rating as a compact icon + number (e.g. ⭐ 8). +- AC-3.5: The user's personal rating compact element has a TalkBack content description: "Your rating: N out of 10". +- AC-3.6: The user cannot interact with or change their rating from the Watched tab (read-only). +- AC-3.7: When the Watched tab is empty, a text-only empty state is shown (e.g. "No movies rated yet. Start exploring and rate movies you've watched."). +- AC-3.8: All data in the Watched tab is loaded from the local database and is available offline. +- AC-3.9: The Watched tab loads all rated movies at once (no pagination). + +--- + +### US-4: Tab and scroll behavior + +**As a** user switching between tabs, +**I want** the app to behave predictably when I navigate between Discover and Watched, +**so that** I don't lose my place or experience jarring reloads. + +#### Acceptance Criteria + +- AC-4.1: Returning to the Discover tab restores the previous scroll position. +- AC-4.2: If the Discover tab was loading when the user switched away, the load is cancelled. When the user returns to Discover, the data reloads from scratch. +- AC-4.3: Pull-to-refresh is available only on the Discover tab, consistent with current behavior. +- AC-4.4: The Watched tab does not support pull-to-refresh. + +--- + +## Data Model + +### Snapshot stored on rating (local DB) + +| Field | Type | Notes | +|---|---|---| +| `movieId` | Int | TMDB movie ID, primary key | +| `title` | String | Movie title at time of rating | +| `posterUrl` | String? | Poster URL at time of rating | +| `overview` | String? | Movie overview at time of rating | +| `publicRating` | Float | TMDB public rating at time of rating | +| `releaseDate` | String? | Release date at time of rating | +| `userRating` | Int | User's rating, 1–10 | +| `ratedAt` | Long | Epoch timestamp of when the rating was saved | + +> The snapshot is a point-in-time copy. The public TMDB rating shown in the Watched tab may be stale relative to current TMDB data — this is acceptable. + +--- + +## Edge Cases + +| # | Scenario | Expected Behavior | +|---|---|---| +| EC-1 | User taps a star and the app is force-killed before the DB write completes | Rating is lost. Acceptable — no durability guarantee required. | +| EC-2 | User opens the details screen while offline | Details screen loads from whatever data is available (existing behavior, unchanged). Rating action may still be attempted; DB is local so save should succeed regardless of connectivity. | +| EC-3 | User is on the Watched tab while offline | Full offline experience — all data served from local DB snapshot. | +| EC-4 | Watched tab is opened with no rated movies | Text-only empty state is displayed. | +| EC-5 | User switches to Watched tab while Discover is mid-load | Discover load is cancelled. On return to Discover, data reloads from scratch. | +| EC-6 | User navigates back to the movie list after rating | No visual change on the movie list item in the Discover tab. | +| EC-7 | User opens details of the same movie from both the Discover and Watched tabs | Both entry points lead to the same details screen with the correct rated/unrated state. | +| EC-8 | DB save fails on rating | Snackbar "Something went wrong. Please try again." Stars remain interactive. No entry written to DB. | +| EC-9 | Very long movie title in Watched tab list item | Title truncates with ellipsis; poster, ratings remain visible. | +| EC-10 | User rates 100+ movies | All loaded at once in Watched tab. No pagination. Performance acceptable for expected usage. | + +--- + +## Out of Scope + +- Changing or deleting a rating (ratings are immutable after first save). +- Filtering or sorting options in the Watched tab (future task). +- Pagination in the Watched tab (future task). +- Visual indicator on Discover tab list items showing a movie has been rated. +- Deep linking directly to a rated movie's details screen. +- Syncing ratings to a backend or across devices. +- Sharing ratings or making them public. +- Rating from the movie list (Discover tab) — rating is only available from the details screen. +- Pull-to-refresh on the Watched tab. +- Accessibility for the star rating input (TalkBack announcements on tap). +- Minimum touch target enforcement for the star row (implementation detail, not spec-level). + +--- + +## Open Questions (Resolved) + +| # | Question | Decision | +|---|---|---| +| OQ-1 | Rating format | 1–10 stars, tap Nth star selects 1–N gold, rest gray | +| OQ-2 | Can user re-rate? | No. Rating is immutable after first save. | +| OQ-3 | Tab name | "Watched" | +| OQ-4 | What data is stored locally? | Full movie snapshot at time of rating | +| OQ-5 | Watched tab list item design | Poster + title + TMDB rating + user rating (⭐ N) | +| OQ-6 | Sort order in Watched tab | Most recently rated first | +| OQ-7 | Empty state | Text only | +| OQ-8 | Error on DB save failure | Snackbar, revert UI, no retry action | +| OQ-9 | Force-kill before save completes | Rating lost, acceptable | +| OQ-10 | Back navigation after rating | Stay on details screen, no list item badge | +| OQ-11 | Deep linking | Out of scope | +| OQ-12 | Accessibility | Content description only on compact user rating icon | +| OQ-13 | Pagination | Out of scope, future task | +| OQ-14 | Discover scroll state on tab return | Restored | +| OQ-15 | Discover load on tab return | Cancelled and reloaded | +| OQ-16 | Pull-to-refresh on Watched tab | Not supported | diff --git a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/di/DataModules.kt b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/di/DataModules.kt index 379d72e..2786ccd 100644 --- a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/di/DataModules.kt +++ b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/di/DataModules.kt @@ -2,6 +2,7 @@ package com.pantelisstampoulis.androidtemplateproject.data.di import com.pantelisstampoulis.androidtemplateproject.data.mapper.ErrorDomainMapper import com.pantelisstampoulis.androidtemplateproject.data.mapper.Mappers +import com.pantelisstampoulis.androidtemplateproject.data.mapper.WatchedMovieDomainMapper import com.pantelisstampoulis.androidtemplateproject.data.mapper.movie.MovieDataMapper import com.pantelisstampoulis.androidtemplateproject.data.mapper.movie.MovieDomainMapper import com.pantelisstampoulis.androidtemplateproject.data.repository.MoviesRepositoryImpl @@ -18,6 +19,7 @@ internal val mappersModule = module { movieDataMapper = MovieDataMapper(), movieDomainMapper = MovieDomainMapper(), errorDomainMapper = ErrorDomainMapper(), + watchedMovieDomainMapper = WatchedMovieDomainMapper(), ) } } diff --git a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/Mappers.kt b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/Mappers.kt index 3629e29..006e5ff 100644 --- a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/Mappers.kt +++ b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/Mappers.kt @@ -7,4 +7,5 @@ internal class Mappers( val movieDataMapper: MovieDataMapper, val movieDomainMapper: MovieDomainMapper, val errorDomainMapper: ErrorDomainMapper, + val watchedMovieDomainMapper: WatchedMovieDomainMapper, ) diff --git a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/WatchedMovieDomainMapper.kt b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/WatchedMovieDomainMapper.kt new file mode 100644 index 0000000..e5186a6 --- /dev/null +++ b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/mapper/WatchedMovieDomainMapper.kt @@ -0,0 +1,18 @@ +package com.pantelisstampoulis.androidtemplateproject.data.mapper + +import com.pantelisstampoulis.androidtemplateproject.architecture.mapper.DbToDomainMapper +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie + +internal class WatchedMovieDomainMapper : DbToDomainMapper { + override fun fromDbToDomain(dbModel: WatchedMovieDbModel): WatchedMovie = WatchedMovie( + movieId = dbModel.movieId, + title = dbModel.title, + posterUrl = dbModel.posterUrl, + overview = dbModel.overview, + publicRating = dbModel.publicRating, + releaseDate = dbModel.releaseDate, + userRating = dbModel.userRating, + ratedAt = dbModel.ratedAt, + ) +} diff --git a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImpl.kt b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImpl.kt index 61f53a6..e11a7ee 100644 --- a/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImpl.kt @@ -3,23 +3,28 @@ package com.pantelisstampoulis.androidtemplateproject.data.repository import com.pantelisstampoulis.androidtemplateproject.data.mapper.Mappers import com.pantelisstampoulis.androidtemplateproject.database.DatabaseDataSource import com.pantelisstampoulis.androidtemplateproject.database.model.MovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel import com.pantelisstampoulis.androidtemplateproject.domain.ResultState import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository import com.pantelisstampoulis.androidtemplateproject.model.error.ErrorModel import com.pantelisstampoulis.androidtemplateproject.model.movies.Movie +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie import com.pantelisstampoulis.androidtemplateproject.network.NetworkDataSource import com.pantelisstampoulis.androidtemplateproject.network.NetworkResult import com.pantelisstampoulis.androidtemplateproject.network.isSuccess import com.pantelisstampoulis.androidtemplateproject.network.request.RateMovieRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map internal class MoviesRepositoryImpl( private val networkDataSource: NetworkDataSource, private val databaseDataSource: DatabaseDataSource, private val mappers: Mappers, + private val clock: () -> Long = System::currentTimeMillis, ) : MoviesRepository { override fun getMovie(movieId: Int): Flow> = flow { @@ -64,6 +69,44 @@ internal class MoviesRepositoryImpl( emit(ResultState.Success(movieDomainList)) } + override fun saveWatchedMovie( + movieId: Int, + title: String, + posterUrl: String?, + overview: String?, + publicRating: Double, + releaseDate: String?, + userRating: Int, + ): Flow> = flow { + val dbModel = WatchedMovieDbModel( + movieId = movieId, + title = title, + posterUrl = posterUrl, + overview = overview, + publicRating = publicRating, + releaseDate = releaseDate, + userRating = userRating, + ratedAt = clock(), + ) + databaseDataSource.insertWatchedMovie(dbModel) + emit(ResultState.Success(Unit)) + } + + override fun getWatchedMovies(): Flow>> = flow { + emitAll( + databaseDataSource.getWatchedMovies().map { dbModels -> + ResultState.Success(dbModels.map(mappers.watchedMovieDomainMapper::fromDbToDomain)) + }, + ) + } + + override fun getWatchedMovie(movieId: Int): Flow> = flow { + val dbModel = databaseDataSource.getWatchedMovie(movieId) + dbModel?.let { + emit(ResultState.Success(mappers.watchedMovieDomainMapper.fromDbToDomain(it))) + } ?: emit(ResultState.Error(ErrorModel.NotFound())) + } + private suspend fun FlowCollector>>.fetchMoviesFromNetwork() { when (val moviesNetworkResult = networkDataSource.getMovies()) { is NetworkResult.Success -> { @@ -79,3 +122,4 @@ internal class MoviesRepositoryImpl( } } } + diff --git a/core/data/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImplTest.kt b/core/data/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImplTest.kt index 8461060..f48540b 100644 --- a/core/data/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImplTest.kt +++ b/core/data/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/data/repository/MoviesRepositoryImplTest.kt @@ -15,6 +15,7 @@ import com.pantelisstampoulis.androidtemplateproject.network.response.ApiResultR import com.pantelisstampoulis.androidtemplateproject.test.doubles.database.DatabaseTestDoubleFactory import com.pantelisstampoulis.androidtemplateproject.test.doubles.network.NetworkTestDoubleFactory import io.mockative.Mock +import io.mockative.any import io.mockative.classOf import io.mockative.coEvery import io.mockative.coVerify @@ -210,4 +211,77 @@ class MoviesRepositoryImplTest : KoinTest { awaitComplete() } } + + @Test + fun shouldInsertWatchedMovieAndEmitSuccessWhenSaving() = runTest { + coEvery { databaseDataSource.insertWatchedMovie(any()) }.returns(Unit) + + repository.saveWatchedMovie( + movieId = 1, + title = "Test Movie", + posterUrl = null, + overview = null, + publicRating = 7.5, + releaseDate = null, + userRating = 8, + ).test { + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success).data).isEqualTo(Unit) + coVerify { databaseDataSource.insertWatchedMovie(any()) }.wasInvoked(exactly = once) + awaitComplete() + } + } + + @Test + fun shouldReturnWatchedMoviesFromDatabase() = runTest { + val mockWatchedMovieDbList = listOf( + DatabaseTestDoubleFactory.provideWatchedMovieDbModel(), + DatabaseTestDoubleFactory.provideWatchedMovieDbModel(), + ) + val expectedWatchedMovies = mockWatchedMovieDbList.map { + dataMappers.watchedMovieDomainMapper.fromDbToDomain(it) + } + + coEvery { databaseDataSource.getWatchedMovies() }.returns(flowOf(mockWatchedMovieDbList)) + + repository.getWatchedMovies().test { + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success).data).isEqualTo(expectedWatchedMovies) + coVerify { databaseDataSource.getWatchedMovies() }.wasInvoked(exactly = once) + awaitComplete() + } + } + + @Test + fun shouldReturnWatchedMovieFromDatabaseWhenFound() = runTest { + val movieId = 42 + val mockWatchedMovieDbModel = DatabaseTestDoubleFactory.provideWatchedMovieDbModel() + val expectedWatchedMovie = dataMappers.watchedMovieDomainMapper.fromDbToDomain(mockWatchedMovieDbModel) + + coEvery { databaseDataSource.getWatchedMovie(movieId) }.returns(mockWatchedMovieDbModel) + + repository.getWatchedMovie(movieId).test { + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success).data).isEqualTo(expectedWatchedMovie) + coVerify { databaseDataSource.getWatchedMovie(movieId) }.wasInvoked(exactly = once) + awaitComplete() + } + } + + @Test + fun shouldReturnErrorWhenWatchedMovieNotFound() = runTest { + val movieId = 42 + coEvery { databaseDataSource.getWatchedMovie(movieId) }.returns(null) + + repository.getWatchedMovie(movieId).test { + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Error::class.java) + assertThat((result as ResultState.Error).error).isInstanceOf(ErrorModel.NotFound::class.java) + coVerify { databaseDataSource.getWatchedMovie(movieId) }.wasInvoked(exactly = once) + awaitComplete() + } + } } diff --git a/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/DatabaseDataSource.kt b/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/DatabaseDataSource.kt index 1cf3b2d..d064b3b 100644 --- a/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/DatabaseDataSource.kt +++ b/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/DatabaseDataSource.kt @@ -1,6 +1,7 @@ package com.pantelisstampoulis.androidtemplateproject.database import com.pantelisstampoulis.androidtemplateproject.database.model.MovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel import kotlinx.coroutines.flow.Flow interface DatabaseDataSource { @@ -12,4 +13,10 @@ interface DatabaseDataSource { suspend fun getMovies(): Flow> suspend fun getMovie(movieId: Int): MovieDbModel? + + suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) + + suspend fun getWatchedMovies(): Flow> + + suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? } diff --git a/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieDbModel.kt b/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieDbModel.kt new file mode 100644 index 0000000..e23c416 --- /dev/null +++ b/core/database/api/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieDbModel.kt @@ -0,0 +1,12 @@ +package com.pantelisstampoulis.androidtemplateproject.database.model + +data class WatchedMovieDbModel( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) diff --git a/core/database/noop/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/NoopDatabaseDataSource.kt b/core/database/noop/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/NoopDatabaseDataSource.kt index cb788ec..94242c0 100644 --- a/core/database/noop/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/NoopDatabaseDataSource.kt +++ b/core/database/noop/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/NoopDatabaseDataSource.kt @@ -1,6 +1,7 @@ package com.pantelisstampoulis.androidtemplateproject.database import com.pantelisstampoulis.androidtemplateproject.database.model.MovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -13,4 +14,10 @@ internal class NoopDatabaseDataSource : DatabaseDataSource { override suspend fun getMovies(): Flow> = flowOf(emptyList()) override suspend fun getMovie(movieId: Int): MovieDbModel? = null + + override suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) { } + + override suspend fun getWatchedMovies(): Flow> = flowOf(emptyList()) + + override suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? = null } diff --git a/core/database/room/schemas/com.pantelisstampoulis.androidtemplateproject.database.AppDatabase/2.json b/core/database/room/schemas/com.pantelisstampoulis.androidtemplateproject.database.AppDatabase/2.json new file mode 100644 index 0000000..e5abbb2 --- /dev/null +++ b/core/database/room/schemas/com.pantelisstampoulis.androidtemplateproject.database.AppDatabase/2.json @@ -0,0 +1,166 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "9292570b66829829ccb1232f021aceec", + "entities": [ + { + "tableName": "movies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `adult` INTEGER NOT NULL, `backdropPath` TEXT NOT NULL, `genreId` INTEGER NOT NULL, `originalLanguage` TEXT NOT NULL, `originalTitle` TEXT NOT NULL, `overview` TEXT NOT NULL, `popularity` REAL NOT NULL, `posterPath` TEXT NOT NULL, `releaseDate` TEXT NOT NULL, `title` TEXT NOT NULL, `video` INTEGER NOT NULL, `voteAverage` REAL NOT NULL, `voteCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "adult", + "columnName": "adult", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backdropPath", + "columnName": "backdropPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "genreId", + "columnName": "genreId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalLanguage", + "columnName": "originalLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalTitle", + "columnName": "originalTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "popularity", + "columnName": "popularity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "posterPath", + "columnName": "posterPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "voteAverage", + "columnName": "voteAverage", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "voteCount", + "columnName": "voteCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "watched_movies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`movieId` INTEGER NOT NULL, `title` TEXT NOT NULL, `posterUrl` TEXT, `overview` TEXT, `publicRating` REAL NOT NULL, `releaseDate` TEXT, `userRating` INTEGER NOT NULL, `ratedAt` INTEGER NOT NULL, PRIMARY KEY(`movieId`))", + "fields": [ + { + "fieldPath": "movieId", + "columnName": "movieId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "publicRating", + "columnName": "publicRating", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT" + }, + { + "fieldPath": "userRating", + "columnName": "userRating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratedAt", + "columnName": "ratedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "movieId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9292570b66829829ccb1232f021aceec')" + ] + } +} \ No newline at end of file diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/AppDatabase.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/AppDatabase.kt index 13f867f..12bfe70 100644 --- a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/AppDatabase.kt +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/AppDatabase.kt @@ -3,15 +3,19 @@ package com.pantelisstampoulis.androidtemplateproject.database import androidx.room.Database import androidx.room.RoomDatabase import com.pantelisstampoulis.androidtemplateproject.database.dao.MovieDao +import com.pantelisstampoulis.androidtemplateproject.database.dao.WatchedMovieDao import com.pantelisstampoulis.androidtemplateproject.database.model.MovieEntity +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity @Database( entities = [ MovieEntity::class, + WatchedMovieEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) internal abstract class AppDatabase : RoomDatabase() { abstract fun movieDao(): MovieDao + abstract fun watchedMovieDao(): WatchedMovieDao } diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/RoomDataSource.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/RoomDataSource.kt index 01e1111..118bc43 100644 --- a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/RoomDataSource.kt +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/RoomDataSource.kt @@ -2,6 +2,7 @@ package com.pantelisstampoulis.androidtemplateproject.database import com.pantelisstampoulis.androidtemplateproject.database.mapper.Mappers import com.pantelisstampoulis.androidtemplateproject.database.model.MovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -23,4 +24,18 @@ internal class RoomDataSource( override suspend fun getMovie(movieId: Int): MovieDbModel? = db.movieDao().getMovieEntity(movieId)?.let { movieEntity -> mappers.movieDbMapper.mapFromDb(movieEntity) } + + override suspend fun insertWatchedMovie(movie: WatchedMovieDbModel) { + db.watchedMovieDao().insertWatchedMovie(mappers.watchedMovieDbMapper.toDb(movie)) + } + + override suspend fun getWatchedMovies(): Flow> = + db.watchedMovieDao().getWatchedMovieEntities().map { entities -> + entities.map { mappers.watchedMovieDbMapper.mapFromDb(it) } + } + + override suspend fun getWatchedMovie(movieId: Int): WatchedMovieDbModel? = + db.watchedMovieDao().getWatchedMovieEntity(movieId)?.let { + mappers.watchedMovieDbMapper.mapFromDb(it) + } } diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/dao/WatchedMovieDao.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/dao/WatchedMovieDao.kt new file mode 100644 index 0000000..8d8ecc8 --- /dev/null +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/dao/WatchedMovieDao.kt @@ -0,0 +1,21 @@ +package com.pantelisstampoulis.androidtemplateproject.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface WatchedMovieDao { + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertWatchedMovie(entity: WatchedMovieEntity): Long + + @Query("SELECT * FROM watched_movies ORDER BY ratedAt DESC") + fun getWatchedMovieEntities(): Flow> + + @Query("SELECT * FROM watched_movies WHERE movieId = :movieId") + fun getWatchedMovieEntity(movieId: Int): WatchedMovieEntity? +} diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/di/DatabaseRoomModules.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/di/DatabaseRoomModules.kt index 3a318dd..34fb158 100644 --- a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/di/DatabaseRoomModules.kt +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/di/DatabaseRoomModules.kt @@ -6,6 +6,8 @@ import com.pantelisstampoulis.androidtemplateproject.database.DatabaseDataSource import com.pantelisstampoulis.androidtemplateproject.database.RoomDataSource import com.pantelisstampoulis.androidtemplateproject.database.mapper.Mappers import com.pantelisstampoulis.androidtemplateproject.database.mapper.MovieDbMapper +import com.pantelisstampoulis.androidtemplateproject.database.mapper.WatchedMovieDbMapper +import com.pantelisstampoulis.androidtemplateproject.database.migration.MIGRATION_1_2 import org.koin.core.module.Module import org.koin.dsl.bind import org.koin.dsl.module @@ -14,6 +16,7 @@ internal val mappersModule = module { factory { Mappers( movieDbMapper = MovieDbMapper(), + watchedMovieDbMapper = WatchedMovieDbMapper(), ) } } @@ -26,7 +29,7 @@ val databaseModule: Module = module { get(), AppDatabase::class.java, "app-database", - ).build() + ).addMigrations(MIGRATION_1_2).build() } single { diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/Mappers.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/Mappers.kt index 53053fe..a930570 100644 --- a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/Mappers.kt +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/Mappers.kt @@ -2,4 +2,5 @@ package com.pantelisstampoulis.androidtemplateproject.database.mapper internal class Mappers( val movieDbMapper: MovieDbMapper, + val watchedMovieDbMapper: WatchedMovieDbMapper, ) diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/WatchedMovieDbMapper.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/WatchedMovieDbMapper.kt new file mode 100644 index 0000000..254ce87 --- /dev/null +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/mapper/WatchedMovieDbMapper.kt @@ -0,0 +1,29 @@ +package com.pantelisstampoulis.androidtemplateproject.database.mapper + +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity + +class WatchedMovieDbMapper { + + fun toDb(model: WatchedMovieDbModel): WatchedMovieEntity = WatchedMovieEntity( + movieId = model.movieId, + title = model.title, + posterUrl = model.posterUrl, + overview = model.overview, + publicRating = model.publicRating, + releaseDate = model.releaseDate, + userRating = model.userRating, + ratedAt = model.ratedAt, + ) + + fun mapFromDb(entity: WatchedMovieEntity): WatchedMovieDbModel = WatchedMovieDbModel( + movieId = entity.movieId, + title = entity.title, + posterUrl = entity.posterUrl, + overview = entity.overview, + publicRating = entity.publicRating, + releaseDate = entity.releaseDate, + userRating = entity.userRating, + ratedAt = entity.ratedAt, + ) +} diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/migration/Migrations.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/migration/Migrations.kt new file mode 100644 index 0000000..01e1707 --- /dev/null +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/migration/Migrations.kt @@ -0,0 +1,23 @@ +package com.pantelisstampoulis.androidtemplateproject.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `watched_movies` ( + `movieId` INTEGER NOT NULL PRIMARY KEY, + `title` TEXT NOT NULL, + `posterUrl` TEXT, + `overview` TEXT, + `publicRating` REAL NOT NULL, + `releaseDate` TEXT, + `userRating` INTEGER NOT NULL, + `ratedAt` INTEGER NOT NULL + ) + """.trimIndent(), + ) + } +} diff --git a/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieEntity.kt b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieEntity.kt new file mode 100644 index 0000000..8d1e0cc --- /dev/null +++ b/core/database/room/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/database/model/WatchedMovieEntity.kt @@ -0,0 +1,16 @@ +package com.pantelisstampoulis.androidtemplateproject.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "watched_movies") +data class WatchedMovieEntity( + @PrimaryKey val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 3387fa5..f53d028 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -3,6 +3,7 @@ import com.pantelisstampoulis.utils.namespaceWithProjectPackage plugins { id(libs.plugins.custom.library.core.get().pluginId) id(libs.plugins.custom.koin.get().pluginId) + id(libs.plugins.custom.testing.get().pluginId) } android { diff --git a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/di/DomainModules.kt b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/di/DomainModules.kt index aa13901..b506186 100644 --- a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/di/DomainModules.kt +++ b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/di/DomainModules.kt @@ -5,8 +5,14 @@ import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMo import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMovieUseCaseImpl import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMoviesUseCase import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMoviesUseCaseImpl +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMovieUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMovieUseCaseImpl +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMoviesUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMoviesUseCaseImpl import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.RateMovieUseCase import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.RateMovieUseCaseImpl +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.SaveWatchedMovieUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.SaveWatchedMovieUseCaseImpl import com.pantelisstampoulis.androidtemplateproject.utils.koin.getWith import org.koin.core.module.Module import org.koin.core.qualifier.named @@ -38,4 +44,28 @@ val domainModule: Module = module { logger = getWith("GetMoviesUseCase"), ) } bind RateMovieUseCase::class + + factory { + SaveWatchedMovieUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("SaveWatchedMovieUseCase"), + ) + } bind SaveWatchedMovieUseCase::class + + factory { + GetWatchedMoviesUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("GetWatchedMoviesUseCase"), + ) + } bind GetWatchedMoviesUseCase::class + + factory { + GetWatchedMovieUseCaseImpl( + moviesRepository = get(), + coroutineContext = get(qualifier = named(CoroutinesDispatchers.IO)), + logger = getWith("GetWatchedMovieUseCase"), + ) + } bind GetWatchedMovieUseCase::class } diff --git a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/repository/MoviesRepository.kt b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/repository/MoviesRepository.kt index a766674..7d77d57 100644 --- a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/repository/MoviesRepository.kt +++ b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/repository/MoviesRepository.kt @@ -2,6 +2,7 @@ package com.pantelisstampoulis.androidtemplateproject.domain.repository import com.pantelisstampoulis.androidtemplateproject.domain.ResultState import com.pantelisstampoulis.androidtemplateproject.model.movies.Movie +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie import kotlinx.coroutines.flow.Flow interface MoviesRepository { @@ -11,4 +12,18 @@ interface MoviesRepository { fun getMovies(ignoreCache: Boolean = false): Flow>> fun rateMovie(movieId: Int, rating: Float): Flow> + + fun saveWatchedMovie( + movieId: Int, + title: String, + posterUrl: String?, + overview: String?, + publicRating: Double, + releaseDate: String?, + userRating: Int, + ): Flow> + + fun getWatchedMovies(): Flow>> + + fun getWatchedMovie(movieId: Int): Flow> } diff --git a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCase.kt b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCase.kt new file mode 100644 index 0000000..012520b --- /dev/null +++ b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCase.kt @@ -0,0 +1,23 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface GetWatchedMovieUseCase : UseCase + +internal class GetWatchedMovieUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : GetWatchedMovieUseCase { + + override operator fun invoke(input: Int): Flow> = + moviesRepository.getWatchedMovie(input) + .onStartCatch(coroutineContext = coroutineContext, logger = logger) +} diff --git a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCase.kt b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCase.kt new file mode 100644 index 0000000..b18a7bc --- /dev/null +++ b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCase.kt @@ -0,0 +1,23 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface GetWatchedMoviesUseCase : UseCase> + +internal class GetWatchedMoviesUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : GetWatchedMoviesUseCase { + + override operator fun invoke(input: Unit): Flow>> = + moviesRepository.getWatchedMovies() + .onStartCatch(coroutineContext = coroutineContext, logger = logger) +} diff --git a/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCase.kt b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCase.kt new file mode 100644 index 0000000..f2a0cb6 --- /dev/null +++ b/core/domain/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCase.kt @@ -0,0 +1,39 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.onStartCatch +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.UseCase +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.CoroutineContext + +interface SaveWatchedMovieUseCase : UseCase + +data class SaveWatchedMovieInput( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, +) + +internal class SaveWatchedMovieUseCaseImpl( + private val moviesRepository: MoviesRepository, + private val coroutineContext: CoroutineContext, + private val logger: Logger, +) : SaveWatchedMovieUseCase { + + override operator fun invoke(input: SaveWatchedMovieInput): Flow> = + moviesRepository.saveWatchedMovie( + movieId = input.movieId, + title = input.title, + posterUrl = input.posterUrl, + overview = input.overview, + publicRating = input.publicRating, + releaseDate = input.releaseDate, + userRating = input.userRating, + ).onStartCatch(coroutineContext = coroutineContext, logger = logger) +} diff --git a/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCaseImplTest.kt b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCaseImplTest.kt new file mode 100644 index 0000000..680e827 --- /dev/null +++ b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMovieUseCaseImplTest.kt @@ -0,0 +1,71 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.error.ErrorModel +import com.pantelisstampoulis.androidtemplateproject.test.doubles.model.DomainTestDoubleFactory +import io.mockative.Mock +import io.mockative.every +import io.mockative.mock +import io.mockative.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class GetWatchedMovieUseCaseImplTest { + + @Mock + private val repository = mock(MoviesRepository::class) + + private val noopLogger = object : Logger { + override val tag: String = "test" + override fun d(throwable: Throwable?, tag: String, message: () -> String) {} + override fun i(throwable: Throwable?, tag: String, message: () -> String) {} + override fun w(throwable: Throwable?, tag: String, message: () -> String) {} + override fun e(throwable: Throwable?, tag: String, message: () -> String) {} + override fun a(throwable: Throwable?, tag: String, message: () -> String) {} + override fun v(throwable: Throwable?, tag: String, message: () -> String) {} + } + + private val useCase = GetWatchedMovieUseCaseImpl( + moviesRepository = repository, + coroutineContext = UnconfinedTestDispatcher(), + logger = noopLogger, + ) + + @Test + fun shouldEmitLoadingThenSuccessWhenWatchedMovieFound() = runTest { + val movieId = 42 + val watchedMovie = DomainTestDoubleFactory.provideWatchedMovieModel() + every { repository.getWatchedMovie(movieId) }.returns(flowOf(ResultState.Success(watchedMovie))) + + useCase(movieId).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success).data).isEqualTo(watchedMovie) + awaitComplete() + } + + verify { repository.getWatchedMovie(movieId) }.wasInvoked() + } + + @Test + fun shouldEmitLoadingThenErrorWhenWatchedMovieNotFound() = runTest { + val movieId = 42 + every { repository.getWatchedMovie(movieId) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + + useCase(movieId).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Error::class.java) + assertThat((result as ResultState.Error).error).isInstanceOf(ErrorModel.NotFound::class.java) + awaitComplete() + } + } +} diff --git a/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCaseImplTest.kt b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCaseImplTest.kt new file mode 100644 index 0000000..fe0aa55 --- /dev/null +++ b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/GetWatchedMoviesUseCaseImplTest.kt @@ -0,0 +1,84 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie +import com.pantelisstampoulis.androidtemplateproject.test.doubles.model.DomainTestDoubleFactory +import io.mockative.Mock +import io.mockative.every +import io.mockative.mock +import io.mockative.verify +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class GetWatchedMoviesUseCaseImplTest { + + @Mock + private val repository = mock(MoviesRepository::class) + + private val noopLogger = object : Logger { + override val tag: String = "test" + override fun d(throwable: Throwable?, tag: String, message: () -> String) {} + override fun i(throwable: Throwable?, tag: String, message: () -> String) {} + override fun w(throwable: Throwable?, tag: String, message: () -> String) {} + override fun e(throwable: Throwable?, tag: String, message: () -> String) {} + override fun a(throwable: Throwable?, tag: String, message: () -> String) {} + override fun v(throwable: Throwable?, tag: String, message: () -> String) {} + } + + private val useCase = GetWatchedMoviesUseCaseImpl( + moviesRepository = repository, + coroutineContext = UnconfinedTestDispatcher(), + logger = noopLogger, + ) + + @Test + fun shouldEmitLoadingThenSuccessWithWatchedMovieList() = runTest { + val watchedMovies = listOf( + DomainTestDoubleFactory.provideWatchedMovieModel(), + DomainTestDoubleFactory.provideWatchedMovieModel(), + ) + every { repository.getWatchedMovies() }.returns(flowOf(ResultState.Success(watchedMovies))) + + useCase(Unit).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success>).data).isEqualTo(watchedMovies) + awaitComplete() + } + + verify { repository.getWatchedMovies() }.wasInvoked() + } + + @Test + fun shouldEmitLoadingThenSuccessWithEmptyList() = runTest { + every { repository.getWatchedMovies() }.returns(flowOf(ResultState.Success(emptyList()))) + + useCase(Unit).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val result = awaitItem() + assertThat(result).isInstanceOf(ResultState.Success::class.java) + assertThat((result as ResultState.Success>).data).isEmpty() + awaitComplete() + } + } + + @Test + fun shouldEmitLoadingThenErrorWhenRepositoryThrows() = runTest { + every { repository.getWatchedMovies() }.returns(flow { throw Exception("fetch failed") }) + + useCase(Unit).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val error = awaitItem() + assertThat(error).isInstanceOf(ResultState.Error::class.java) + awaitComplete() + } + } +} diff --git a/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCaseImplTest.kt b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCaseImplTest.kt new file mode 100644 index 0000000..428ad20 --- /dev/null +++ b/core/domain/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/domain/usecase/movies/SaveWatchedMovieUseCaseImplTest.kt @@ -0,0 +1,103 @@ +package com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.repository.MoviesRepository +import com.pantelisstampoulis.androidtemplateproject.logging.Logger +import io.mockative.Mock +import io.mockative.every +import io.mockative.mock +import io.mockative.verify +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SaveWatchedMovieUseCaseImplTest { + + @Mock + private val repository = mock(MoviesRepository::class) + + private val noopLogger = object : Logger { + override val tag: String = "test" + override fun d(throwable: Throwable?, tag: String, message: () -> String) {} + override fun i(throwable: Throwable?, tag: String, message: () -> String) {} + override fun w(throwable: Throwable?, tag: String, message: () -> String) {} + override fun e(throwable: Throwable?, tag: String, message: () -> String) {} + override fun a(throwable: Throwable?, tag: String, message: () -> String) {} + override fun v(throwable: Throwable?, tag: String, message: () -> String) {} + } + + private val useCase = SaveWatchedMovieUseCaseImpl( + moviesRepository = repository, + coroutineContext = UnconfinedTestDispatcher(), + logger = noopLogger, + ) + + private val input = SaveWatchedMovieInput( + movieId = 1, + title = "Test Movie", + posterUrl = null, + overview = null, + publicRating = 7.5, + releaseDate = null, + userRating = 8, + ) + + @Test + fun shouldEmitLoadingThenSuccessWhenRepositorySucceeds() = runTest { + every { + repository.saveWatchedMovie( + input.movieId, + input.title, + input.posterUrl, + input.overview, + input.publicRating, + input.releaseDate, + input.userRating, + ) + }.returns(flowOf(ResultState.Success(Unit))) + + useCase(input).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + assertThat(awaitItem()).isEqualTo(ResultState.Success(Unit)) + awaitComplete() + } + + verify { + repository.saveWatchedMovie( + input.movieId, + input.title, + input.posterUrl, + input.overview, + input.publicRating, + input.releaseDate, + input.userRating, + ) + }.wasInvoked() + } + + @Test + fun shouldEmitLoadingThenErrorWhenRepositoryThrows() = runTest { + every { + repository.saveWatchedMovie( + input.movieId, + input.title, + input.posterUrl, + input.overview, + input.publicRating, + input.releaseDate, + input.userRating, + ) + }.returns(flow { throw Exception("save failed") }) + + useCase(input).test { + assertThat(awaitItem()).isEqualTo(ResultState.Loading) + val error = awaitItem() + assertThat(error).isInstanceOf(ResultState.Error::class.java) + awaitComplete() + } + } +} diff --git a/core/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/model/movies/WatchedMovie.kt b/core/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/model/movies/WatchedMovie.kt new file mode 100644 index 0000000..ac47ba8 --- /dev/null +++ b/core/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/model/movies/WatchedMovie.kt @@ -0,0 +1,12 @@ +package com.pantelisstampoulis.androidtemplateproject.model.movies + +data class WatchedMovie( + val movieId: Int, + val title: String, + val posterUrl: String?, + val overview: String?, + val publicRating: Double, + val releaseDate: String?, + val userRating: Int, + val ratedAt: Long, +) diff --git a/core/presentation/common-ui/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/presentation/common/ui/uicomponent/PullToRefreshLazyColumn.kt b/core/presentation/common-ui/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/presentation/common/ui/uicomponent/PullToRefreshLazyColumn.kt index b885fb0..793b714 100644 --- a/core/presentation/common-ui/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/presentation/common/ui/uicomponent/PullToRefreshLazyColumn.kt +++ b/core/presentation/common-ui/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/presentation/common/ui/uicomponent/PullToRefreshLazyColumn.kt @@ -56,4 +56,4 @@ fun PullToRefreshLazyColumn( } } } -} \ No newline at end of file +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/navigation/MovieCatalogNavigation.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/navigation/MovieCatalogNavigation.kt index 5be805f..a18deaa 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/navigation/MovieCatalogNavigation.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/navigation/MovieCatalogNavigation.kt @@ -6,10 +6,11 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation import androidx.navigation.toRoute +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.MovieCatalogTabbedScreen import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.moviedetails.MovieDetailsScreen import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.moviedetails.MovieDetailsViewModel -import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListScreen import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListViewModel +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListViewModel import org.koin.androidx.compose.koinViewModel fun NavGraphBuilder.movieCatalogGraph( @@ -27,12 +28,19 @@ private fun NavGraphBuilder.addMovieListScreen( onMovieClicked: (Int) -> Unit, ) { composable { - val viewModel = koinViewModel() - val state by viewModel.viewState.collectAsStateWithLifecycle() - MovieListScreen( - state = state, - effect = viewModel.effect, - onEvent = viewModel::setEvent, + val movieListViewModel = koinViewModel() + val movieListState by movieListViewModel.viewState.collectAsStateWithLifecycle() + + val watchedMovieListViewModel = koinViewModel() + val watchedMovieListState by watchedMovieListViewModel.viewState.collectAsStateWithLifecycle() + + MovieCatalogTabbedScreen( + movieListState = movieListState, + movieListEffect = movieListViewModel.effect, + onMovieListEvent = movieListViewModel::setEvent, + watchedMovieListState = watchedMovieListState, + watchedMovieListEffect = watchedMovieListViewModel.effect, + onWatchedMovieListEvent = watchedMovieListViewModel::setEvent, onMovieClicked = onMovieClicked, ) } diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/di/FeatureMovieCatalogPresentationModule.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/di/FeatureMovieCatalogPresentationModule.kt index 2fd6171..6709eee 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/di/FeatureMovieCatalogPresentationModule.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/di/FeatureMovieCatalogPresentationModule.kt @@ -1,8 +1,10 @@ package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.di import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.MovieUiMapper +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.WatchedMovieUiMapper import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.moviedetails.MovieDetailsViewModel import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListViewModel +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.module.Module import org.koin.dsl.module @@ -13,6 +15,10 @@ val featureMovieCatalogPresentationModule: Module = module { MovieUiMapper() } + single { + WatchedMovieUiMapper() + } + viewModel { MovieListViewModel( getMoviesUseCase = get(), @@ -24,6 +30,15 @@ val featureMovieCatalogPresentationModule: Module = module { MovieDetailsViewModel( getMovieUseCase = get(), rateMovieUseCase = get(), + saveWatchedMovieUseCase = get(), + getWatchedMovieUseCase = get(), + mapper = get(), + ) + } + + viewModel { + WatchedMovieListViewModel( + getWatchedMoviesUseCase = get(), mapper = get(), ) } diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/mapper/WatchedMovieUiMapper.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/mapper/WatchedMovieUiMapper.kt new file mode 100644 index 0000000..9ce170d --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/mapper/WatchedMovieUiMapper.kt @@ -0,0 +1,21 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper + +import com.pantelisstampoulis.androidtemplateproject.architecture.mapper.DomainToUiMapper +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.WatchedMovieUiModel +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie + +class WatchedMovieUiMapper : DomainToUiMapper { + + override fun fromDomainToUi(domainModel: WatchedMovie): WatchedMovieUiModel = + WatchedMovieUiModel( + movieId = domainModel.movieId, + title = domainModel.title, + posterPath = domainModel.posterUrl, + voteAverage = domainModel.publicRating, + userRating = domainModel.userRating, + releaseYear = domainModel.releaseDate + ?.split("-") + ?.firstOrNull() + .orEmpty(), + ) +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/MovieCatalogTabbedScreen.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/MovieCatalogTabbedScreen.kt new file mode 100644 index 0000000..5fa8b70 --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/MovieCatalogTabbedScreen.kt @@ -0,0 +1,91 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.launch +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.R +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListEvent +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListScreen +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListSideEffect +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.movielist.MovieListUiState +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListEvent +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListScreen +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListSideEffect +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist.WatchedMovieListUiState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +@Composable +fun MovieCatalogTabbedScreen( + movieListState: MovieListUiState, + movieListEffect: Flow, + onMovieListEvent: (MovieListEvent) -> Unit, + watchedMovieListState: WatchedMovieListUiState, + watchedMovieListEffect: Flow, + onWatchedMovieListEvent: (WatchedMovieListEvent) -> Unit, + onMovieClicked: (Int) -> Unit, +) { + val tabTitles = listOf( + stringResource(R.string.tab_discover), + stringResource(R.string.tab_watched), + ) + val pagerState = rememberPagerState { tabTitles.size } + val coroutineScope = rememberCoroutineScope() + + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = pagerState.currentPage) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(title) }, + ) + } + } + + HorizontalPager( + state = pagerState, + beyondViewportPageCount = 1, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> MovieListScreen( + state = movieListState, + effect = movieListEffect, + onEvent = onMovieListEvent, + onMovieClicked = onMovieClicked, + ) + 1 -> WatchedMovieListScreen( + state = watchedMovieListState, + effect = watchedMovieListEffect, + onEvent = onWatchedMovieListEvent, + onMovieClicked = onMovieClicked, + ) + } + } + } +} + +@Preview +@Composable +fun PreviewMovieCatalogTabbedScreen() { + MovieCatalogTabbedScreen( + movieListState = MovieListUiState(isLoading = true), + movieListEffect = emptyFlow(), + onMovieListEvent = {}, + watchedMovieListState = WatchedMovieListUiState(), + watchedMovieListEffect = emptyFlow(), + onWatchedMovieListEvent = {}, + onMovieClicked = {}, + ) +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsScreen.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsScreen.kt index bd94620..137541f 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsScreen.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsScreen.kt @@ -1,6 +1,5 @@ package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.moviedetails -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,9 +19,15 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,6 +47,7 @@ import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presen import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.ObserveEffects import com.pantelisstampoulis.androidtemplateproject.presentation.theme.StarYellow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import org.koin.compose.getKoin import org.koin.core.qualifier.named import kotlin.coroutines.CoroutineContext @@ -53,46 +59,62 @@ fun MovieDetailsScreen( onEvent: (MovieDetailsEvent) -> Unit, movieId: Int, ) { + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - Box( - modifier = Modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.background), - contentAlignment = Alignment.Center, - ) { - when { - state.isLoading -> { - CircularProgressIndicator() - } - state.errorMessage != null -> { - Text( - text = state.errorMessage, - modifier = Modifier.padding(horizontal = 16.dp), - ) + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) { Snackbar(it) } }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background) + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + when { + state.isLoading -> { + CircularProgressIndicator() + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + state.data != null -> { + MovieDetails( + movie = state.data, + modifier = Modifier.padding(horizontal = 16.dp), + onEvent = onEvent, + userRating = state.userRating, + isRatingInProgress = state.isRatingInProgress, + ) + } } - state.data != null -> { - MovieDetails( - movie = state.data, - modifier = Modifier.padding(horizontal = 16.dp), - onEvent = onEvent, - ) + LifecycleEventEffect(event = Lifecycle.Event.ON_CREATE) { + onEvent(MovieDetailsEvent.Init(movieId)) } - } - - LifecycleEventEffect(event = Lifecycle.Event.ON_CREATE) { - onEvent(MovieDetailsEvent.Init(movieId)) - } - ObserveEffects( - effect = effect, - coroutineContext = getKoin().get(named(CoroutinesDispatchers.MainImmediate)), - lifecycleOwner = LocalLifecycleOwner.current, - ) { sideEffect -> - when (sideEffect) { - is MovieDetailsSideEffect.ShowToast -> - Toast.makeText(context, sideEffect.text, Toast.LENGTH_SHORT).show() + ObserveEffects( + effect = effect, + coroutineContext = getKoin().get(named(CoroutinesDispatchers.MainImmediate)), + lifecycleOwner = LocalLifecycleOwner.current, + ) { sideEffect -> + when (sideEffect) { + MovieDetailsSideEffect.RatingSaved -> + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_rating_saved)) + } + MovieDetailsSideEffect.RatingError -> + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_rating_error)) + } + } } } } @@ -103,6 +125,8 @@ fun MovieDetails( movie: MovieUiModel, modifier: Modifier = Modifier, onEvent: (MovieDetailsEvent) -> Unit, + userRating: Int?, + isRatingInProgress: Boolean, ) { Column( modifier = Modifier @@ -173,6 +197,8 @@ fun MovieDetails( modifier = modifier, onEvent = onEvent, movie = movie, + userRating = userRating, + isRatingInProgress = isRatingInProgress, ) } } @@ -182,31 +208,45 @@ fun RateMovie( modifier: Modifier = Modifier, onEvent: (MovieDetailsEvent) -> Unit, movie: MovieUiModel, + userRating: Int?, + isRatingInProgress: Boolean, ) { - val ratingState = rememberSaveable { mutableIntStateOf(0) } - Column( modifier = modifier .fillMaxWidth() - .wrapContentSize(), // Wrap the content size to center horizontally - verticalArrangement = Arrangement.spacedBy(16.dp), // Space between items - horizontalAlignment = Alignment.CenterHorizontally, // Center the items horizontally + .wrapContentSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - UserRatingBar( - ratingState = ratingState, - size = 28.dp, - ) - - Button( - onClick = { - onEvent(MovieDetailsEvent.RateMovie(movie.id, ratingState.intValue.toFloat())) - }, - enabled = ratingState.intValue >= 1, - ) { + if (userRating != null) { Text( - text = stringResource(id = R.string.label_rate), + text = stringResource(id = R.string.label_you_rated_this), style = MaterialTheme.typography.labelMedium, ) + val lockedRatingState = remember { mutableIntStateOf(userRating) } + UserRatingBar( + ratingState = lockedRatingState, + size = 28.dp, + enabled = false, + ) + } else { + val ratingState = rememberSaveable { mutableIntStateOf(0) } + UserRatingBar( + ratingState = ratingState, + size = 28.dp, + enabled = !isRatingInProgress, + ) + Button( + onClick = { + onEvent(MovieDetailsEvent.RateMovie(movie.id, ratingState.intValue.toFloat())) + }, + enabled = ratingState.intValue >= 1 && !isRatingInProgress, + ) { + Text( + text = stringResource(id = R.string.label_rate), + style = MaterialTheme.typography.labelMedium, + ) + } } } } diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsSideEffect.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsSideEffect.kt index 98a02d9..627cebd 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsSideEffect.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsSideEffect.kt @@ -3,5 +3,6 @@ package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.prese import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.SideEffect sealed interface MovieDetailsSideEffect : SideEffect { - data class ShowToast(val text: String) : MovieDetailsSideEffect + data object RatingSaved : MovieDetailsSideEffect + data object RatingError : MovieDetailsSideEffect } diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModel.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModel.kt index 1065991..a375593 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModel.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModel.kt @@ -4,8 +4,11 @@ import com.pantelisstampoulis.androidtemplateproject.domain.onError import com.pantelisstampoulis.androidtemplateproject.domain.onLoading import com.pantelisstampoulis.androidtemplateproject.domain.onSuccess import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMovieUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMovieUseCase import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.RateMovieUseCase import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.RateMovieUseCaseInput +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.SaveWatchedMovieInput +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.SaveWatchedMovieUseCase import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.MovieUiMapper import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.MovieUiModel import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.MviViewModel @@ -15,6 +18,8 @@ import kotlinx.coroutines.launch class MovieDetailsViewModel( private val getMovieUseCase: GetMovieUseCase, private val rateMovieUseCase: RateMovieUseCase, + private val saveWatchedMovieUseCase: SaveWatchedMovieUseCase, + private val getWatchedMovieUseCase: GetWatchedMovieUseCase, private val mapper: MovieUiMapper, ) : MviViewModel( initialState = MovieDetailsUiState(), @@ -46,36 +51,74 @@ class MovieDetailsViewModel( } } } + viewModelScope.launch { + getWatchedMovieUseCase(input = event.movieId).collect { resultState -> + resultState + .onSuccess { watchedMovie -> + setState { copy(userRating = watchedMovie.userRating) } + } + .onError { + // NotFound = not rated yet, leave userRating as null + } + } + } } is MovieDetailsEvent.RateMovie -> { + setState { copy(isRatingInProgress = true) } viewModelScope.launch { rateMovieUseCase.invoke( - RateMovieUseCaseInput( - event.movieId, - event.rating, - ), + RateMovieUseCaseInput(event.movieId, event.rating), ).collect { resultState -> resultState .onSuccess { - setEffect { - MovieDetailsSideEffect.ShowToast("Movie rated successfully") + val movie = viewState.value.data + if (movie == null) { + setState { copy(isRatingInProgress = false) } + setEffect { MovieDetailsSideEffect.RatingError } + return@onSuccess } + saveRating(movie = movie, rating = event.rating.toInt()) } - .onError { error -> - setEffect { - MovieDetailsSideEffect.ShowToast("Error while rating Movie: ${error.message}") - } + .onError { + setState { copy(isRatingInProgress = false) } + setEffect { MovieDetailsSideEffect.RatingError } } } } } } } + + private suspend fun saveRating(movie: MovieUiModel, rating: Int) { + saveWatchedMovieUseCase.invoke( + SaveWatchedMovieInput( + movieId = movie.id, + title = movie.title, + posterUrl = movie.posterPath, + overview = movie.overview, + publicRating = movie.voteAverage, + releaseDate = movie.releaseYear, + userRating = rating, + ), + ).collect { saveResult -> + saveResult + .onSuccess { + setState { copy(userRating = rating, isRatingInProgress = false) } + setEffect { MovieDetailsSideEffect.RatingSaved } + } + .onError { + setState { copy(isRatingInProgress = false) } + setEffect { MovieDetailsSideEffect.RatingError } + } + } + } } data class MovieDetailsUiState( val isLoading: Boolean = false, val errorMessage: String? = null, val data: MovieUiModel? = null, + val userRating: Int? = null, + val isRatingInProgress: Boolean = false, ) : UiState diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/movielist/MovieListScreen.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/movielist/MovieListScreen.kt index 3320004..f59c18e 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/movielist/MovieListScreen.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/movielist/MovieListScreen.kt @@ -34,10 +34,10 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LocalLifecycleOwner import coil3.compose.AsyncImage -import com.pantelisstampoulis.androidtemplateproject.presentation.common.ui.uicomponent.PullToRefreshLazyColumn import com.pantelisstampoulis.androidtemplateproject.dispatcher.CoroutinesDispatchers import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.R import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.MovieUiModel +import com.pantelisstampoulis.androidtemplateproject.presentation.common.ui.uicomponent.PullToRefreshLazyColumn import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.ObserveEffects import com.pantelisstampoulis.androidtemplateproject.presentation.theme.StarYellow import kotlinx.collections.immutable.ImmutableList diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListEvent.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListEvent.kt new file mode 100644 index 0000000..6408433 --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListEvent.kt @@ -0,0 +1,7 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.Event + +sealed interface WatchedMovieListEvent : Event { + data class ShowMovieDetails(val movieId: Int) : WatchedMovieListEvent +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListScreen.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListScreen.kt new file mode 100644 index 0000000..c5ebb50 --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListScreen.kt @@ -0,0 +1,239 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import coil3.compose.AsyncImage +import com.pantelisstampoulis.androidtemplateproject.dispatcher.CoroutinesDispatchers +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.R +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.WatchedMovieUiModel +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.ObserveEffects +import com.pantelisstampoulis.androidtemplateproject.presentation.theme.StarYellow +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.koin.compose.getKoin +import org.koin.core.qualifier.named +import kotlin.coroutines.CoroutineContext + +@Composable +fun WatchedMovieListScreen( + state: WatchedMovieListUiState, + effect: Flow, + onEvent: (WatchedMovieListEvent) -> Unit, + onMovieClicked: (Int) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + when { + state.isLoading -> { + CircularProgressIndicator() + } + + state.errorRes != null -> { + Text( + text = stringResource(id = state.errorRes), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + state.data != null -> { + if (state.data.isEmpty()) { + Text(text = stringResource(id = R.string.watched_empty_state)) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().align(Alignment.TopStart)) { + items(state.data, key = { it.movieId }) { movie -> + WatchedMovieRow( + movie = movie, + onClick = { onEvent(WatchedMovieListEvent.ShowMovieDetails(movie.movieId)) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } + } + + ObserveEffects( + effect = effect, + coroutineContext = getKoin().get(named(CoroutinesDispatchers.MainImmediate)), + lifecycleOwner = LocalLifecycleOwner.current, + ) { sideEffect -> + when (sideEffect) { + is WatchedMovieListSideEffect.NavigateToMovieDetails -> + onMovieClicked(sideEffect.movieId) + } + } + } +} + +@Composable +fun WatchedMovieRow( + movie: WatchedMovieUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.height(150.dp), + onClick = onClick, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + AsyncImage( + model = movie.posterPath, + contentDescription = null, + modifier = Modifier.fillMaxHeight(), + ) + + Column( + modifier = Modifier + .padding(all = 16.dp) + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = movie.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = movie.releaseYear, + style = MaterialTheme.typography.labelMedium, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_star), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = StarYellow, + ) + + Text( + text = movie.voteAverage.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 4.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + val userRatingDescription = stringResource( + R.string.content_description_user_rating, + movie.userRating, + ) + Icon( + painter = painterResource(id = R.drawable.ic_star), + contentDescription = null, + modifier = Modifier + .size(28.dp) + .semantics { contentDescription = userRatingDescription }, + tint = StarYellow, + ) + + Text( + text = movie.userRating.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + } +} + +private val previewMovie = WatchedMovieUiModel( + movieId = 1, + title = "Interstellar", + posterPath = null, + voteAverage = 8.6, + userRating = 9, + releaseYear = "2014", +) + +@Preview +@Composable +fun PreviewWatchedMovieRow() { + WatchedMovieRow( + movie = previewMovie, + onClick = {}, + ) +} + +@Preview +@Composable +fun PreviewWatchedMovieListLoading() { + WatchedMovieListScreen( + state = WatchedMovieListUiState(isLoading = true), + effect = emptyFlow(), + onEvent = {}, + onMovieClicked = {}, + ) +} + +@Preview +@Composable +fun PreviewWatchedMovieListEmpty() { + WatchedMovieListScreen( + state = WatchedMovieListUiState(data = persistentListOf()), + effect = emptyFlow(), + onEvent = {}, + onMovieClicked = {}, + ) +} + +@Preview +@Composable +fun PreviewWatchedMovieListWithData() { + WatchedMovieListScreen( + state = WatchedMovieListUiState( + data = persistentListOf(previewMovie, previewMovie.copy(movieId = 2, title = "Dune")), + ), + effect = emptyFlow(), + onEvent = {}, + onMovieClicked = {}, + ) +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListSideEffect.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListSideEffect.kt new file mode 100644 index 0000000..55a2277 --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListSideEffect.kt @@ -0,0 +1,7 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.SideEffect + +sealed interface WatchedMovieListSideEffect : SideEffect { + data class NavigateToMovieDetails(val movieId: Int) : WatchedMovieListSideEffect +} diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModel.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModel.kt new file mode 100644 index 0000000..81f9cad --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModel.kt @@ -0,0 +1,66 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import androidx.annotation.StringRes +import com.pantelisstampoulis.androidtemplateproject.domain.onError +import com.pantelisstampoulis.androidtemplateproject.domain.onLoading +import com.pantelisstampoulis.androidtemplateproject.domain.onSuccess +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMoviesUseCase +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.R +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.WatchedMovieUiMapper +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel.WatchedMovieUiModel +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.MviViewModel +import com.pantelisstampoulis.androidtemplateproject.presentation.mvi.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +class WatchedMovieListViewModel( + private val getWatchedMoviesUseCase: GetWatchedMoviesUseCase, + private val mapper: WatchedMovieUiMapper, +) : MviViewModel( + initialState = WatchedMovieListUiState(), +) { + + init { + viewModelScope.launch { + getWatchedMoviesUseCase(input = Unit).collect { resultState -> + resultState + .onLoading { + setState { copy(isLoading = true) } + } + .onSuccess { + setState { + copy( + isLoading = false, + errorRes = null, + data = it.map { movie -> mapper.fromDomainToUi(movie) } + .toImmutableList(), + ) + } + } + .onError { + setState { + copy( + isLoading = false, + errorRes = R.string.error_generic, + ) + } + } + } + } + } + + override fun handleEvents(event: WatchedMovieListEvent) { + when (event) { + is WatchedMovieListEvent.ShowMovieDetails -> setEffect { + WatchedMovieListSideEffect.NavigateToMovieDetails(event.movieId) + } + } + } +} + +data class WatchedMovieListUiState( + val isLoading: Boolean = false, + @StringRes val errorRes: Int? = null, + val data: ImmutableList? = null, +) : UiState diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uicomponent/UserRatingBar.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uicomponent/UserRatingBar.kt index 548749a..20e06fa 100644 --- a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uicomponent/UserRatingBar.kt +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uicomponent/UserRatingBar.kt @@ -36,6 +36,7 @@ fun UserRatingBar( ratingIconPainter: Painter = painterResource(id = R.drawable.ic_star), selectedColor: Color = Color(0xFFFFD700), unselectedColor: Color = Color(0xFFA2ADB1), + enabled: Boolean = true, ) { FlowRow(modifier = modifier) { // 2. Star Icon Generation Loop @@ -47,6 +48,7 @@ fun UserRatingBar( ratingState = ratingState, selectedColor = selectedColor, unselectedColor = unselectedColor, + enabled = enabled, ) } } @@ -62,6 +64,7 @@ fun StarIcon( ratingValue: Int, selectedColor: Color, unselectedColor: Color, + enabled: Boolean, ) { // 4. Color Animation val tint by animateColorAsState( @@ -76,9 +79,11 @@ fun StarIcon( .size(size) // 5. Touch Interaction Handling .pointerInteropFilter { - when (it.action) { - MotionEvent.ACTION_DOWN -> { - ratingState.value = ratingValue + if (enabled) { + when (it.action) { + MotionEvent.ACTION_DOWN -> { + ratingState.value = ratingValue + } } } true diff --git a/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uimodel/WatchedMovieUiModel.kt b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uimodel/WatchedMovieUiModel.kt new file mode 100644 index 0000000..7a14b05 --- /dev/null +++ b/feature/movie-catalog/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/uimodel/WatchedMovieUiModel.kt @@ -0,0 +1,13 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.uimodel + +import androidx.compose.runtime.Immutable + +@Immutable +data class WatchedMovieUiModel( + val movieId: Int, + val title: String, + val posterPath: String?, + val voteAverage: Double, + val userRating: Int, + val releaseYear: String, +) diff --git a/feature/movie-catalog/src/main/res/values/strings.xml b/feature/movie-catalog/src/main/res/values/strings.xml index 9f8efc9..bbba515 100644 --- a/feature/movie-catalog/src/main/res/values/strings.xml +++ b/feature/movie-catalog/src/main/res/values/strings.xml @@ -3,6 +3,16 @@ Rate + You rated this + Rating saved + Something went wrong. Please try again. + + + An error occurred + Discover + Watched + No movies rated yet. Start exploring and rate movies you\'ve watched. + Your rating: %d out of 10 Action diff --git a/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModelTest.kt b/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModelTest.kt index cad90bf..810707b 100644 --- a/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModelTest.kt +++ b/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/moviedetails/MovieDetailsViewModelTest.kt @@ -4,7 +4,9 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.pantelisstampoulis.androidtemplateproject.domain.ResultState import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetMovieUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMovieUseCase import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.RateMovieUseCase +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.SaveWatchedMovieUseCase import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.MovieUiMapper import com.pantelisstampoulis.androidtemplateproject.model.error.ErrorModel import com.pantelisstampoulis.androidtemplateproject.test.doubles.model.DomainTestDoubleFactory @@ -38,24 +40,29 @@ class MovieDetailsViewModelTest : KoinTest { @Mock private val rateMovieUseCase = mock(RateMovieUseCase::class) + @Mock + private val saveWatchedMovieUseCase = mock(SaveWatchedMovieUseCase::class) + + @Mock + private val getWatchedMovieUseCase = mock(GetWatchedMovieUseCase::class) + private val uiMapper: MovieUiMapper by inject() - // Inject the ViewModel private val viewModel: MovieDetailsViewModel by inject() - // Use TestDispatcher for coroutines testing private val testDispatcher = StandardTestDispatcher() @Before fun setUp() { Dispatchers.setMain(testDispatcher) - // Configure Koin for dependency injection val mockModule = module { single { getMovieUseCase } single { rateMovieUseCase } + single { saveWatchedMovieUseCase } + single { getWatchedMovieUseCase } single { MovieUiMapper() } - single { MovieDetailsViewModel(get(), get(), get()) } + single { MovieDetailsViewModel(get(), get(), get(), get(), get()) } } startKoin { @@ -72,11 +79,9 @@ class MovieDetailsViewModelTest : KoinTest { @Test fun shouldEmitLoadingStateWhenFetchingMovieDetailsStarts() = runTest { every { getMovieUseCase(any()) } - .returns( - flow { - emit(ResultState.Loading) // Emit loading state - }, - ) + .returns(flow { emit(ResultState.Loading) }) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) viewModel.setEvent(MovieDetailsEvent.Init(movieId = 123)) @@ -84,8 +89,8 @@ class MovieDetailsViewModelTest : KoinTest { viewModel.viewState.test { val firstItem = awaitItem() - assertThat(firstItem.isLoading).isTrue() // Loading state should be emitted first - assertThat(firstItem.data).isNull() // No data yet, still loading + assertThat(firstItem.isLoading).isTrue() + assertThat(firstItem.data).isNull() cancelAndIgnoreRemainingEvents() } @@ -98,11 +103,9 @@ class MovieDetailsViewModelTest : KoinTest { val uiMovie = uiMapper.fromDomainToUi(domainMovie) every { getMovieUseCase(any()) } - .returns( - flow { - emit(ResultState.Success(domainMovie)) // Emit success state - }, - ) + .returns(flowOf(ResultState.Success(domainMovie))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) viewModel.setEvent(MovieDetailsEvent.Init(movieId = 123)) @@ -110,9 +113,9 @@ class MovieDetailsViewModelTest : KoinTest { viewModel.viewState.test { val firstItem = awaitItem() - assertThat(firstItem.isLoading).isFalse() // Loading should be done - assertThat(firstItem.errorMessage).isNull() // No error - assertThat(firstItem.data).isEqualTo(uiMovie) // Data should be the mapped UI model + assertThat(firstItem.isLoading).isFalse() + assertThat(firstItem.errorMessage).isNull() + assertThat(firstItem.data).isEqualTo(uiMovie) cancelAndIgnoreRemainingEvents() } @@ -125,7 +128,9 @@ class MovieDetailsViewModelTest : KoinTest { val errorState = ErrorModel.NotFound(errorMessage) every { getMovieUseCase(any()) } - .returns(flowOf(ResultState.Error(errorState))) // Emit error state + .returns(flowOf(ResultState.Error(errorState))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) viewModel.setEvent(MovieDetailsEvent.Init(movieId = 123)) @@ -133,9 +138,9 @@ class MovieDetailsViewModelTest : KoinTest { viewModel.viewState.test { val firstItem = awaitItem() - assertThat(firstItem.isLoading).isFalse() // Loading should be false - assertThat(firstItem.errorMessage).isEqualTo(errorMessage) // Error message should be set - assertThat(firstItem.data).isNull() // No data due to error + assertThat(firstItem.isLoading).isFalse() + assertThat(firstItem.errorMessage).isEqualTo(errorMessage) + assertThat(firstItem.data).isNull() cancelAndIgnoreRemainingEvents() } @@ -143,37 +148,140 @@ class MovieDetailsViewModelTest : KoinTest { } @Test - fun shouldEmitSuccessStateWhenRatingMovie() = runTest { - every { rateMovieUseCase(any()) } - .returns(flowOf(ResultState.Success(Unit))) // Emit success for rating + fun shouldSetUserRatingWhenMovieAlreadyRated() = runTest { + val watchedMovie = DomainTestDoubleFactory.provideWatchedMovieModel() + + every { getMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Success(watchedMovie))) + + viewModel.setEvent(MovieDetailsEvent.Init(movieId = watchedMovie.movieId)) + + advanceUntilIdle() + + viewModel.viewState.test { + val state = awaitItem() + assertThat(state.userRating).isEqualTo(watchedMovie.userRating) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun shouldLeaveUserRatingNullWhenMovieNotRated() = runTest { + every { getMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + + viewModel.setEvent(MovieDetailsEvent.Init(movieId = 123)) + + advanceUntilIdle() + + viewModel.viewState.test { + val state = awaitItem() + assertThat(state.userRating).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun shouldSaveWatchedMovieAndEmitSnackbarWhenRatingFullySucceeds() = runTest { + val domainMovie = DomainTestDoubleFactory.provideMovieModel() + + // Load movie into state first + every { getMovieUseCase(any()) } + .returns(flowOf(ResultState.Success(domainMovie))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + viewModel.setEvent(MovieDetailsEvent.Init(movieId = domainMovie.id)) + advanceUntilIdle() + + // Rate the movie + every { rateMovieUseCase(any()) }.returns(flowOf(ResultState.Success(Unit))) + every { saveWatchedMovieUseCase(any()) }.returns(flowOf(ResultState.Success(Unit))) - viewModel.setEvent(MovieDetailsEvent.RateMovie(movieId = 123, rating = 5F)) + viewModel.setEvent(MovieDetailsEvent.RateMovie(movieId = domainMovie.id, rating = 8f)) viewModel.effect.test { val effect = awaitItem() - assertThat(effect).isEqualTo(MovieDetailsSideEffect.ShowToast("Movie rated successfully")) + assertThat(effect).isEqualTo(MovieDetailsSideEffect.RatingSaved) + cancelAndIgnoreRemainingEvents() + } + + advanceUntilIdle() + + viewModel.viewState.test { + val state = awaitItem() + assertThat(state.userRating).isEqualTo(8) + assertThat(state.isRatingInProgress).isFalse() cancelAndIgnoreRemainingEvents() } verify { rateMovieUseCase(any()) }.wasInvoked() + verify { saveWatchedMovieUseCase(any()) }.wasInvoked() } @Test - fun shouldEmitErrorStateWhenRatingMovieFails() = runTest { - val errorMessage = "Rating failed" - val errorState = ErrorModel.ServerError(errorMessage) + fun shouldEmitErrorSnackbarWhenNetworkRatingFails() = runTest { + every { getMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) every { rateMovieUseCase(any()) } - .returns(flowOf(ResultState.Error(errorState))) // Emit error for rating + .returns(flowOf(ResultState.Error(ErrorModel.ServerError("Network error")))) - viewModel.setEvent(MovieDetailsEvent.RateMovie(movieId = 123, rating = 5F)) + viewModel.setEvent(MovieDetailsEvent.RateMovie(movieId = 123, rating = 5f)) viewModel.effect.test { val effect = awaitItem() - assertThat(effect).isEqualTo(MovieDetailsSideEffect.ShowToast("Error while rating Movie: $errorMessage")) + assertThat(effect).isEqualTo(MovieDetailsSideEffect.RatingError) + cancelAndIgnoreRemainingEvents() + } + + advanceUntilIdle() + + viewModel.viewState.test { + val state = awaitItem() + assertThat(state.userRating).isNull() + assertThat(state.isRatingInProgress).isFalse() cancelAndIgnoreRemainingEvents() } verify { rateMovieUseCase(any()) }.wasInvoked() } + + @Test + fun shouldEmitErrorSnackbarWhenLocalSaveFails() = runTest { + val domainMovie = DomainTestDoubleFactory.provideMovieModel() + + every { getMovieUseCase(any()) } + .returns(flowOf(ResultState.Success(domainMovie))) + every { getWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.NotFound()))) + viewModel.setEvent(MovieDetailsEvent.Init(movieId = domainMovie.id)) + advanceUntilIdle() + + every { rateMovieUseCase(any()) }.returns(flowOf(ResultState.Success(Unit))) + every { saveWatchedMovieUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.Unknown("db error")))) + + viewModel.setEvent(MovieDetailsEvent.RateMovie(movieId = domainMovie.id, rating = 5f)) + + viewModel.effect.test { + val effect = awaitItem() + assertThat(effect).isEqualTo(MovieDetailsSideEffect.RatingError) + cancelAndIgnoreRemainingEvents() + } + + advanceUntilIdle() + + viewModel.viewState.test { + val state = awaitItem() + assertThat(state.userRating).isNull() + assertThat(state.isRatingInProgress).isFalse() + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModelTest.kt b/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModelTest.kt new file mode 100644 index 0000000..e32c7d3 --- /dev/null +++ b/feature/movie-catalog/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/feature/moviecatalog/presentation/screen/watchedmovielist/WatchedMovieListViewModelTest.kt @@ -0,0 +1,135 @@ +package com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.screen.watchedmovielist + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.pantelisstampoulis.androidtemplateproject.domain.ResultState +import com.pantelisstampoulis.androidtemplateproject.domain.usecase.movies.GetWatchedMoviesUseCase +import com.pantelisstampoulis.androidtemplateproject.feature.moviecatalog.presentation.mapper.WatchedMovieUiMapper +import com.pantelisstampoulis.androidtemplateproject.model.error.ErrorModel +import com.pantelisstampoulis.androidtemplateproject.test.doubles.model.DomainTestDoubleFactory +import io.mockative.Mock +import io.mockative.any +import io.mockative.every +import io.mockative.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.inject + +class WatchedMovieListViewModelTest : KoinTest { + + @Mock + private val getWatchedMoviesUseCase = mock(GetWatchedMoviesUseCase::class) + + private val uiMapper: WatchedMovieUiMapper by inject() + + private val viewModel: WatchedMovieListViewModel by inject() + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + val mockModule = module { + single { getWatchedMoviesUseCase } + single { WatchedMovieUiMapper() } + single { WatchedMovieListViewModel(get(), get()) } + } + + startKoin { + modules(mockModule) + } + } + + @After + fun tearDown() { + stopKoin() + Dispatchers.resetMain() + } + + @Test + fun shouldEmitMappedWatchedMoviesWhenFetchSucceeds() = runTest { + val watchedMovies = listOf( + DomainTestDoubleFactory.provideWatchedMovieModel(), + DomainTestDoubleFactory.provideWatchedMovieModel(), + ) + val expectedUiModels = watchedMovies.map { uiMapper.fromDomainToUi(it) } + + every { getWatchedMoviesUseCase(any()) } + .returns(flowOf(ResultState.Success(watchedMovies))) + + // Trigger ViewModel creation (init {} launches the collection coroutine) + // then run it to completion before collecting viewState. + val vm = viewModel + advanceUntilIdle() + + vm.viewState.test { + val state = awaitItem() + assertThat(state.isLoading).isFalse() + assertThat(state.errorRes).isNull() + assertThat(state.data).isEqualTo(expectedUiModels) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun shouldEmitEmptyListWhenNoWatchedMovies() = runTest { + every { getWatchedMoviesUseCase(any()) } + .returns(flowOf(ResultState.Success(emptyList()))) + + val vm = viewModel + advanceUntilIdle() + + vm.viewState.test { + val state = awaitItem() + assertThat(state.isLoading).isFalse() + assertThat(state.errorRes).isNull() + assertThat(state.data).isNotNull() + assertThat(state.data).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun shouldEmitErrorMessageWhenFetchFails() = runTest { + every { getWatchedMoviesUseCase(any()) } + .returns(flowOf(ResultState.Error(ErrorModel.ServerError("Server error")))) + + val vm = viewModel + advanceUntilIdle() + + vm.viewState.test { + val state = awaitItem() + assertThat(state.isLoading).isFalse() + assertThat(state.errorRes).isNotNull() + assertThat(state.data).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun shouldEmitNavigateEffectWhenShowMovieDetailsEventTriggered() = runTest { + every { getWatchedMoviesUseCase(any()) }.returns(emptyFlow()) + + viewModel.setEvent(WatchedMovieListEvent.ShowMovieDetails(movieId = 42)) + + viewModel.effect.test { + val effect = awaitItem() + assertThat(effect).isEqualTo(WatchedMovieListSideEffect.NavigateToMovieDetails(42)) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/test/doubles/database/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/database/DatabaseTestDoubleFactory.kt b/test/doubles/database/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/database/DatabaseTestDoubleFactory.kt index 710a848..ed08f87 100644 --- a/test/doubles/database/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/database/DatabaseTestDoubleFactory.kt +++ b/test/doubles/database/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/database/DatabaseTestDoubleFactory.kt @@ -1,6 +1,7 @@ package com.pantelisstampoulis.androidtemplateproject.test.doubles.database import com.pantelisstampoulis.androidtemplateproject.database.model.MovieDbModel +import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel import com.pantelisstampoulis.androidtemplateproject.random.randomBoolean import com.pantelisstampoulis.androidtemplateproject.random.randomFloat import com.pantelisstampoulis.androidtemplateproject.random.randomInt @@ -24,4 +25,15 @@ object DatabaseTestDoubleFactory { voteAverage = randomFloat(from = 0F, until = 10F).toDouble(), voteCount = randomInt(from = 0), ) + + fun provideWatchedMovieDbModel() = WatchedMovieDbModel( + movieId = randomInt(from = 1, until = 1000), + title = randomString(), + posterUrl = randomString(), + overview = randomString(), + publicRating = randomFloat(from = 0F, until = 10F).toDouble(), + releaseDate = randomString(), + userRating = randomInt(from = 1, until = 10), + ratedAt = System.currentTimeMillis(), + ) } diff --git a/test/doubles/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/model/DomainTestDoubleFactory.kt b/test/doubles/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/model/DomainTestDoubleFactory.kt index 2bc07dd..b8af363 100644 --- a/test/doubles/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/model/DomainTestDoubleFactory.kt +++ b/test/doubles/model/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/test/doubles/model/DomainTestDoubleFactory.kt @@ -1,6 +1,7 @@ package com.pantelisstampoulis.androidtemplateproject.test.doubles.model import com.pantelisstampoulis.androidtemplateproject.model.movies.Movie +import com.pantelisstampoulis.androidtemplateproject.model.movies.WatchedMovie import com.pantelisstampoulis.androidtemplateproject.random.randomBoolean import com.pantelisstampoulis.androidtemplateproject.random.randomFloat import com.pantelisstampoulis.androidtemplateproject.random.randomInt @@ -24,4 +25,15 @@ object DomainTestDoubleFactory { voteAverage = randomFloat(from = 0F, until = 10F).toDouble(), voteCount = randomInt(from = 0), ) + + fun provideWatchedMovieModel() = WatchedMovie( + movieId = randomInt(from = 1, until = 1000), + title = randomString(), + posterUrl = randomString(), + overview = randomString(), + publicRating = randomFloat(from = 0F, until = 10F).toDouble(), + releaseDate = randomString(), + userRating = randomInt(from = 1, until = 10), + ratedAt = System.currentTimeMillis(), + ) } diff --git a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/ArchitectureLayerDependenciesKonsistTest.kt b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/ArchitectureLayerDependenciesKonsistTest.kt index e1668e5..d28ba61 100644 --- a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/ArchitectureLayerDependenciesKonsistTest.kt +++ b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/ArchitectureLayerDependenciesKonsistTest.kt @@ -31,7 +31,7 @@ class ArchitectureLayerDependenciesKonsistTest { networkLayer, databaseLayer, modelLayer, - domainLayer + domainLayer, ) domainLayer.dependsOn(layer = modelLayer) diff --git a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DatabaseLayerKonsistTest.kt b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DatabaseLayerKonsistTest.kt index ba25905..2137cc2 100644 --- a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DatabaseLayerKonsistTest.kt +++ b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DatabaseLayerKonsistTest.kt @@ -63,7 +63,8 @@ class DatabaseLayerKonsistTest { "isPublicOrDefault: $isPropertyPublicOrDefault.", ) isVal && isPropertyPublicOrDefault - } && areFunctionsEmpty + } && + areFunctionsEmpty } } @@ -75,13 +76,13 @@ class DatabaseLayerKonsistTest { .withName(DatabaseDataSource) .assertTrue(strict = true) { declaration -> declaration.resideInPackage(name = DatabasePackage) && - declaration.hasPublicOrDefaultModifier && - declaration.functions().all { functionDeclaration -> - functionDeclaration.hasSuspendModifier - } && - declaration.hasAllChildren(indirectChildren = true) { childDeclaration -> - childDeclaration.resideInPackage(name = DatabasePackage) - } + declaration.hasPublicOrDefaultModifier && + declaration.functions().all { functionDeclaration -> + functionDeclaration.hasSuspendModifier + } && + declaration.hasAllChildren(indirectChildren = true) { childDeclaration -> + childDeclaration.resideInPackage(name = DatabasePackage) + } } } diff --git a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DomainLayerKonsistTest.kt b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DomainLayerKonsistTest.kt index daad28f..64c0ab6 100644 --- a/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DomainLayerKonsistTest.kt +++ b/test/konsist/src/test/kotlin/com/pantelisstampoulis/androidtemplateproject/DomainLayerKonsistTest.kt @@ -35,8 +35,8 @@ class DomainLayerKonsistTest { val hasSingleInvokeOperatorMethod = declaration.hasFunction { function -> function.let { it.name == InvokeFunction && - it.hasPublicOrDefaultModifier && - it.hasOperatorModifier + it.hasPublicOrDefaultModifier && + it.hasOperatorModifier } } diff --git a/utils/random/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/random/Utils.kt b/utils/random/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/random/Utils.kt index bf14fb2..7516c37 100644 --- a/utils/random/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/random/Utils.kt +++ b/utils/random/src/main/kotlin/com/pantelisstampoulis/androidtemplateproject/random/Utils.kt @@ -1,8 +1,9 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package com.pantelisstampoulis.androidtemplateproject.random import androidx.annotation.VisibleForTesting import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -12,6 +13,7 @@ import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.random.Random +import kotlin.time.Instant @VisibleForTesting val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9')