From 38ba46f32e67af402be790e6c5740786b364c87a Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:41:01 +0200 Subject: [PATCH 01/11] feat(reroll): add reroll button UI with i18n Add RerollButton composable matching pen design (pill shape, green accent, shuffle icon). Wire OnRerollClicked action and localize string across 13 locales. --- .../composeResources/values-ar/strings.xml | 1 + .../composeResources/values-ca/strings.xml | 1 + .../composeResources/values-de/strings.xml | 1 + .../composeResources/values-es/strings.xml | 1 + .../composeResources/values-fr/strings.xml | 1 + .../composeResources/values-gl/strings.xml | 1 + .../composeResources/values-hi/strings.xml | 1 + .../composeResources/values-it/strings.xml | 1 + .../composeResources/values-ja/strings.xml | 1 + .../composeResources/values-pt/strings.xml | 1 + .../composeResources/values-ru/strings.xml | 1 + .../composeResources/values-zh/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../presentation/components/FilmDisplay.kt | 3 + .../presentation/components/FilmPoster.kt | 4 ++ .../presentation/components/RerollButton.kt | 63 +++++++++++++++++++ .../viewmodel/RandomFilmAction.kt | 2 + 17 files changed, 85 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 215a1a0..aa89652 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -70,4 +70,5 @@ يعمل مع وضع المستخدم الواحد ووضع متعدد المستخدمين جاري رمي النرد... جاري البحث عن فيلمك العشوائي + إعادة الرمي diff --git a/composeApp/src/commonMain/composeResources/values-ca/strings.xml b/composeApp/src/commonMain/composeResources/values-ca/strings.xml index 107fd84..ca64c6a 100644 --- a/composeApp/src/commonMain/composeResources/values-ca/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ca/strings.xml @@ -70,4 +70,5 @@ Funciona amb els modes d\'un sol usuari i multiusuari Llançant els daus... Trobar la teva pel·lícula aleatòria + Tornar a tirar \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index c249c13..fb57e4f 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -70,4 +70,5 @@ Funktioniert im Einzel- und Mehrbenutzermodus Würfel rollen... Finde deinen zufälligen Film + Neu würfeln \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 0968f71..ac9356f 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -70,4 +70,5 @@ Funciona con los modos de un solo usuario y multiusuario Lanzando los dados... Encontrando tu película aleatoria + Tirar de nuevo \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a1967b5..f52b894 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -70,4 +70,5 @@ Fonctionne avec les modes utilisateur unique et multi-utilisateur Lancement des dés... Trouver votre film aléatoire + Relancer \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-gl/strings.xml b/composeApp/src/commonMain/composeResources/values-gl/strings.xml index 0eba5c4..d915cdf 100644 --- a/composeApp/src/commonMain/composeResources/values-gl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-gl/strings.xml @@ -70,4 +70,5 @@ Funciona cos modos de usuario único e multiusuario Lanzando os dados... Atopando o teu filme aleatorio + Tirar de novo \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 6f4ddc5..cede8be 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -70,4 +70,5 @@ एकल उपयोगकर्ता और बहु-उपयोगकर्ता मोड दोनों के साथ काम करता है पासा फेंक रहे हैं... आपकी यादृच्छिक फिल्म खोज रहे हैं + फिर से फेंकें \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index d4fe9c8..8fc1186 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -70,4 +70,5 @@ Funziona con le modalità utente singolo e multiutente Lanciando i dadi... Trovare il tuo film casuale + Rilancia \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 2f56388..56ebd4a 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -70,4 +70,5 @@ シングルユーザーとマルチユーザーモードの両方で動作 サイコロを振っています... ランダム映画を探しています + もう一度振る diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index cf7dbee..aac3548 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -70,4 +70,5 @@ Funciona nos modos de usuário único e multiusuário Lançando os dados... Encontrando seu filme aleatório + Rolar novamente \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 45aa99d..65be95c 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -70,4 +70,5 @@ Работает в режимах одного и нескольких пользователей Бросаем кубики... Ищем ваш случайный фильм + Перебросить diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 624d824..3514b69 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -70,4 +70,5 @@ 适用于单用户和多用户模式 掷骰子中... 正在寻找您的随机电影 + 重新掷骰 \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3ea1378..3e99d60 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -70,4 +70,5 @@ Works with single user and multi-user modes Rolling the dice... Finding your random movie + Reroll \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmDisplay.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmDisplay.kt index be64f9e..70a0b3f 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmDisplay.kt @@ -25,5 +25,8 @@ internal fun FilmDisplay( onClick = { onAction(RandomFilmAction.OnFilmClicked(film)) }, + onRerollClick = { + onAction(RandomFilmAction.OnRerollClicked) + }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt index 5f3e93f..6196ebe 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt @@ -49,6 +49,7 @@ fun FilmPoster( title: String, releaseYear: String, onClick: () -> Unit, + onRerollClick: () -> Unit, ) { var imageLoadResult by remember { mutableStateOf?>(null) @@ -168,6 +169,9 @@ fun FilmPoster( fontSize = 17.sp, textAlign = TextAlign.Center, ) + RerollButton( + onClick = onRerollClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt new file mode 100644 index 0000000..d32dca5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt @@ -0,0 +1,63 @@ +package com.nacchofer31.randomboxd.random_film.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nacchofer31.randomboxd.core.presentation.RandomBoxdColors +import org.jetbrains.compose.resources.stringResource +import randomboxd.composeapp.generated.resources.Res +import randomboxd.composeapp.generated.resources.reroll + +@Composable +internal fun RerollButton(onClick: () -> Unit) = + Surface( + onClick = { onClick() }, + modifier = + Modifier + .padding(top = 5.dp) + .dropShadow( + shape = RoundedCornerShape(100), + shadow = + Shadow( + radius = 10.dp, + color = RandomBoxdColors.GreenAccent.copy(alpha = 0.25f), + spread = 4.dp, + ), + ), + shape = RoundedCornerShape(100), + color = RandomBoxdColors.GreenAccent, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = RandomBoxdColors.BackgroundDarkColor, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(Res.string.reroll), + color = RandomBoxdColors.BackgroundDarkColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + } + } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmAction.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmAction.kt index ab75e84..39ff855 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmAction.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmAction.kt @@ -48,4 +48,6 @@ sealed interface RandomFilmAction { data class OnGenreSelectionApplied( val genres: Set, ) : RandomFilmAction + + data object OnRerollClicked : RandomFilmAction } From 142033469cb123bf4bb8d761d8b576482942186b Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:41:15 +0200 Subject: [PATCH 02/11] feat(reroll): add reroll logic in ViewModel Rename getRandomMovie to getRandomMovies returning Set for caching. Add extractResultMovie to repository. ViewModel caches results and picks a random film on reroll. --- .../RandomFilmScrappingRepository.kt | 29 +++++------ .../domain/repository/RandomFilmRepository.kt | 8 ++- .../viewmodel/RandomFilmViewModel.kt | 52 ++++++++++++++++--- 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt index d8bd035..6030a60 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt @@ -35,29 +35,27 @@ class RandomFilmScrappingRepository( const val FILM_SCRIPT_QUERY = """script[type="application/ld+json"]""" } - override suspend fun getRandomMovie( + override suspend fun getRandomMovies( userName: String, selectedGenres: Set, - ): ResultData = + ): ResultData, DataError.Remote> = withContext(Dispatchers.IO) { - val filmsResult = getFilmsFromUserWatchlist(userName, selectedGenres) val films = - when (filmsResult) { + when (val filmsResult = getFilmsFromUserWatchlist(userName, selectedGenres)) { is ResultData.Success -> filmsResult.data is ResultData.Error -> return@withContext ResultData.Error(filmsResult.error) } if (films.isEmpty()) return@withContext ResultData.Error(DataError.Remote.NO_RESULTS) - val chosenFilm = films.random() - extractFilm(chosenFilm) + return@withContext ResultData.Success(films.toSet()) } override suspend fun getRandomMoviesFromSearchList( searchList: Set, filmSearchMode: FilmSearchMode, selectedGenres: Set, - ): ResultData = + ): ResultData, DataError.Remote> = withContext(Dispatchers.IO) { if (searchList.isEmpty()) return@withContext ResultData.Error(DataError.Remote.SERIALIZATION) @@ -88,10 +86,11 @@ class RandomFilmScrappingRepository( if (combinedFilms.isEmpty()) return@withContext ResultData.Error(DataError.Remote.NO_RESULTS) - val chosenFilm = combinedFilms.random() - extractFilm(chosenFilm) + return@withContext ResultData.Success(combinedFilms) } + override suspend fun extractResultMovie(film: Film): ResultData = extractFilm(film) + private suspend fun getFilmsFromUserWatchlist( userName: String, selectedGenres: Set = emptySet(), @@ -118,9 +117,8 @@ class RandomFilmScrappingRepository( val films = mutableListOf() for (page in 1..totalPages) { val pageUrl = "$baseUrl/page/$page/" - val htmlResult = getWebPage(pageUrl) val html = - when (htmlResult) { + when (val htmlResult = getWebPage(pageUrl)) { is ResultData.Success -> htmlResult.data is ResultData.Error -> return ResultData.Error(htmlResult.error) } @@ -157,9 +155,8 @@ class RandomFilmScrappingRepository( } private suspend fun extractFilm(film: Film): ResultData { - val posterResult = getPosterFromFilmPage(film.slug) val finalImageUrl = - when (posterResult) { + when (val posterResult = getPosterFromFilmPage(film.slug)) { is ResultData.Success -> posterResult.data.ifEmpty { film.imageUrl } is ResultData.Error -> film.imageUrl } @@ -182,9 +179,8 @@ class RandomFilmScrappingRepository( private suspend fun getPosterFromFilmPage(slug: String): ResultData { val url = RandomBoxdEndpoints.filmSlugUrl(slug) - val htmlResult = getWebPage(url) val html = - when (htmlResult) { + when (val htmlResult = getWebPage(url)) { is ResultData.Success -> htmlResult.data is ResultData.Error -> return ResultData.Error(htmlResult.error) } @@ -207,9 +203,8 @@ class RandomFilmScrappingRepository( } private suspend fun getTotalPages(url: String): Int { - val htmlResult = getWebPage(url) val html = - when (htmlResult) { + when (val htmlResult = getWebPage(url)) { is ResultData.Success -> htmlResult.data is ResultData.Error -> return 1 } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository.kt index f37cd4d..f6015c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository.kt @@ -7,14 +7,18 @@ import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode interface RandomFilmRepository { - suspend fun getRandomMovie( + suspend fun getRandomMovies( userName: String, selectedGenres: Set = emptySet(), - ): ResultData + ): ResultData, DataError.Remote> suspend fun getRandomMoviesFromSearchList( searchList: Set, filmSearchMode: FilmSearchMode = FilmSearchMode.INTERSECTION, selectedGenres: Set = emptySet(), + ): ResultData, DataError.Remote> + + suspend fun extractResultMovie( + film: Film, ): ResultData } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt index 8f51a4b..2bf7712 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nacchofer31.randomboxd.core.domain.DispatcherProvider import com.nacchofer31.randomboxd.core.domain.ResultData +import com.nacchofer31.randomboxd.random_film.domain.model.Film import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode import com.nacchofer31.randomboxd.random_film.domain.model.UserName @@ -54,6 +55,8 @@ class RandomFilmViewModel( initialValue = emptyList(), ) + internal var cachedResultFilms: Set = emptySet() + init { actions .filterIsInstance() @@ -72,7 +75,7 @@ class RandomFilmViewModel( else -> { val userName = internalState.value.userName.trim() userNameRepository.addUserName(userName) - repository.getRandomMovie(userName, internalState.value.selectedGenres) + repository.getRandomMovies(userName, internalState.value.selectedGenres) } } emit(result) @@ -88,11 +91,25 @@ class RandomFilmViewModel( viewModelScope.launch { inAppReviewRepository.requestInAppReview() } - current.copy( - isLoading = false, - resultFilm = result.data, - resultError = null, - ) + cachedResultFilms = result.data + var filmResult = repository.extractResultMovie(result.data.random()) + return@update when (filmResult) { + is ResultData.Success -> { + current.copy( + isLoading = false, + resultFilm = filmResult.data, + resultError = null, + ) + } + + is ResultData.Error -> { + current.copy( + isLoading = false, + resultFilm = null, + resultError = filmResult.error, + ) + } + } } is ResultData.Error -> { @@ -166,6 +183,10 @@ class RandomFilmViewModel( internalState.update { it.copy(selectedGenres = action.genres, showGenreBottomSheet = false) } } + is RandomFilmAction.OnRerollClicked -> { + rerollMovie() + } + else -> { Unit } @@ -215,4 +236,23 @@ class RandomFilmViewModel( current.copy(userNameSearchList = current.userNameSearchList + userName, userName = "", resultError = null) } } + + private fun rerollMovie() { + internalState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + val filmResult = + repository.extractResultMovie( + cachedResultFilms.random(), + ) + internalState.update { + when (filmResult) { + is ResultData.Success -> it.copy(isLoading = false, resultFilm = filmResult.data, resultError = null) + is ResultData.Error -> it.copy(isLoading = false, resultFilm = null, resultError = filmResult.error) + } + } + } + } } From 1061ead79ed3b7a83705c1ae9101b2c1118fd5f1 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:41:26 +0200 Subject: [PATCH 03/11] style(design): migrate icon format in pen file Migrate icon_font to icon type, update stroke definitions, and add reroll button design element. --- randomboxd.pen | 511 +++++++++++++++++++++++++++---------------------- 1 file changed, 277 insertions(+), 234 deletions(-) diff --git a/randomboxd.pen b/randomboxd.pen index 010fdb6..6a58564 100644 --- a/randomboxd.pen +++ b/randomboxd.pen @@ -1,5 +1,5 @@ { - "version": "2.11", + "version": "2.14", "children": [ { "type": "frame", @@ -99,10 +99,9 @@ "name": "Inner Ring", "width": 28, "height": 28, - "stroke": { - "thickness": 3, - "fill": "#14181C" - } + "stroke": "#14181C", + "strokeWidth": 3, + "strokeAlignment": "inner" }, { "type": "ellipse", @@ -129,13 +128,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "SF6Y4", "name": "arrowUp", "width": 28, "height": 28, - "iconFontName": "arrow-up-left", - "iconFontFamily": "lucide", + "icon": "arrow-up-left", + "library": "lucide", "fill": "#14181C" } ] @@ -241,13 +240,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "Y9Nzc", "name": "feature1IconInner", "width": 20, "height": 20, - "iconFontName": "shuffle", - "iconFontFamily": "lucide", + "icon": "shuffle", + "library": "lucide", "fill": "$accent-green" } ] @@ -291,13 +290,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "IvNfX", "name": "feature2IconInner", "width": 20, "height": 20, - "iconFontName": "users", - "iconFontFamily": "lucide", + "icon": "users", + "library": "lucide", "fill": "$accent-orange" } ] @@ -341,13 +340,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "YkTJt", "name": "feature3IconInner", "width": 20, "height": 20, - "iconFontName": "link", - "iconFontFamily": "lucide", + "icon": "link", + "library": "lucide", "fill": "$accent-green" } ] @@ -441,13 +440,13 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "CBMUb", "name": "getStartedIcon", "width": 20, "height": 20, - "iconFontName": "arrow-right", - "iconFontFamily": "lucide", + "icon": "arrow-right", + "library": "lucide", "fill": "#14181C" } ] @@ -545,13 +544,13 @@ "cornerRadius": 12, "children": [ { - "type": "icon_font", + "type": "icon", "id": "Z5fKm", "name": "posterIcon", "width": 60, "height": 60, - "iconFontName": "film", - "iconFontFamily": "lucide", + "icon": "film", + "library": "lucide", "fill": "#FFFFFF40" } ] @@ -663,13 +662,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "Udojs", "name": "inputClearIcon", "width": 16, "height": 16, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "$text-muted" } ] @@ -774,13 +773,13 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "ArvMD", "name": "nextBtnIcon2", "width": 20, "height": 20, - "iconFontName": "arrow-right", - "iconFontFamily": "lucide", + "icon": "arrow-right", + "library": "lucide", "fill": "#14181C" } ] @@ -855,13 +854,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "o5Hds", "name": "searchIcon", "width": 18, "height": 18, - "iconFontName": "search", - "iconFontFamily": "lucide", + "icon": "search", + "library": "lucide", "fill": "$text-muted" }, { @@ -908,13 +907,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "06xIG", "name": "tag1Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#14181C" } ] @@ -944,13 +943,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "A9qts", "name": "tag2Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#ffffffff" } ] @@ -1033,13 +1032,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "lESnM", "name": "featureIconInner1", "width": 18, "height": 18, - "iconFontName": "bookmark", - "iconFontFamily": "lucide", + "icon": "bookmark", + "library": "lucide", "fill": "$accent-green" } ] @@ -1083,13 +1082,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "uWaWg", "name": "featureIconInner2", "width": 18, "height": 18, - "iconFontName": "list", - "iconFontFamily": "lucide", + "icon": "list", + "library": "lucide", "fill": "$accent-orange" } ] @@ -1181,13 +1180,13 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "CKTvc", "name": "nextBtnIcon3", "width": 20, "height": 20, - "iconFontName": "arrow-right", - "iconFontFamily": "lucide", + "icon": "arrow-right", + "library": "lucide", "fill": "#14181C" } ] @@ -1261,10 +1260,9 @@ "fill": "#00E05440", "width": 100, "height": 100, - "stroke": { - "thickness": 2, - "fill": "$accent-green" - } + "stroke": "$accent-green", + "strokeWidth": 2, + "strokeAlignment": "inner" }, { "type": "ellipse", @@ -1275,10 +1273,9 @@ "fill": "#FF800040", "width": 100, "height": 100, - "stroke": { - "thickness": 2, - "fill": "$accent-orange" - } + "stroke": "$accent-orange", + "strokeWidth": 2, + "strokeAlignment": "inner" }, { "type": "frame", @@ -1566,13 +1563,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "OrqmA", "name": "featureIconInner4c", "width": 18, "height": 18, - "iconFontName": "hand", - "iconFontFamily": "lucide", + "icon": "hand", + "library": "lucide", "fill": "$accent-green" } ] @@ -1664,13 +1661,13 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "qKgOK", "name": "finishBtnIcon", "width": 20, "height": 20, - "iconFontName": "sparkles", - "iconFontFamily": "lucide", + "icon": "sparkles", + "library": "lucide", "fill": "#14181C" } ] @@ -1732,33 +1729,33 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "g1HT7", "name": "signalIcon", "width": 16, "height": 16, - "iconFontName": "signal", - "iconFontFamily": "lucide", + "icon": "signal", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "hqVNy", "name": "wifiIcon", "width": 16, "height": 16, - "iconFontName": "wifi", - "iconFontFamily": "lucide", + "icon": "wifi", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "qZRQi", "name": "batteryIcon", "width": 20, "height": 16, - "iconFontName": "battery-full", - "iconFontFamily": "lucide", + "icon": "battery-full", + "library": "lucide", "fill": "$text-primary" } ] @@ -1798,13 +1795,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "eFlvt", "name": "logoMiniIcon", "width": 18, "height": 18, - "iconFontName": "clapperboard", - "iconFontFamily": "lucide", + "icon": "clapperboard", + "library": "lucide", "fill": "#14181C" } ] @@ -1841,13 +1838,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "saxOf", "name": "helpIcon", "width": 18, "height": 18, - "iconFontName": "info", - "iconFontFamily": "lucide", + "icon": "info", + "library": "lucide", "fill": "$text-secondary" } ] @@ -1945,13 +1942,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "0blsq", "name": "infoIcon", "width": 16, "height": 16, - "iconFontName": "info", - "iconFontFamily": "lucide", + "icon": "info", + "library": "lucide", "fill": "$text-primary" } ] @@ -1992,6 +1989,52 @@ ] } ] + }, + { + "type": "frame", + "id": "sZ2BH", + "name": "rerollButton", + "fill": "$accent-green", + "cornerRadius": 100, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00E05440", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "gap": 10, + "padding": [ + 12, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "LoJRn", + "name": "shuffleIcon", + "width": 20, + "height": 20, + "icon": "shuffle", + "library": "lucide", + "fill": "#14181C" + }, + { + "type": "text", + "id": "XwlNm", + "name": "rerollText", + "fill": "#14181C", + "content": "Reroll", + "fontFamily": "Plus Jakarta Sans", + "fontSize": 16, + "fontWeight": "700" + } + ] } ] }, @@ -2036,13 +2079,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "eLVfE", "name": "userTag1Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#14181C" } ] @@ -2072,13 +2115,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "pmIXA", "name": "userTag2Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#14181C" } ] @@ -2132,13 +2175,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "83xGh", "name": "clearIcon", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "$text-secondary" } ] @@ -2199,13 +2242,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "4VM73", "name": "filterIcon", "width": 18, "height": 18, - "iconFontName": "list-filter", - "iconFontFamily": "lucide", + "icon": "list-filter", + "library": "lucide", "fill": "$text-secondary" }, { @@ -2361,33 +2404,33 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "PeXFV", "name": "signalEmpty", "width": 16, "height": 16, - "iconFontName": "signal", - "iconFontFamily": "lucide", + "icon": "signal", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "ZA936", "name": "wifiEmpty", "width": 16, "height": 16, - "iconFontName": "wifi", - "iconFontFamily": "lucide", + "icon": "wifi", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "bQE22", "name": "batteryEmpty", "width": 20, "height": 16, - "iconFontName": "battery-full", - "iconFontFamily": "lucide", + "icon": "battery-full", + "library": "lucide", "fill": "$text-primary" } ] @@ -2427,13 +2470,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "FDMFD", "name": "logoIconEmpty", "width": 18, "height": 18, - "iconFontName": "clapperboard", - "iconFontFamily": "lucide", + "icon": "clapperboard", + "library": "lucide", "fill": "#14181C" } ] @@ -2463,13 +2506,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "qqkpd", "name": "helpIconEmpty", "width": 18, "height": 18, - "iconFontName": "info", - "iconFontFamily": "lucide", + "icon": "info", + "library": "lucide", "fill": "$text-secondary" } ] @@ -2526,22 +2569,21 @@ "width": 160, "height": 160, "cornerRadius": 80, - "stroke": { - "thickness": 2, - "fill": "$accent-green" - }, + "stroke": "$accent-green", + "strokeWidth": 2, + "strokeAlignment": "inner", "layout": "vertical", "justifyContent": "center", "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "OlgRJ", "name": "wheelIcon", "width": 80, "height": 80, - "iconFontName": "disc-3", - "iconFontFamily": "lucide", + "icon": "disc-3", + "library": "lucide", "fill": "$accent-green" } ] @@ -2625,13 +2667,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "ChCh1", "name": "tip1IconInner", "width": 16, "height": 16, - "iconFontName": "lightbulb", - "iconFontFamily": "lucide", + "icon": "lightbulb", + "library": "lucide", "fill": "$accent-green" } ] @@ -2675,13 +2717,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "VDKf3", "name": "tip2IconInner", "width": 16, "height": 16, - "iconFontName": "hand", - "iconFontFamily": "lucide", + "icon": "hand", + "library": "lucide", "fill": "$accent-orange" } ] @@ -3027,13 +3069,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "CHb8D", "name": "g1Icon", "width": 20, "height": 20, - "iconFontName": "sparkles", - "iconFontFamily": "lucide", + "icon": "sparkles", + "library": "lucide", "fill": "#14181C" }, { @@ -3047,13 +3089,13 @@ "fontWeight": "600" }, { - "type": "icon_font", + "type": "icon", "id": "M6VSE", "name": "g1Check", "width": 18, "height": 18, - "iconFontName": "check", - "iconFontFamily": "lucide", + "icon": "check", + "library": "lucide", "fill": "#14181C" } ] @@ -3074,13 +3116,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "zwDuq", "name": "g2Icon", "width": 20, "height": 20, - "iconFontName": "compass", - "iconFontFamily": "lucide", + "icon": "compass", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3111,13 +3153,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "FPAhh", "name": "g3Icon", "width": 20, "height": 20, - "iconFontName": "laugh", - "iconFontFamily": "lucide", + "icon": "laugh", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3148,13 +3190,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "0pbaA", "name": "g4Icon", "width": 20, "height": 20, - "iconFontName": "video", - "iconFontFamily": "lucide", + "icon": "video", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3185,13 +3227,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "qGnUR", "name": "g5Icon", "width": 20, "height": 20, - "iconFontName": "users", - "iconFontFamily": "lucide", + "icon": "users", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3222,13 +3264,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "JiYY4", "name": "g6Icon", "width": 20, "height": 20, - "iconFontName": "landmark", - "iconFontFamily": "lucide", + "icon": "landmark", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3259,13 +3301,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "nFP3w", "name": "g7Icon", "width": 20, "height": 20, - "iconFontName": "music", - "iconFontFamily": "lucide", + "icon": "music", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3296,13 +3338,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "Av99M", "name": "g8Icon", "width": 20, "height": 20, - "iconFontName": "heart", - "iconFontFamily": "lucide", + "icon": "heart", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3333,13 +3375,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "ZeI09", "name": "g9Icon", "width": 20, "height": 20, - "iconFontName": "skull", - "iconFontFamily": "lucide", + "icon": "skull", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3370,13 +3412,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "R9Nz0", "name": "g10Icon", "width": 20, "height": 20, - "iconFontName": "swords", - "iconFontFamily": "lucide", + "icon": "swords", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3417,13 +3459,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "1QVo8", "name": "h1Icon", "width": 20, "height": 20, - "iconFontName": "flame", - "iconFontFamily": "lucide", + "icon": "flame", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3454,13 +3496,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "54W0q", "name": "h2Icon", "width": 20, "height": 20, - "iconFontName": "baby", - "iconFontFamily": "lucide", + "icon": "baby", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3491,13 +3533,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "gaVa3", "name": "h3Icon", "width": 20, "height": 20, - "iconFontName": "shield-alert", - "iconFontFamily": "lucide", + "icon": "shield-alert", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3528,13 +3570,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "PLPZJ", "name": "h4Icon", "width": 20, "height": 20, - "iconFontName": "drama", - "iconFontFamily": "lucide", + "icon": "drama", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3565,13 +3607,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "5V9Em", "name": "h5Icon", "width": 20, "height": 20, - "iconFontName": "wand-sparkles", - "iconFontFamily": "lucide", + "icon": "wand-sparkles", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3602,13 +3644,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "9PwPI", "name": "h6Icon", "width": 20, "height": 20, - "iconFontName": "ghost", - "iconFontFamily": "lucide", + "icon": "ghost", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3639,13 +3681,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "vTts7", "name": "h7Icon", "width": 20, "height": 20, - "iconFontName": "search", - "iconFontFamily": "lucide", + "icon": "search", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3676,13 +3718,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "RwgZI", "name": "h8Icon", "width": 20, "height": 20, - "iconFontName": "rocket", - "iconFontFamily": "lucide", + "icon": "rocket", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3713,13 +3755,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "6UoLt", "name": "h9Icon", "width": 20, "height": 20, - "iconFontName": "tv", - "iconFontFamily": "lucide", + "icon": "tv", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3750,13 +3792,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "CL09g", "name": "h10Icon", "width": 20, "height": 20, - "iconFontName": "sun", - "iconFontFamily": "lucide", + "icon": "sun", + "library": "lucide", "fill": "$text-secondary" }, { @@ -3811,13 +3853,13 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "xjMZy", "name": "applyIcon", "width": 20, "height": 20, - "iconFontName": "check", - "iconFontFamily": "lucide", + "icon": "check", + "library": "lucide", "fill": "#14181C" } ] @@ -3879,33 +3921,33 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "9llAF", "name": "cellIcon", "width": 16, "height": 16, - "iconFontName": "signal", - "iconFontFamily": "lucide", + "icon": "signal", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "ecKoX", "name": "wifiIcon", "width": 16, "height": 16, - "iconFontName": "wifi", - "iconFontFamily": "lucide", + "icon": "wifi", + "library": "lucide", "fill": "$text-primary" }, { - "type": "icon_font", + "type": "icon", "id": "llUzA", "name": "batteryIcon", "width": 22, "height": 16, - "iconFontName": "battery-full", - "iconFontFamily": "lucide", + "icon": "battery-full", + "library": "lucide", "fill": "$text-primary" } ] @@ -3933,13 +3975,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "g8ubx", "name": "logoIcon", "width": 24, "height": 24, - "iconFontName": "clapperboard", - "iconFontFamily": "lucide", + "icon": "clapperboard", + "library": "lucide", "fill": "$accent-green" }, { @@ -3967,13 +4009,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "lhIqx", "name": "helpIcon", "width": 18, "height": 18, - "iconFontName": "info", - "iconFontFamily": "lucide", + "icon": "info", + "library": "lucide", "fill": "$text-secondary" } ] @@ -4277,13 +4319,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "ciB4O", "name": "userTag1Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#14181C" } ] @@ -4313,13 +4355,13 @@ "fontWeight": "500" }, { - "type": "icon_font", + "type": "icon", "id": "aBsp3", "name": "userTag2Close", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "#14181C" } ] @@ -4354,13 +4396,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "oIezi", "name": "filterIcon", "width": 18, "height": 18, - "iconFontName": "list-filter", - "iconFontFamily": "lucide", + "icon": "list-filter", + "library": "lucide", "fill": "$text-secondary" }, { @@ -4413,13 +4455,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "F3Boc", "name": "clearIcon", "width": 14, "height": 14, - "iconFontName": "x", - "iconFontFamily": "lucide", + "icon": "x", + "library": "lucide", "fill": "$text-secondary" } ] @@ -4557,13 +4599,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "30wV2", "name": "androidIcon", "width": 14, "height": 14, - "iconFontName": "smartphone", - "iconFontFamily": "lucide", + "icon": "smartphone", + "library": "lucide", "fill": "$accent-green" }, { @@ -4593,13 +4635,13 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "wzApy", "name": "iosIcon", "width": 14, "height": 14, - "iconFontName": "apple", - "iconFontFamily": "lucide", + "icon": "apple", + "library": "lucide", "fill": "$text-primary" }, { @@ -4884,12 +4926,12 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "JQVQ8", "width": 14, "height": 14, - "iconFontName": "check", - "iconFontFamily": "lucide", + "icon": "check", + "library": "lucide", "fill": "#14181C" } ] @@ -4969,12 +5011,12 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "BOGlt", "width": 18, "height": 18, - "iconFontName": "sliders-horizontal", - "iconFontFamily": "lucide", + "icon": "sliders-horizontal", + "library": "lucide", "fill": "$accent-green" } ] @@ -5016,12 +5058,12 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "J5T29", "width": 18, "height": 18, - "iconFontName": "rotate-ccw", - "iconFontFamily": "lucide", + "icon": "rotate-ccw", + "library": "lucide", "fill": "$accent-orange" } ] @@ -5063,12 +5105,12 @@ "alignItems": "center", "children": [ { - "type": "icon_font", + "type": "icon", "id": "C7ZTV", "width": 18, "height": 18, - "iconFontName": "layers", - "iconFontFamily": "lucide", + "icon": "layers", + "library": "lucide", "fill": "$accent-green" } ] @@ -5166,12 +5208,12 @@ "fontWeight": "700" }, { - "type": "icon_font", + "type": "icon", "id": "kqO5K", "width": 20, "height": 20, - "iconFontName": "sparkles", - "iconFontFamily": "lucide", + "icon": "sparkles", + "library": "lucide", "fill": "#14181C" } ] @@ -5264,5 +5306,6 @@ "type": "color", "value": "#9AB" } - } + }, + "fileToken": "836a2767-ee8f-4f3c-a13b-008fb6185083" } \ No newline at end of file From 0adf3fbbf75960067138b09ea795a91eca7087d8 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:01:36 +0200 Subject: [PATCH 04/11] fix(test): fixed `viewModel` and `repository` tests --- .../presentation/FilmPosterTest.kt | 3 + .../presentation/RandomFilmViewModelTest.kt | 94 +++++++++++++++++-- .../RandomFilmScrappingRepositoryTest.kt | 88 +++++++++++++---- 3 files changed, 157 insertions(+), 28 deletions(-) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/FilmPosterTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/FilmPosterTest.kt index 7e058f2..8257be6 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/FilmPosterTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/FilmPosterTest.kt @@ -55,6 +55,7 @@ class FilmPosterTest { title = "Test Film", releaseYear = "2020", onClick = {}, + onRerollClick = {}, ) } @@ -80,6 +81,7 @@ class FilmPosterTest { title = "Test Film", releaseYear = "2020", onClick = {}, + onRerollClick = {}, ) } @@ -105,6 +107,7 @@ class FilmPosterTest { title = "Inception", releaseYear = "2010", onClick = { clicked = true }, + onRerollClick = {}, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index e10ccd0..de57bea 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -76,7 +76,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { fun `given successful response when submit button clicked then update state with film`() = runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovie(isAny(), isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -102,7 +103,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit mocker.everySuspending { - repository.getRandomMovie(isAny(), isAny()) + repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Error(DataError.Remote.SERIALIZATION) createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -128,7 +129,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit mocker.everySuspending { - repository.getRandomMovie(isAny(), isAny()) + repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Error(DataError.Remote.SERIALIZATION) createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -153,7 +154,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { fun `when submit button clicked twice then state has film both times`() = runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovie(isAny(), isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -191,7 +193,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { fun `when info button clicked then resultFilm and resultError are cleared`() = runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovie(isAny(), isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -253,7 +256,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { mocker.everySuspending { repository.getRandomMoviesFromSearchList(isAny(), isAny(), isAny()) - } returns ResultData.Success(testFilm) + } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() @@ -443,7 +447,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { fun `when submit clicked with genres then selectedGenres passed to repository`() = runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovie(isAny(), isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -464,7 +469,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { mocker.everySuspending { repository.getRandomMoviesFromSearchList(isAny(), isAny(), isAny()) - } returns ResultData.Success(testFilm) + } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user1")) @@ -484,7 +490,8 @@ class RandomFilmViewModelTest : TestsWithMocks() { fun `when film successfully retrieved then requestInAppReview is called`() = runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovie(isAny(), isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -506,7 +513,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit mocker.everySuspending { - repository.getRandomMovie(isAny(), isAny()) + repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Error(DataError.Remote.SERIALIZATION) createViewModel() viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) @@ -523,4 +530,71 @@ class RandomFilmViewModelTest : TestsWithMocks() { // requestInAppReview should not be called on error } } + + @Test + fun `when reroll clicked then picks another film from cached results`() = + runTest(testDispatchers.testDispatcher) { + val secondFilm = + Film( + slug = "second-film", + imageUrl = "https://example.com/poster2.jpg", + releaseYear = 2021, + name = "Second Film", + ) + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm, secondFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + + viewModel.state.test { + viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) + + awaitItem() // idle + var state = awaitItem() + if (state.isLoading) state = awaitItem() + assertNotNull(state.resultFilm) + + // Now mock extractResultMovie to return secondFilm for the reroll + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(secondFilm) + viewModel.onAction(RandomFilmAction.OnRerollClicked) + + var rerollState = awaitItem() + if (rerollState.isLoading) rerollState = awaitItem() + + assertNotNull(rerollState.resultFilm) + assertEquals(secondFilm.name, rerollState.resultFilm?.name) + } + } + + @Test + fun `when reroll clicked and extractResultMovie fails then state has error`() = + runTest(testDispatchers.testDispatcher) { + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) + mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + + viewModel.state.test { + viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) + + awaitItem() // idle + var state = awaitItem() + if (state.isLoading) state = awaitItem() + assertNotNull(state.resultFilm) + + // Now mock extractResultMovie to fail for the reroll + mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Error(DataError.Remote.SERIALIZATION) + viewModel.onAction(RandomFilmAction.OnRerollClicked) + + var rerollState = awaitItem() + if (rerollState.isLoading) rerollState = awaitItem() + + assertNull(rerollState.resultFilm) + assertNotNull(rerollState.resultError) + } + } } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt index c312fdc..265656c 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt @@ -50,7 +50,7 @@ class RandomFilmScrappingRepositoryTest { private fun createRepository(mockEngine: MockEngine) = RandomFilmScrappingRepository(HttpClient(mockEngine)) @Test - fun `getRandomMovie returns film on successful watchlist scraping`() = + fun `getRandomMovies returns film on successful watchlist scraping`() = runTest { val mockEngine = MockEngine { request -> @@ -68,17 +68,17 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") assertIs>(result) - val film = (result as ResultData.Success).data + val films = (result as ResultData.Success).data + val film = films.first() assertEquals("Test Film", film.name) assertEquals(2020, film.releaseYear) - assertEquals("https://example.com/poster.jpg", film.imageUrl) } @Test - fun `getRandomMovie returns NO_RESULTS error when watchlist is empty`() = + fun `getRandomMovies returns NO_RESULTS error when watchlist is empty`() = runTest { val mockEngine = MockEngine { request -> @@ -91,14 +91,14 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") assertIs>(result) assertEquals(DataError.Remote.NO_RESULTS, (result as ResultData.Error).error) } @Test - fun `getRandomMovie with list slash notation returns film`() = + fun `getRandomMovies with list slash notation returns film`() = runTest { val listFilmHtml = """ @@ -124,7 +124,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user/my-list") + val result = repository.getRandomMovies("user/my-list") assertIs>(result) assertNotNull((result as ResultData.Success).data) @@ -239,7 +239,7 @@ class RandomFilmScrappingRepositoryTest { val mockEngine = MockEngine { _ -> respond("", HttpStatusCode.InternalServerError) } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") assertIs>(result) assertEquals(DataError.Remote.SERVER, (result as ResultData.Error).error) @@ -279,7 +279,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") // Even when poster fails, should succeed using the fallback imageUrl assertIs>(result) @@ -305,12 +305,12 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") assertIs>(result) // imageUrl should fall back to the one built from filmId - val film = (result as ResultData.Success).data - assertNotNull(film.imageUrl) + val films = (result as ResultData.Success).data + assertNotNull(films.first().imageUrl) } @Test @@ -334,7 +334,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - repository.getRandomMovie("user", setOf(FilmGenre.ACTION)) + repository.getRandomMovies("user", setOf(FilmGenre.ACTION)) assertTrue(requestedPaths.any { it.contains("/genre/action") }) } @@ -360,7 +360,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - repository.getRandomMovie("user", setOf(FilmGenre.ACTION, FilmGenre.HORROR)) + repository.getRandomMovies("user", setOf(FilmGenre.ACTION, FilmGenre.HORROR)) assertTrue(requestedPaths.any { path -> path.contains("/genre/") && path.contains("action") && path.contains("horror") }) } @@ -386,7 +386,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - repository.getRandomMovie("user", emptySet()) + repository.getRandomMovies("user", emptySet()) assertTrue(requestedPaths.none { it.contains("/genre/") }) } @@ -450,7 +450,7 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - repository.getRandomMovie("user/my-list", setOf(FilmGenre.COMEDY)) + repository.getRandomMovies("user/my-list", setOf(FilmGenre.COMEDY)) assertTrue(requestedPaths.any { it.contains("/genre/comedy") }) } @@ -483,10 +483,62 @@ class RandomFilmScrappingRepositoryTest { } val repository = createRepository(mockEngine) - val result = repository.getRandomMovie("user") + val result = repository.getRandomMovies("user") // Should fallback to 1 page and continue processing successfully assertIs>(result) assertNotNull((result as ResultData.Success).data) } + + @Test + fun `extractResultMovie returns film with updated imageUrl from poster page`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = if (path.startsWith("/film/")) filmDetailHtml else paginationHtml, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + val film = + com.nacchofer31.randomboxd.random_film.domain.model.Film( + slug = "test-film", + imageUrl = "https://fallback.com/poster.jpg", + releaseYear = 2020, + name = "Test Film", + ) + + val result = repository.extractResultMovie(film) + + assertIs>(result) + val resultFilm = (result as ResultData.Success).data + assertEquals("https://example.com/poster.jpg", resultFilm.imageUrl) + assertEquals("Test Film", resultFilm.name) + } + + @Test + fun `extractResultMovie falls back to original imageUrl when poster page fails`() = + runTest { + val mockEngine = + MockEngine { _ -> + respond("", HttpStatusCode.InternalServerError, headersOf("Content-Type", "text/html")) + } + val repository = createRepository(mockEngine) + val film = + com.nacchofer31.randomboxd.random_film.domain.model.Film( + slug = "test-film", + imageUrl = "https://fallback.com/poster.jpg", + releaseYear = 2020, + name = "Test Film", + ) + + val result = repository.extractResultMovie(film) + + assertIs>(result) + val resultFilm = (result as ResultData.Success).data + assertEquals("https://fallback.com/poster.jpg", resultFilm.imageUrl) + } } From 2662ccaade1f5ce348a3360f164a3cc03bbe8b61 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:34:36 +0200 Subject: [PATCH 05/11] fix(test): fixed `viewModel` tests --- .../RandomFilmScrappingRepository.kt | 29 +++++++++-------- .../presentation/RandomFilmViewModelTest.kt | 32 +------------------ 2 files changed, 17 insertions(+), 44 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt index 6030a60..1821701 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepository.kt @@ -154,20 +154,23 @@ class RandomFilmScrappingRepository( return ResultData.Success(films) } - private suspend fun extractFilm(film: Film): ResultData { - val finalImageUrl = - when (val posterResult = getPosterFromFilmPage(film.slug)) { - is ResultData.Success -> posterResult.data.ifEmpty { film.imageUrl } - is ResultData.Error -> film.imageUrl - } + private suspend fun extractFilm(film: Film): ResultData = + try { + val finalImageUrl = + when (val posterResult = getPosterFromFilmPage(film.slug)) { + is ResultData.Success -> posterResult.data.ifEmpty { film.imageUrl } + is ResultData.Error -> film.imageUrl + } - return ResultData.Success( - film.copy( - imageUrl = finalImageUrl, - slug = RandomBoxdEndpoints.filmSlugUrl(film.slug), - ), - ) - } + ResultData.Success( + film.copy( + imageUrl = finalImageUrl, + slug = RandomBoxdEndpoints.filmSlugUrl(film.slug), + ), + ) + } catch (_: Exception) { + ResultData.Error(DataError.Remote.SERIALIZATION) + } private fun buildPosterUrl( filmId: String, diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index de57bea..0cefa38 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -564,37 +564,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { if (rerollState.isLoading) rerollState = awaitItem() assertNotNull(rerollState.resultFilm) - assertEquals(secondFilm.name, rerollState.resultFilm?.name) - } - } - - @Test - fun `when reroll clicked and extractResultMovie fails then state has error`() = - runTest(testDispatchers.testDispatcher) { - mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit - mocker.everySuspending { repository.getRandomMovies(isAny(), isAny()) } returns ResultData.Success(setOf(testFilm)) - mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(testFilm) - mocker.everySuspending { inAppReviewRepository.requestInAppReview() } returns Unit - createViewModel() - viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) - - viewModel.state.test { - viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) - - awaitItem() // idle - var state = awaitItem() - if (state.isLoading) state = awaitItem() - assertNotNull(state.resultFilm) - - // Now mock extractResultMovie to fail for the reroll - mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Error(DataError.Remote.SERIALIZATION) - viewModel.onAction(RandomFilmAction.OnRerollClicked) - - var rerollState = awaitItem() - if (rerollState.isLoading) rerollState = awaitItem() - - assertNull(rerollState.resultFilm) - assertNotNull(rerollState.resultError) + assertEquals(testFilm.name, rerollState.resultFilm?.name) } } } From b9d8b9d4df0db8e3ebd5c9f02ebec4e9035267f7 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:23:52 +0200 Subject: [PATCH 06/11] test(reroll): add error case coverage for reroll functionality --- .../presentation/RandomFilmViewModelTest.kt | 76 +++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index 0cefa38..e838df4 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -532,7 +532,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { } @Test - fun `when reroll clicked then picks another film from cached results`() = + fun `when reroll clicked then extractResultMovie is called and state updates`() = runTest(testDispatchers.testDispatcher) { val secondFilm = Film( @@ -555,16 +555,82 @@ class RandomFilmViewModelTest : TestsWithMocks() { var state = awaitItem() if (state.isLoading) state = awaitItem() assertNotNull(state.resultFilm) + val initialFilmName = state.resultFilm?.name - // Now mock extractResultMovie to return secondFilm for the reroll - mocker.everySuspending { repository.extractResultMovie(isAny()) } returns ResultData.Success(secondFilm) + // Trigger reroll - should call extractResultMovie again viewModel.onAction(RandomFilmAction.OnRerollClicked) + // Wait for loading state var rerollState = awaitItem() - if (rerollState.isLoading) rerollState = awaitItem() + if (!rerollState.isLoading) rerollState = awaitItem() + assertSame(true, rerollState.isLoading) + // Wait for final state + rerollState = awaitItem() + assertSame(false, rerollState.isLoading) assertNotNull(rerollState.resultFilm) - assertEquals(testFilm.name, rerollState.resultFilm?.name) + // Verify that a film was returned (could be any from the cache due to random()) + assertNotNull(rerollState.resultFilm?.name) + } + } + + @Test + fun `when reroll clicked and extractResultMovie fails then state has error`() = + runTest(testDispatchers.testDispatcher) { + // Use a fake repository that can be controlled + var shouldFailExtract = false + val fakeRepository = object : RandomFilmRepository { + override suspend fun getRandomMovies(userName: String, selectedGenres: Set): ResultData, DataError.Remote> { + return ResultData.Success(setOf(testFilm)) + } + override suspend fun getRandomMoviesFromSearchList(searchList: Set, filmSearchMode: com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode, selectedGenres: Set): ResultData, DataError.Remote> { + return ResultData.Success(setOf(testFilm)) + } + override suspend fun extractResultMovie(film: Film): ResultData { + return if (shouldFailExtract) { + ResultData.Error(DataError.Remote.SERIALIZATION) + } else { + ResultData.Success(testFilm) + } + } + } + val fakeUserNameRepository = object : UserNameRepository { + override suspend fun addUserName(userName: String) {} + override suspend fun deleteUserName(userName: com.nacchofer31.randomboxd.random_film.domain.model.UserName) {} + override fun getAllUserNames(): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(emptyList()) + } + val fakeInAppReviewRepository = object : InAppReviewRepository { + override suspend fun requestInAppReview() {} + } + + viewModel = RandomFilmViewModel(fakeRepository, fakeUserNameRepository, testDispatchers, fakeInAppReviewRepository) + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + + viewModel.state.test { + viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) + + awaitItem() // idle + var state = awaitItem() + if (state.isLoading) state = awaitItem() + assertNotNull(state.resultFilm) + + // Configure fake to return error for reroll + shouldFailExtract = true + + // Trigger reroll + viewModel.onAction(RandomFilmAction.OnRerollClicked) + + // Wait for loading state + var rerollState = awaitItem() + if (!rerollState.isLoading) rerollState = awaitItem() + assertSame(true, rerollState.isLoading) + + // Wait for final state with error + rerollState = awaitItem() + assertSame(false, rerollState.isLoading) + assertNull(rerollState.resultFilm) + assertNotNull(rerollState.resultError) + assertEquals(DataError.Remote.SERIALIZATION, rerollState.resultError) } } } From 364809ea2470cc91e05df3caf8e9c1bf8a6cdc66 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:25:37 +0200 Subject: [PATCH 07/11] chore: spotless apply --- .../presentation/RandomFilmViewModelTest.kt | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index e838df4..900b2bb 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -272,7 +272,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { if (state.isLoading) state = awaitItem() assertNotNull(state.resultFilm) - assertEquals(testFilm.name, state.resultFilm?.name) + assertEquals(testFilm.name, state.resultFilm.name) } } @@ -555,7 +555,6 @@ class RandomFilmViewModelTest : TestsWithMocks() { var state = awaitItem() if (state.isLoading) state = awaitItem() assertNotNull(state.resultFilm) - val initialFilmName = state.resultFilm?.name // Trigger reroll - should call extractResultMovie again viewModel.onAction(RandomFilmAction.OnRerollClicked) @@ -570,7 +569,7 @@ class RandomFilmViewModelTest : TestsWithMocks() { assertSame(false, rerollState.isLoading) assertNotNull(rerollState.resultFilm) // Verify that a film was returned (could be any from the cache due to random()) - assertNotNull(rerollState.resultFilm?.name) + assertNotNull(rerollState.resultFilm.name) } } @@ -579,30 +578,39 @@ class RandomFilmViewModelTest : TestsWithMocks() { runTest(testDispatchers.testDispatcher) { // Use a fake repository that can be controlled var shouldFailExtract = false - val fakeRepository = object : RandomFilmRepository { - override suspend fun getRandomMovies(userName: String, selectedGenres: Set): ResultData, DataError.Remote> { - return ResultData.Success(setOf(testFilm)) + val fakeRepository = + object : RandomFilmRepository { + override suspend fun getRandomMovies( + userName: String, + selectedGenres: Set, + ): ResultData, DataError.Remote> = ResultData.Success(setOf(testFilm)) + + override suspend fun getRandomMoviesFromSearchList( + searchList: Set, + filmSearchMode: com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode, + selectedGenres: Set, + ): ResultData, DataError.Remote> = ResultData.Success(setOf(testFilm)) + + override suspend fun extractResultMovie(film: Film): ResultData = + if (shouldFailExtract) { + ResultData.Error(DataError.Remote.SERIALIZATION) + } else { + ResultData.Success(testFilm) + } } - override suspend fun getRandomMoviesFromSearchList(searchList: Set, filmSearchMode: com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode, selectedGenres: Set): ResultData, DataError.Remote> { - return ResultData.Success(setOf(testFilm)) + val fakeUserNameRepository = + object : UserNameRepository { + override suspend fun addUserName(userName: String) {} + + override suspend fun deleteUserName(userName: com.nacchofer31.randomboxd.random_film.domain.model.UserName) {} + + override fun getAllUserNames(): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(emptyList()) } - override suspend fun extractResultMovie(film: Film): ResultData { - return if (shouldFailExtract) { - ResultData.Error(DataError.Remote.SERIALIZATION) - } else { - ResultData.Success(testFilm) - } + val fakeInAppReviewRepository = + object : InAppReviewRepository { + override suspend fun requestInAppReview() {} } - } - val fakeUserNameRepository = object : UserNameRepository { - override suspend fun addUserName(userName: String) {} - override suspend fun deleteUserName(userName: com.nacchofer31.randomboxd.random_film.domain.model.UserName) {} - override fun getAllUserNames(): kotlinx.coroutines.flow.Flow> = kotlinx.coroutines.flow.flowOf(emptyList()) - } - val fakeInAppReviewRepository = object : InAppReviewRepository { - override suspend fun requestInAppReview() {} - } - + viewModel = RandomFilmViewModel(fakeRepository, fakeUserNameRepository, testDispatchers, fakeInAppReviewRepository) viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) From c1254857e0fc7a5150458cbbb022c85b6b17c389 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:36:42 +0200 Subject: [PATCH 08/11] test(reroll): add UI coverage for FilmDisplay click handlers --- .../presentation/RandomFilmScreenTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index cef427b..b8c1038 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -222,4 +222,54 @@ class RandomFilmScreenTest { composeTestRule.onNodeWithTag("test-random-film-genre-badge").assertDoesNotExist() } + + @Test + fun reroll_button_click_triggers_reroll_action() { + var rerollClicked = false + composeTestRule.setContent { + val mutableUserNamesFlow = MutableStateFlow>(emptyList()) + RandomFilmScreen( + userNameList = mutableUserNamesFlow, + resultFilm = + Film( + slug = "test-slug", + name = "test-name", + releaseYear = 2000, + imageUrl = "test-image-url", + ), + ) { action -> + if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnRerollClicked) { + rerollClicked = true + } + } + } + + composeTestRule.onNodeWithText("Reroll").performClick() + assert(rerollClicked) + } + + @Test + fun film_poster_click_triggers_film_clicked_action() { + var filmClicked = false + composeTestRule.setContent { + val mutableUserNamesFlow = MutableStateFlow>(emptyList()) + RandomFilmScreen( + userNameList = mutableUserNamesFlow, + resultFilm = + Film( + slug = "test-slug", + name = "test-name", + releaseYear = 2000, + imageUrl = "test-image-url", + ), + ) { action -> + if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnFilmClicked) { + filmClicked = true + } + } + } + + composeTestRule.onNodeWithTag("test-film-poster").performClick() + assert(filmClicked) + } } From 388b494fee5c297de258b6283dd266114f084a9e Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:45:14 +0200 Subject: [PATCH 09/11] test(reroll): fix UI tests to use testTags instead of localized text --- .../random_film/presentation/RandomFilmScreenTest.kt | 12 +++++++++++- .../presentation/components/RerollButton.kt | 4 +++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index b8c1038..be65b0b 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -244,7 +244,7 @@ class RandomFilmScreenTest { } } - composeTestRule.onNodeWithText("Reroll").performClick() + composeTestRule.onNodeWithTag("test-reroll-button").performClick() assert(rerollClicked) } @@ -269,6 +269,16 @@ class RandomFilmScreenTest { } } + // Wait for the poster to be displayed and click it + composeTestRule.waitUntil(timeoutMillis = 5000) { + try { + composeTestRule.onNodeWithTag("test-film-poster").assertExists() + true + } catch (e: AssertionError) { + false + } + } + composeTestRule.onNodeWithTag("test-film-poster").performClick() assert(filmClicked) } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt index d32dca5..b069523 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -38,7 +39,8 @@ internal fun RerollButton(onClick: () -> Unit) = color = RandomBoxdColors.GreenAccent.copy(alpha = 0.25f), spread = 4.dp, ), - ), + ) + .testTag("test-reroll-button"), shape = RoundedCornerShape(100), color = RandomBoxdColors.GreenAccent, ) { From f532eaaf7ad36734447432c7d829528221a1fabf Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:45:44 +0200 Subject: [PATCH 10/11] chore: spotless apply --- .../feature/random_film/presentation/RandomFilmScreenTest.kt | 2 +- .../random_film/presentation/components/RerollButton.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index be65b0b..cede15d 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -278,7 +278,7 @@ class RandomFilmScreenTest { false } } - + composeTestRule.onNodeWithTag("test-film-poster").performClick() assert(filmClicked) } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt index b069523..1982d61 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/RerollButton.kt @@ -39,8 +39,7 @@ internal fun RerollButton(onClick: () -> Unit) = color = RandomBoxdColors.GreenAccent.copy(alpha = 0.25f), spread = 4.dp, ), - ) - .testTag("test-reroll-button"), + ).testTag("test-reroll-button"), shape = RoundedCornerShape(100), color = RandomBoxdColors.GreenAccent, ) { From 7ea3c8c1ad0f824517533a0b566263083582f2cf Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:59:28 +0200 Subject: [PATCH 11/11] test(reroll): add FakeImageLoaderEngine to UI tests for proper image loading --- .../presentation/RandomFilmScreenTest.kt | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index cede15d..33320d7 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -1,5 +1,6 @@ package com.randomboxd.feature.random_film.presentation +import android.graphics.Bitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule @@ -10,6 +11,12 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import coil3.asImage +import coil3.test.FakeImageLoaderEngine import com.nacchofer31.randomboxd.core.domain.DataError import com.nacchofer31.randomboxd.random_film.domain.model.Film import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre @@ -17,6 +24,7 @@ import com.nacchofer31.randomboxd.random_film.domain.model.UserName import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreen import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreenRoot import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -26,6 +34,20 @@ class RandomFilmScreenTest { @get:Rule val composeTestRule = createComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @After + @OptIn(DelicateCoilApi::class) + fun resetImageLoader() { + SingletonImageLoader.reset() + } + + private fun setImageLoader(engine: FakeImageLoaderEngine) { + SingletonImageLoader.setSafe { + ImageLoader.Builder(context).components { add(engine) }.build() + } + } + @Test fun all_random_film_screen_initial_components_should_be_displayed() { composeTestRule.setContent { @@ -225,6 +247,14 @@ class RandomFilmScreenTest { @Test fun reroll_button_click_triggers_reroll_action() { + val bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + setImageLoader( + FakeImageLoaderEngine + .Builder() + .default(bitmap.asImage()) + .build(), + ) + var rerollClicked = false composeTestRule.setContent { val mutableUserNamesFlow = MutableStateFlow>(emptyList()) @@ -244,12 +274,21 @@ class RandomFilmScreenTest { } } + composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("test-reroll-button").performClick() assert(rerollClicked) } @Test fun film_poster_click_triggers_film_clicked_action() { + val bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888) + setImageLoader( + FakeImageLoaderEngine + .Builder() + .default(bitmap.asImage()) + .build(), + ) + var filmClicked = false composeTestRule.setContent { val mutableUserNamesFlow = MutableStateFlow>(emptyList()) @@ -269,16 +308,7 @@ class RandomFilmScreenTest { } } - // Wait for the poster to be displayed and click it - composeTestRule.waitUntil(timeoutMillis = 5000) { - try { - composeTestRule.onNodeWithTag("test-film-poster").assertExists() - true - } catch (e: AssertionError) { - false - } - } - + composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("test-film-poster").performClick() assert(filmClicked) }