Output too large. Showing first 8,000 and last 32,000 characters. For full output see: /home/Messier82/.gemini/tmp/service-book/tool-outputs/session-437894cf-202f-460d-aaa5-2b32db81d478/run_shell_command_1777956427511_1.txt Output: On branch master Your branch is up to date with 'origin/master'. Changes not staged for commit: (use "git add ..." to update what will be committed) (use "git restore ..." to discard changes in working directory) modified: app/src/main/java/com/servicebook/ServiceBookApplication.kt modified: app/src/main/java/com/servicebook/data/billing/BillingManager.kt modified: app/src/main/java/com/servicebook/data/billing/BillingManagerImpl.kt modified: app/src/main/java/com/servicebook/data/billing/DebugBillingActions.kt modified: app/src/main/java/com/servicebook/domain/AppGate.kt modified: app/src/main/java/com/servicebook/ui/components/UpgradeDialog.kt modified: app/src/main/java/com/servicebook/ui/settings/SettingsScreen.kt modified: app/src/main/java/com/servicebook/ui/settings/SettingsViewModel.kt modified: app/src/main/java/com/servicebook/ui/util/ViewModelDependencies.kt modified: app/src/main/java/com/servicebook/ui/vehicles/VehicleListScreen.kt modified: app/src/main/java/com/servicebook/ui/vehicles/VehicleListViewModel.kt modified: app/src/main/res/values/strings.xml modified: app/src/test/java/com/servicebook/data/billing/FakeBillingManager.kt modified: app/src/test/java/com/servicebook/domain/AppGateTest.kt modified: app/src/test/java/com/servicebook/ui/settings/SettingsViewModelTest.kt modified: app/src/testDebug/java/com/servicebook/data/billing/BillingManagerImplTest.kt no changes added to commit (use "git add" and/or "git commit -a") diff --git a/app/src/main/java/com/servicebook/ServiceBookApplication.kt b/app/src/main/java/com/servicebook/ServiceBookApplication.kt index 3a0f0b9..e31cf44 100644 --- a/app/src/main/java/com/servicebook/ServiceBookApplication.kt +++ b/app/src/main/java/com/servicebook/ServiceBookApplication.kt @@ -45,7 +45,7 @@ open class ServiceBookApplication : Application() { open lateinit var billingManager: BillingManager protected set - open lateinit var debugBillingOverride: DebugBillingOverride + open var debugBillingOverride: DebugBillingOverride? = null protected set open lateinit var appGate: AppGate @@ -71,14 +71,16 @@ open class ServiceBookApplication : Application() { vehicleRepository = VehicleRepositoryImpl(storage) recordRepository = RecordRepositoryImpl(storage, categoryRepository, vehicleRepository) - debugBillingOverride = DebugBillingOverride(preferences, applicationScope) + if (BuildConfig.DEBUG) { + debugBillingOverride = DebugBillingOverride(preferences, applicationScope) + } val clientProvider = DefaultBillingClientProvider(this) billingManager = BillingManagerImpl( preferences = preferences, coroutineScope = applicationScope, billingClientProvider = clientProvider, - debugOverride = if (BuildConfig.DEBUG) debugBillingOverride else null, + debugOverride = debugBillingOverride, ) appGate = AppGate(vehicleRepository, billingManager, applicationScope) } diff --git a/app/src/main/java/com/servicebook/data/billing/BillingManager.kt b/app/src/main/java/com/servicebook/data/billing/BillingManager.kt index 67f4cf6..1dcc22a 100644 --- a/app/src/main/java/com/servicebook/data/billing/BillingManager.kt +++ b/app/src/main/java/com/servicebook/data/billing/BillingManager.kt @@ -7,6 +7,9 @@ interface BillingManager { /** Emits true when the user has an active premium entitlement. */ val isPremium: StateFlow + /** Emits true when a purchase is pending confirmation from Google Play. */ + val hasPendingPurchase: StateFlow + /** Connect to Google Play and query existing purchases. */ fun startConnection() diff --git a/app/src/main/java/com/servicebook/data/billing/BillingManagerImpl.kt b/app/src/main/java/com/servicebook/data/billing/BillingManagerImpl.kt index 3d43ce3..dfea225 100644 --- a/app/src/main/java/com/servicebook/data/billing/BillingManagerImpl.kt +++ b/app/src/main/java/com/servicebook/data/billing/BillingManagerImpl.kt @@ -54,6 +54,9 @@ class BillingManagerImpl( private val _isPremium = MutableStateFlow(false) override val isPremium: StateFlow = _isPremium.asStateFlow() + private val _hasPendingPurchase = MutableStateFlow(false) + override val hasPendingPurchase: StateFlow = _hasPendingPurchase.asStateFlow() + /** * Holds the premium status as determined by the most recent successful billing query, * or `null` if no billing query has completed yet. When `null`, [isPremium] falls back @@ -150,7 +153,11 @@ class BillingManagerImpl( } override fun refresh() { - coroutineScope.launch { queryPurchases() } + if (!billingClient.isReady) { + startConnection() + } else { + coroutineScope.launch { queryPurchases() } + } } override fun launchPremiumPurchaseFlow(activity: Activity): BillingLaunchResult { @@ -247,26 +254,31 @@ class BillingManagerImpl( private suspend fun processPurchases(purchases: List): Boolean { val premiumPurchases = purchases.filter { purchase -> - purchase.products.contains(PRODUCT_ID) && - purchase.purchaseState == Purchase.PurchaseState.PURCHASED + purchase.products.contains(PRODUCT_ID) } - val hasPremium = premiumPurchases.isNotEmpty() + val hasPurchased = + premiumPurchases.any { it.purchaseState == Purchase.PurchaseState.PURCHASED } + val hasPending = + premiumPurchases.any { it.purchaseState == Purchase.PurchaseState.PENDING } + // Always update the billing-derived state; StateFlow discards same-value writes. - billingDerivedPremium.value = hasPremium + billingDerivedPremium.value = hasPurchased + _hasPendingPurchase.value = hasPending + // Guard the DataStore write using the in-memory cachedPremiumFlow so there is no extra // Flow suspension on the hot billing path. Only write when the persisted value actually // differs from the billing result (e.g., a premium user's cold-start re-confirmation // does not trigger a redundant DataStore edit). - if (cachedPremiumFlow.value != hasPremium) { - preferences.setIsPremiumCached(hasPremium) + if (cachedPremiumFlow.value != hasPurchased) { + preferences.setIsPremiumCached(hasPurchased) } premiumPurchases - .filter { !it.isAcknowledged } + .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged } .forEach { acknowledgeOnePurchase(it) } - return hasPremium + return hasPurchased } /** @@ -345,10 +357,6 @@ class BillingManagerImpl( preferences.setIsPremiumCached(true) } - override suspend fun simulatePurchaseCancel() { - // Handled at call-site in ViewModel to provide feedback - } - override suspend fun resetBillingState() { preferences.setDebugBillingOverrideMode(DebugBillingOverrideMode.NONE) billingDerivedPremium.value = null diff --git a/app/src/main/java/com/servicebook/data/billing/DebugBillingActions.kt b/app/src/main/java/com/servicebook/data/billing/DebugBillingActions.kt index 7fa17ca..3a6195c 100644 --- a/app/src/main/java/com/servicebook/data/billing/DebugBillingActions.kt +++ b/app/src/main/java/com/servicebook/data/billing/DebugBillingActions.kt @@ -10,13 +10,6 @@ interface DebugBillingActions { */ suspend fun simulatePurchaseSuccess() - /** - * Simulates a cancelled purchase flow. - * This is a no-op method exposed purely for symmetr ... [4,095 characters omitted] ... isPremium: Boolean = false, + val isPremiumPending: Boolean = false, /** Added to feed Phase 5 UI (disabling buttons/showing banners); logic gating is in VM. */ val isReadOnly: Boolean = false, val isRestoringPurchases: Boolean = false, @@ -73,8 +74,6 @@ sealed class SettingsEvent { data object SimulatePurchaseSuccess : SettingsEvent() - data object SimulatePurchaseCancel : SettingsEvent() - data object ResetBillingState : SettingsEvent() data class SetDebugOverride(val mode: DebugBillingOverrideMode) : SettingsEvent() @@ -140,12 +139,15 @@ class SettingsViewModel( appGate.isPremium .onEach { premium -> _state.update { it.copy(isPremium = premium) } } .launchIn(viewModelScope) + appGate.hasPendingPurchase + .onEach { pending -> _state.update { it.copy(isPremiumPending = pending) } } + .launchIn(viewModelScope) appGate.isReadOnly .onEach { readOnly -> _state.update { it.copy(isReadOnly = readOnly) } } .launchIn(viewModelScope) - debugBillingOverride.mode - .onEach { mode -> _state.update { it.copy(debugOverrideMode = mode) } } - .launchIn(viewModelScope) + debugBillingOverride?.mode + ?.onEach { mode -> _state.update { it.copy(debugOverrideMode = mode) } } + ?.launchIn(viewModelScope) } private fun getAppVersion(): String { @@ -198,11 +200,10 @@ class SettingsViewModel( } SettingsEvent.RestorePurchases -> restorePurchases() SettingsEvent.SimulatePurchaseSuccess -> simulateDebugPurchaseSuccess() - SettingsEvent.SimulatePurchaseCancel -> simulateDebugPurchaseCancel() SettingsEvent.ResetBillingState -> resetDebugBillingState() is SettingsEvent.SetDebugOverride -> { if (BuildConfig.DEBUG) { - viewModelScope.launch { debugBillingOverride.setMode(event.mode) } + viewModelScope.launch { debugBillingOverride?.setMode(event.mode) } } else { Log.w("SettingsViewModel", "SetDebugOverride called in non-debug build") } @@ -219,15 +220,6 @@ class SettingsViewModel( } } - private fun simulateDebugPurchaseCancel() { - if (BuildConfig.DEBUG) { - viewModelScope.launch { - (billingManager as? DebugBillingActions)?.simulatePurchaseCancel() - _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_debug_purchase_cancel))) - } - } - } - private fun resetDebugBillingState() { if (BuildConfig.DEBUG) { viewModelScope.launch { diff --git a/app/src/main/java/com/servicebook/ui/util/ViewModelDependencies.kt b/app/src/main/java/com/servicebook/ui/util/ViewModelDependencies.kt index 1659499..ec29e81 100644 --- a/app/src/main/java/com/servicebook/ui/util/ViewModelDependencies.kt +++ b/app/src/main/java/com/servicebook/ui/util/ViewModelDependencies.kt @@ -23,5 +23,5 @@ class ViewModelDependencies( val vaultStorage: VaultStorage, val preferences: AppPreferencesStore, val billingManager: BillingManager, - val debugBillingOverride: DebugBillingOverride, + val debugBillingOverride: DebugBillingOverride?, ) diff --git a/app/src/main/java/com/servicebook/ui/vehicles/VehicleListScreen.kt b/app/src/main/java/com/servicebook/ui/vehicles/VehicleListScreen.kt index bf7f5e0..42a0fe0 100644 --- a/app/src/main/java/com/servicebook/ui/vehicles/VehicleListScreen.kt +++ b/app/src/main/java/com/servicebook/ui/vehicles/VehicleListScreen.kt @@ -63,6 +63,7 @@ import com.servicebook.ui.TestTags import com.servicebook.ui.components.ConfirmDeleteDialog import com.servicebook.ui.components.EmptyStateView import com.servicebook.ui.components.PaywallScaffold +import com.servicebook.ui.components.PurchasePendingDialog import com.servicebook.ui.components.UpgradeDialog import com.servicebook.ui.components.shimmerBrush import com.servicebook.ui.navigation.AddVehicle @@ -159,6 +160,10 @@ fun VehicleListScreen( ) } + if (state.isPremiumPending) { + PurchasePendingDialog() + } + PaywallScaffold( isReadOnly = state.isReadOnly, snackbarHostState = snackbarHostState, diff --git a/app/src/main/java/com/servicebook/ui/vehicles/VehicleListViewModel.kt b/app/src/main/java/com/servicebook/ui/vehicles/VehicleListViewModel.kt index bf87077..50c77d4 100644 --- a/app/src/main/java/com/servicebook/ui/vehicles/VehicleListViewModel.kt +++ b/app/src/main/java/com/servicebook/ui/vehicles/VehicleListViewModel.kt @@ -26,6 +26,7 @@ data class VehicleListState( val deleteError: String? = null, /** Added to feed Phase 5 UI (disabling buttons/showing banners); logic gating is in VM. */ val isReadOnly: Boolean = false, + val isPremiumPending: Boolean = false, /** Visible when a user attempts to add a vehicle beyond the free limit. */ val showUpgradeDialog: Boolean = false, ) @@ -101,6 +102,10 @@ class VehicleListViewModel( .onEach { readOnly -> _state.update { it.copy(isReadOnly = readOnly) } } .launchIn(viewModelScope) + appGate.hasPendingPurchase + .onEach { pending -> _state.update { it.copy(isPremiumPending = pending) } } + .launchIn(viewModelScope) + // Auto-navigate on successful purchase (FR-P3): // drop(1) ignores the initial cached value; filter { it } waits for premium=true. // This is safe for already-premium users because the wasDialogVisible check @@ -173,7 +178,6 @@ class VehicleListViewModel( // the auto-navigate observer (appGate.isPremium) can detect it and // navigate to AddVehicle on a successful purchase. On cancel/error the user // dismisses manually via DismissUpgradeDialog. - // TODO(phase-5): replace with billingManager.launchBillingFlow(activity) viewModelScope.launch { _effects.send(VehicleListEffect.LaunchPurchaseFlow) } VehicleListEvent.DismissUpgradeDialog -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3fd23ac..462caed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,8 @@ Purchase failed: %1$s unknown error Premium required for additional vehicles + Purchase Pending + Your purchase is awaiting confirmation from Google Play. Premium features will be unlocked automatically once the transaction is complete. Developer @@ -167,10 +169,8 @@ Force free Force premium Simulate purchase success - Simulate purchase cancel Reset all billing state Simulated purchase success - Simulated purchase cancel Billing state reset diff --git a/app/src/test/java/com/servicebook/data/billing/FakeBillingManager.kt b/app/src/test/java/com/servicebook/data/billing/FakeBillingManager.kt index d4e7d44..976415a 100644 --- a/app/src/test/java/com/servicebook/data/billing/FakeBillingManager.kt +++ b/app/src/test/java/com/servicebook/data/billing/FakeBillingManager.kt @@ -19,6 +19,9 @@ class FakeBillingManager : BillingManager, DebugBillingActions { val premiumState = MutableStateFlow(false) override val isPremium: StateFlow = premiumState.asStateFlow() + val pendingState = MutableStateFlow(false) + override val hasPendingPurchase: StateFlow = pendingState.asStateFlow() + var launchResult: BillingLaunchResult = BillingLaunchResult.Success var restoreResult: RestoreResult = RestoreResult.Success(hadPurchase = false) var restoreDeferred: CompletableDeferred? = null @@ -53,8 +56,6 @@ class FakeBillingManager : BillingManager, DebugBillingActions { premiumState.value = true } - override suspend fun simulatePurchaseCancel() = Unit - override suspend fun resetBillingState() { premiumState.value = false } diff --git a/app/src/test/java/com/servicebook/domain/AppGateTest.kt b/app/src/test/java/com/servicebook/domain/AppGateTest.kt index 18a73ae..f304379 100644 --- a/app/src/test/java/com/servicebook/domain/AppGateTest.kt +++ b/app/src/test/java/com/servicebook/domain/AppGateTest.kt @@ -21,6 +21,7 @@ class AppGateTest { private val vehiclesFlow = MutableStateFlow>(emptyList()) private val premiumFlow = MutableStateFlow(false) + private val pendingFlow = MutableStateFlow(false) private val vehicleRepository = mockk() private val billingManager = mockk() @@ -30,6 +31,7 @@ class AppGateTest { fun setUp() { every { vehicleRepository.observeVehicles() } returns vehiclesFlow every { billingManager.isPremium } returns premiumFlow + every { billingManager.hasPendingPurchase } returns pendingFlow appGate = AppGate(vehicleRepository, billingManager, testScope.backgroundScope) } diff --git a/app/src/test/java/com/servicebook/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/servicebook/ui/settings/SettingsViewModelTest.kt index 41d35c4..dd20d17 100644 --- a/app/src/test/java/com/servicebook/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/servicebook/ui/settings/SettingsViewModelTest.kt @@ -48,6 +48,7 @@ class SettingsViewModelTest { private lateinit var viewModel: SettingsViewModel private lateinit var appGate: AppGate private lateinit var isReadOnlyFlow: MutableStateFlow + private lateinit var hasPendingPurchaseFlow: MutableStateFlow private lateinit var fakeBilling: FakeBillingManager private lateinit var debugBillingOverride: DebugBillingOverride private val testDispatcher = UnconfinedTestDispatcher() @@ -66,6 +67,7 @@ class SettingsViewModelTest { fakeBilling = FakeBillingManager() debugBillingOverride = mockk(relaxed = true) isReadOnlyFlow = MutableStateFlow(false) + hasPendingPurchaseFlow = MutableStateFlow(false) // Setup PackageManager mocks because SettingsViewModel reads the app version name on init. val pm = mockk() @@ -77,11 +79,11 @@ class SettingsViewModelTest { // Mock localized strings for debug events every { app.getString(R.string.settings_debug_purchase_success) } returns "Simulated purchase success" - every { app.getString(R.string.settings_debug_purchase_cancel) } returns "Simulated purchase cancel" every { app.getString(R.string.settings_debug_billing_reset) } returns "Billing state reset" every { preferences.preferences } returns flowOf(AppPreferences(vaultUriString = "content://vault")) every { appGate.isReadOnly } returns isReadOnlyFlow + every { appGate.hasPendingPurchase } returns hasPendingPurchaseFlow every { appGate.isPremium } returns fakeBilling.premiumState every { debugBillingOverride.mode } returns MutableStateFlow(DebugBillingOverrideMode.NONE) } @@ -413,16 +415,18 @@ class SettingsViewModelTest { } @Test - fun `onEvent SimulatePurchaseCancel should show snackbar`() = + fun `isPremiumPending should reflect appGate hasPendingPurchase`() = runTest { viewModel = createViewModel() - viewModel.effects.test { - viewModel.onEvent(SettingsEvent.SimulatePurchaseCancel) - val effect = awaitItem() - assertThat(effect).isInstanceOf(SettingsEffect.ShowSnackbar::class.java) - assertThat((effect as SettingsEffect.ShowSnackbar).message).isEqualTo("Simulated purchase cancel") - cancelAndIgnoreRemainingEvents() + viewModel.state.test { + awaitItem() // Skip initial + + hasPendingPurchaseFlow.value = true + assertThat(awaitItem().isPremiumPending).isTrue() + + hasPendingPurchaseFlow.value = false + assertThat(awaitItem().isPremiumPending).isFalse() } } diff --git a/app/src/testDebug/java/com/servicebook/data/billing/BillingManagerImplTest.kt b/app/src/testDebug/java/com/servicebook/data/billing/BillingManagerImplTest.kt index bca735c..d19f556 100644 --- a/app/src/testDebug/java/com/servicebook/data/billing/BillingManagerImplTest.kt +++ b/app/src/testDebug/java/com/servicebook/data/billing/BillingManagerImplTest.kt @@ -169,6 +169,63 @@ class BillingManagerImplTest { advanceUntilIdle() assertThat(billingManagerImpl.isPremium.value).isTrue() + assertThat(billingManagerImpl.hasPendingPurchase.value).isFalse() + } + + @Test + fun `isPremium flips from false to true when purchase state transitions from PENDING to PURCHASED`() = + testScope.runTest { + preferences.isPremiumCached = false + val pendingPurchase = makePurchase(state = Purchase.PurchaseState.PENDING, token = "token_1") + stubStartConnectionOk() + stubQueryPurchases(okResult(), listOf(pendingPurchase)) + stubQueryProductDetailsOk() + + billingManagerImpl.startConnection() + advanceUntilIdle() + + assertThat(billingManagerImpl.isPremium.value).isFalse() + assertThat(billingManagerImpl.hasPendingPurchase.value).isTrue() + + // Transition to PURCHASED + val purchasedPurchase = makePurchase(state = Purchase.PurchaseState.PURCHASED, token = "token_1") + capturedPurchasesUpdatedListener.onPurchasesUpdated(okResult(), listOf(purchasedPurchase)) + advanceUntilIdle() + + assertThat(billingManagerImpl.isPremium.value).isTrue() + assertThat(billingManagerImpl.hasPendingPurchase.value).isFalse() + } + + @Test + fun `launchPremiumPurchaseFlow returns BillingUnavailable when cachedProductDetails is null`() = + testScope.runTest { + stubStartConnectionOk() + // Stub queryProductDetails to return empty list, so cachedProductDetails remains null + val listenerSlot = slot() + every { + mockBillingClient.queryProductDetailsAsync(any(), capture(listenerSlot)) + } answers { + listenerSlot.captured.onProductDetailsResponse(okResult(), emptyList()) + } + + billingManagerImpl.startConnection() + advanceUntilIdle() + + val mockActivity = mockk(relaxed = true) + val result = billingManagerImpl.launchPremiumPurchaseFlow(mockActivity) + + assertThat(result).isEqualTo(BillingLaunchResult.BillingUnavailable) + } + + @Test + fun `restorePurchases returns Error when queryPurchasesAsync returns SERVICE_DISCONNECTED`() = + testScope.runTest { + every { mockBillingClient.isReady } returns true + stubQueryPurchases(errorResult(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED)) + + val result = billingManagerImpl.restorePurchases() + + assertThat(result).isEqualTo(RestoreResult.Error(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED)) } @Test commit e6b1622ab241911908cd45b9c5e8997ee31e5d9c (HEAD -> master, origin/master, origin/HEAD) Author: Igor Vinogradov Date: Mon May 4 21:44:09 2026 +0300 feat: implement Phase 5 - Paywall UI (banner, dialog, PaywallScaffold, settings) (#53) * feat: implement Phase 5 - Paywall UI (banner, dialog, PaywallScaffold, settings) - Create ReadOnlyWarningBanner, UpgradeDialog, and PaywallScaffold components. - Integrate PaywallScaffold across 7 main screens. - Implement read-only gating for actions (edit, delete, save, add) in UI. - Add Premium section and Debug Developer Panel to Settings. - Update strings and BillingManager for Phase 5 requirements. - Add screenshot tests for new paywall components and update baselines. - Refactor ViewModelFactory and SettingsViewModel to resolve detekt length issues. - Fix ktlint, detekt, and lint violations. * fix: address PR #53 review feedback for Phase 5 Paywall UI - Extract rememberUpgradeLauncher for centralized purchase flow handling - Refactor debug billing to use type-safe enums and segregated interface - Fix PaywallScaffold padding and banner integration - Restore critical documentation comments in BillingManagerImpl - Fix ViewModelFactory to handle empty CreationExtras in tests - Resolve all test compilation errors and ktlint/detekt violations * fix: address remaining PR #53 re-review issues - Render SnackbarHost by default in PaywallScaffold to prevent silent loss - Localize debug simulation snackbar messages in SettingsViewModel - Document simulatePurchaseCancel as a no-op for UI feedback - Remove redundant BillingUIUtilsTest stub - Update SettingsViewModelTest to handle localized strings * chore: refresh smoke coverage baseline for Phase 5 * fix: address final polish items for PR #53 - Remove redundant snackbarHost wiring from PaywallScaffold callers - Decouple preferences from billing by extracting DebugBillingOverrideMode - Refresh smoke coverage baseline to reflect final UI changes - Fix import ordering to satisfy ktlint requirements * test: update PaywallScaffold screenshots --------- Co-authored-by: Mesya82 commit 6548e4ee416cc656b64c83b6d11bc4a827478562 Author: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun May 3 18:31:21 2026 +0300 Phase 4 — ViewModel paywall gating (#52) * Initial plan * feat: Phase 4 ViewModel paywall gating - Add isPremium to AppGate (mirrors BillingManager.isPremium) - VehicleListViewModel: isReadOnly state, upgrade dialog, auto-navigate on purchase, LaunchPurchaseFlow effect - AddEditVehicleViewModel: isReadOnly state, block save when read-only - VehicleDetailViewModel: isReadOnly state, block AddRecord/EditVehicle/RequestDeleteVehicle/RequestDeleteRecord - RecordDetailViewModel: isReadOnly state, block EditRecord/RequestDelete - AddEditRecordViewModel: isReadOnly state, block save - CategoriesViewModel: isReadOnly state, block AddCategory/RequestDelete - SettingsViewModel: isPremium/isReadOnly/isRestoringPurchases state, RestorePurchases event, RequestUpgrade/SetDebugOverride events, LaunchPurchaseFlow/ShowSnackbar/ShowBillingError effects - ViewModelFactory: pass appGate to all VMs, billingManager+debugBillingOverride to VehicleList+Settings - Add billing string resources - Update all 7 VM tests with gating tests + new events" Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/063dd5d5-3741-4799-a713-4d43ca4cdfd1 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address code review comments — atomic state check for auto-navigate, strengthen no-emit test Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/063dd5d5-3741-4799-a713-4d43ca4cdfd1 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address code review feedback on Phase 4 paywall gating - SettingsViewModel.restorePurchases(): add re-entrancy guard - SettingsViewModelTest: rename misleading test; add re-entrancy test - VehicleListViewModel.RequestUpgrade: document why dialog not dismissed - Defense-in-depth: gate ConfirmDeleteVehicle/ConfirmDeleteRecord/ConfirmDelete on isReadOnly in VehicleListVM, VehicleDetailVM, RecordDetailVM, CategoriesVM - Add ConfirmDelete* guard tests for all gated VMs - AppGate: document threshold mismatch (> 1 migration vs >= 1 new-user) - Remove repeated '// Observe read-only gate' boilerplate comments (5 VMs) Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/3fd307c1-f9b7-4ca0-a813-5af9456d890d Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address re-review issues on Phase 4 paywall gating - Fix BLOCKER: add LaunchPurchaseFlow->Unit branches to VehicleListScreen and SettingsScreen when-expressions (assembleDebug was broken) - Remove dead ShowBillingError from VehicleListEffect and SettingsEffect - VehicleListViewModel: use appGate.isPremium instead of billingManager.isPremium; remove BillingManager dependency; update ViewModelFactory accordingly - FakeBillingManager: add restoreDeferred CompletableDeferred for suspend control - Fix shadow ConfirmDeleteVehicle/ConfirmDeleteRecord/ConfirmDelete tests: set pendingDeleteId/confirmDeleteId via RequestDelete first, then flip isReadOnly via live MutableStateFlow so guard is what actually blocks - Fix re-entrancy test: use CompletableDeferred to suspend first call, assert isRestoringPurchases=true mid-flight, verify guard drops second call Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/480a13e7-1fea-4202-9162-5197a16cb4ec Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: update *IT.kt constructors for Phase 4 signatures; make SettingsViewModel._effects buffered - RecordDetailViewModelIT: add appGate mock + update both VM constructions - SettingsViewModelIT: add appGate/fakeBilling/debugBillingOverride mocks + update all 5 VM constructions - AddEditVehicleViewModelIT: add appGate mock + update VM construction - SettingsViewModel: change _effects Channel to BUFFERED to prevent coroutine leak in re-entrancy test Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/8813c1ba-40dd-4db5-9209-9a7cc401716a Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: subscribe to WhileSubscribed state before reading in 3 SettingsViewModelTest tests - state reflects isPremium: wrapped in state.test{} loop (same pattern as init test) - state reflects isReadOnly: same - is re-entrancy-guarded: launch hot collector so state.value reflects live combine output - add import kotlinx.coroutines.launch Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/f7835ed5-a7ab-4da6-af37-5738826c202e Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: apply guardWrites helper, @Suppress(LongParameterList), and baseline for detekt compliance Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/c0a2580d-92ee-4d66-b6ee-236fabb6b78c Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: apply ktlintFormat to clear remaining 5 style violations Co-Authored-By: Claude Opus 4.7 * fix: address self-review on Phase 4 paywall gating - Drop AppGate.isPremium alias; route reads through BillingManager directly (VehicleListViewModel now takes BillingManager). - Localize the BillingUnavailable / Error branches by replacing restore_error with restore_error_billing_unavailable + restore_error_code (%1$d). - Refactor SettingsViewModel.onEvent into handleBillingEvent so the CyclomaticComplexMethod baseline entry can be removed. - Add WHY comment on SettingsViewModel _state vs WhileSubscribed-cached state. - Add defense-in-depth comments on AddEditVehicle/Record save() no-ops. - Add live-transition tests covering isReadOnly false→true post-construction for VehicleList, VehicleDetail, and AddEditRecord ViewModels. Co-Authored-By: Claude Opus 4.7 * docs: fix misleading save-guard comments and rewrite AppGate.isReadOnly KDoc Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/ef18e7da-49ec-47e4-b694-cb6ef4d2cd3b Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: resolve all remaining code review issues (items 3-6) Issue 3: Add AppGate.isPremium, remove BillingManager from VehicleListViewModel - AppGate now exposes isPremium (mirrors BillingManager.isPremium directly) - VehicleListViewModel uses appGate.isPremium everywhere; BillingManager dep removed - ViewModelFactory updated; VehicleListViewModelTest stubs appGate.isPremium Issue 4: Soften SettingsViewModel comment about _state.value vs state.value - Now accurately notes that prefs-sourced fields must be read from state.value Issue 5: Consolidate guardWrites into GatedViewModel base class - New GatedViewModel abstract class in ui/util provides guardWrites - VehicleListViewModel, VehicleDetailViewModel, RecordDetailViewModel, CategoriesViewModel all extend GatedViewModel; local guardWrites helpers removed Issue 6: Make SettingsViewModel.onEvent exhaustive - Remove handleBillingEvent helper; billing events folded inline into main when - Exhaustive when catches any new event added without a branch at compile time Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/a024c0f2-9a8c-4d66-b0cd-28f04b11b897 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address code review issues #1-#2 (direct isReadOnly.value read, deduplicate comment) Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/ef65f7c6-f248-4910-a919-1002f9ba5efa Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address Phase 4 review issues #1-#4 and all minors Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/6b9fe108-4672-423c-bb4d-1fb8a7f07b0f Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix: address PR #52 review feedback on paywall gating * fix: address PR #52 review feedback on paywall gating (documentation and strings) * docs: add tradeoff comment to RecordDetailViewModel RequestDelete * refactor: introduce ViewModelDependencies aggregate and fix linting * fix: address PR #52 review feedback (dialog UI, security, and cleanup) * fix: address PR #52 review feedback (error handling, test reliability, and cleanup) * fix: address final PR #52 review feedback (comments and tests) * Address Phase 4 review comments: add defense-in-depth, fix i18n, and update tests * fix: address review comments for PR #52 - Remove committed JVM crash log and update .gitignore to prevent recurrence - Localize 'Premium required' error string in AddEditVehicleViewModel - Fix inconsistent logging in SettingsViewModel - Fix stale state access in OpenFileManager event - Refactor ViewModelDependencies from data class to regular class - Update AddEditVehicleViewModelTest to handle localized strings - Fix lint issues in affected ViewModels * fix: address PR #52 review comments for Phase 4 gating - Fix stale isPremium read in SettingsViewModel - Surfacing paywall error instead of throwing in AddEditVehicleViewModel - Persist showUpgradeDialog via SavedStateHandle in VehicleListViewModel - Add logging to GatedViewModel.guardWrites - Simplify RecordDetailViewModel state reset - Fix 31 test failures by mocking Log and updating ViewModelFactoryTest * fix: resolve ViewModelFactory crash and remove SavedStateHandle --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> Co-authored-by: Mesya82 Co-authored-by: Claude Opus 4.7 Co-authored-by: Igor Vinogradov commit 89542bb6b24a097bc5d5187597b01e182b16b85c Author: Igor Vinogradov Date: Thu Apr 30 08:49:25 2026 +0300 feat(paywall): Phase 3 — App wiring: AppGate + lifecycle hooks (#51) * feat(paywall): Phase 3 — App wiring: AppGate + lifecycle hooks - Implement AppGate to centralize read-only logic - Wire BillingManager and AppGate into ServiceBookApplication - Add billing lifecycle hooks to MainActivity - Update test infrastructure with FakeBillingManager - Add AppGate derivation matrix tests - Enable BuildConfig in buildFeatures * fix(paywall): address Phase 3 review feedback Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/393d6d96-1f86-45a6-b43d-d664f2c36d62 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix(paywall): address remaining Phase 3 nits Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/048aba52-c456-4482-babd-d1126458b4c0 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> * fix(detekt): eliminate ReturnCount violations in BillingManagerImpl Agent-Logs-Url: https://github.com/Mesya82/Service-Book/sessions/60812478-190d-4152-9e0a-731f2aa16343 Co-authored-by: Mesya82 <32867735+Mesya82@users.noreply.github.com> --------- Co-authored-by: Mesya82 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> commit ef2db901f3ad322518b4272bf0987d8b6b81a271 Author: Igor Vinogradov Date: Wed Apr 29 13:46:11 2026 +0300 test: fix release unit test compilation by moving billing tests to testDebug (#50) * test: move BillingManagerImplTest to testDebug to fix release compilation * test: update screenshot baselines after dependency bumps --------- Co-authored-by: Mesya82 commit 8d975e67b23252f2760f27bb51a133ff36eb14d9 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed Apr 29 11:21:46 2026 +0300 build(deps): bump org.robolectric:robolectric from 4.12.2 to 4.16.1 (#46) Bumps [org.robolectric:robolectric](https://github.com/robolectric/robolectric) from 4.12.2 to 4.16.1. - [Release notes](https://github.com/robolectric/robolectric/releases) - [Commits](https://github.com/robolectric/robolectric/compare/robolectric-4.12.2...robolectric-4.16.1) --- updated-dependencies: - dependency-name: org.robolectric:robolectric dependency-version: 4.16.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Process Group PGID: 1895647