diff options
| author | David Göransson <david.goransson90@gmail.com> | 2023-09-21 14:30:47 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson90@gmail.com> | 2023-10-04 09:19:41 +0200 |
| commit | 3c54cb0a604226fae03ccb1880ed0ccda1d388ea (patch) | |
| tree | ed0675c81484ca989c491589f51f96f687593de2 /android/app | |
| parent | 66059ddae495b1fb173adb33cc950f6840937bb3 (diff) | |
| download | mullvadvpn-3c54cb0a604226fae03ccb1880ed0ccda1d388ea.tar.xz mullvadvpn-3c54cb0a604226fae03ccb1880ed0ccda1d388ea.zip | |
Create view in compose
Diffstat (limited to 'android/app')
17 files changed, 886 insertions, 495 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index e0f84db5ea..0d032f962a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -34,6 +34,7 @@ fun ScaffoldWithTopBar( topBarColor: Color, statusBarColor: Color, navigationBarColor: Color, + modifier: Modifier = Modifier, iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, @@ -48,6 +49,7 @@ fun ScaffoldWithTopBar( } Scaffold( + modifier = modifier, topBar = { TopBar( backgroundColor = topBarColor, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt index 35ddc58592..52f2f1d726 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt @@ -72,19 +72,33 @@ import kotlinx.coroutines.flow.collectLatest fun Modifier.drawHorizontalScrollbar( state: ScrollState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) } fun Modifier.drawVerticalScrollbar( state: ScrollState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) } + +fun Modifier.drawHorizontalScrollbar( + state: ScrollState, + color: Color, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Horizontal, color, reverseScrolling) + +fun Modifier.drawVerticalScrollbar( + state: ScrollState, + color: Color, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Vertical, color, reverseScrolling) private fun Modifier.drawScrollbar( state: ScrollState, orientation: Orientation, + color: Color, reverseScrolling: Boolean ): Modifier = - drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha + -> if (state.maxValue > 0) { val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height val totalSize = canvasSize + state.maxValue @@ -94,7 +108,7 @@ private fun Modifier.drawScrollbar( orientation, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -105,19 +119,21 @@ private fun Modifier.drawScrollbar( fun Modifier.drawHorizontalScrollbar( state: LazyListState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) } fun Modifier.drawVerticalScrollbar( state: LazyListState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) } private fun Modifier.drawScrollbar( state: LazyListState, orientation: Orientation, + color: Color, reverseScrolling: Boolean ): Modifier = - drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha + -> val layoutInfo = state.layoutInfo val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset val items = layoutInfo.visibleItemsInfo @@ -137,7 +153,7 @@ private fun Modifier.drawScrollbar( orientation, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -148,9 +164,14 @@ private fun Modifier.drawScrollbar( fun Modifier.drawVerticalScrollbar( state: LazyGridState, spanCount: Int, - reverseScrolling: Boolean = false + color: Color, + reverseScrolling: Boolean = false, ): Modifier = - drawScrollbar(Orientation.Vertical, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(Orientation.Vertical, color, reverseScrolling) { + reverseDirection, + atEnd, + paintColor, + alpha -> val layoutInfo = state.layoutInfo val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset val items = layoutInfo.visibleItemsInfo @@ -176,7 +197,7 @@ fun Modifier.drawVerticalScrollbar( Orientation.Vertical, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -225,6 +246,7 @@ private fun DrawScope.drawScrollbar( private fun Modifier.drawScrollbar( orientation: Orientation, + color: Color, reverseScrolling: Boolean, onDraw: DrawScope.( @@ -269,8 +291,6 @@ private fun Modifier.drawScrollbar( } else reverseScrolling val atEnd = if (orientation == Orientation.Vertical) isLtr else true - val color = BarColor - Modifier.nestedScroll(nestedScrollConnection).drawWithContent { drawContent() onDraw(reverseDirection, atEnd, color, alpha::value) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt new file mode 100644 index 0000000000..ff9b658211 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt @@ -0,0 +1,86 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton + +@Preview +@Composable +private fun PreviewReportProblemNoEmailDialog() { + ReportProblemNoEmailDialog( + onDismiss = {}, + onConfirm = {}, + ) +} + +@Composable +fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = "Remove", + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = stringResource(id = R.string.confirm_no_email), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + dismissButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.red), + contentColor = Color.White + ), + onClick = onConfirm, + ) { + Text(text = stringResource(id = R.string.send_anyway), fontSize = 18.sp) + } + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = { onDismiss() }, + ) { + Text(text = stringResource(id = R.string.back), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt new file mode 100644 index 0000000000..6a63423399 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt @@ -0,0 +1,221 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState + +@Composable +fun ShowReportProblemStateDialog( + sendingState: SendingReportUiState, + onDismiss: () -> Unit, + onClearForm: () -> Unit, + retry: () -> Unit +) { + when (sendingState) { + SendingReportUiState.Sending -> ReportProblemSendingDialog() + is SendingReportUiState.Error -> + ReportProblemErrorDialog(onDismiss = onDismiss, retry = retry) + is SendingReportUiState.Success -> + ReportProblemSuccessDialog( + sendingState.email, + onConfirm = { + onClearForm() + onDismiss() + } + ) + } +} + +@Preview +@Composable +private fun PreviewReportProblemSendingDialog() { + ReportProblemSendingDialog() +} + +@Composable +private fun ReportProblemSendingDialog() { + AlertDialog( + onDismissRequest = {}, + title = { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(Dimens.progressIndicatorSize) + ) + } + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.sending), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + fontStyle = FontStyle.Normal, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = {}, + properties = + DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + containerColor = colorResource(id = R.color.darkBlue) + ) +} + +@Preview +@Composable +private fun PreviewReportProblemSuccessDialog() { + ReportProblemSuccessDialog( + "Email@em.com", + onConfirm = {}, + ) +} + +@Composable +fun ReportProblemSuccessDialog(email: String?, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { onConfirm() }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_success), + contentDescription = "Remove", + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = + buildAnnotatedString { + withStyle(SpanStyle(color = colorResource(id = R.color.green))) { + append(stringResource(id = R.string.sent_thanks)) + } + append(" ") + + withStyle(SpanStyle(color = colorResource(id = R.color.white))) { + append(stringResource(id = R.string.we_will_look_into_this)) + } + }, + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = { onConfirm() }, + ) { + Text(text = stringResource(id = R.string.dismiss), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} + +@Preview +@Composable +private fun PreviewReportProblemErrorDialog() { + ReportProblemErrorDialog( + onDismiss = {}, + retry = {}, + ) +} + +@Composable +fun ReportProblemErrorDialog(onDismiss: () -> Unit, retry: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = stringResource(id = R.string.failed_to_send_details), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + dismissButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = onDismiss, + ) { + Text(text = stringResource(id = R.string.edit_message), fontSize = 18.sp) + } + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.green), + contentColor = Color.White + ), + onClick = retry, + ) { + Text(text = stringResource(id = R.string.try_again), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index 3c4d9e1202..c02982427d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +58,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState import net.mullvad.mullvadvpn.compose.state.LoginState.* import net.mullvad.mullvadvpn.compose.state.LoginUiState +import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -195,21 +195,7 @@ private fun LoginContent( maxLines = 1, visualTransformation = accountTokenVisualTransformation(), enabled = uiState.loginState is Idle, - colors = - TextFieldDefaults.colors( - focusedTextColor = Color.Black, - unfocusedTextColor = Color.Gray, - disabledTextColor = Color.Gray, - errorTextColor = Color.Black, - cursorColor = MaterialTheme.colorScheme.background, - focusedPlaceholderColor = MaterialTheme.colorScheme.background, - unfocusedPlaceholderColor = MaterialTheme.colorScheme.primary, - focusedLabelColor = MaterialTheme.colorScheme.background, - disabledLabelColor = Color.Gray, - unfocusedLabelColor = MaterialTheme.colorScheme.background, - focusedLeadingIconColor = Color.Black, - unfocusedSupportingTextColor = Color.Black, - ), + colors = mullvadWhiteTextFieldColors(), isError = uiState.loginState.isError(), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt new file mode 100644 index 0000000000..007b7bacf2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -0,0 +1,183 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.component.CollapsingToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.dialog.ReportProblemNoEmailDialog +import net.mullvad.mullvadvpn.compose.dialog.ShowReportProblemStateDialog +import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState +import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState + +@Preview +@Composable +private fun ReportProblemScreenPreview() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState()) } +} + +@Preview +@Composable +private fun ReportProblemSendingScreenPreview() { + AppTheme { + ReportProblemScreen(uiState = ReportProblemUiState(false, SendingReportUiState.Sending)) + } +} + +@Preview +@Composable +private fun ReportProblemConfirmNoEmailScreenPreview() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState(true)) } +} + +@Preview +@Composable +private fun ReportProblemSuccessScreenPreview() { + AppTheme { + ReportProblemScreen( + uiState = ReportProblemUiState(false, SendingReportUiState.Success(null)) + ) + } +} + +@Preview +@Composable +private fun ReportProblemErrorScreenPreview() { + AppTheme { + ReportProblemScreen( + uiState = + ReportProblemUiState( + false, + SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog) + ) + ) + } +} + +@Composable +fun ReportProblemScreen( + uiState: ReportProblemUiState, + onSendReport: (String, String) -> Unit = { _, _ -> }, + onDismissNoEmailDialog: () -> Unit = {}, + onClearSendResult: () -> Unit = {}, + onNavigateToViewLogs: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + + val scaffoldState = rememberCollapsingToolbarScaffoldState() + val progress = scaffoldState.toolbarState.progress + CollapsingToolbarScaffold( + backgroundColor = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + state = scaffoldState, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = false, + toolbar = { + val scaffoldModifier = + Modifier.road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MaterialTheme.colorScheme.background, + onBackClicked = onBackClick, + title = stringResource(id = R.string.report_a_problem), + progress = progress, + modifier = scaffoldModifier, + ) + }, + ) { + var email by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + Surface(color = MaterialTheme.colorScheme.background) { + if (uiState.sendingState != null) { + ShowReportProblemStateDialog( + uiState.sendingState, + onDismiss = onClearSendResult, + onClearForm = { + email = "" + description = "" + }, + retry = { onSendReport(email, description) } + ) + } + Column( + modifier = + Modifier.padding( + horizontal = Dimens.sideMargin, + vertical = Dimens.screenVerticalMargin + ), + verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding) + ) { + Text(text = stringResource(id = R.string.problem_report_description)) + TextField( + modifier = Modifier.fillMaxWidth(), + value = email, + onValueChange = { email = it }, + maxLines = 1, + singleLine = true, + placeholder = { Text(text = stringResource(id = R.string.user_email_hint)) }, + colors = mullvadWhiteTextFieldColors() + ) + TextField( + modifier = Modifier.fillMaxWidth().weight(1f), + value = description, + onValueChange = { description = it }, + placeholder = { Text(stringResource(R.string.user_message_hint)) }, + colors = mullvadWhiteTextFieldColors() + ) + + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + onClick = onNavigateToViewLogs, + text = stringResource(id = R.string.view_logs) + ) + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { onSendReport(email, description) }, + isEnabled = description.isNotEmpty(), + text = stringResource(id = R.string.send) + ) + + if (uiState.showConfirmNoEmail) { + ReportProblemNoEmailDialog( + onDismiss = onDismissNoEmailDialog, + onConfirm = { onSendReport(email, description) } + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt new file mode 100644 index 0000000000..80bb49f1af --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt @@ -0,0 +1,115 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.CollapsingToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState + +@Preview +@Composable +private fun ViewLogsScreenPreview() { + AppTheme { ViewLogsScreen(uiState = ViewLogsUiState("Lorem ipsum")) } +} + +@Preview +@Composable +private fun ViewLogsLoadingScreenPreview() { + AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) } +} + +@Composable +fun ViewLogsScreen( + uiState: ViewLogsUiState, + onBackClick: () -> Unit = {}, +) { + + val scaffoldState = rememberCollapsingToolbarScaffoldState() + val progress = scaffoldState.toolbarState.progress + CollapsingToolbarScaffold( + backgroundColor = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + state = scaffoldState, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = false, + toolbar = { + val scaffoldModifier = + Modifier.road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MaterialTheme.colorScheme.secondary, + onBackClicked = onBackClick, + title = stringResource(id = R.string.view_logs), + progress = progress, + modifier = scaffoldModifier, + ) + }, + ) { + Card( + modifier = + Modifier.fillMaxSize() + .padding( + vertical = Dimens.sideMargin, + horizontal = Dimens.screenVerticalMargin + ), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = + Modifier.padding(Dimens.mediumPadding).align(Alignment.CenterHorizontally) + ) + } else { + SelectionContainer { + val scrollState = rememberScrollState() + Column( + modifier = + Modifier.drawVerticalScrollbar( + scrollState, + color = MaterialTheme.colorScheme.primary + ) + ) { + TextField( + modifier = + Modifier.verticalScroll(scrollState) + .padding(horizontal = Dimens.smallPadding), + value = uiState.allLines, + textStyle = MaterialTheme.typography.bodySmall, + onValueChange = {}, + readOnly = true, + colors = + TextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + disabledTextColor = Color.Black, + cursorColor = MaterialTheme.colorScheme.background, + ) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt new file mode 100644 index 0000000000..c3060a46d5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun mullvadWhiteTextFieldColors(): TextFieldColors = + TextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Gray, + disabledTextColor = Color.Gray, + errorTextColor = Color.Black, + cursorColor = MaterialTheme.colorScheme.background, + focusedPlaceholderColor = MaterialTheme.colorScheme.background, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.background, + disabledLabelColor = Color.Gray, + unfocusedLabelColor = MaterialTheme.colorScheme.background, + focusedLeadingIconColor = Color.Black, + unfocusedSupportingTextColor = Color.Black, + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt new file mode 100644 index 0000000000..a93422d3f6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.constant + +const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt index 69fd7275e7..f36c9a4975 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt @@ -1,134 +1,89 @@ package net.mullvad.mullvadvpn.dataproxy +import android.content.Context import java.io.File -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.withContext const val PROBLEM_REPORT_FILE = "problem_report.txt" -class MullvadProblemReport { - private sealed class Command { - data object Collect : Command() +sealed interface SendProblemReportResult { + data object Success : SendProblemReportResult - class Load(val logs: CompletableDeferred<String>) : Command() + sealed interface Error : SendProblemReportResult { + data object CollectLog : Error - class Send(val result: CompletableDeferred<Boolean>) : Command() - - data object Delete : Command() + // This is usually due to network error or bad email address + data object SendReport : Error } +} - val logDirectory = CompletableDeferred<File>() - val cacheDirectory = CompletableDeferred<File>() - - private val commandChannel = spawnActor() - - private val problemReportPath = - GlobalScope.async(Dispatchers.Default) { File(logDirectory.await(), PROBLEM_REPORT_FILE) } - - private var isCollected = false +data class UserReport(val email: String, val message: String) - var confirmNoEmail: CompletableDeferred<Boolean>? = null +class MullvadProblemReport(context: Context) { + private val logDirectory = File(context.filesDir.toURI()) + private val cacheDirectory = File(context.cacheDir.toURI()) + private val problemReportPath = File(logDirectory, PROBLEM_REPORT_FILE) - var userEmail = "" - var userMessage = "" + private var hasCollectedReport = false init { System.loadLibrary("mullvad_jni") } - fun collect() { - commandChannel.trySendBlocking(Command.Collect) - } - - suspend fun load(): String { - val logs = CompletableDeferred<String>() - - commandChannel.send(Command.Load(logs)) - - return logs.await() - } - - fun send(): Deferred<Boolean> { - val result = CompletableDeferred<Boolean>() - - commandChannel.trySendBlocking(Command.Send(result)) - - return result - } - - fun deleteReportFile() { - commandChannel.trySendBlocking(Command.Delete) - } - - private fun spawnActor() = - GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { - try { - while (true) { - - when (val command = channel.receive()) { - is Command.Collect -> doCollect() - is Command.Load -> command.logs.complete(doLoad()) - is Command.Send -> command.result.complete(doSend()) - is Command.Delete -> doDelete() - } - } - } catch (exception: ClosedReceiveChannelException) {} + private suspend fun collectReport() = + withContext(Dispatchers.IO) { + val logDirectoryPath = logDirectory.absolutePath + val reportPath = problemReportPath.absolutePath + // Delete any old report + deleteReport() + hasCollectedReport = collectReport(logDirectoryPath, reportPath) } - private suspend fun doCollect() { - val logDirectoryPath = logDirectory.await().absolutePath - val reportPath = problemReportPath.await().absolutePath - - doDelete() - - isCollected = collectReport(logDirectoryPath, reportPath) - } - - private suspend fun doLoad(): String { - if (!isCollected) { - doCollect() + suspend fun sendReport(userReport: UserReport): SendProblemReportResult { + if (!hasCollectedReport) { + collectReport() + } + if (!hasCollectedReport) { + return SendProblemReportResult.Error.CollectLog } - return if (isCollected) { - problemReportPath.await().readText() + val sentSuccessfully = + withContext(Dispatchers.IO) { + sendProblemReport( + userReport.email, + userReport.message, + problemReportPath.absolutePath, + cacheDirectory.absolutePath + ) + } + + return if (sentSuccessfully) { + deleteReport() + SendProblemReportResult.Success } else { - "Failed to collect logs for problem report" + SendProblemReportResult.Error.SendReport } } - private suspend fun doSend(): Boolean { - if (!isCollected) { - doCollect() + suspend fun readLogs(): List<String> { + if (!hasCollectedReport) { + collectReport() } - val result = - isCollected && - sendProblemReport( - userEmail, - userMessage, - problemReportPath.await().absolutePath, - cacheDirectory.await().absolutePath - ) - - if (result) { - doDelete() + return if (hasCollectedReport) { + problemReportPath.readLines() + } else { + listOf("Failed to collect logs for problem report") } - - return result } - private suspend fun doDelete() { - problemReportPath.await().delete() - isCollected = false + fun deleteReport() { + problemReportPath.delete() + hasCollectedReport = false } + // TODO We should remove the external functions from this class and migrate it to the service private external fun collectReport(logDirectory: String, reportPath: String): Boolean private external fun sendProblemReport( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 987a55b45f..18e98964e8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository @@ -26,9 +27,11 @@ import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator @@ -70,6 +73,7 @@ val uiModule = module { ) } single { SettingsRepository(get()) } + single { MullvadProblemReport(get()) } single<IChangelogDataProvider> { ChangelogDataProvider(get()) } @@ -87,6 +91,8 @@ val uiModule = module { viewModel { SettingsViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } + viewModel { ReportProblemViewModel(get()) } + viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get()) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index fa88696cd9..3d30d28845 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration @@ -59,7 +58,6 @@ import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules open class MainActivity : FragmentActivity() { - val problemReport = MullvadProblemReport() private var requestNotificationPermissionLauncher: ActivityResultLauncher<String> = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // NotificationManager.areNotificationsEnabled is used to check the state rather than @@ -105,11 +103,6 @@ open class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) - problemReport.apply { - logDirectory.complete(filesDir) - cacheDirectory.complete(cacheDir) - } - setContentView(R.layout.main) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt deleted file mode 100644 index d31c5551b5..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.widget.Button -import androidx.fragment.app.DialogFragment -import kotlinx.coroutines.CompletableDeferred -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.MainActivity - -class ConfirmNoEmailDialogFragment : DialogFragment() { - private var confirmNoEmail: CompletableDeferred<Boolean>? = null - - override fun onAttach(context: Context) { - super.onAttach(context) - - val parentActivity = context as MainActivity - - confirmNoEmail = parentActivity.problemReport.confirmNoEmail - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val view = inflater.inflate(R.layout.confirm_no_email, container, false) - - view.findViewById<Button>(R.id.back_button).setOnClickListener { activity?.onBackPressed() } - - view.findViewById<Button>(R.id.send_button).setOnClickListener { - confirmNoEmail?.complete(true) - confirmNoEmail = null - dismiss() - } - - return view - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) - - return dialog - } - - override fun onStart() { - super.onStart() - - dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - } - - override fun onDismiss(dialogInterface: DialogInterface) { - confirmNoEmail?.complete(false) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt index b30b5713fb..aba7bd860a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt @@ -1,136 +1,46 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.Context -import android.graphics.Typeface import android.os.Bundle -import android.text.Editable -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.TextWatcher -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import android.widget.ScrollView -import android.widget.TextView -import android.widget.ViewSwitcher -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.CompletableDeferred +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.CollapsibleTitleController -import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.compose.screen.ReportProblemScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel -class ProblemReportFragment : BaseFragment() { - private val jobTracker = JobTracker() - - private var showingEmail by - observable(false) { _, oldValue, newValue -> - if (oldValue != newValue) { - if (newValue == true) { - parentActivity.enterSecureScreen(this) - } else { - parentActivity.leaveSecureScreen(this) - } - } - } - - private lateinit var parentActivity: MainActivity - private lateinit var problemReport: MullvadProblemReport - - private lateinit var bodyContainer: ViewSwitcher - private lateinit var userEmailInput: EditText - private lateinit var userMessageInput: EditText - private lateinit var sendButton: Button - - private lateinit var sendingSpinner: View - private lateinit var sentSuccessfullyIcon: View - private lateinit var failedToSendIcon: View - - private lateinit var sendStatusLabel: TextView - private lateinit var sendDetailsLabel: TextView - private lateinit var responseMessageLabel: TextView - - private lateinit var editMessageButton: Button - private lateinit var tryAgainButton: Button - - private lateinit var scrollArea: ScrollView - private lateinit var titleController: CollapsibleTitleController - - override fun onAttach(context: Context) { - super.onAttach(context) +// TODO +// [ ] - Showing enable email secure screen? +// [ ] - Decide to save logs? - parentActivity = context as MainActivity - - problemReport = parentActivity.problemReport - problemReport.collect() - } +class ProblemReportFragment : BaseFragment() { + private val vm by viewModel<ReportProblemViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val view = inflater.inflate(R.layout.problem_report, container, false) - - view.findViewById<View>(R.id.back).setOnClickListener { activity?.onBackPressed() } - - bodyContainer = view.findViewById<ViewSwitcher>(R.id.body_container) - userEmailInput = view.findViewById<EditText>(R.id.user_email) - userMessageInput = view.findViewById<EditText>(R.id.user_message) - sendButton = view.findViewById<Button>(R.id.send_button) - - sendingSpinner = view.findViewById<View>(R.id.sending_spinner) - sentSuccessfullyIcon = view.findViewById<View>(R.id.sent_successfully_icon) - failedToSendIcon = view.findViewById<View>(R.id.failed_to_send_icon) + ): View? { - sendStatusLabel = view.findViewById<TextView>(R.id.send_status) - sendDetailsLabel = view.findViewById<TextView>(R.id.send_details) - responseMessageLabel = view.findViewById<TextView>(R.id.response_message) - - editMessageButton = view.findViewById<Button>(R.id.edit_message_button) - tryAgainButton = view.findViewById<Button>(R.id.try_again_button) - - view.findViewById<Button>(R.id.view_logs).setOnClickListener { showLogs() } - - sendButton.setOnClickListener { jobTracker.newUiJob("sendReport") { sendReport(true) } } - - editMessageButton.setOnClickListener { showForm() } - - tryAgainButton.setOnClickListener { - jobTracker.newUiJob("sendReport") { sendReport(false) } + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val uiState = vm.uiState.collectAsState().value + ReportProblemScreen( + uiState, + onSendReport = { email, description -> vm.sendReport(email, description) }, + onDismissNoEmailDialog = vm::dismissConfirmNoEmail, + onClearSendResult = vm::clearSendResult, + onNavigateToViewLogs = { showLogs() } + ) { + activity?.onBackPressed() + } + } + } } - - userEmailInput.setText(problemReport.userEmail) - userMessageInput.setText(problemReport.userMessage) - - setSendButtonEnabled(!userMessageInput.text.isEmpty()) - userMessageInput.addTextChangedListener(InputWatcher()) - - scrollArea = view.findViewById(R.id.scroll_area) - titleController = CollapsibleTitleController(view) - - return view - } - - override fun onDestroyView() { - problemReport.userEmail = userEmailInput.text.toString() - problemReport.userMessage = userMessageInput.text.toString() - problemReport.deleteReportFile() - - titleController.onDestroy() - - super.onDestroyView() - } - - override fun onDetach() { - showingEmail = false - - super.onDetach() } private fun showLogs() { @@ -146,151 +56,4 @@ class ProblemReportFragment : BaseFragment() { commitAllowingStateLoss() } } - - private suspend fun sendReport(shouldConfirmNoEmail: Boolean) { - val userEmail = userEmailInput.text.trim().toString() - - problemReport.userEmail = userEmail - problemReport.userMessage = userMessageInput.text.toString() - - if (!userEmail.isEmpty() || !shouldConfirmNoEmail || confirmSendWithNoEmail()) { - showSendingScreen() - - if (problemReport.send().await()) { - clearForm() - showSuccessScreen(userEmail) - } else { - showErrorScreen() - } - } - } - - private suspend fun confirmSendWithNoEmail(): Boolean { - val confirmation = CompletableDeferred<Boolean>() - - problemReport.confirmNoEmail = confirmation - showConfirmNoEmailDialog() - - return confirmation.await() - } - - private fun clearForm() { - userEmailInput.setText("") - userMessageInput.setText("") - - problemReport.userEmail = "" - problemReport.userMessage = "" - } - - private fun showForm() { - bodyContainer.displayedChild = 0 - } - - private fun showConfirmNoEmailDialog() { - val transaction = parentFragmentManager.beginTransaction() - - transaction.addToBackStack(null) - - ConfirmNoEmailDialogFragment().show(transaction, null) - } - - private fun showSendingScreen() { - bodyContainer.displayedChild = 1 - - sendingSpinner.visibility = View.VISIBLE - sentSuccessfullyIcon.visibility = View.GONE - failedToSendIcon.visibility = View.GONE - - sendStatusLabel.visibility = View.VISIBLE - sendDetailsLabel.visibility = View.GONE - responseMessageLabel.visibility = View.GONE - - sendStatusLabel.setText(R.string.sending) - - editMessageButton.visibility = View.GONE - tryAgainButton.visibility = View.GONE - } - - private fun showSuccessScreen(userEmail: String) { - sendingSpinner.visibility = View.GONE - - sentSuccessfullyIcon.visibility = View.VISIBLE - sendStatusLabel.visibility = View.VISIBLE - - if (!userEmail.isEmpty()) { - showResponseMessage(userEmail) - } - - showThanksMessage() - sendStatusLabel.setText(R.string.sent) - - scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) - } - - private fun showThanksMessage() { - val thanks = parentActivity.getString(R.string.sent_thanks) - val weWillLookIntoThis = parentActivity.getString(R.string.we_will_look_into_this) - - val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.green)) - - sendDetailsLabel.text = - SpannableStringBuilder("$thanks $weWillLookIntoThis").apply { - setSpan(colorStyle, 0, thanks.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - sendDetailsLabel.visibility = View.VISIBLE - } - - private fun showResponseMessage(userEmail: String) { - val responseMessageTemplate = parentActivity.getString(R.string.sent_contact) - val responseMessage = parentActivity.getString(R.string.sent_contact, userEmail) - - val emailStart = responseMessageTemplate.indexOf('%') - val emailEndFromStringEnd = responseMessageTemplate.length - (emailStart + 4) - val emailEnd = responseMessage.length - emailEndFromStringEnd - - val boldStyle = StyleSpan(Typeface.BOLD) - val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.white)) - - responseMessageLabel.text = - SpannableStringBuilder(responseMessage).apply { - setSpan(boldStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - setSpan(colorStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - responseMessageLabel.visibility = View.VISIBLE - - showingEmail = true - } - - private fun showErrorScreen() { - sendingSpinner.visibility = View.GONE - - failedToSendIcon.visibility = View.VISIBLE - sendStatusLabel.visibility = View.VISIBLE - sendDetailsLabel.visibility = View.VISIBLE - - sendStatusLabel.setText(R.string.failed_to_send) - sendDetailsLabel.setText(R.string.failed_to_send_details) - - editMessageButton.visibility = View.VISIBLE - tryAgainButton.visibility = View.VISIBLE - - scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) - } - - private fun setSendButtonEnabled(enabled: Boolean) { - sendButton.isEnabled = enabled - sendButton.alpha = if (enabled) 1.0F else 0.5F - } - - inner class InputWatcher : TextWatcher { - override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(text: Editable) { - setSendButtonEnabled(!text.isEmpty()) - } - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt index e519526f52..21931ab876 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt @@ -1,52 +1,36 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.compose.screen.ViewLogsScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class ViewLogsFragment : BaseFragment() { - private val jobTracker = JobTracker() - - private lateinit var problemReport: MullvadProblemReport - - private lateinit var logArea: EditText - - override fun onAttach(context: Context) { - super.onAttach(context) - - val parentActivity = context as MainActivity - - problemReport = parentActivity.problemReport - } + private val vm by viewModel<ViewLogsViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - val view = inflater.inflate(R.layout.view_logs, container, false) - - view.findViewById<View>(R.id.back).setOnClickListener { activity?.onBackPressed() } - - logArea = view.findViewById<EditText>(R.id.log_area) - - return view - } - - override fun onStart() { - super.onStart() - - jobTracker.newUiJob("showLogs") { - val logs = jobTracker.runOnBackground { problemReport.load() } - logArea.setText(logs) + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val uiState = vm.uiState.collectAsState() + ViewLogsScreen( + uiState = uiState.value, + onBackClick = { activity?.onBackPressed() } + ) + } + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt new file mode 100644 index 0000000000..c007ca5c8f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -0,0 +1,81 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.constant.MINIMUM_LOADING_SPINNER_TIME_MILLIS +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult +import net.mullvad.mullvadvpn.dataproxy.UserReport + +data class ReportProblemUiState( + val showConfirmNoEmail: Boolean = false, + val sendingState: SendingReportUiState? = null +) + +sealed interface SendingReportUiState { + data object Sending : SendingReportUiState + + data class Success(val email: String?) : SendingReportUiState + + data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState +} + +class ReportProblemViewModel(private val mullvadProblemReporter: MullvadProblemReport) : + ViewModel() { + + private val _uiState = MutableStateFlow(ReportProblemUiState()) + val uiState = _uiState.asStateFlow() + + fun sendReport( + email: String, + description: String, + ) { + viewModelScope.launch { + val userEmail = email.trim() + if (shouldShowConfirmNoEmail(userEmail)) { + _uiState.update { it.copy(showConfirmNoEmail = true) } + } else { + _uiState.update { + it.copy(sendingState = SendingReportUiState.Sending, showConfirmNoEmail = false) + } + + // Ensure we show loading for at least 500 ms + val deferredResult = async { mullvadProblemReporter.sendReport(UserReport(userEmail, description)) } + delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS) + + _uiState.update { it.copy(sendingState = deferredResult.await().toUiResult(userEmail)) } + } + } + } + + fun clearSendResult() { + _uiState.update { it.copy(sendingState = null) } + } + + fun dismissConfirmNoEmail() { + _uiState.update { it.copy(showConfirmNoEmail = false) } + } + + private fun shouldShowConfirmNoEmail(userEmail: String): Boolean = + userEmail.isEmpty() && + !uiState.value.showConfirmNoEmail && + uiState.value.sendingState !is SendingReportUiState + + private fun SendProblemReportResult.toUiResult(email: String): SendingReportUiState = + when (this) { + is SendProblemReportResult.Error -> SendingReportUiState.Error(this) + SendProblemReportResult.Success -> SendingReportUiState.Success(email) + } + + override fun onCleared() { + super.onCleared() + // Delete any logs if user leaves the screen + mullvadProblemReporter.deleteReport() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt new file mode 100644 index 0000000000..cff6bf6043 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport + +data class ViewLogsUiState(val allLines: String = "", val isLoading: Boolean = true) + +class ViewLogsViewModel(private val mullvadProblemReporter: MullvadProblemReport) : ViewModel() { + + private val _uiState = MutableStateFlow(ViewLogsUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + // Loading this much text takes a while, so we show a loading indicator until the + // fragment transitions is done. I'd very much prefer to use LazyColumn in the view + // which would make the loading way faster but then the SelectionContainer is broken. + delay(500) + _uiState.update { + it.copy( + allLines = mullvadProblemReporter.readLogs().joinToString("\n"), + isLoading = false + ) + } + } + } +} |
