package com.servicebook.ui.reportbug import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import com.servicebook.R import com.servicebook.ServiceBookApplication import com.servicebook.ui.TestTags import com.servicebook.ui.theme.appTopAppBarColors import com.servicebook.ui.util.ViewModelFactory @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportBugScreen( backStack: NavBackStack, viewModel: ReportBugViewModel = viewModel( factory = ViewModelFactory(LocalContext.current.applicationContext as ServiceBookApplication), ), ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scrollState = rememberScrollState() val noEmailAppMessage = stringResource(R.string.report_bug_no_email_app) val expandedDescription = stringResource(R.string.a11y_state_expanded) val collapsedDescription = stringResource(R.string.a11y_state_collapsed) LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { is ReportBugEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) ReportBugEffect.NavigateBack -> backStack.removeLastOrNull() is ReportBugEffect.FallbackToEmail -> { val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() putExtra(Intent.EXTRA_EMAIL, arrayOf(effect.recipient)) putExtra(Intent.EXTRA_SUBJECT, effect.subject) putExtra(Intent.EXTRA_TEXT, effect.body) } try { context.startActivity(intent) } catch (_: Exception) { snackbarHostState.showSnackbar(noEmailAppMessage) } } } } } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text( text = stringResource(R.string.report_bug_title), modifier = Modifier.testTag(TestTags.SCREEN_TITLE), ) }, navigationIcon = { IconButton(onClick = { backStack.removeLastOrNull() }, enabled = !state.isSubmitting) { Icon( Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back), ) } }, actions = { if (state.isSubmitting) { CircularProgressIndicator( modifier = Modifier.size(24.dp).padding(end = 16.dp).testTag(TestTags.REPORT_BUG_LOADING), strokeWidth = 2.dp, ) } else { TextButton( onClick = { viewModel.onEvent(ReportBugEvent.Submit) }, enabled = state.canSubmit, modifier = Modifier.testTag(TestTags.REPORT_BUG_SUBMIT_BUTTON), ) { Text(stringResource(R.string.report_bug_submit)) } } }, colors = appTopAppBarColors(), ) }, ) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) .imePadding() .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (state.isSubmitting) { LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), ) } // Type Text( text = stringResource(R.string.report_bug_type_section), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( selected = state.type == ReportType.BUG, onClick = { viewModel.onEvent(ReportBugEvent.TypeChanged(ReportType.BUG)) }, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), enabled = !state.isSubmitting, label = { Text(stringResource(R.string.report_bug_type_bug)) }, modifier = Modifier.testTag(TestTags.REPORT_BUG_TYPE_BUG), ) SegmentedButton( selected = state.type == ReportType.SUGGESTION, onClick = { viewModel.onEvent(ReportBugEvent.TypeChanged(ReportType.SUGGESTION)) }, shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), enabled = !state.isSubmitting, label = { Text(stringResource(R.string.report_bug_type_suggestion)) }, modifier = Modifier.testTag(TestTags.REPORT_BUG_TYPE_SUGGESTION), ) } OutlinedTextField( value = state.summary, onValueChange = { viewModel.onEvent(ReportBugEvent.SummaryChanged(it)) }, label = { Text(stringResource(R.string.report_bug_summary)) }, modifier = Modifier.fillMaxWidth().testTag(TestTags.REPORT_BUG_SUMMARY_FIELD), singleLine = true, enabled = !state.isSubmitting, supportingText = { Text( "${state.summary.length}/${ReportBugViewModel.MAX_SUMMARY_LENGTH}", modifier = Modifier.fillMaxWidth(), ) }, ) OutlinedTextField( value = state.description, onValueChange = { viewModel.onEvent(ReportBugEvent.DescriptionChanged(it)) }, label = { Text(stringResource(R.string.report_bug_description)) }, modifier = Modifier .fillMaxWidth() .height(160.dp) .testTag(TestTags.REPORT_BUG_DESCRIPTION_FIELD), enabled = !state.isSubmitting, supportingText = { Text( "${state.description.length}/${ReportBugViewModel.MAX_DESCRIPTION_LENGTH}", modifier = Modifier.fillMaxWidth(), ) }, ) OutlinedTextField( value = state.contactEmail, onValueChange = { viewModel.onEvent(ReportBugEvent.ContactEmailChanged(it)) }, label = { Text(stringResource(R.string.report_bug_contact_email)) }, modifier = Modifier.fillMaxWidth().testTag(TestTags.REPORT_BUG_EMAIL_FIELD), singleLine = true, enabled = !state.isSubmitting, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), isError = state.isEmailInvalid, supportingText = if (state.isEmailInvalid) { { Text(stringResource(R.string.report_bug_contact_email_invalid)) } } else { null }, ) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), ) { Column { Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = !state.isSubmitting, onClickLabel = stringResource( if (state.showDeviceInfo) R.string.action_show_less else R.string.action_show_more, ), ) { viewModel.onEvent(ReportBugEvent.ToggleDeviceInfo) } .padding(16.dp) .testTag(TestTags.REPORT_BUG_DEVICE_INFO_CARD) .semantics { stateDescription = if (state.showDeviceInfo) { expandedDescription } else { collapsedDescription } role = Role.Button }, verticalAlignment = Alignment.CenterVertically, ) { Text( stringResource(R.string.report_bug_device_info_payload), style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f), ) Icon( if (state.showDeviceInfo) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null, ) } AnimatedVisibility( visible = state.showDeviceInfo, enter = expandVertically( animationSpec = tween(250), expandFrom = Alignment.Top, ) + fadeIn(tween(250)), exit = shrinkVertically( animationSpec = tween(250), shrinkTowards = Alignment.Top, ) + fadeOut(tween(250)), ) { Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 8.dp)) { Text( stringResource(R.string.report_bug_include_device_info), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) Switch( checked = state.includeDeviceInfo, onCheckedChange = { viewModel.onEvent(ReportBugEvent.IncludeDeviceInfoChanged(it)) }, modifier = Modifier.testTag(TestTags.REPORT_BUG_DEVICE_INFO_TOGGLE), enabled = !state.isSubmitting, ) } DeviceInfoItem(stringResource(R.string.report_bug_app_version), state.appVersion) DeviceInfoItem(stringResource(R.string.report_bug_android_version), state.androidVersion) DeviceInfoItem(stringResource(R.string.report_bug_locale), state.locale) DeviceInfoItem(stringResource(R.string.report_bug_device_model), state.deviceModel) DeviceInfoItem(stringResource(R.string.report_bug_vault_configured), state.vaultConfigured.toString()) } } } } if (!state.crashReportingEnabled) { Text( text = stringResource(R.string.report_bug_sentry_opt_out_disclosure), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().testTag(TestTags.REPORT_BUG_DISCLOSURE), ) } } } } @Composable private fun DeviceInfoItem( label: String, value: String, ) { Row(modifier = Modifier.padding(vertical = 2.dp)) { Text(text = "$label: ", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) Text(text = value, style = MaterialTheme.typography.bodySmall) } }