diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 4fcfa54..da092db 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(libs.bundles.decompose) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.splashscreen) implementation(libs.compose.uiToolingPreview) debugImplementation(libs.compose.uiTooling) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9446ff3..ee04844 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.BlockBlast" + android:theme="@style/Theme.BlockBlast.Starter" android:localeConfig="@xml/locales_config"> @color/splash_background + + diff --git a/composeApp/src/commonMain/composeResources/files/audio/block.mp3 b/composeApp/src/commonMain/composeResources/files/audio/block.mp3 new file mode 100644 index 0000000..ba6849d Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/block.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/feltwood.mp3 b/composeApp/src/commonMain/composeResources/files/audio/feltwood.mp3 new file mode 100644 index 0000000..870dbda Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/feltwood.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/mossy.mp3 b/composeApp/src/commonMain/composeResources/files/audio/mossy.mp3 new file mode 100644 index 0000000..2d44253 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/mossy.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/music_ambient.mp3 b/composeApp/src/commonMain/composeResources/files/audio/music_ambient.mp3 deleted file mode 100644 index 1fb7a18..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/music_ambient.mp3 and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.mp3 b/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.mp3 new file mode 100644 index 0000000..ad0d038 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.wav b/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.wav deleted file mode 100644 index cd59f14..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/voice_amazing.wav and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.mp3 b/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.mp3 new file mode 100644 index 0000000..027304c Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.wav b/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.wav deleted file mode 100644 index 8978ffc..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/voice_excellent.wav and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_good.mp3 b/composeApp/src/commonMain/composeResources/files/audio/voice_good.mp3 new file mode 100644 index 0000000..2b2a29a Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/voice_good.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_good.wav b/composeApp/src/commonMain/composeResources/files/audio/voice_good.wav deleted file mode 100644 index bc0f88f..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/voice_good.wav and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_great.mp3 b/composeApp/src/commonMain/composeResources/files/audio/voice_great.mp3 new file mode 100644 index 0000000..ef5257f Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/voice_great.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_great.wav b/composeApp/src/commonMain/composeResources/files/audio/voice_great.wav deleted file mode 100644 index 5eb6bc7..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/voice_great.wav and /dev/null differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.mp3 b/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.mp3 new file mode 100644 index 0000000..1332944 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.mp3 differ diff --git a/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.wav b/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.wav deleted file mode 100644 index b0f2b10..0000000 Binary files a/composeApp/src/commonMain/composeResources/files/audio/voice_unbelievable.wav and /dev/null differ diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt index ee6c15f..7272a6d 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt @@ -101,7 +101,8 @@ private val DRAG_GHOST_VERTICAL_LIFT = 28.dp @Composable fun GameContent(component: GameComponent) { - val model by component.model.subscribeAsState() + val uiModel by component.model.subscribeAsState() + val model = uiModel.game var selectedPieceId by remember { mutableStateOf(null) } // ── Effect states ──────────────────────────────────────────────────── @@ -252,10 +253,9 @@ fun GameContent(component: GameComponent) { // owned by the GameStore (see GameStoreFactory) and projected onto a // Value via DefaultGameComponent. ── val interstitial = rememberGameOverInterstitial() - val continueCountdownRaw by component.continueCountdown.subscribeAsState() // Store emits -1 (COUNTDOWN_INACTIVE) when no game-over countdown is // active; the overlay API uses a nullable Int for the same concept. - val continueCountdown: Int? = continueCountdownRaw.takeIf { it >= 0 } + val continueCountdown: Int? = uiModel.continueCountdown.takeIf { it >= 0 } Scaffold( modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/ReviewPromptContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/ReviewPromptContent.kt index 128cbdb..fe04b91 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/ReviewPromptContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/ReviewPromptContent.kt @@ -27,7 +27,7 @@ import blockblast.composeapp.generated.resources.review_prompt_body import blockblast.composeapp.generated.resources.review_prompt_dont_show import blockblast.composeapp.generated.resources.review_prompt_leave_feedback import blockblast.composeapp.generated.resources.review_prompt_title -import ge.yet.blockblast.feature.game.ReviewPromptComponent +import ge.yet.blockblast.feature.game.reviewprompt.ReviewPromptComponent import ge.yet3.blokblast.component.button.PrimaryTerracottaButton import ge.yet3.blokblast.component.button.SecondaryWarmSandButton import org.jetbrains.compose.resources.stringResource diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/LibrariesSettingsContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/LibrariesSettingsContent.kt index 7d008a4..1059e3b 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/LibrariesSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/LibrariesSettingsContent.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import blockblast.composeapp.generated.resources.Res import blockblast.composeapp.generated.resources.open_source_libraries -import ge.yet.blockblast.feature.settings.LibrariesSettingsComponent +import ge.yet.blockblast.feature.settings.libraries.LibrariesSettingsComponent import ge.yet3.blokblast.component.icon.OpenInNew import ge.yet3.blokblast.screen.settings.SettingsDivider import ge.yet3.blokblast.screen.settings.SettingsHeader diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt index bf5fe51..34fc048 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt @@ -22,7 +22,7 @@ import blockblast.composeapp.generated.resources.sound_subtitle import blockblast.composeapp.generated.resources.vibration import blockblast.composeapp.generated.resources.vibration_subtitle import com.arkivanov.decompose.extensions.compose.subscribeAsState -import ge.yet.blockblast.feature.settings.MainSettingsComponent +import ge.yet.blockblast.feature.settings.main.MainSettingsComponent import ge.yet3.blokblast.component.icon.DarkMode import ge.yet3.blokblast.component.icon.NotificationsActive import ge.yet3.blokblast.component.icon.Settings diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MoreSettingsContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MoreSettingsContent.kt index 8f8a22c..d1cd76f 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MoreSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MoreSettingsContent.kt @@ -18,7 +18,7 @@ import blockblast.composeapp.generated.resources.open_source_libraries_subtitle import blockblast.composeapp.generated.resources.privacy_policy import blockblast.composeapp.generated.resources.privacy_policy_subtitle import com.app.common.config.AppConfig -import ge.yet.blockblast.feature.settings.MoreSettingsComponent +import ge.yet.blockblast.feature.settings.more.MoreSettingsComponent import ge.yet3.blokblast.component.icon.Github import ge.yet3.blokblast.component.icon.OpenInNew import ge.yet3.blokblast.component.icon.PrivacyTip diff --git a/core/data/src/androidMain/kotlin/ge/yet/blokblast/data/platform/AndroidPlatformSoundPlayer.kt b/core/data/src/androidMain/kotlin/ge/yet/blokblast/data/platform/AndroidPlatformSoundPlayer.kt index 7e9db26..0ad2190 100644 --- a/core/data/src/androidMain/kotlin/ge/yet/blokblast/data/platform/AndroidPlatformSoundPlayer.kt +++ b/core/data/src/androidMain/kotlin/ge/yet/blokblast/data/platform/AndroidPlatformSoundPlayer.kt @@ -53,22 +53,12 @@ internal class AndroidPlatformSoundPlayer( private var musicPlayer: MediaPlayer? = null private var musicState: MusicState = MusicState.IDLE + private var lastTrackIndex: Int = -1 init { pool.setOnLoadCompleteListener { _, sampleId, status -> if (status == 0) readyIds += sampleId } - // Pre-load common SFX so the first placement/clear isn't dropped. - // Voice files are still lazy-loaded the first time they're requested - // and will silently drop the very first play (rare and not worth a - // queue). Plain SFX are warmed here. - listOf( - "block_place", - "line_clear_1", "line_clear_2", "line_clear_3", "line_clear_4", - ).forEach { name -> - val id = resolve(name) - if (id != 0) ids[name] = id - } } override fun playPlacement() = safePlay("block_place") @@ -93,8 +83,14 @@ internal class AndroidPlatformSoundPlayer( // Re-entrancy guard. PREPARING means a previous startMusic is in flight; // do not release it from under its OnPreparedListener. if (musicState != MusicState.IDLE) return + playTrack(MusicPlaylist.nextIndex(lastTrackIndex)) + } + + private fun playTrack(index: Int) { + lastTrackIndex = index + val filename = MusicPlaylist.TRACKS[index] runCatching { - val afd = ctx.assets.openFd("${AUDIO_DIR}music_ambient.mp3") + val afd = ctx.assets.openFd("${AUDIO_DIR}$filename") val player = MediaPlayer().apply { setAudioAttributes( AudioAttributes.Builder() @@ -104,12 +100,8 @@ internal class AndroidPlatformSoundPlayer( ) setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) afd.close() - isLooping = true setVolume(MUSIC_VOLUME, MUSIC_VOLUME) setOnPreparedListener { - // The user may have called stopMusic() while we were - // preparing; honor that by tearing down here instead of - // starting a stream that will immediately be stopped. if (musicState == MusicState.PREPARING) { musicState = MusicState.PLAYING it.start() @@ -117,6 +109,14 @@ internal class AndroidPlatformSoundPlayer( runCatching { it.release() } } } + setOnCompletionListener { mp -> + runCatching { mp.release() } + if (musicPlayer === mp && musicState == MusicState.PLAYING) { + musicPlayer = null + musicState = MusicState.IDLE + playTrack(MusicPlaylist.nextIndex(lastTrackIndex)) + } + } setOnErrorListener { mp, _, _ -> runCatching { mp.release() } if (musicPlayer === mp) { @@ -130,7 +130,6 @@ internal class AndroidPlatformSoundPlayer( musicState = MusicState.PREPARING player.prepareAsync() }.onFailure { - // Asset missing or MediaPlayer construction failed — stay idle. musicPlayer = null musicState = MusicState.IDLE } diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/platform/MusicPlaylist.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/platform/MusicPlaylist.kt new file mode 100644 index 0000000..79fe8fd --- /dev/null +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/platform/MusicPlaylist.kt @@ -0,0 +1,20 @@ +package ge.yet.blokblast.data.platform + +internal object MusicPlaylist { + val TRACKS: List = listOf( + "block.mp3", + "feltwood.mp3", + "mossy.mp3", + ) + + /** + * Returns a random track index that is not [previous]. Falls back to a + * random index when the playlist has a single track. + */ + fun nextIndex(previous: Int, random: kotlin.random.Random = kotlin.random.Random): Int { + if (TRACKS.size <= 1) return 0 + var next = random.nextInt(TRACKS.size) + while (next == previous) next = random.nextInt(TRACKS.size) + return next + } +} diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt index d311c5c..3af3d51 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt @@ -88,6 +88,14 @@ internal class SettingsBackedSettingsRepository( } } + override suspend fun suppressReviewPrompts(max: Int) = withContext(dispatchers.io) { + writeMutex.withLock { + if (settings.getInt(KEY_REVIEW_PROMPT_COUNT, 0) < max) { + settings.putInt(KEY_REVIEW_PROMPT_COUNT, max) + } + } + } + override suspend fun setTutorialSeen() = withContext(dispatchers.io) { settings.putBoolean(KEY_TUTORIAL_SEEN, true) } diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt index f03b938..624925c 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt @@ -161,6 +161,7 @@ class DefaultAudioRepositoryTest { override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } } diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt index 8e9357a..b3b0f78 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt @@ -74,6 +74,7 @@ class DefaultVibrationRepositoryTest { override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } } diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt index 8c23bb8..0c7bcd3 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt @@ -94,4 +94,66 @@ class SettingsBackedSettingsRepositoryTest { repo.setTutorialSeen() assertTrue(repo.tutorialSeen.value) } + + // ── Race-condition / mutex coverage ────────────────────────────────── + // + // These tests run many writers concurrently against the same key. Without + // writeMutex, the read-modify-write pairs in setBestScore / + // incrementReviewPromptCount / suppressReviewPrompts would interleave and + // produce lost updates or non-monotonic state. + + @Test + fun setBestScore_concurrent_writers_keep_max() = runTest { + // 100 writers shuffle values 1..100. The monotonic guard inside + // setBestScore must survive all interleavings — final value must be + // exactly the highest candidate, never anything lower. + val candidates = (1L..100L).shuffled() + candidates + .map { async(Dispatchers.Unconfined) { repo.setBestScore(it) } } + .awaitAll() + assertEquals(100L, repo.bestScore.value) + } + + @Test + fun setBestScore_concurrent_lower_values_never_overwrite_higher() = runTest { + repo.setBestScore(500) + // Hammer with values below the current best. None must take effect. + (1L..200L) + .map { async(Dispatchers.Unconfined) { repo.setBestScore(it) } } + .awaitAll() + assertEquals(500L, repo.bestScore.value) + } + + @Test + fun suppressReviewPrompts_concurrent_callers_settle_at_max() = runTest { + // Multiple parallel suppress calls — idempotent, must end exactly at max. + List(25) { async(Dispatchers.Unconfined) { repo.suppressReviewPrompts(max = 3) } } + .awaitAll() + assertEquals(3, repo.reviewPromptCount.value) + } + + @Test + fun suppressReviewPrompts_no_op_when_already_at_or_above_max() = runTest { + repeat(5) { repo.incrementReviewPromptCount() } // count = 5 + repo.suppressReviewPrompts(max = 3) // already above + assertEquals(5, repo.reviewPromptCount.value) + repo.suppressReviewPrompts(max = 5) // equal to max + assertEquals(5, repo.reviewPromptCount.value) + } + + @Test + fun increment_and_suppress_concurrent_never_drops_below_max() = runTest { + // Mix N increments with one suppress(max=10). After everything settles, + // count must be at least max — the suppress floor must hold even when + // increments race against it. + val increments = List(20) { + async(Dispatchers.Unconfined) { repo.incrementReviewPromptCount() } + } + val suppress = async(Dispatchers.Unconfined) { repo.suppressReviewPrompts(max = 10) } + (increments + suppress).awaitAll() + assertTrue( + repo.reviewPromptCount.value >= 10, + "expected count ≥ 10 after concurrent suppress+increment, got ${repo.reviewPromptCount.value}", + ) + } } diff --git a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt index dfdae32..b880823 100644 --- a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt +++ b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt @@ -10,6 +10,8 @@ import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import platform.AVFAudio.AVAudioPlayer @@ -42,15 +44,14 @@ internal class NativePlatformSoundPlayer( private val sfxCache: MutableMap = mutableMapOf() private var musicPlayer: AVAudioPlayer? = null + private var musicJob: Job? = null + private var lastTrackIndex: Int = -1 /** Known SFX filenames to preload eagerly at startup. */ private val knownSfx = listOf( - "block_place.wav", - "line_clear_1.wav", "line_clear_2.wav", - "line_clear_3.wav", "line_clear_4.wav", - "voice_good.wav", "voice_great.wav", - "voice_excellent.wav", "voice_unbelievable.wav", - "voice_amazing.wav", "voice_combo.wav", + "voice_good.mp3", "voice_great.mp3", + "voice_excellent.mp3", "voice_unbelievable.mp3", + "voice_amazing.mp3", ) init { @@ -78,17 +79,28 @@ internal class NativePlatformSoundPlayer( } override fun startMusic() { - if (musicPlayer?.playing == true) return - scope.launch(Dispatchers.Main) { - val player = loadPlayer("music_ambient.mp3") ?: return@launch - player.numberOfLoops = -1 - player.volume = MUSIC_VOLUME - musicPlayer = player - player.play() + if (musicJob?.isActive == true || musicPlayer?.playing == true) return + musicJob = scope.launch(Dispatchers.Main) { + while (true) { + val index = MusicPlaylist.nextIndex(lastTrackIndex) + lastTrackIndex = index + val filename = MusicPlaylist.TRACKS[index] + val player = loadPlayer(filename) ?: return@launch + player.numberOfLoops = 0 + player.volume = MUSIC_VOLUME + musicPlayer = player + player.play() + // Sleep until the track is done, then loop to the next one. + // Add a small tail buffer to avoid an audible end-of-buffer cut. + val durationMs = (player.duration * 1000.0).toLong().coerceAtLeast(0L) + delay(durationMs + 50L) + } } } override fun stopMusic() { + musicJob?.cancel() + musicJob = null musicPlayer?.stop() musicPlayer = null } diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt index a1f34a7..e622dd8 100644 --- a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt +++ b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt @@ -20,12 +20,18 @@ interface SettingsRepository { suspend fun setVibrationEnabled(enabled: Boolean) suspend fun setDarkTheme(enabled: Boolean) - /** Write a new best score (caller is responsible for the `>` check). */ + /** Monotonic write: implementations must ignore scores ≤ current best. */ suspend fun setBestScore(score: Long) /** Increment the lifetime review-prompt counter by one. */ suspend fun incrementReviewPromptCount() + /** + * Cap the review-prompt counter at [max] so no further prompts ever fire. + * Idempotent: no-op when the counter is already at or above [max]. + */ + suspend fun suppressReviewPrompts(max: Int) + /** Mark the first-launch tutorial as seen so it does not re-appear. */ suspend fun setTutorialSeen() } diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt new file mode 100644 index 0000000..7b68dc0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/9.txt @@ -0,0 +1 @@ +Fresh tunes incoming! 🎶 The background music now rotates across multiple tracks, so your puzzle sessions stay lively. Android also gets a slicker startup with the modern SplashScreen API. Under the hood: lots of polish — cleaner components, tighter analytics, and safer settings storage. 🧩✨ diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt index 8a15929..e23837e 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt @@ -15,10 +15,12 @@ import com.arkivanov.essenty.lifecycle.doOnDestroy import com.arkivanov.mvikotlin.core.instancekeeper.getStore import com.arkivanov.mvikotlin.extensions.coroutines.labels import dev.zacsweers.metro.Inject +import ge.yet.blockblast.feature.game.integration.stateToModel +import ge.yet.blockblast.feature.game.reviewprompt.DefaultReviewPromptComponent +import ge.yet.blockblast.feature.game.store.GameAnalyticsLogger import ge.yet.blockblast.feature.game.store.GameStore import ge.yet.blockblast.feature.game.store.GameStoreFactory import ge.yet.blockblast.feature.settings.SettingsComponent -import ge.yet.blokblast.domain.model.GameState import ge.yet.blokblast.domain.repository.AnalyticRepository import ge.yet.blokblast.domain.repository.AudioRepository import ge.yet.blokblast.domain.repository.SettingsRepository @@ -28,12 +30,12 @@ import kotlinx.serialization.Serializable internal class DefaultGameComponent( componentContext: ComponentContext, + analytics: AnalyticRepository, private val gameStoreFactory: GameStoreFactory, private val settingsComponent: SettingsComponent.Factory, private val audio: AudioRepository, private val settings: SettingsRepository, private val storeReview: StoreReviewRepository, - private val analytics: AnalyticRepository, private val isNewGame: Boolean, private val onExitClickedCb: () -> Unit, ) : ComponentContext by componentContext, @@ -41,13 +43,9 @@ internal class DefaultGameComponent( private val store = instanceKeeper.getStore { gameStoreFactory.create(isNewGame = isNewGame) } private val sheetNavigation = SlotNavigation() private val lifecycleScope = coroutineScope() + private val logger = GameAnalyticsLogger(analytics) - // Single source of truth = the store's combined state. The two public - // Values are plain projections so consumers recompose only on the slice - // they care about. - private val storeState = store.asValue() - override val model: Value = storeState.map { it.game } - override val continueCountdown: Value = storeState.map { it.continueCountdown } + override val model: Value = store.asValue().map(stateToModel) override val sheetSlot: Value> = childSlot( @@ -67,10 +65,7 @@ internal class DefaultGameComponent( store.labels.collect { label -> when (label) { GameStore.Label.RequestReview -> { - analytics.logEvent( - eventName = "review_prompt_shown", - params = gameParams(), - ) + log("review_prompt_shown") sheetNavigation.activate(SheetConfig.ReviewPrompt) } } @@ -86,69 +81,39 @@ internal class DefaultGameComponent( override fun onReviveClicked() = store.accept(GameStore.Intent.Revive) override fun onRestartClicked() = store.accept(GameStore.Intent.Restart) override fun onSettingsClicked() { - analytics.logEvent( - eventName = "settings_opened", - params = gameParams(), - ) + log("settings_opened") sheetNavigation.activate(SheetConfig.Settings) } override fun onExitClicked() { - analytics.logEvent( - eventName = "exit_clicked", - params = gameParams(), - ) + log("exit_clicked") onExitClickedCb() } override fun onDismissSheet() { when (sheetSlot.value.child?.instance) { - is GameComponent.SheetChild.Settings -> - analytics.logEvent( - eventName = "settings_closed", - params = gameParams(), - ) - is GameComponent.SheetChild.ReviewPrompt -> - analytics.logEvent( - eventName = "review_prompt_closed", - params = gameParams(), - ) + is GameComponent.SheetChild.Settings -> log("settings_closed") + is GameComponent.SheetChild.ReviewPrompt -> log("review_prompt_closed") null -> Unit } sheetNavigation.dismiss() } private fun onReviewPromptDontShowAgainClicked() { - analytics.logEvent( - eventName = "review_prompt_suppressed", - params = gameParams(), - ) + log("review_prompt_suppressed") lifecycleScope.launch { - while (settings.reviewPromptCount.value < AppConfig.REVIEW_MAX_PROMPTS) { - settings.incrementReviewPromptCount() - } + settings.suppressReviewPrompts(AppConfig.REVIEW_MAX_PROMPTS) } } private fun onReviewPromptLeaveFeedbackClicked() { - analytics.logEvent( - eventName = "review_requested", - params = gameParams(), - ) + log("review_requested") lifecycleScope.launch { storeReview.requestInAppReview().collect {} } } - private fun gameParams(): Map { - val game = store.state.game - return mapOf( - "score" to game.score, - "best_score" to game.bestScore, - "revives_used" to game.revivesUsed, - "remaining_pieces" to game.currentPieces.size, - ) - } + private fun log(eventName: String) = logger.log(eventName, store.state.game) private fun createSheetChild( config: SheetConfig, diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt index a1d8daa..2b43692 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt @@ -3,6 +3,7 @@ package ge.yet.blockblast.feature.game import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value +import ge.yet.blockblast.feature.game.reviewprompt.ReviewPromptComponent import ge.yet.blockblast.feature.settings.SettingsComponent import ge.yet.blokblast.domain.model.GameState @@ -15,22 +16,15 @@ import ge.yet.blokblast.domain.model.GameState */ interface GameComponent { - val model: Value - - /** - * Seconds remaining on the Game Over "Continue" button. Starts at - * [CONTINUE_COUNTDOWN_SECONDS] the moment the game ends and counts down to - * zero. The sentinel [COUNTDOWN_INACTIVE] (= -1) is emitted whenever the - * game is not in the game-over state. Owned by the component so it - * survives configuration changes and is unit-testable. - * - * Decompose's `Value` requires `T : Any`, which is why we use a sentinel - * instead of `Value`. - */ - val continueCountdown: Value + val model: Value val sheetSlot: Value> + data class Model( + val game: GameState, + val continueCountdown: Int, + ) + fun onCellClicked(pieceId: Long, x: Int, y: Int) fun onReviveClicked() fun onRestartClicked() diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/integration/Mappers.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/integration/Mappers.kt new file mode 100644 index 0000000..dbfae34 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/integration/Mappers.kt @@ -0,0 +1,11 @@ +package ge.yet.blockblast.feature.game.integration + +import ge.yet.blockblast.feature.game.GameComponent +import ge.yet.blockblast.feature.game.store.GameStoreState + +internal val stateToModel: (GameStoreState) -> GameComponent.Model = { + GameComponent.Model( + game = it.game, + continueCountdown = it.continueCountdown, + ) +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/ReviewPromptComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/reviewprompt/ReviewPromptComponent.kt similarity index 92% rename from feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/ReviewPromptComponent.kt rename to feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/reviewprompt/ReviewPromptComponent.kt index 262e217..9075101 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/ReviewPromptComponent.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/reviewprompt/ReviewPromptComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.game +package ge.yet.blockblast.feature.game.reviewprompt import com.arkivanov.decompose.ComponentContext diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameAnalyticsLogger.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameAnalyticsLogger.kt new file mode 100644 index 0000000..579dad7 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameAnalyticsLogger.kt @@ -0,0 +1,25 @@ +package ge.yet.blockblast.feature.game.store + +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.repository.AnalyticRepository + +internal class GameAnalyticsLogger(private val analytics: AnalyticRepository) { + + fun log( + eventName: String, + state: GameState, + extra: Map = emptyMap(), + ) { + analytics.logEvent( + eventName = eventName, + params = gameParams(state) + extra, + ) + } + + private fun gameParams(state: GameState): Map = mapOf( + "score" to state.score, + "best_score" to state.bestScore, + "revives_used" to state.revivesUsed, + "remaining_pieces" to state.currentPieces.size, + ) +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameInitializer.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameInitializer.kt new file mode 100644 index 0000000..a1658d7 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameInitializer.kt @@ -0,0 +1,49 @@ +package ge.yet.blockblast.feature.game.store + +import ge.yet.blokblast.domain.engine.GameEngine +import ge.yet.blokblast.domain.repository.GameSaveRepository +import ge.yet.blokblast.domain.repository.SettingsRepository + +/** + * Decides what to do at game bootstrap: start a fresh round, restore a saved + * one, or leave a warm engine alone. Pulled out of GameStoreFactory so the + * factory only wires concerns together; the branching logic lives here and is + * unit-testable in isolation. + */ +internal class GameInitializer( + private val engine: GameEngine, + private val saveRepository: GameSaveRepository, + private val settings: SettingsRepository, +) { + enum class Source(val tag: String) { + New("new"), + Continue("continue"), + } + + fun seedBestScore() { + engine.seedBestScore(settings.bestScore.value) + } + + suspend fun initialize(isNewGame: Boolean): Source { + val current = engine.state.value + + if (isNewGame || current.isGameOver) { + engine.startNewGame(bestScore = current.bestScore) + return Source.New + } + + if (current.currentPieces.isEmpty()) { + val saved = saveRepository.load() + return if (saved != null && !saved.isGameOver && saved.currentPieces.isNotEmpty()) { + engine.restore(saved) + Source.Continue + } else { + engine.startNewGame(bestScore = current.bestScore) + Source.New + } + } + + // Warm continue: engine already holds an in-flight round; leave it. + return Source.Continue + } +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactory.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactory.kt index cb2034f..db2e994 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactory.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactory.kt @@ -9,14 +9,11 @@ import com.arkivanov.mvikotlin.extensions.coroutines.coroutineExecutorFactory import dev.zacsweers.metro.Inject import ge.yet.blokblast.domain.engine.GameEngine import ge.yet.blokblast.domain.model.GameEvent -import ge.yet.blokblast.domain.model.GameState import ge.yet.blokblast.domain.repository.AnalyticRepository import ge.yet.blokblast.domain.repository.AudioRepository import ge.yet.blokblast.domain.repository.GameSaveRepository import ge.yet.blokblast.domain.repository.SettingsRepository import ge.yet.blokblast.domain.repository.StoreReviewRepository -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -26,13 +23,15 @@ internal class GameStoreFactory( private val storeFactory: StoreFactory, private val engine: GameEngine, private val audio: AudioRepository, - private val storeReview: StoreReviewRepository, private val saveRepository: GameSaveRepository, private val settings: SettingsRepository, private val analytics: AnalyticRepository, ) { - fun create(isNewGame: Boolean): GameStore = - object : + fun create(isNewGame: Boolean): GameStore { + val logger = GameAnalyticsLogger(analytics) + val initializer = GameInitializer(engine, saveRepository, settings) + + return object : GameStore, Store by storeFactory.create( name = "GameStore", @@ -42,80 +41,49 @@ internal class GameStoreFactory( ), executorFactory = coroutineExecutorFactory { onAction { - // ── 0. Bootstrap: seed best, attempt save-restore ───────────────── - // GameEngine starts at bestScore = 0; without seeding it, the - // first `engine.state` emission (section 1) would overwrite our - // initialState with that 0, and `max(currentBest, score)` in - // placePiece would collapse "Best" onto the current round's - // score. seedBestScore is a no-op if the engine already knows a - // higher value (e.g. carried over from an earlier session in - // the same process), so it is safe to run on every bootstrap. - engine.seedBestScore(settings.bestScore.value) - - // Save-restore / Initialization: - // We consolidate this here to avoid race conditions between - // cold-start restoration and the Decompose-triggered Intent.Start. + // ── 0. Bootstrap ────────────────────────────────────────────────── + // seedBestScore must run before the state collector below, otherwise + // the engine's initial bestScore=0 emission could clobber initialState. + initializer.seedBestScore() launch { - val current = engine.state.value - // 1. New game requested OR existing game in memory is already over - if (isNewGame || current.isGameOver) { - engine.startNewGame(bestScore = current.bestScore) - logGameEvent("game_started", extra = mapOf("source" to "new")) - } - // 2. "Continue" requested AND engine is empty (Cold Start) - else if (current.currentPieces.isEmpty()) { - val saved = saveRepository.load() - if (saved != null && !saved.isGameOver && saved.currentPieces.isNotEmpty()) { - engine.restore(saved) - logGameEvent("game_started", extra = mapOf("source" to "continue")) - } else { - // Nothing to continue, start fresh - engine.startNewGame(bestScore = current.bestScore) - logGameEvent("game_started", extra = mapOf("source" to "new")) - } - } - // 3. "Continue" requested AND engine already has state (Warm Start) - // -> Do nothing, the state-snapshot collector will pick up current state. - else { - logGameEvent("game_started", extra = mapOf("source" to "continue")) - } + val source = initializer.initialize(isNewGame) + logger.log( + eventName = "game_started", + state = engine.state.value, + extra = mapOf("source" to source.tag), + ) } // ── 1. State snapshots ──────────────────────────────────────────── - // StateFlow already deduplicates by structural equality. Grid uses - // IntArray with contentEquals-based equals, so equal states compare - // equal and no-op emissions are suppressed upstream. launch { - engine.state.collect { gameState -> - dispatch(GameStore.Msg.Snapshot(gameState)) - } + engine.state.collect { dispatch(GameStore.Msg.Snapshot(it)) } } // ── 2. Best-score persistence ───────────────────────────────────── + // setBestScore is monotonic at the repo level — no caller-side guard. launch { engine.state .map { it.bestScore } .distinctUntilChanged() - .collect { best -> - if (best > settings.bestScore.value) settings.setBestScore(best) - } + .collect { best -> settings.setBestScore(best) } } // ── 3. Game-over edge → countdown + (one-shot) review ───────────── - // Edge-triggered: only react when isGameOver actually flips. - // We seed `previous` from the current engine value so the initial - // StateFlow replay (= true if the user re-enters during game-over) - // does not look like a fresh transition and re-fire the countdown. - // - // The "is this a new personal best worth prompting for review?" - // qualifier and the "did we already prompt this round?" flag both - // live on GameState now (bestAtRoundStart, reviewPromptFiredThisRound) - // so they survive store recreation across Home → Play. Engine-state - // is the source of truth — the executor is stateless wrt these. + // bestAtRoundStart / reviewPromptFiredThisRound live on GameState so + // the qualifier survives store recreation across Home → Play. The + // executor stays stateless wrt those flags. + val countdown = ReviveCountdownManager( + onTick = { secs -> dispatch(GameStore.Msg.CountdownTick(secs)) }, + onExpired = { + logger.log( + eventName = "revive_countdown_expired", + state = engine.state.value, + extra = mapOf("countdown_seconds" to 0), + ) + }, + ) launch { - var countdownJob: Job? = null var previousIsGameOver = engine.state.value.isGameOver - engine.state .map { it.isGameOver } .collect { isGameOver -> @@ -123,60 +91,29 @@ internal class GameStoreFactory( previousIsGameOver = isGameOver val gameState = engine.state.value if (isGameOver) { - logGameEvent("game_over", state = gameState) - val score = gameState.score - val beatBy = score - gameState.bestAtRoundStart - val qualifies = - !gameState.reviewPromptFiredThisRound && - score >= AppConfig.REVIEW_MIN_SCORE && - beatBy >= AppConfig.REVIEW_BEST_SCORE_DELTA && - settings.reviewPromptCount.value < - AppConfig.REVIEW_MAX_PROMPTS - if (qualifies) { + logger.log("game_over", gameState) + if (qualifiesForReview(gameState)) { engine.markReviewPromptFired() launch { settings.incrementReviewPromptCount() } - // Hand the actual prompt to the component via a - // Label so navigation/SDK calls don't live in - // the executor. Per the mvikotlin-code skill. publish(GameStore.Label.RequestReview) } - countdownJob?.cancel() - countdownJob = launch { - var seconds = GameStoreState.CONTINUE_COUNTDOWN_SECONDS - dispatch(GameStore.Msg.CountdownTick(seconds)) - while (seconds > 0) { - delay(1000) - seconds -= 1 - dispatch(GameStore.Msg.CountdownTick(seconds)) - } - logGameEvent( - eventName = "revive_countdown_expired", - extra = mapOf("countdown_seconds" to 0), - ) - } + countdown.start(this) } else { - countdownJob?.cancel() - countdownJob = null - dispatch( - GameStore.Msg.CountdownTick(GameStoreState.COUNTDOWN_INACTIVE), - ) + countdown.cancel() } } } // ── 4a. SFX/voice: edge-triggered from engine events ────────────── - // Per-placement sounds and clear-line voice lines fire on - // discrete events. These collectors only see emissions made - // after they subscribe — fine, because every placement - // happens long after bootstrap. launch { engine.events.collect { event -> when (event) { is GameEvent.PiecePlaced -> audio.playPlacementSound() is GameEvent.LinesCleared -> { audio.playClearSound(event.linesCount) - logGameEvent( + logger.log( eventName = "lines_cleared", + state = engine.state.value, extra = mapOf( "lines_count" to event.linesCount, "is_cross_clear" to event.isCrossClear, @@ -186,12 +123,12 @@ internal class GameStoreFactory( is GameEvent.Feedback -> audio.playVoiceFeedback(event.type) is GameEvent.ComboActive -> { audio.playVoiceCombo(event.level) - logGameEvent( + logger.log( eventName = "combo_reached", + state = engine.state.value, extra = mapOf("combo_level" to event.level), ) } - // Music is *not* driven from events — see 4b. is GameEvent.GameOver, is GameEvent.GameStarted -> Unit } @@ -199,16 +136,10 @@ internal class GameStoreFactory( } // ── 4b. Music: derived from continuous state, not events ────────── - // The previous implementation reacted to GameStarted/GameOver - // edges. That misses the *first* GameStarted on cold launch: - // engine.events is a SharedFlow with replay = 0, and the - // bootstrap's save-restore launch may emit before this - // coroutine has actually subscribed → event lost → music - // never starts. Music is a continuous "is a round in flight?" - // signal, so derive it from state instead. Idempotent: the - // same `shouldPlay = true` emission collapses through - // distinctUntilChanged, and the audio repository de-dupes - // start/stop at the player level too. + // Driving music from events would miss the first GameStarted on cold + // launch (SharedFlow replay=0, bootstrap may emit before this + // collector subscribes). State-derived is idempotent through + // distinctUntilChanged. launch { engine.state .map { !it.isGameOver && it.currentPieces.isNotEmpty() } @@ -226,39 +157,31 @@ internal class GameStoreFactory( "y" to intent.y, "remaining_pieces" to before.currentPieces.size, ) - logGameEvent( - eventName = "piece_place_attempt", - state = before, - extra = placementParams, - ) + logger.log("piece_place_attempt", before, placementParams) val placed = engine.placePiece(intent.pieceId, intent.x, intent.y) - logGameEvent( + logger.log( eventName = if (placed) "piece_place_success" else "piece_place_failed", state = engine.state.value, extra = placementParams, ) } onIntent { - logGameEvent("revive_clicked") + logger.log("revive_clicked", engine.state.value) launch { if (engine.continueWithSmallBlocks()) { - logGameEvent( - eventName = "revive_completed", - extra = mapOf("source" to "revive"), - ) - logGameEvent( - eventName = "game_started", - extra = mapOf("source" to "revive"), - ) + val state = engine.state.value + logger.log("revive_completed", state, mapOf("source" to "revive")) + logger.log("game_started", state, mapOf("source" to "revive")) } } } onIntent { - logGameEvent("restart_clicked") + logger.log("restart_clicked", engine.state.value) launch { engine.startNewGame(bestScore = engine.state.value.bestScore) - logGameEvent( + logger.log( eventName = "game_started", + state = engine.state.value, extra = mapOf("source" to "restart"), ) } @@ -267,30 +190,20 @@ internal class GameStoreFactory( reducer = GameReducer, bootstrapper = SimpleBootstrapper(GameStore.Action.Init), ) {} - - private fun logGameEvent( - eventName: String, - state: GameState = engine.state.value, - extra: Map = emptyMap(), - ) { - analytics.logEvent( - eventName = eventName, - params = gameParams(state) + extra, - ) } - private fun gameParams(state: GameState): Map = - mapOf( - "score" to state.score, - "best_score" to state.bestScore, - "revives_used" to state.revivesUsed, - "remaining_pieces" to state.currentPieces.size, - ) - internal object GameReducer : Reducer { override fun GameStoreState.reduce(msg: GameStore.Msg): GameStoreState = when (msg) { is GameStore.Msg.Snapshot -> copy(game = msg.state) is GameStore.Msg.CountdownTick -> copy(continueCountdown = msg.secondsRemaining) } } + + private fun qualifiesForReview(state: ge.yet.blokblast.domain.model.GameState): Boolean { + val beatBy = state.score - state.bestAtRoundStart + return !state.reviewPromptFiredThisRound && + state.score >= AppConfig.REVIEW_MIN_SCORE && + beatBy >= AppConfig.REVIEW_BEST_SCORE_DELTA && + settings.reviewPromptCount.value < AppConfig.REVIEW_MAX_PROMPTS + } } diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/ReviveCountdownManager.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/ReviveCountdownManager.kt new file mode 100644 index 0000000..2703257 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/store/ReviveCountdownManager.kt @@ -0,0 +1,39 @@ +package ge.yet.blockblast.feature.game.store + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Owns the revive countdown lifecycle: starts a ticking job, cancels the + * previous one on retrigger, and surfaces ticks/expiration to the caller via + * callbacks. Stateful (holds the current Job), so create one instance per + * Store, not per tick. + */ +internal class ReviveCountdownManager( + private val onTick: (Int) -> Unit, + private val onExpired: () -> Unit, +) { + private var job: Job? = null + + fun start(scope: CoroutineScope) { + job?.cancel() + job = scope.launch { + var seconds = GameStoreState.CONTINUE_COUNTDOWN_SECONDS + onTick(seconds) + while (seconds > 0) { + delay(1000) + seconds -= 1 + onTick(seconds) + } + onExpired() + } + } + + fun cancel() { + job?.cancel() + job = null + onTick(GameStoreState.COUNTDOWN_INACTIVE) + } +} diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt index 407dcf8..4b95256 100644 --- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt @@ -3,7 +3,6 @@ package ge.yet.blockblast.feature.game import com.app.common.config.AppConfig import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.DefaultComponentContext -import com.arkivanov.decompose.value.MutableValue import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.destroy import com.arkivanov.essenty.lifecycle.resume @@ -80,7 +79,6 @@ class DefaultGameComponentTest { storeFactory = DefaultStoreFactory(), engine = engine, audio = audio, - storeReview = storeReview, saveRepository = save, settings = settings, analytics = analytics, @@ -241,6 +239,7 @@ class DefaultGameComponentTest { if (score > bestScoreFlow.value) bestScoreFlow.value = score } override suspend fun incrementReviewPromptCount() { reviewFlow.value += 1 } + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultReviewPromptComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/reviewprompt/DefaultReviewPromptComponentTest.kt similarity index 96% rename from feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultReviewPromptComponentTest.kt rename to feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/reviewprompt/DefaultReviewPromptComponentTest.kt index 49c4e38..53c83f0 100644 --- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultReviewPromptComponentTest.kt +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/reviewprompt/DefaultReviewPromptComponentTest.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.game +package ge.yet.blockblast.feature.game.reviewprompt import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt index 531423e..7251fe7 100644 --- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt @@ -2,7 +2,6 @@ package ge.yet.blockblast.feature.game.store import com.app.common.config.AppConfig import com.arkivanov.mvikotlin.extensions.coroutines.labels -import com.arkivanov.mvikotlin.extensions.coroutines.states import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import ge.yet.blokblast.domain.engine.GameEngine import ge.yet.blokblast.domain.engine.ScoreCalculator @@ -24,26 +23,23 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.test.advanceTimeBy import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -432,7 +428,6 @@ class GameStoreFactoryTest { storeFactory = DefaultStoreFactory(), engine = engine, audio = audio, - storeReview = storeReview, saveRepository = saveRepo, settings = settings, analytics = analytics, @@ -472,6 +467,7 @@ private class FakeSettings( override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) { if (score > bestScoreFlow.value) bestScoreFlow.value = score } override suspend fun incrementReviewPromptCount() { reviewFlow.value += 1 } + override suspend fun suppressReviewPrompts(max: Int) { if (reviewFlow.value < max) reviewFlow.value = max } override suspend fun setTutorialSeen() {} } diff --git a/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponent.kt b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponent.kt index 5b4b6fb..4d68e08 100644 --- a/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponent.kt +++ b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponent.kt @@ -8,6 +8,7 @@ import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.mvikotlin.core.instancekeeper.getStore import dev.zacsweers.metro.Inject import ge.yet.blockblast.feature.home.integration.stateToModel +import ge.yet.blockblast.feature.home.store.HomeAnalyticsLogger import ge.yet.blockblast.feature.home.store.HomeStore import ge.yet.blockblast.feature.home.store.HomeStoreFactory import ge.yet.blokblast.domain.repository.AnalyticRepository @@ -22,6 +23,7 @@ internal class DefaultHomeComponent( ) : ComponentContext by componentContext, HomeComponent { private val store = instanceKeeper.getStore { homeStoreFactory.create() } + private val logger = HomeAnalyticsLogger(analytics) override val model: Value = store.asValue().map(stateToModel) @@ -43,13 +45,7 @@ internal class DefaultHomeComponent( private fun logHomeClick(eventName: String) { val state = store.state - analytics.logEvent( - eventName = eventName, - params = mapOf( - "best_score" to state.bestScore, - "has_saved_game" to state.hasSavedGame, - ), - ) + logger.log(eventName, state.bestScore, state.hasSavedGame) } } diff --git a/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeAnalyticsLogger.kt b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeAnalyticsLogger.kt new file mode 100644 index 0000000..65540e4 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeAnalyticsLogger.kt @@ -0,0 +1,16 @@ +package ge.yet.blockblast.feature.home.store + +import ge.yet.blokblast.domain.repository.AnalyticRepository + +internal class HomeAnalyticsLogger(private val analytics: AnalyticRepository) { + + fun log(eventName: String, bestScore: Long, hasSavedGame: Boolean) { + analytics.logEvent( + eventName = eventName, + params = mapOf( + "best_score" to bestScore, + "has_saved_game" to hasSavedGame, + ), + ) + } +} diff --git a/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactory.kt b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactory.kt index e993741..4765a8e 100644 --- a/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactory.kt +++ b/feature/home/src/commonMain/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactory.kt @@ -18,8 +18,9 @@ internal class HomeStoreFactory( private val settings: SettingsRepository, private val analytics: AnalyticRepository, ) { - fun create(): HomeStore = - object : + fun create(): HomeStore { + val logger = HomeAnalyticsLogger(analytics) + return object : HomeStore, Store by storeFactory.create( name = "HomeStore", @@ -48,13 +49,7 @@ internal class HomeStoreFactory( val bestScore = maxOf(settings.bestScore.value, saved?.bestScore ?: 0L) val hasSavedGame = saved != null && !saved.isGameOver && !saved.grid.isBoardEmpty() - analytics.logEvent( - eventName = "home_shown", - params = mapOf( - "best_score" to bestScore, - "has_saved_game" to hasSavedGame, - ), - ) + logger.log("home_shown", bestScore, hasSavedGame) dispatch( HomeStore.Msg.Loaded( bestScore = bestScore, @@ -66,6 +61,7 @@ internal class HomeStoreFactory( }, reducer = HomeReducer, ) {} + } internal object HomeReducer : Reducer { diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt index 0b87fda..708c692 100644 --- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt @@ -142,6 +142,7 @@ class DefaultHomeComponentTest { override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt index 60b05e8..6201c8b 100644 --- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt @@ -136,6 +136,7 @@ class HomeStoreFactoryTest { override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } diff --git a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt index 9f9aca4..dff5b46 100644 --- a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt +++ b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt @@ -153,9 +153,11 @@ class DefaultRootComponentTest { private class FakeGame : GameComponent { override val model = com.arkivanov.decompose.value.MutableValue( - ge.yet.blokblast.domain.model.GameState(), + GameComponent.Model( + game = ge.yet.blokblast.domain.model.GameState(), + continueCountdown = -1, + ), ) - override val continueCountdown = com.arkivanov.decompose.value.MutableValue(-1) override val sheetSlot = com.arkivanov.decompose.value.MutableValue( com.arkivanov.decompose.router.slot.ChildSlot(child = null), ) @@ -196,6 +198,7 @@ class DefaultRootComponentTest { override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() { tutorialFlow.value = true } } } diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultSettingsComponent.kt index 767a347..50d2764 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultSettingsComponent.kt @@ -9,7 +9,10 @@ import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.value.Value import com.arkivanov.mvikotlin.core.instancekeeper.getStore import dev.zacsweers.metro.Inject -import ge.yet.blockblast.feature.settings.store.SettingsStoreFactory +import ge.yet.blockblast.feature.settings.libraries.DefaultLibrariesSettingsComponent +import ge.yet.blockblast.feature.settings.main.DefaultMainSettingsComponent +import ge.yet.blockblast.feature.settings.main.store.SettingsStoreFactory +import ge.yet.blockblast.feature.settings.more.DefaultMoreSettingsComponent import ge.yet.blokblast.domain.repository.AnalyticRepository import kotlinx.serialization.Serializable diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/SettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/SettingsComponent.kt index 1f6f941..43263f1 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/SettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/SettingsComponent.kt @@ -4,6 +4,9 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackHandlerOwner +import ge.yet.blockblast.feature.settings.libraries.LibrariesSettingsComponent +import ge.yet.blockblast.feature.settings.main.MainSettingsComponent +import ge.yet.blockblast.feature.settings.more.MoreSettingsComponent /** * Settings screen. Reachable from BOTH Home and Game via Root navigation. diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultLibrariesSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/DefaultLibrariesSettingsComponent.kt similarity index 88% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultLibrariesSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/DefaultLibrariesSettingsComponent.kt index c4b35ba..1f5128e 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultLibrariesSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/DefaultLibrariesSettingsComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.libraries import com.arkivanov.decompose.ComponentContext diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/Libraries.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/Libraries.kt similarity index 98% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/Libraries.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/Libraries.kt index 172718a..553adf4 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/Libraries.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/Libraries.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.libraries internal val DEFAULT_LIBRARIES: List = listOf( LibrariesSettingsComponent.Library( diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/LibrariesSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/LibrariesSettingsComponent.kt similarity index 80% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/LibrariesSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/LibrariesSettingsComponent.kt index 3ed89d6..39bdf71 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/LibrariesSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/libraries/LibrariesSettingsComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.libraries interface LibrariesSettingsComponent { diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt similarity index 84% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt index 2ec3f96..11f3193 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt @@ -1,11 +1,11 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.main import com.app.common.decompose.asValue import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.operator.map -import ge.yet.blockblast.feature.settings.integration.stateToModel -import ge.yet.blockblast.feature.settings.store.SettingsStore +import ge.yet.blockblast.feature.settings.main.integration.stateToModel +import ge.yet.blockblast.feature.settings.main.store.SettingsStore internal class DefaultMainSettingsComponent( componentContext: ComponentContext, diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt similarity index 89% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MainSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt index 79732db..c4aa6de 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MainSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.main import com.arkivanov.decompose.value.Value diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/integration/Mappers.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt similarity index 58% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/integration/Mappers.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt index 54fa6c7..cc902a3 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/integration/Mappers.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt @@ -1,7 +1,7 @@ -package ge.yet.blockblast.feature.settings.integration +package ge.yet.blockblast.feature.settings.main.integration -import ge.yet.blockblast.feature.settings.MainSettingsComponent -import ge.yet.blockblast.feature.settings.store.SettingsStore +import ge.yet.blockblast.feature.settings.main.MainSettingsComponent +import ge.yet.blockblast.feature.settings.main.store.SettingsStore internal val stateToModel: (SettingsStore.State) -> MainSettingsComponent.Model = { state -> diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStore.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt similarity index 93% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStore.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt index 289a4ab..c5e7b07 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStore.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings.store +package ge.yet.blockblast.feature.settings.main.store import com.arkivanov.mvikotlin.core.store.Store diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactory.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactory.kt similarity index 98% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactory.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactory.kt index 1f4cc6b..4c1f597 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactory.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactory.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings.store +package ge.yet.blockblast.feature.settings.main.store import com.arkivanov.mvikotlin.core.store.Reducer import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMoreSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/DefaultMoreSettingsComponent.kt similarity index 89% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMoreSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/DefaultMoreSettingsComponent.kt index 001aa08..62a5d90 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/DefaultMoreSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/DefaultMoreSettingsComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.more import com.arkivanov.decompose.ComponentContext diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MoreSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/MoreSettingsComponent.kt similarity index 65% rename from feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MoreSettingsComponent.kt rename to feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/MoreSettingsComponent.kt index 6203ab7..9e323f6 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/MoreSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/more/MoreSettingsComponent.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.more interface MoreSettingsComponent { diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt similarity index 94% rename from feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt rename to feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt index 09ff129..218670f 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt @@ -1,10 +1,10 @@ -package ge.yet.blockblast.feature.settings +package ge.yet.blockblast.feature.settings.main import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import ge.yet.blockblast.feature.settings.store.SettingsStore -import ge.yet.blockblast.feature.settings.store.SettingsStoreFactory +import ge.yet.blockblast.feature.settings.main.store.SettingsStore +import ge.yet.blockblast.feature.settings.main.store.SettingsStoreFactory import ge.yet.blokblast.domain.repository.AnalyticRepository import ge.yet.blokblast.domain.repository.SettingsRepository import kotlinx.coroutines.Dispatchers @@ -108,6 +108,7 @@ class DefaultMainSettingsComponentTest { override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt similarity index 83% rename from feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt rename to feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt index 7d50642..ed10ffd 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt @@ -1,6 +1,6 @@ -package ge.yet.blockblast.feature.settings.integration +package ge.yet.blockblast.feature.settings.main.integration -import ge.yet.blockblast.feature.settings.store.SettingsStore +import ge.yet.blockblast.feature.settings.main.store.SettingsStore import kotlin.test.Test import kotlin.test.assertEquals diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt similarity index 97% rename from feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt rename to feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt index 09341a4..cb25583 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt @@ -1,4 +1,4 @@ -package ge.yet.blockblast.feature.settings.store +package ge.yet.blockblast.feature.settings.main.store import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import ge.yet.blokblast.domain.repository.AnalyticRepository @@ -111,6 +111,7 @@ class SettingsStoreFactoryTest { override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} override suspend fun incrementReviewPromptCount() {} + override suspend fun suppressReviewPrompts(max: Int) {} override suspend fun setTutorialSeen() {} } diff --git a/gradle.properties b/gradle.properties index 0ad5b78..bf51369 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,5 +12,5 @@ android.nonTransitiveRClass=true android.useAndroidX=true #App version (single source of truth; CI overrides via -PappVersionName / -PappVersionCode) -appVersionName=1.3.5 -appVersionCode=8 \ No newline at end of file +appVersionName=1.4.0 +appVersionCode=9 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3cd933..c1640a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ firebase-gitlive-sdk = "2.4.0" android-review = "2.0.2" androidx-activity = "1.13.0" +androidx-core-splashscreen = "1.2.0" androidx-lifecycle = "2.10.0" composeMultiplatform = "1.10.3" confettikit = "0.8.0" @@ -45,6 +46,7 @@ coreKtx = "1.7.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 1b8495a..8f95183 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Logica PRODUCT_BUNDLE_IDENTIFIER=ge.yet3.blokblast.BlockBlast$(TEAM_ID) -CURRENT_PROJECT_VERSION=8 -MARKETING_VERSION=1.3.5 \ No newline at end of file +CURRENT_PROJECT_VERSION=9 +MARKETING_VERSION=1.4.0 \ No newline at end of file