Skip to content

feat: watched movies collection - #14

Merged
pantstamp merged 7 commits into
developfrom
feature/watched-movies
Apr 12, 2026
Merged

feat: watched movies collection#14
pantstamp merged 7 commits into
developfrom
feature/watched-movies

Conversation

@pantstamp

Copy link
Copy Markdown
Owner

Summary

Implements the Watched Movies feature as specified in SPEC.md and planned in PLAN.md.

Users can rate any movie (1–10 stars) from the Movie Details screen. Rated movies are persisted locally and surfaced in a new dedicated Watched tab on the main screen, showing the user's personal rating alongside the public TMDB rating. Ratings are immutable once saved.


Key Changes by Layer

Database (core/database)

  • Added WatchedMovieDbModel (API model) and WatchedMovieEntity (Room entity)
  • Added WatchedMovieDao with insertWatchedMovie, getWatchedMovieEntities, and getWatchedMovieEntity queries
  • Extended DatabaseDataSource interface with insertWatchedMovie, getWatchedMovies, and getWatchedMovie
  • Implemented the new interface methods in RoomDataSource and NoopDatabaseDataSource
  • Added WatchedMovieDbMapper (ApiToDb + DbToDomain contracts)
  • Added Room DB migration v1 → v2 (watched_movies table)
  • Fixed: getWatchedMovies() made suspend to satisfy Konsist architecture rule

Domain (core/domain, core/model)

  • Added WatchedMovie domain model
  • Extended MoviesRepository interface with getWatchedMovies, getWatchedMovie, and saveWatchedMovie
  • Implemented the new repository methods in MoviesRepositoryImpl (:core:data)
  • Added WatchedMovieDomainMapper (DbToDomain)
  • Added three new use cases: GetWatchedMoviesUseCase, GetWatchedMovieUseCase, SaveWatchedMovieUseCase
  • Wired all new use cases and mappers into Koin DI modules

UI / Feature (feature:movie-catalog)

  • Added WatchedMovieUiModel and WatchedMovieUiMapper (DomainToUi)
  • Replaced the flat movie list screen with MovieCatalogTabbedScreen (Discover + Watched tabs via HorizontalPager)
  • Added WatchedMovieListScreen with: movie list, compact user-rating badge (⭐ N), empty state, and offline-capable loading from local DB
  • Added WatchedMovieListViewModel (MVI: WatchedMovieListEvent, WatchedMovieListSideEffect)
  • Updated MovieDetailsScreen to show the star rating row (interactive for unrated, read-only for rated), with a snackbar on save success/failure
  • Updated MovieDetailsViewModel to orchestrate GetWatchedMovieUseCase (check rated state on load) and SaveWatchedMovieUseCase (persist rating on user action)
  • Updated navigation to pass through the WatchedMovieListViewModel Koin factory and connect all routes

Testing

Unit tests added

Test file What it covers
GetWatchedMoviesUseCaseImplTest Returns a Flow<ResultState.Success> with the full list of watched movies from the repository
GetWatchedMovieUseCaseImplTest Returns Success(movie) for a known ID and Success(null) when not found
SaveWatchedMovieUseCaseImplTest Persists a movie snapshot and emits Success(Unit); emits Error on repository failure
MoviesRepositoryImplTest (new cases) getWatchedMovies maps DB models correctly; getWatchedMovie returns domain model or null; saveWatchedMovie calls data source and emits success
WatchedMovieListViewModelTest Initial state is Loading, transitions to Success with mapped UI models; empty list shows empty state
MovieDetailsViewModelTest (extended) Loads rated state on init; RateMovie event triggers save use case; snackbar side-effects fired on success/failure

All tests use Mockative (coEvery/coVerify for suspend functions, every/verify for Flow-returning non-suspend functions) and Turbine for Flow assertions, consistent with project conventions.


Reference

  • Full feature specification: SPEC.md
  • Implementation plan with step-by-step design decisions: PLAN.md

Checklist

  • Code review
  • Manual testing on device
  • UI matches expected behavior

🤖 Generated with Claude Code

