package com.yorvana.ui.settings import android.content.pm.PackageManager import android.net.Uri import android.util.Log import androidx.lifecycle.viewModelScope import com.yorvana.BuildConfig import com.yorvana.R import com.yorvana.YorvanaApplication import com.yorvana.data.billing.DebugBillingActions import com.yorvana.data.billing.RestoreResult import com.yorvana.data.model.OdometerUnit import com.yorvana.data.preferences.DebugBillingOverrideMode import com.yorvana.data.storage.DestinationNotEmptyException import com.yorvana.ui.util.GatedViewModel import com.yorvana.ui.util.ViewModelDependencies import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class SettingsState( val vaultUriString: String? = null, val defaultOdometerUnit: OdometerUnit = OdometerUnit.KM, val defaultCurrency: String = "USD", val appVersion: String = "", val isValidating: Boolean = false, val isMigrating: Boolean = false, val showMoveOrFreshDialog: Boolean = false, val showConflictDialog: Boolean = false, val showUseExistingVaultDialog: Boolean = false, val pendingNewUri: Uri? = null, val 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, val debugOverrideMode: DebugBillingOverrideMode = DebugBillingOverrideMode.NONE, val crashReportingEnabled: Boolean = false, ) sealed class SettingsEvent { data object OpenCategories : SettingsEvent() data object OpenReportBug : SettingsEvent() data object OpenContact : SettingsEvent() data object OpenFileManager : SettingsEvent() data class VaultFolderPicked(val uri: Uri) : SettingsEvent() data object MoveVault : SettingsEvent() data object StartFresh : SettingsEvent() data object DismissMoveDialog : SettingsEvent() data object DismissConflictDialog : SettingsEvent() data object DismissUseExistingVaultDialog : SettingsEvent() data class OdometerUnitChanged(val unit: OdometerUnit) : SettingsEvent() data class CurrencyChanged(val currency: String) : SettingsEvent() data object RequestUpgrade : SettingsEvent() data object RestorePurchases : SettingsEvent() data object SimulatePurchaseSuccess : SettingsEvent() data object ResetBillingState : SettingsEvent() data class SetDebugOverride(val mode: DebugBillingOverrideMode) : SettingsEvent() data class CrashReportingToggled(val enabled: Boolean) : SettingsEvent() } sealed class SettingsEffect { data object NavigateToCategories : SettingsEffect() data object NavigateToReportBug : SettingsEffect() data class LaunchContactEmail( val recipient: String, val subject: String, ) : SettingsEffect() data class LaunchFileManager(val vaultUriString: String) : SettingsEffect() data class ShowSnackbar(val message: String) : SettingsEffect() data object LaunchPurchaseFlow : SettingsEffect() } /** * SettingsViewModel manages vault selection, preferences, and billing actions. * * **State architecture note**: two state holders coexist: * - `_state` (`MutableStateFlow`) holds billing/internal fields (isPremium, * isReadOnly, isRestoringPurchases, debugOverrideMode). Internal logic **must** read these * via `_state.value` — the public `state` flow is `WhileSubscribed`-cached and returns * `initialValue = SettingsState()` when there is no active subscriber. * - `state` (public `StateFlow`) is the `combine` of `_state` + preferences. Fields that * originate from preferences (vaultUriString, defaultOdometerUnit, etc.) are **only** * available via `state.value` since they flow exclusively through the combine. * * This distinction applies to every field added to [SettingsState] in the future. */ class SettingsViewModel( dependencies: ViewModelDependencies, ) : GatedViewModel(dependencies) { private val preferences = dependencies.preferences private val vaultStorage = dependencies.vaultStorage private val billingManager = dependencies.billingManager private val debugBillingOverride = dependencies.debugBillingOverride private val _state = MutableStateFlow(SettingsState()) val state: StateFlow = combine( _state, preferences.preferences, ) { internalState, prefs -> internalState.copy( vaultUriString = prefs.vaultUriString, defaultOdometerUnit = prefs.defaultOdometerUnit, defaultCurrency = prefs.defaultCurrency, appVersion = getAppVersion(), crashReportingEnabled = prefs.crashReportingEnabled, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = SettingsState(), ) // BUFFERED so that effects emitted before the UI subscribes (e.g., during tests or when // the UI briefly unsubscribes) are not dropped rather than suspending the sender. private val _effects = Channel(Channel.BUFFERED) val effects = _effects.receiveAsFlow() private var currencyChangeJob: Job? = null init { 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) } private fun getAppVersion(): String { return try { val packageInfo = app.packageManager.getPackageInfo(app.packageName, 0) packageInfo.versionName ?: "" } catch (e: PackageManager.NameNotFoundException) { Log.w("SettingsViewModel", "Could not get app version", e) "" } } @Suppress("CyclomaticComplexMethod") fun onEvent(event: SettingsEvent) { when (event) { is SettingsEvent.VaultFolderPicked -> handleVaultPicked(event.uri) is SettingsEvent.OdometerUnitChanged -> viewModelScope.launch { preferences.setDefaultOdometerUnit(event.unit) } is SettingsEvent.CurrencyChanged -> { currencyChangeJob?.cancel() currencyChangeJob = viewModelScope.launch { delay(CURRENCY_CHANGE_DELAY_MILLIS) preferences.setDefaultCurrency(event.currency) } } SettingsEvent.MoveVault -> moveVault() SettingsEvent.StartFresh -> startFresh() SettingsEvent.DismissMoveDialog -> _state.update { it.copy(showMoveOrFreshDialog = false, pendingNewUri = null) } SettingsEvent.DismissConflictDialog -> _state.update { it.copy(showConflictDialog = false) } SettingsEvent.DismissUseExistingVaultDialog -> _state.update { it.copy( showUseExistingVaultDialog = false, pendingNewUri = null, ) } SettingsEvent.OpenCategories -> { viewModelScope.launch { _effects.send(SettingsEffect.NavigateToCategories) } } SettingsEvent.OpenReportBug -> { viewModelScope.launch { _effects.send(SettingsEffect.NavigateToReportBug) } } SettingsEvent.OpenContact -> { viewModelScope.launch { _effects.send( SettingsEffect.LaunchContactEmail( recipient = app.getString(R.string.report_bug_support_email), subject = app.getString(R.string.settings_contact_email_subject), ), ) } } SettingsEvent.OpenFileManager -> { viewModelScope.launch { val uriString = preferences.preferences.first().vaultUriString ?: return@launch _effects.send(SettingsEffect.LaunchFileManager(uriString)) } } SettingsEvent.RequestUpgrade -> if (!appGate.isPremium.value) { viewModelScope.launch { _effects.send(SettingsEffect.LaunchPurchaseFlow) } } SettingsEvent.RestorePurchases -> restorePurchases() SettingsEvent.SimulatePurchaseSuccess -> handleSimulatePurchaseSuccess() SettingsEvent.ResetBillingState -> handleResetBillingState() is SettingsEvent.SetDebugOverride -> handleSetDebugOverride(event.mode) is SettingsEvent.CrashReportingToggled -> handleCrashReportingToggled(event.enabled) } } private fun handleSimulatePurchaseSuccess() { if (BuildConfig.DEBUG) { viewModelScope.launch { (billingManager as? DebugBillingActions)?.simulatePurchaseSuccess() _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_debug_purchase_success))) } } } private fun handleResetBillingState() { if (BuildConfig.DEBUG) { viewModelScope.launch { (billingManager as? DebugBillingActions)?.resetBillingState() _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_debug_billing_reset))) } } } private fun handleSetDebugOverride(mode: DebugBillingOverrideMode) { if (BuildConfig.DEBUG) { viewModelScope.launch { debugBillingOverride?.setMode(mode) } } else { Log.w("SettingsViewModel", "SetDebugOverride called in non-debug build") } } private fun handleCrashReportingToggled(enabled: Boolean) { viewModelScope.launch { preferences.setCrashReportingEnabled(enabled) if (enabled) { getApplication().enableSentryNow() } else { getApplication().disableSentryNow() } } } @Suppress("TooGenericExceptionCaught") private fun restorePurchases() { // Reads internal _state.value directly; see class KDoc for rationale. if (_state.value.isRestoringPurchases) return _state.update { it.copy(isRestoringPurchases = true) } viewModelScope.launch { try { val result = billingManager.restorePurchases() val message = when (result) { is RestoreResult.Success -> if (result.hadPurchase) { app.getString(R.string.restore_success) } else { app.getString(R.string.restore_none_found) } is RestoreResult.BillingUnavailable -> app.getString(R.string.restore_error_billing_unavailable) is RestoreResult.Error -> app.getString(R.string.restore_error_code, result.responseCode) } _effects.send(SettingsEffect.ShowSnackbar(message)) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { Log.e("SettingsViewModel", "restorePurchases failed", e) val errorSuffix = e.message ?: app.getString(R.string.billing_error_unknown) _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.billing_error, errorSuffix))) } finally { _state.update { it.copy(isRestoringPurchases = false) } } } } private fun handleVaultPicked(uri: Uri) { val currentUri = state.value.vaultUriString if (currentUri == null) { // First-time setup — no existing data to migrate viewModelScope.launch { preferences.setVaultUri(uri.toString()) } } else { // Show loading state while analyzing the directory _state.update { it.copy(isValidating = true, pendingNewUri = uri) } viewModelScope.launch { val isEmpty = vaultStorage.isDestinationEmpty(uri) if (isEmpty) { _state.update { it.copy(isValidating = false, showMoveOrFreshDialog = true) } } else { val isVault = vaultStorage.isVaultFolder(uri) if (isVault) { _state.update { it.copy(isValidating = false, showUseExistingVaultDialog = true) } } else { // Not empty and not a vault folder _state.update { it.copy(isValidating = false, showConflictDialog = true, pendingNewUri = null) } } } } } } private fun moveVault() { val newUri = _state.value.pendingNewUri ?: return _state.update { it.copy(showMoveOrFreshDialog = false, isMigrating = true) } viewModelScope.launch { val result = vaultStorage.moveVaultTo(newUri) result.onSuccess { val uriString = newUri.toString() preferences.setVaultUri(uriString) _state.update { it.copy(isMigrating = false, pendingNewUri = null) } _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_vault_changed_success))) }.onFailure { e -> _state.update { it.copy(isMigrating = false) } if (e is DestinationNotEmptyException) { _state.update { it.copy(showConflictDialog = true) } } else { _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_vault_move_error, e.message ?: ""))) } } } } private fun startFresh() { val uriString = _state.value.pendingNewUri?.toString() ?: return _state.update { it.copy(showMoveOrFreshDialog = false, showUseExistingVaultDialog = false, pendingNewUri = null) } viewModelScope.launch { preferences.setVaultUri(uriString) _effects.send(SettingsEffect.ShowSnackbar(app.getString(R.string.settings_vault_changed_success))) } } companion object { private const val STOP_TIMEOUT_MILLIS = 5000L private const val CURRENCY_CHANGE_DELAY_MILLIS = 400L } }