package com.servicebook.ui.reportbug import android.os.Build import android.util.Log import androidx.lifecycle.viewModelScope import com.servicebook.BuildConfig import com.servicebook.R import com.servicebook.ui.util.GatedViewModel import com.servicebook.ui.util.ViewModelDependencies import com.servicebook.util.SentryWrapper import com.servicebook.util.SentryWrapperImpl import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.Locale enum class ReportType { BUG, SUGGESTION } data class ReportBugState( val type: ReportType = ReportType.BUG, val summary: String = "", val description: String = "", val contactEmail: String = "", val includeDeviceInfo: Boolean = true, val showDeviceInfo: Boolean = false, val isSubmitting: Boolean = false, val appVersion: String = "", val androidVersion: String = "", val locale: String = "", val deviceModel: String = "", val vaultConfigured: Boolean = false, ) { val canSubmit: Boolean get() = summary.isNotBlank() && description.isNotBlank() && !isSubmitting } sealed class ReportBugEvent { data class TypeChanged(val type: ReportType) : ReportBugEvent() data class SummaryChanged(val summary: String) : ReportBugEvent() data class DescriptionChanged(val description: String) : ReportBugEvent() data class ContactEmailChanged(val email: String) : ReportBugEvent() data class IncludeDeviceInfoChanged(val include: Boolean) : ReportBugEvent() data object ToggleDeviceInfo : ReportBugEvent() data object Submit : ReportBugEvent() } sealed class ReportBugEffect { data class ShowSnackbar(val message: String) : ReportBugEffect() data object NavigateBack : ReportBugEffect() data class FallbackToEmail( val recipient: String, val subject: String, val body: String, ) : ReportBugEffect() } class ReportBugViewModel( dependencies: ViewModelDependencies, private val sentryWrapper: SentryWrapper = SentryWrapperImpl(), ) : GatedViewModel(dependencies) { private val _state = MutableStateFlow(ReportBugState()) val state: StateFlow = _state.asStateFlow() private val _effects = Channel(Channel.BUFFERED) val effects = _effects.receiveAsFlow() private var retryCount = 0 init { viewModelScope.launch { val prefs = dependencies.preferences.preferences.first() _state.update { it.copy( appVersion = BuildConfig.VERSION_NAME, androidVersion = Build.VERSION.RELEASE, locale = Locale.getDefault().toString(), deviceModel = Build.MODEL, vaultConfigured = !prefs.vaultUriString.isNullOrEmpty(), ) } } } fun onEvent(event: ReportBugEvent) { when (event) { is ReportBugEvent.TypeChanged -> _state.update { it.copy(type = event.type) } is ReportBugEvent.SummaryChanged -> _state.update { it.copy(summary = event.summary.take(MAX_SUMMARY_LENGTH)) } is ReportBugEvent.DescriptionChanged -> _state.update { it.copy(description = event.description.take(MAX_DESCRIPTION_LENGTH)) } is ReportBugEvent.ContactEmailChanged -> _state.update { it.copy(contactEmail = event.email) } is ReportBugEvent.IncludeDeviceInfoChanged -> _state.update { it.copy(includeDeviceInfo = event.include) } ReportBugEvent.ToggleDeviceInfo -> _state.update { it.copy(showDeviceInfo = !it.showDeviceInfo) } ReportBugEvent.Submit -> { retryCount = 0 submitReport() } } } private fun submitReport() { if (!state.value.canSubmit) return viewModelScope.launch { _state.update { it.copy(isSubmitting = true) } performSubmission() } } private suspend fun performSubmission() { try { val s = _state.value val comments = "${s.summary}\n\n${s.description}" val email = s.contactEmail.ifBlank { null } val tags = mutableMapOf( "type" to s.type.name.lowercase(), "app_version" to s.appVersion, "android_version" to s.androidVersion, "locale" to s.locale, "device_model" to s.deviceModel, "vault_configured" to s.vaultConfigured.toString(), ) val submissionTags = if (s.includeDeviceInfo) tags else emptyMap() if (sentryWrapper.isEnabled()) { sentryWrapper.captureFeedback(comments, email, submissionTags) } else { sentryWrapper.lazyInitAndSendFeedback( app, BuildConfig.SENTRY_DSN, comments, email, submissionTags, ) } _effects.send(ReportBugEffect.ShowSnackbar(app.getString(R.string.report_bug_success))) _effects.send(ReportBugEffect.NavigateBack) } catch ( @Suppress("TooGenericExceptionCaught") e: Exception, ) { Log.e("ReportBugViewModel", "Failed to submit report", e) if (retryCount < MAX_RETRY_COUNT) { retryCount++ _effects.send(ReportBugEffect.ShowSnackbar(app.getString(R.string.report_bug_retrying, retryCount))) performSubmission() } else { _state.update { it.copy(isSubmitting = false) } _effects.send(ReportBugEffect.ShowSnackbar(app.getString(R.string.report_bug_error_failed))) fallbackToEmail() } } } private fun fallbackToEmail() { val s = _state.value val subject = "[${s.type.name}] ${s.summary}" val body = buildString { appendLine(s.description) if (s.includeDeviceInfo) { appendLine() appendLine("--- Device Info ---") appendLine("App Version: ${s.appVersion}") appendLine("Android Version: ${s.androidVersion}") appendLine("Locale: ${s.locale}") appendLine("Device Model: ${s.deviceModel}") appendLine("Vault Configured: ${s.vaultConfigured}") } } viewModelScope.launch { _effects.send(ReportBugEffect.FallbackToEmail(SUPPORT_EMAIL, subject, body)) } } companion object { private const val MAX_SUMMARY_LENGTH = 80 private const val MAX_DESCRIPTION_LENGTH = 2000 private const val MAX_RETRY_COUNT = 2 private const val SUPPORT_EMAIL = "reports@servicebook.app" } }