pantstamp and others added 6 commits April 11, 2026 11:22
Konsist architecture test enforces that all DatabaseDataSource interface
functions must have the suspend modifier. Apply it to getWatchedMovies()
in the interface and both implementations (Room, Noop), and update the
repository test to use coEvery/coVerify accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

9 similar comments
@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

RateMovieUseCaseInput(event.movieId, event.rating),
).collect { resultState ->
resultState
.onSuccess {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Stuck UI state on null data race condition

If viewState.value.data is null (e.g., the movie hasn't loaded yet due to a race between the two concurrent coroutines launched in the Init event), return@onSuccess exits silently. At this point isRatingInProgress was set to true before the launch and never gets reset. The UI will be permanently stuck with the rating controls disabled and no feedback to the user.

Suggested change
.onSuccess {
val movie = viewState.value.data
if (movie == null) {
setState { copy(isRatingInProgress = false) }
setEffect { MovieDetailsSideEffect.ShowSnackbar("Something went wrong. Please try again.") }
return@onSuccess
}

publicRating = movie.voteAverage,
releaseDate = movie.releaseYear,
userRating = event.rating.toInt(),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Nested .collect inside a suspending onSuccess callback

Calling saveWatchedMovieUseCase.invoke(...).collect { ... } synchronously inside the rateMovieUseCase collect block creates nested Flow collection. This blocks the outer collector while the inner one is running and makes the control flow very hard to follow and test.

The cleaner and idiomatic approach is to chain flows using operators so the control flow stays flat:

is MovieDetailsEvent.RateMovie -> {
    setState { copy(isRatingInProgress = true) }
    viewModelScope.launch {
        rateMovieUseCase(RateMovieUseCaseInput(event.movieId, event.rating))
            .flatMapLatest { rateResult ->
                when {
                    rateResult is ResultState.Success -> {
                        val movie = viewState.value.data
                            ?: return@flatMapLatest flowOf(ResultState.Error(ErrorModel.Unknown()))
                        saveWatchedMovieUseCase(SaveWatchedMovieInput(...))
                    }
                    else -> flowOf(rateResult.map { })
                }
            }
            .collect { ... }
    }
}

userRating = event.rating.toInt(),
isRatingInProgress = false,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Hardcoded string literal in ViewModel

ViewModels should not own UI strings. They have no Context and strings can't be localised when hardcoded here. This applies to all three literal messages ("Rating saved", "Something went wrong. Please try again.").

Prefer one of these approaches:

  1. Typed side effect — replace the message: String field in ShowSnackbar with a sealed type (e.g. RatingSaved, RatingError) and let the Composable resolve each case to the correct stringResource.
  2. UiText wrapper — a common pattern that wraps either a StringRes or a plain String, keeping ViewModel logic and UI strings decoupled.

override fun handleEvents(event: WatchedMovieListEvent) {
when (event) {
is WatchedMovieListEvent.GetWatchedMovies -> {
viewModelScope.launch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Accumulating coroutines on repeated GetWatchedMovies events

getWatchedMoviesUseCase returns a Room-backed Flow that never completes. Every time GetWatchedMovies is dispatched — which happens on every ON_RESUME of the NavBackStackEntry, including every tab switch — a new, long-lived coroutine is launched and left running alongside the previous ones. After N tab switches, N coroutines all collect the same flow and race to update viewState.

Load the data once in init, or cancel the previous job before launching a new one:

private var loadJob: Job? = null

is WatchedMovieListEvent.GetWatchedMovies -> {
    loadJob?.cancel()
    loadJob = viewModelScope.launch {
        getWatchedMoviesUseCase(input = Unit).collect { ... }
    }
}

Alternatively, kick off collection in init {} and remove the event entirely — the Watched list is always reactive to DB changes and doesn't need a manual refresh trigger.

copy(
isLoading = false,
errorMessage = null,
data = it.map { mapper.fromDomainToUi(it) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Shadowed it parameter

it.map { mapper.fromDomainToUi(it) } uses it for two different things: the outer it is the List<WatchedMovie> received from onSuccess, and the inner it is each WatchedMovie element. Kotlin allows this, but it's a readability hazard and easy to misread as "map each element using itself".

Suggested change
data = it.map { mapper.fromDomainToUi(it) }
data = it.map { movie -> mapper.fromDomainToUi(movie) }

.onError { error ->
setState {
copy(
isLoading = false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Hardcoded fallback string in ViewModel

Same concern as in MovieDetailsViewModel: hardcoded English strings in a ViewModel bypass localisation and couple business logic to UI text. Expose a typed error state or a StringRes-based wrapper instead.

Suggested change
isLoading = false,
errorMessage = error.message ?: "An error occurred",

→ Consider replacing errorMessage: String? in WatchedMovieListUiState with an errorRes: Int? (a @StringRes resource ID) so the string is only resolved in the Composable.

posterUrl = posterUrl,
overview = overview,
publicRating = publicRating,
releaseDate = releaseDate,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — System.currentTimeMillis() is not injectable — breaks unit tests

System.currentTimeMillis() cannot be controlled in unit tests. The MoviesRepositoryImplTest for saveWatchedMovie therefore cannot assert on ratedAt, nor can it verify ordering logic that depends on the timestamp. This is already a gap in the existing tests.

Inject a Clock (or a simple () -> Long lambda) into the repository:

internal class MoviesRepositoryImpl(
    private val databaseDataSource: DatabaseDataSource,
    private val networkDataSource: NetworkDataSource,
    private val mappers: Mappers,
    private val clock: () -> Long = System::currentTimeMillis, // default keeps prod behaviour
)

Then use clock() instead of System.currentTimeMillis(). Tests can pass { FIXED_TIMESTAMP }.

databaseDataSource.insertWatchedMovie(dbModel)
emit(ResultState.Success(Unit))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — flow { innerFlow.collect { emit(...) } } anti-pattern breaks cancellation

Using .collect inside a flow {} builder is flagged by the Kotlin coroutines team as an anti-pattern. If the outer flow is cancelled while the inner .collect is running, the inner collect is NOT automatically cancelled — cancellation has to propagate through a cooperative check at the next emit call, which may never come for a Room Flow that's waiting for DB changes.

The correct idiom is emitAll, which properly links cancellation:

Suggested change
override fun getWatchedMovies(): Flow<ResultState<List<WatchedMovie>>> = flow {
emitAll(
databaseDataSource.getWatchedMovies().map { dbModels ->
ResultState.Success(dbModels.map(mappers.watchedMovieDomainMapper::fromDbToDomain))
}
)
}

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — LifecycleEventEffect(ON_RESUME) triggers repeated data loads across tab switches

LifecycleEventEffect uses addObserver under the hood. When the composable re-enters composition while the NavBackStackEntry is already RESUMED, Android's LifecycleRegistry synthetically dispatches ON_RESUME to the newly-added observer. This means every tab switch (Discover → Watched → Discover → Watched …) fires GetWatchedMovies again.

Paired with the accumulation issue noted in WatchedMovieListViewModel, this causes a new long-lived coroutine to be started on each switch. The data will still appear correct (all coroutines emit the same Room data), but the duplicated subscriptions waste resources and can cause state flickering if Room emits updates at the wrong moment.

Preferred fix: Remove this LifecycleEventEffect and instead start the data load once from WatchedMovieListViewModel.init {}:

init {
    handleEvents(WatchedMovieListEvent.GetWatchedMovies)
}

The Room Flow is reactive — it will automatically push fresh data when the DB changes (e.g., after a new rating is saved). No manual refresh is needed on every resume.

movie: WatchedMovieUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Use stringResource() instead of LocalContext.current.getString()

LocalContext.current.getString(...) works but bypasses Compose's idiomatic API. stringResource() is the preferred approach inside composables — it reads from the composition-local context, works correctly with Compose Preview, and is more readable.

Suggested change
) {
val userRatingDescription = stringResource(R.string.content_description_user_rating, movie.userRating)

You can also remove the val context = LocalContext.current at line 110 if this is its only usage.

onMovieClicked = onMovieClicked,
)
}
if (selectedTabIndex == 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Suggestion — Consider HorizontalPager for native swipe-between-tabs UX

The current if/else conditional rendering tears down each screen completely when switching tabs — all local Compose state is lost and the screen is recomposed from scratch. This also means LifecycleEventEffect in WatchedMovieListScreen fires on every tab visit (see that comment), and swipe gestures between tabs are not supported.

HorizontalPager keeps both pages in the composition (controlled by beyondBoundsPageCount) and supports the standard swipe gesture that Material 3 users expect from a TabRow:

val pagerState = rememberPagerState { tabTitles.size }
val scope = rememberCoroutineScope()

TabRow(selectedTabIndex = pagerState.currentPage) {
    tabTitles.forEachIndexed { index, title ->
        Tab(
            selected = pagerState.currentPage == index,
            onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
            text = { Text(title) },
        )
    }
}
HorizontalPager(state = pagerState) { page ->
    when (page) {
        0 -> MovieListScreen(...)
        1 -> WatchedMovieListScreen(...)
    }
}

Note: the PR description mentions HorizontalPager was the intended approach — this appears to have been swapped out before merge.

onMovieClicked(sideEffect.movieId)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Suggestion — Add @Preview for WatchedMovieListScreen and WatchedMovieRow

No @Preview composables were added for any of the new screens in this PR (WatchedMovieListScreen, WatchedMovieRow, MovieCatalogTabbedScreen). The project already has previews for existing screens — keeping that convention makes it easier to iterate on UI without running the full app.

@Preview(showBackground = true)
@Composable
private fun WatchedMovieRowPreview() {
    AppTheme {
        WatchedMovieRow(
            movie = WatchedMovieUiModel(
                movieId = 1,
               ,
                posterPath = null,
                voteAverage = 8.6,
                userRating = 9,
                releaseYear = "2014",
            ),
            onClick = {},
        )
    }
}

@claude

claude Bot commented Apr 12, 2026

Copy link
Copy Markdown

Code Review — feat: watched movies collection

Overall this is a solid feature implementation. The layering is clean, the MVI pattern is applied consistently, the Room migration is correct, and the test coverage is comprehensive. A few issues need attention before merge.


Finding Summary

Severity Count Issues
🔴 Critical 1 Stuck UI state when movie data is null during rating
🟠 Major 4 Coroutine accumulation, nested flow collection, flow{collect{}} anti-pattern, non-injectable clock
🟡 Minor 4 Hardcoded strings in ViewModels (×2 files), shadowed it, LocalContext vs stringResource
🟢 Suggestion 2 HorizontalPager for swipe UX, missing @Previews

🔴 Critical

MovieDetailsViewModel — stuck isRatingInProgress on null data race (line 74)
If viewState.value.data is null when the network rating succeeds (possible race: the two concurrent coroutines launched in Init run independently), return@onSuccess exits silently while isRatingInProgress remains true. The rating controls are permanently disabled with no error shown to the user. Fix: explicitly reset isRatingInProgress = false and emit an error snackbar in the null branch.


🟠 Major

  1. WatchedMovieListViewModel — coroutine accumulation on repeated GetWatchedMovies (line 25)
    Every tab switch fires ON_RESUME (via LifecycleEventEffect's synthetic dispatch), which launches a new long-lived coroutine collecting the Room Flow. After N switches, N subscriptions are active simultaneously. Fix: cancel the previous job before launching, or move the single load into init {}.

  2. MovieDetailsViewModel — nested .collect inside a suspend callback (line 85)
    Calling saveWatchedMovieUseCase.invoke(...).collect { ... } synchronously inside the rateMovieUseCase collector blocks the outer stream and tangles the control flow. Flatten using flatMapLatest or chain as sequential launch calls.

  3. MoviesRepositoryImpl.getWatchedMovies()flow { innerFlow.collect { emit } } breaks cancellation (line 91)
    Upstream cancellation does not propagate reliably through nested .collect inside flow {}. Replace with emitAll(databaseFlow.map { ... }).

  4. MoviesRepositoryImpl.saveWatchedMovie()System.currentTimeMillis() is not injectable (line 84)
    Hard-coded wall-clock call makes ratedAt untestable and prevents asserting insertion order in tests. Inject a clock: () -> Long parameter with a default of System::currentTimeMillis.


🟡 Minor

  1. WatchedMovieListScreenLifecycleEventEffect + tab switching = redundant loads (line 87)
    Ties directly into the coroutine-accumulation issue above. Since the Room Flow is reactive, a one-time subscription in init {} replaces all of this cleanly.

  2. Hardcoded English strings in ViewModelsMovieDetailsViewModel (line 94) and WatchedMovieListViewModel (line 44)
    ViewModels have no Context and cannot be localised. Use typed side-effect variants or @StringRes IDs.

  3. Shadowed it in WatchedMovieListViewModel (line 36)
    it.map { mapper.fromDomainToUi(it) } — inner it shadows outer it. Use an explicit name: { movie -> mapper.fromDomainToUi(movie) }.

  4. LocalContext.current.getString() in a Composable (line 110)
    Use stringResource(R.string.content_description_user_rating, movie.userRating) instead.


🟢 Suggestions

  1. MovieCatalogTabbedScreen — consider HorizontalPager (line 63)
    The if/else approach destroys and recreates each screen on every tab switch. HorizontalPager keeps both pages alive and adds the swipe gesture expected by Material 3 tab UIs (also matches what the PR description originally described).

  2. Missing @Preview composables (end of WatchedMovieListScreen.kt)
    WatchedMovieListScreen, WatchedMovieRow, and MovieCatalogTabbedScreen have no previews. The existing screens all have them — worth keeping the convention.


What looks good ✅

  • Clean Architecture layering is correctly respected throughout (DB model → domain model → UI model, no layer skipping)
  • Room migration (MIGRATION_1_2) is correct and the exported schema JSON is committed
  • WatchedMovieUiModel is correctly annotated @Immutable — good for recomposition stability
  • ImmutableList used in WatchedMovieListUiState — correct choice for stable Compose state
  • Comprehensive use-case and repository unit tests with Mockative + Turbine, consistent with project conventions
  • WatchedMovieDao uses ABORT conflict strategy — correct for immutable ratings
  • Navigation correctly scopes both ViewModels to the same NavBackStackEntry

🤖 Generated with Claude Code

Bug fixes:
- Reset isRatingInProgress = false when viewState.data is null in
  MovieDetailsViewModel to prevent permanently stuck UI state
- Fix accumulating coroutines on WatchedMovieListViewModel by moving
  data load to init {} instead of triggering on every ON_RESUME event;
  remove GetWatchedMovies event and LifecycleEventEffect from screen
- Fix flow { collect } anti-pattern in getWatchedMovies() by replacing
  with flow { emitAll(...) } for correct cancellation propagation
- Inject clock: () -> Long into MoviesRepositoryImpl to make
  System.currentTimeMillis() testable

Code quality:
- Extract saveRating() helper in MovieDetailsViewModel to flatten
  nested .collect inside rateMovieUseCase success callback
- Replace ShowSnackbar(message: String) side effect with typed
  RatingSaved / RatingError data objects; resolve strings from
  string resources in the screen instead of hardcoding in ViewModel
- Replace WatchedMovieListUiState.errorMessage: String? with
  @stringres errorRes: Int? to keep string resolution in the UI layer
- Rename shadowed 'it' lambda parameter to 'movie' in
  WatchedMovieListViewModel for readability
- Replace LocalContext.current.getString() with stringResource()
  in WatchedMovieRow composable

UX improvement:
- Replace if/else tab switching in MovieCatalogTabbedScreen with
  HorizontalPager to support swipe gestures and retain scroll position

Compose:
- Add @Preview composables for WatchedMovieRow, WatchedMovieListScreen
  (loading / empty / with-data states) and MovieCatalogTabbedScreen

Tests: update MovieDetailsViewModelTest and WatchedMovieListViewModelTest
to reflect renamed side effects, removed event, and renamed state field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pantstamp

Copy link
Copy Markdown
Owner Author

@claude Please review this PR

fun getWatchedMovieEntities(): Flow<List<WatchedMovieEntity>>

@Query("SELECT * FROM watched_movies WHERE movieId = :movieId")
fun getWatchedMovieEntity(movieId: Int): WatchedMovieEntity?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Data / Threading

getWatchedMovieEntity() is neither suspend nor returns Flow, making it a synchronous blocking DAO query. Room 2.2+ requires these to be marked @WorkerThread and will throw a IllegalStateException at runtime if called on the main thread. Even though RoomDataSource.getWatchedMovie() is suspend, the coroutine context inside flow { } builder isn't guaranteed unless flowOn is explicitly applied.

Suggested change
fun getWatchedMovieEntity(movieId: Int): WatchedMovieEntity?
@Query("SELECT * FROM watched_movies WHERE movieId = :movieId")
suspend fun getWatchedMovieEntity(movieId: Int): WatchedMovieEntity?

This aligns with the sibling insertWatchedMovie pattern and removes any threading ambiguity.

import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieDbModel
import com.pantelisstampoulis.androidtemplateproject.database.model.WatchedMovieEntity

class WatchedMovieDbMapper {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Convention (CLAUDE.md)

Two violations in one declaration:

  1. Missing internal: Per CLAUDE.md, all DatabaseDataSource impl classes and their collaborators in :core:database:room must be internal. WatchedMovieDbMapper is public and leaks into the module's public surface.

  2. Missing mapper interface: CLAUDE.md states "All mappers implement typed interfaces from :architecture:mapper". WatchedMovieDbMapper doesn't implement ApiToDbMapper<WatchedMovieDbModel, WatchedMovieEntity> or DbToApiMapper. Compare with WatchedMovieDomainMapper which correctly implements DbToDomainMapper.

Suggested change
class WatchedMovieDbMapper {
internal class WatchedMovieDbMapper : ApiToDbMapper<WatchedMovieDbModel, WatchedMovieEntity> {

Then rename toDbfromApiToDb and keep mapFromDb as a convenience (or add a reverse interface).

setState { copy(userRating = rating, isRatingInProgress = false) }
setEffect { MovieDetailsSideEffect.RatingSaved }
}
.onError {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Data Integrity Bug

movie.releaseYear is the UI-formatted year string (e.g. "2014") produced by MovieUiMapper via movie.releaseDate.take(4). You're storing a truncated year where the DB schema expects a full release date string ("2014-03-07").

The data works coincidentally — WatchedMovieUiMapper.releaseYear will still show "2014" by taking the first 4 chars of "2014" — but the stored value is semantically incorrect. Any future feature that needs the actual date (e.g. sorting by exact release date, display formatting) will silently get a stale/invalid value.

Fix: MovieUiModel doesn't carry the original releaseDate. The cleanest solution is to keep the domain Movie object alongside data: MovieUiModel? in MovieDetailsUiState, then read movie.releaseDate from the domain object in saveRating. Alternatively, add a releaseDate: String? field to MovieUiModel (in addition to releaseYear).

}

override fun getWatchedMovies(): Flow<ResultState<List<WatchedMovie>>> = flow {
emitAll(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major — Design / Error Modelling

ResultState.Error(ErrorModel.NotFound()) is used to signal "movie has not been rated yet" — a completely normal, non-exceptional state. This forces every caller to silently swallow the error:

.onError {
    // NotFound = not rated yet, leave userRating as null
}

Using an error state for control flow is semantically wrong and fragile: a future change that adds real error cases here (e.g. DB corruption, decryption failure) would be indistinguishable from "not found" unless the caller explicitly inspects ErrorModel subtype.

Suggested fix: Change the return type to Flow<ResultState<WatchedMovie?>> and emit ResultState.Success(null) when no row is found:

override fun getWatchedMovie(movieId: Int): Flow<ResultState<WatchedMovie?>> = flow {
    val dbModel = databaseDataSource.getWatchedMovie(movieId)
    emit(ResultState.Success(dbModel?.let { mappers.watchedMovieDomainMapper.fromDbToDomain(it) }))
}

Update MoviesRepository, GetWatchedMovieUseCase, and MovieDetailsViewModel accordingly — null becomes the clear "not yet rated" sentinel.

@pantstamp
pantstamp merged commit 410da16 into develop Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant