summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson90@gmail.com>2023-09-22 09:14:44 +0200
committerDavid Göransson <david.goransson90@gmail.com>2023-09-27 08:23:26 +0200
commit08a74bc5857076e7dc2eb3963c49ec775cedb688 (patch)
tree46f9c584818d908332f7830c0eff2e3c7e82f038 /android
parent24acf70d47b15e3d4ece53b8f0cd8c3dffcf52fd (diff)
downloadmullvadvpn-08a74bc5857076e7dc2eb3963c49ec775cedb688.tar.xz
mullvadvpn-08a74bc5857076e7dc2eb3963c49ec775cedb688.zip
Migrate login screen to compose
Fixes removed and fixes some of the compose colors, recreates login states
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt143
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt360
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt190
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt40
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt295
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt164
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt154
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt3
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt29
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt8
13 files changed, 854 insertions, 589 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
index 15e94dddb9..331e9d2248 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
@@ -118,6 +118,7 @@ fun ContentBlockersDisableModeCellSubtitle(modifier: Modifier) {
Text(
text = spanned.toAnnotatedString(boldFontWeight = FontWeight.ExtraBold),
style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary,
modifier = modifier
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
index c84804d955..a2e5eec8df 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -110,53 +111,52 @@ fun AccountScreen(
val scrollState = rememberScrollState()
- Column(
- verticalArrangement = Arrangement.Bottom,
- horizontalAlignment = Alignment.Start,
- modifier =
- Modifier.background(MaterialTheme.colorScheme.background)
- .fillMaxSize()
- .drawVerticalScrollbar(scrollState)
- .verticalScroll(scrollState)
- .animateContentSize()
- ) {
- Text(
- style = MaterialTheme.typography.labelMedium,
- text = stringResource(id = R.string.device_name),
- modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
- )
-
- InformationView(
- content = uiState.deviceName.capitalizeFirstCharOfEachWord(),
- whenMissing = MissingPolicy.SHOW_SPINNER
- )
-
- Text(
- style = MaterialTheme.typography.labelMedium,
- text = stringResource(id = R.string.account_number),
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ Column(
+ verticalArrangement = Arrangement.Bottom,
+ horizontalAlignment = Alignment.Start,
modifier =
- Modifier.padding(
- start = Dimens.sideMargin,
- end = Dimens.sideMargin,
- top = Dimens.smallPadding
- )
- )
+ Modifier.fillMaxSize()
+ .drawVerticalScrollbar(scrollState)
+ .verticalScroll(scrollState)
+ .animateContentSize()
+ ) {
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.device_name),
+ modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
+ )
- CopyableObfuscationView(content = uiState.accountNumber)
+ InformationView(
+ content = uiState.deviceName.capitalizeFirstCharOfEachWord(),
+ whenMissing = MissingPolicy.SHOW_SPINNER
+ )
- Text(
- style = MaterialTheme.typography.labelMedium,
- text = stringResource(id = R.string.paid_until),
- modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
- )
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.account_number),
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding
+ )
+ )
- InformationView(
- content = uiState.accountExpiry?.toExpiryDateString() ?: "",
- whenMissing = MissingPolicy.SHOW_SPINNER
- )
+ CopyableObfuscationView(content = uiState.accountNumber)
- Spacer(modifier = Modifier.weight(1f))
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.paid_until),
+ modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
+ )
+
+ InformationView(
+ content = uiState.accountExpiry?.toExpiryDateString() ?: "",
+ whenMissing = MissingPolicy.SHOW_SPINNER
+ )
+ Spacer(modifier = Modifier.weight(1f))
if (IS_PLAY_BUILD.not()) {
ActionButton(
text = stringResource(id = R.string.manage_account),
@@ -175,37 +175,38 @@ fun AccountScreen(
)
}
- ActionButton(
- text = stringResource(id = R.string.redeem_voucher),
- onClick = onRedeemVoucherClick,
- modifier =
- Modifier.padding(
- start = Dimens.sideMargin,
- end = Dimens.sideMargin,
- bottom = Dimens.screenVerticalMargin
- ),
- colors =
- ButtonDefaults.buttonColors(
- contentColor = MaterialTheme.colorScheme.onPrimary,
- containerColor = MaterialTheme.colorScheme.surface
- )
- )
+ ActionButton(
+ text = stringResource(id = R.string.redeem_voucher),
+ onClick = onRedeemVoucherClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
- ActionButton(
- text = stringResource(id = R.string.log_out),
- onClick = onLogoutClick,
- modifier =
- Modifier.padding(
- start = Dimens.sideMargin,
- end = Dimens.sideMargin,
- bottom = Dimens.screenVerticalMargin
- ),
- colors =
- ButtonDefaults.buttonColors(
- contentColor = MaterialTheme.colorScheme.onPrimary,
- containerColor = MaterialTheme.colorScheme.error
- )
- )
+ ActionButton(
+ text = stringResource(id = R.string.log_out),
+ onClick = onLogoutClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ )
+ }
}
}
}
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
new file mode 100644
index 0000000000..b1a25fb581
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
@@ -0,0 +1,360 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.Icon
+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
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.window.PopupProperties
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.ActionButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+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.util.accountTokenVisualTransformation
+import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewIdle() {
+ AppTheme { LoginScreen(state = LoginUiState()) }
+}
+
+@Preview
+@Composable
+private fun PreviewLoggingIn() {
+ AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.LoggingIn)) }
+}
+
+@Preview
+@Composable
+private fun PreviewCreatingAccount() {
+ AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.CreatingAccount)) }
+}
+
+@Preview
+@Composable
+private fun PreviewLoginError() {
+ AppTheme { LoginScreen(state = LoginUiState(loginState = Idle(LoginError.InvalidCredentials))) }
+}
+
+@Preview
+@Composable
+private fun PreviewLoginSuccess() {
+ AppTheme { LoginScreen(state = LoginUiState(loginState = Success)) }
+}
+
+@Composable
+fun LoginScreen(
+ state: LoginUiState,
+ onLoginClick: (String) -> Unit = {},
+ onCreateAccountClick: () -> Unit = {},
+ onDeleteHistoryClick: () -> Unit = {},
+ onAccountNumberChange: (String) -> Unit = {},
+ onSettingsClick: () -> Unit = {},
+) {
+ ScaffoldWithTopBar(
+ topBarColor = MaterialTheme.colorScheme.primary,
+ statusBarColor = MaterialTheme.colorScheme.primary,
+ navigationBarColor = MaterialTheme.colorScheme.background,
+ iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
+ onSettingsClicked = onSettingsClick,
+ onAccountClicked = null
+ ) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier =
+ Modifier.padding(it)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.primary)
+ .verticalScroll(scrollState)
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ LoginIcon(
+ state.loginState,
+ modifier =
+ Modifier.align(Alignment.CenterHorizontally)
+ .padding(bottom = Dimens.largePadding)
+ )
+ LoginContent(state, onAccountNumberChange, onLoginClick, onDeleteHistoryClick)
+ Spacer(modifier = Modifier.weight(3f))
+ CreateAccountPanel(onCreateAccountClick, isEnabled = state.loginState is Idle)
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun LoginContent(
+ state: LoginUiState,
+ onAccountNumberChange: (String) -> Unit,
+ onLoginClick: (String) -> Unit,
+ onDeleteHistoryClick: () -> Unit
+) {
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) {
+ Text(
+ text = state.loginState.title(),
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.smallPadding)
+ )
+
+ var expanded by remember { mutableStateOf(false) }
+
+ Text(
+ modifier = Modifier.padding(bottom = Dimens.smallPadding),
+ text = state.loginState.supportingText() ?: "",
+ style = MaterialTheme.typography.labelMedium,
+ color =
+ if (state.loginState.isError()) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onPrimary
+ },
+ )
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ TextField(
+ modifier = Modifier.menuAnchor().fillMaxWidth(),
+ value = state.accountNumberInput,
+ label = {
+ Text(
+ text = stringResource(id = R.string.login_description),
+ color = Color.Unspecified
+ )
+ },
+ keyboardActions =
+ KeyboardActions(onDone = { onLoginClick(state.accountNumberInput) }),
+ keyboardOptions =
+ KeyboardOptions(
+ imeAction =
+ if (state.loginButtonEnabled) ImeAction.Done else ImeAction.None,
+ keyboardType = KeyboardType.NumberPassword
+ ),
+ onValueChange = onAccountNumberChange,
+ singleLine = true,
+ maxLines = 1,
+ visualTransformation = accountTokenVisualTransformation(),
+ enabled = state.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,
+ ),
+ isError = state.loginState.isError(),
+ )
+
+ // If we have a previous account, show dropdown for quick re-login
+ state.lastUsedAccount?.let { token ->
+ DropdownMenu(
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.background)
+ .exposedDropdownSize(true),
+ expanded = expanded,
+ properties = PopupProperties(focusable = false),
+ onDismissRequest = { expanded = false }
+ ) {
+ val accountTransformation = remember { accountTokenVisualTransformation() }
+ val transformedText =
+ remember(token.value) {
+ accountTransformation.filter(AnnotatedString(token.value)).text
+ }
+
+ AccountDropDownItem(
+ accountToken = transformedText.toString(),
+ onClick = {
+ onAccountNumberChange(token.value)
+ expanded = false
+ onLoginClick(token.value)
+ }
+ ) {
+ onDeleteHistoryClick()
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.size(Dimens.largePadding))
+ ActionButton(
+ isEnabled = state.loginButtonEnabled,
+ onClick = { onLoginClick(state.accountNumberInput) },
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ text = stringResource(id = R.string.login_title),
+ modifier = Modifier.padding(bottom = Dimens.mediumPadding)
+ )
+ }
+}
+
+@Composable
+private fun LoginIcon(state: LoginState, modifier: Modifier = Modifier) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.size(Dimens.loginIconContainerSize)
+ ) {
+ when (state) {
+ is Idle ->
+ if (state.loginError != null) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_fail),
+ contentDescription = null,
+ contentScale = ContentScale.Inside
+ )
+ }
+ is Loading ->
+ CircularProgressIndicator(
+ modifier = Modifier.size(Dimens.progressIndicatorSize),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = Dimens.loadingSpinnerStrokeWidth,
+ strokeCap = StrokeCap.Round
+ )
+ Success ->
+ Image(
+ modifier = Modifier.offset(-Dimens.smallPadding, -Dimens.smallPadding),
+ painter = painterResource(id = R.drawable.icon_success),
+ contentDescription = null,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LoginState.title(): String =
+ stringResource(
+ id =
+ when (this) {
+ is Idle ->
+ when (this.loginError) {
+ is LoginError -> R.string.login_fail_title
+ null -> R.string.login_title
+ }
+ is Loading -> R.string.logging_in_title
+ Success -> R.string.logged_in_title
+ }
+ )
+
+@Composable
+private fun LoginState.supportingText(): String? {
+ val res =
+ when (this) {
+ is Idle -> {
+ when (loginError) {
+ LoginError.InvalidCredentials -> R.string.login_fail_description
+ LoginError.UnableToCreateAccount -> R.string.failed_to_create_account
+ is LoginError.Unknown -> R.string.error_occurred
+ null -> return null
+ }
+ }
+ is Loading.CreatingAccount -> R.string.creating_new_account
+ is Loading.LoggingIn,
+ Success -> R.string.logging_in_description
+ }
+ return stringResource(id = res)
+}
+
+@Composable
+private fun AccountDropDownItem(
+ accountToken: String,
+ onClick: () -> Unit,
+ onDeleteClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier.clickable(onClick = onClick),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier =
+ Modifier.weight(1f)
+ .padding(horizontal = Dimens.mediumPadding, vertical = Dimens.smallPadding),
+ text = accountToken
+ )
+ IconButton(onClick = onDeleteClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.account_history_remove_pressed),
+ contentDescription = null,
+ modifier = Modifier.size(Dimens.listIconSize),
+ tint = Color.Unspecified
+ )
+ }
+ }
+}
+
+@Composable
+private fun CreateAccountPanel(onCreateAccountClick: () -> Unit, isEnabled: Boolean) {
+ Column(
+ Modifier.fillMaxWidth()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin),
+ ) {
+ Text(
+ modifier = Modifier.padding(bottom = Dimens.smallPadding),
+ text = stringResource(id = R.string.dont_have_an_account),
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ ActionButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(id = R.string.create_account),
+ isEnabled = isEnabled,
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.primary
+ ),
+ onClick = onCreateAccountClick
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
index 5170ef0845..f5e512f8f6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
@@ -14,11 +14,13 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -110,49 +112,108 @@ fun SplitTunnelingScreen(
)
},
) {
- LazyColumn(
- modifier = Modifier.drawVerticalScrollbar(state = lazyListState).fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- state = lazyListState
- ) {
- item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) {
- Box(modifier = Modifier.fillMaxWidth()) {
- Text(
- style = MaterialTheme.typography.labelMedium,
- text = stringResource(id = R.string.split_tunneling_description),
- modifier =
- Modifier.padding(
- start = Dimens.mediumPadding,
- end = Dimens.mediumPadding,
- bottom = Dimens.mediumPadding
- )
- )
- }
- }
- when (uiState) {
- SplitTunnelingUiState.Loading -> {
- item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.onBackground,
+ Surface(color = MaterialTheme.colorScheme.background) {
+ LazyColumn(
+ modifier = Modifier.drawVerticalScrollbar(state = lazyListState).fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ state = lazyListState
+ ) {
+ item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) {
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.split_tunneling_description),
modifier =
- Modifier.size(
- width = Dimens.progressIndicatorSize,
- height = Dimens.progressIndicatorSize
+ Modifier.padding(
+ start = Dimens.mediumPadding,
+ end = Dimens.mediumPadding,
+ bottom = Dimens.mediumPadding
)
)
}
}
- is SplitTunnelingUiState.ShowAppList -> {
- if (uiState.excludedApps.isNotEmpty()) {
+ when (uiState) {
+ SplitTunnelingUiState.Loading -> {
+ item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.size(Dimens.progressIndicatorSize),
+ strokeCap = StrokeCap.Round
+ )
+ }
+ }
+ is SplitTunnelingUiState.ShowAppList -> {
+ if (uiState.excludedApps.isNotEmpty()) {
+ itemWithDivider(
+ key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
+ contentType = ContentType.HEADER
+ ) {
+ BaseCell(
+ title = {
+ Text(
+ text =
+ stringResource(id = R.string.exclude_applications),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ },
+ bodyView = {},
+ background = MaterialTheme.colorScheme.primary,
+ )
+ }
+ itemsIndexed(
+ items = uiState.excludedApps,
+ key = { _, listItem -> listItem.packageName },
+ contentType = { _, _ -> ContentType.ITEM }
+ ) { index, listItem ->
+ SplitTunnelingCell(
+ title = listItem.name,
+ packageName = listItem.packageName,
+ isSelected = true,
+ modifier = Modifier.animateItemPlacement().fillMaxWidth(),
+ onResolveIcon = onResolveIcon
+ ) {
+ // Move focus down unless the clicked item was the last in this
+ // section.
+ if (index < uiState.excludedApps.size - 1) {
+ focusManager.moveFocus(FocusDirection.Down)
+ } else {
+ focusManager.moveFocus(FocusDirection.Up)
+ }
+
+ onIncludeAppClick(listItem.packageName)
+ }
+ }
+ item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) {
+ Spacer(
+ modifier =
+ Modifier.animateItemPlacement().height(Dimens.mediumPadding)
+ )
+ }
+ }
+
itemWithDivider(
- key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
+ key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS,
+ contentType = ContentType.OTHER_ITEM
+ ) {
+ HeaderSwitchComposeCell(
+ title = stringResource(id = R.string.show_system_apps),
+ isToggled = uiState.showSystemApps,
+ onCellClicked = { newValue -> onShowSystemAppsClick(newValue) },
+ modifier = Modifier.animateItemPlacement()
+ )
+ }
+ itemWithDivider(
+ key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS,
contentType = ContentType.HEADER
) {
BaseCell(
+ modifier = Modifier.animateItemPlacement(),
title = {
Text(
- text = stringResource(id = R.string.exclude_applications),
- style = MaterialTheme.typography.titleMedium
+ text = stringResource(id = R.string.all_applications),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimary
)
},
bodyView = {},
@@ -160,84 +221,27 @@ fun SplitTunnelingScreen(
)
}
itemsIndexed(
- items = uiState.excludedApps,
+ items = uiState.includedApps,
key = { _, listItem -> listItem.packageName },
contentType = { _, _ -> ContentType.ITEM }
) { index, listItem ->
SplitTunnelingCell(
title = listItem.name,
packageName = listItem.packageName,
- isSelected = true,
+ isSelected = false,
modifier = Modifier.animateItemPlacement().fillMaxWidth(),
onResolveIcon = onResolveIcon
) {
// Move focus down unless the clicked item was the last in this
// section.
- if (index < uiState.excludedApps.size - 1) {
+ if (index < uiState.includedApps.size - 1) {
focusManager.moveFocus(FocusDirection.Down)
} else {
focusManager.moveFocus(FocusDirection.Up)
}
- onIncludeAppClick(listItem.packageName)
- }
- }
- item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) {
- Spacer(
- modifier =
- Modifier.animateItemPlacement().height(Dimens.mediumPadding)
- )
- }
- }
-
- itemWithDivider(
- key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS,
- contentType = ContentType.OTHER_ITEM
- ) {
- HeaderSwitchComposeCell(
- title = stringResource(id = R.string.show_system_apps),
- isToggled = uiState.showSystemApps,
- onCellClicked = { newValue -> onShowSystemAppsClick(newValue) },
- modifier = Modifier.animateItemPlacement()
- )
- }
- itemWithDivider(
- key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS,
- contentType = ContentType.HEADER
- ) {
- BaseCell(
- modifier = Modifier.animateItemPlacement(),
- title = {
- Text(
- text = stringResource(id = R.string.all_applications),
- style = MaterialTheme.typography.titleMedium
- )
- },
- bodyView = {},
- background = MaterialTheme.colorScheme.primary,
- )
- }
- itemsIndexed(
- items = uiState.includedApps,
- key = { _, listItem -> listItem.packageName },
- contentType = { _, _ -> ContentType.ITEM }
- ) { index, listItem ->
- SplitTunnelingCell(
- title = listItem.name,
- packageName = listItem.packageName,
- isSelected = false,
- modifier = Modifier.animateItemPlacement().fillMaxWidth(),
- onResolveIcon = onResolveIcon
- ) {
- // Move focus down unless the clicked item was the last in this
- // section.
- if (index < uiState.includedApps.size - 1) {
- focusManager.moveFocus(FocusDirection.Down)
- } else {
- focusManager.moveFocus(FocusDirection.Up)
+ onExcludeAppClick(listItem.packageName)
}
-
- onExcludeAppClick(listItem.packageName)
}
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt
new file mode 100644
index 0000000000..bcbc181b85
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt
@@ -0,0 +1,40 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.model.AccountToken
+
+const val MIN_ACCOUNT_LOGIN_LENGTH = 8
+
+data class LoginUiState(
+ val accountNumberInput: String = "",
+ val lastUsedAccount: AccountToken? = null,
+ val loginState: LoginState = LoginState.Idle(null)
+) {
+ val loginButtonEnabled =
+ accountNumberInput.length >= MIN_ACCOUNT_LOGIN_LENGTH && loginState is LoginState.Idle
+
+ companion object {
+ val INITIAL = LoginUiState()
+ }
+}
+
+sealed interface LoginState {
+ fun isError() = this is Idle && loginError != null
+
+ data class Idle(val loginError: LoginError? = null) : LoginState
+
+ sealed interface Loading : LoginState {
+ data object LoggingIn : Loading
+
+ data object CreatingAccount : Loading
+ }
+
+ data object Success : LoginState
+}
+
+sealed class LoginError {
+ data object UnableToCreateAccount : LoginError()
+
+ data object InvalidCredentials : LoginError()
+
+ data class Unknown(val reason: String) : LoginError()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt
new file mode 100644
index 0000000000..2e294e48e4
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+
+const val ACCOUNT_TOKEN_SEPARATOR = " "
+const val ACCOUNT_TOKEN_CHUNK_SIZE = 4
+
+fun accountTokenVisualTransformation() = VisualTransformation {
+ val transformedString =
+ it.chunked(ACCOUNT_TOKEN_CHUNK_SIZE).joinToString(ACCOUNT_TOKEN_SEPARATOR)
+ val transformedAnnotatedString = AnnotatedString(transformedString)
+
+ TransformedText(
+ transformedAnnotatedString,
+ object : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int =
+ offset + (offset - 1) / ACCOUNT_TOKEN_CHUNK_SIZE
+
+ override fun transformedToOriginal(offset: Int): Int =
+ offset - offset / ACCOUNT_TOKEN_CHUNK_SIZE
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
index ddcde56407..de72fa8d93 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
@@ -3,32 +3,36 @@ package net.mullvad.mullvadvpn.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
import net.mullvad.mullvadvpn.lib.ipc.Event
import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.AccountHistory
+import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.accountDataSource
import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
class AccountRepository(
private val serviceConnectionManager: ServiceConnectionManager,
- dispatcher: CoroutineDispatcher = Dispatchers.IO
+ val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val _cachedCreatedAccount = MutableStateFlow<String?>(null)
val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow()
- val accountCreationEvents: SharedFlow<AccountCreationResult> =
+ private val accountCreationEvents: SharedFlow<AccountCreationResult> =
serviceConnectionManager.connectionState
.flatMapReadyConnectionOrDefault(flowOf()) { state ->
state.container.accountDataSource.accountCreationResult
@@ -49,7 +53,7 @@ class AccountRepository(
AccountExpiry.Missing
)
- val accountHistoryEvents: StateFlow<AccountHistory> =
+ val accountHistory: StateFlow<AccountHistory> =
serviceConnectionManager.connectionState
.flatMapReadyConnectionOrDefault(flowOf()) { state ->
state.container.accountDataSource.accountHistory
@@ -61,20 +65,26 @@ class AccountRepository(
AccountHistory.Missing
)
- val loginEvents: SharedFlow<Event.LoginEvent> =
+ private val loginEvents: SharedFlow<Event.LoginEvent> =
serviceConnectionManager.connectionState
.flatMapReadyConnectionOrDefault(flowOf()) { state ->
state.container.accountDataSource.loginEvents
}
.shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed())
- fun createAccount() {
- serviceConnectionManager.accountDataSource()?.createAccount()
- }
+ suspend fun createAccount(): AccountCreationResult =
+ withContext(dispatcher) {
+ val deferred = async { accountCreationEvents.first() }
+ serviceConnectionManager.accountDataSource()?.createAccount()
+ deferred.await()
+ }
- fun login(accountToken: String) {
- serviceConnectionManager.accountDataSource()?.login(accountToken)
- }
+ suspend fun login(accountToken: String): LoginResult =
+ withContext(Dispatchers.IO) {
+ val deferred = async { loginEvents.first().result }
+ serviceConnectionManager.accountDataSource()?.login(accountToken)
+ deferred.await()
+ }
fun logout() {
clearCreatedAccountCache()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt
index 7e298e3f73..175d34df82 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt
@@ -1,198 +1,74 @@
package net.mullvad.mullvadvpn.ui.fragment
-import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.ScrollView
-import android.widget.TextView
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.lib.common.util.JobTracker
-import net.mullvad.mullvadvpn.ui.LoginState
+import net.mullvad.mullvadvpn.compose.screen.LoginScreen
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.model.AccountToken
+import net.mullvad.mullvadvpn.ui.MainActivity
import net.mullvad.mullvadvpn.ui.NavigationBarPainter
-import net.mullvad.mullvadvpn.ui.extension.requireMainActivity
-import net.mullvad.mullvadvpn.ui.paintNavigationBar
-import net.mullvad.mullvadvpn.ui.widget.AccountInput
-import net.mullvad.mullvadvpn.ui.widget.AccountLogin
-import net.mullvad.mullvadvpn.ui.widget.Button
-import net.mullvad.mullvadvpn.ui.widget.HeaderBar
+import net.mullvad.mullvadvpn.viewmodel.LoginViewAction
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
class LoginFragment : BaseFragment(), NavigationBarPainter {
-
- private val loginViewModel: LoginViewModel by viewModel()
-
- private lateinit var title: TextView
- private lateinit var subtitle: TextView
- private lateinit var loggingInStatus: View
- private lateinit var loggedInStatus: View
- private lateinit var loginFailStatus: View
- private lateinit var accountLogin: AccountLogin
- private lateinit var scrollArea: ScrollView
- private lateinit var background: View
- private lateinit var headerBar: HeaderBar
- private lateinit var input: AccountInput
- private lateinit var createAccountButton: Button
-
- @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- lifecycleScope.launchUiSubscriptionsOnResume()
- }
+ private val vm: LoginViewModel by viewModel()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- val view = inflater.inflate(R.layout.login, container, false)
-
- headerBar =
- view.findViewById<HeaderBar?>(R.id.header_bar).apply {
- setAccountButtonVisibility(false)
- }
-
- title = view.findViewById(R.id.title)
- subtitle = view.findViewById(R.id.subtitle)
- loggingInStatus = view.findViewById(R.id.logging_in_status)
- loggedInStatus = view.findViewById(R.id.logged_in_status)
- loginFailStatus = view.findViewById(R.id.login_fail_status)
-
- accountLogin =
- view.findViewById<AccountLogin>(R.id.account_login).apply {
- onLogin = loginViewModel::login
- onClearHistory = loginViewModel::clearAccountHistory
- }
- createAccountButton = view.findViewById(R.id.create_account)
- createAccountButton.setOnClickAction(
- "createAccount",
- jobTracker,
- loginViewModel::createAccount
- )
-
- scrollArea = view.findViewById(R.id.scroll_area)
-
- background =
- view.findViewById<View>(R.id.contents).apply { setOnClickListener { requestFocus() } }
-
- scrollToShow(accountLogin)
-
- loginViewModel.clearState()
- triggerAutoLoginIfAccountTokenPresent()
- input = accountLogin.findViewById(R.id.input)
- return view
- }
-
- override fun onStart() {
- super.onStart()
- requireMainActivity().backButtonHandler = {
- if (accountLogin.hasFocus) {
- background.requestFocus()
- true
- } else {
- false
- }
+ // TODO: Remove this when we have a better solution for login after clearing max devices
+ val accountTokenArgument = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY)
+ if (accountTokenArgument != null) {
+ // Login and set initial TextField value
+ vm.onAccountNumberChange(accountTokenArgument)
+ vm.login(accountTokenArgument)
}
- input.onTextChanged.subscribe(this) { createAccountButton.isEnabled = it.isEmpty() }
- }
- override fun onResume() {
- super.onResume()
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- override fun onStop() {
- jobTracker.cancelAllJobs()
- requireMainActivity().backButtonHandler = null
- input.onTextChanged.unsubscribe(this)
- super.onStop()
- }
-
- private fun triggerAutoLoginIfAccountTokenPresent() {
- arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY)?.also { accountToken ->
- accountLogin.setAccountToken(accountToken)
- loginViewModel.login(accountToken)
- }
- }
-
- private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch {
- repeatOnLifecycle(Lifecycle.State.RESUMED) {
- lanuchUpdateAccountHistory()
- launchUpdateUiOnViewModelStateChanges()
- }
- }
-
- private fun CoroutineScope.lanuchUpdateAccountHistory() = launch {
- loginViewModel.accountHistory.collect { history ->
- accountLogin.accountHistory = history.accountToken()
- }
- }
-
- private fun CoroutineScope.launchUpdateUiOnViewModelStateChanges() = launch {
- loginViewModel.uiState
- .onEach {
- // Adds a short delay to prevent loading spinner flickering.
- if (it.isLoading().not()) {
- delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS)
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val loginUiState by vm.uiState.collectAsState()
+ LaunchedEffect(Unit) {
+ vm.viewActions.collect {
+ when (it) {
+ LoginViewAction.NavigateToWelcome,
+ LoginViewAction
+ .NavigateToConnect -> {} // TODO Fix when we redo navigation
+ is LoginViewAction.TooManyDevices -> {
+ navigateToDeviceListFragment(it.accountToken)
+ }
+ }
+ }
+ }
+ LoginScreen(
+ loginUiState,
+ vm::login,
+ vm::createAccount,
+ vm::clearAccountHistory,
+ vm::onAccountNumberChange,
+ ::openSettingsView
+ )
}
}
- .collect { uiState -> updateUi(uiState) }
- }
-
- private fun updateUi(uiState: LoginViewModel.LoginUiState) {
- when (uiState) {
- is LoginViewModel.LoginUiState.Default -> {
- showDefault()
- }
- is LoginViewModel.LoginUiState.Success -> {
- // MainActivity responsible for transition to connect/out-of-time view.
- showLoggedIn()
- }
- is LoginViewModel.LoginUiState.AccountCreated -> {
- // MainActivity responsible for transition to welcome view.
- }
- is LoginViewModel.LoginUiState.CreatingAccount -> {
- showCreatingAccount()
- }
- is LoginViewModel.LoginUiState.Loading -> {
- showLoading()
- }
- is LoginViewModel.LoginUiState.InvalidAccountError -> {
- loginFailure(resources.getString(R.string.login_fail_description))
- }
- is LoginViewModel.LoginUiState.TooManyDevicesError -> {
- showLoading(overrideSpinnerWithErrorIcon = true)
- openDeviceListFragment(uiState.accountToken)
- }
- is LoginViewModel.LoginUiState.TooManyDevicesMissingListError -> {
- loginFailure(context?.getString(R.string.failed_to_fetch_devices))
- }
- is LoginViewModel.LoginUiState.UnableToCreateAccountError -> {
- loginFailure(resources.getString(R.string.failed_to_create_account))
- }
- is LoginViewModel.LoginUiState.OtherError -> {
- loginFailure(resources.getString(R.string.error_occurred))
- }
}
}
- private fun openDeviceListFragment(accountToken: String) {
-
+ private fun navigateToDeviceListFragment(accountToken: AccountToken) {
val deviceFragment =
DeviceListFragment().apply {
- arguments = Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken) }
+ arguments =
+ Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken.value) }
}
parentFragmentManager.beginTransaction().apply {
@@ -208,88 +84,7 @@ class LoginFragment : BaseFragment(), NavigationBarPainter {
}
}
- private fun showDefault() {
- accountLogin.state = LoginState.Initial
- headerBar.tunnelState = null
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- private fun showLoading(overrideSpinnerWithErrorIcon: Boolean = false) {
- accountLogin.state = LoginState.InProgress
- headerBar.setSettingsButtonEnabled(false)
-
- title.setText(R.string.logging_in_title)
- subtitle.setText(R.string.logging_in_description)
-
- loggingInStatus.visibility =
- if (overrideSpinnerWithErrorIcon == false) {
- View.VISIBLE
- } else {
- View.GONE
- }
-
- loginFailStatus.visibility =
- if (overrideSpinnerWithErrorIcon == false) {
- View.GONE
- } else {
- View.VISIBLE
- }
-
- loggedInStatus.visibility = View.GONE
-
- background.requestFocus()
-
- scrollToShow(loggingInStatus)
- }
-
- private fun showLoggedIn() {
- title.setText(R.string.logged_in_title)
- subtitle.setText(R.string.logged_in_description)
-
- loggingInStatus.visibility = View.GONE
- loginFailStatus.visibility = View.GONE
- loggedInStatus.visibility = View.VISIBLE
-
- accountLogin.state = LoginState.Success
- headerBar.setSettingsButtonEnabled(false)
-
- scrollToShow(loggedInStatus)
- }
-
- private fun showCreatingAccount() {
- title.setText(R.string.logging_in_title)
- subtitle.setText(R.string.creating_new_account)
-
- loggingInStatus.visibility = View.VISIBLE
- loginFailStatus.visibility = View.GONE
- loggedInStatus.visibility = View.GONE
-
- accountLogin.state = LoginState.InProgress
- headerBar.setSettingsButtonEnabled(true)
-
- scrollToShow(loggingInStatus)
- }
-
- private fun loginFailure(description: String? = "") {
- title.setText(R.string.login_fail_title)
- subtitle.text = description
-
- loggingInStatus.visibility = View.GONE
- loginFailStatus.visibility = View.VISIBLE
- loggedInStatus.visibility = View.GONE
-
- accountLogin.state = LoginState.Failure
- headerBar.setSettingsButtonEnabled(true)
-
- scrollToShow(accountLogin)
- }
-
- private fun scrollToShow(view: View) {
- val rectangle = Rect(0, 0, view.width, view.height)
- scrollArea.requestChildRectangleOnScreen(view, rectangle, false)
- }
-
- companion object {
- private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 200L
+ private fun openSettingsView() {
+ (context as? MainActivity)?.openSettings()
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
index 07e8f48705..194b6462a5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
@@ -4,109 +4,129 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+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.model.AccountCreationResult
+import net.mullvad.mullvadvpn.model.AccountToken
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
+private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L
+
+sealed interface LoginViewAction {
+ data object NavigateToWelcome : LoginViewAction
+
+ data object NavigateToConnect : LoginViewAction
+
+ data class TooManyDevices(val accountToken: AccountToken) : LoginViewAction
+}
+
class LoginViewModel(
private val accountRepository: AccountRepository,
private val deviceRepository: DeviceRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
- private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Default)
- val uiState: StateFlow<LoginUiState> = _uiState
-
- val accountHistory = accountRepository.accountHistoryEvents
-
- sealed class LoginUiState {
- data object Default : LoginUiState()
-
- data object Loading : LoginUiState()
-
- data class Success(val isOutOfTime: Boolean) : LoginUiState()
-
- data object CreatingAccount : LoginUiState()
-
- data object AccountCreated : LoginUiState()
-
- data object UnableToCreateAccountError : LoginUiState()
-
- data object InvalidAccountError : LoginUiState()
-
- data class TooManyDevicesError(val accountToken: String) : LoginUiState()
+ private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState)
+ private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput)
- data object TooManyDevicesMissingListError : LoginUiState()
+ private val _viewActions = MutableSharedFlow<LoginViewAction>(extraBufferCapacity = 1)
+ val viewActions = _viewActions.asSharedFlow()
- data class OtherError(val errorMessage: String) : LoginUiState()
-
- fun isLoading(): Boolean {
- return this is Loading
+ private val _uiState =
+ combine(
+ _loginInput,
+ accountRepository.accountHistory,
+ _loginState,
+ ) { loginInput, accountHistoryState, loginState ->
+ LoginUiState(
+ loginInput,
+ accountHistoryState.accountToken()?.let(::AccountToken),
+ loginState
+ )
}
- }
+ val uiState: StateFlow<LoginUiState> =
+ _uiState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL)
fun clearAccountHistory() = accountRepository.clearAccountHistory()
- fun clearState() {
- _uiState.value = LoginUiState.Default
- }
-
fun createAccount() {
- _uiState.value = LoginUiState.CreatingAccount
+ _loginState.value = Loading.CreatingAccount
viewModelScope.launch(dispatcher) {
- _uiState.value =
- accountRepository.accountCreationEvents
- .onStart { accountRepository.createAccount() }
- .first()
- .mapToUiState()
+ accountRepository.createAccount().mapToUiState()?.let { _loginState.value = it }
}
}
fun login(accountToken: String) {
- _uiState.value = LoginUiState.Loading
+ _loginState.value = Loading.LoggingIn
viewModelScope.launch(dispatcher) {
- _uiState.value =
- accountRepository.loginEvents
- .onStart { accountRepository.login(accountToken) }
- .map { it.result.mapToUiState(accountToken) }
- .first()
- }
- }
+ // Ensure we always take at least MINIMUM_LOADING_SPINNER_TIME_MILLIS to show the
+ // loading indicator
+ val loginDeferred = async { accountRepository.login(accountToken) }
+ delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS)
- private fun AccountCreationResult.mapToUiState(): LoginUiState {
- return if (this is AccountCreationResult.Success) {
- LoginUiState.AccountCreated
- } else {
- LoginUiState.UnableToCreateAccountError
+ val uiState =
+ when (val result = loginDeferred.await()) {
+ LoginResult.Ok -> {
+ launch {
+ delay(1000)
+ _viewActions.emit(LoginViewAction.NavigateToConnect)
+ }
+ Success
+ }
+ LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials)
+ LoginResult.MaxDevicesReached -> {
+ // TODO this refresh process should be handled by DeviceListScreen.
+ val refreshResult =
+ deviceRepository.refreshAndAwaitDeviceListWithTimeout(
+ accountToken = accountToken,
+ shouldClearCache = true,
+ shouldOverrideCache = true,
+ timeoutMillis = 5000L
+ )
+
+ if (refreshResult.isAvailable()) {
+ // Navigate to device list
+ _viewActions.emit(
+ LoginViewAction.TooManyDevices(AccountToken(accountToken))
+ )
+ return@launch
+ } else {
+ // Failed to fetch devices list
+ Idle(LoginError.Unknown(result.toString()))
+ }
+ }
+ else -> Idle(LoginError.Unknown(result.toString()))
+ }
+ _loginState.update { uiState }
}
}
- private suspend fun LoginResult.mapToUiState(accountToken: String): LoginUiState {
- return when (this) {
- LoginResult.Ok -> LoginUiState.Success(false)
- LoginResult.InvalidAccount -> LoginUiState.InvalidAccountError
- LoginResult.MaxDevicesReached -> {
- val refreshResult =
- deviceRepository.refreshAndAwaitDeviceListWithTimeout(
- accountToken = accountToken,
- shouldClearCache = true,
- shouldOverrideCache = true,
- timeoutMillis = 5000L
- )
+ fun onAccountNumberChange(accountNumber: String) {
+ _loginInput.value = accountNumber.filter { it.isDigit() }
+ // If there is an error, clear it
+ _loginState.update { if (it is Idle) Idle() else it }
+ }
- if (refreshResult.isAvailable()) {
- LoginUiState.TooManyDevicesError(accountToken)
- } else {
- LoginUiState.TooManyDevicesMissingListError
- }
- }
- else -> LoginUiState.OtherError(errorMessage = this.toString())
+ private suspend fun AccountCreationResult.mapToUiState(): LoginState? {
+ return if (this is AccountCreationResult.Success) {
+ _viewActions.emit(LoginViewAction.NavigateToWelcome)
+ null
+ } else {
+ Idle(LoginError.UnableToCreateAccount)
}
}
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index 4ba80511c6..73bfd1ef38 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -2,58 +2,48 @@ package net.mullvad.mullvadvpn.viewmodel
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
+import app.cash.turbine.turbineScope
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
-import junit.framework.Assert.assertEquals
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
-import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.compose.state.LoginError
+import net.mullvad.mullvadvpn.compose.state.LoginState.*
+import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountHistory
+import net.mullvad.mullvadvpn.model.AccountToken
import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import org.junit.Assert.assertEquals
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
class LoginViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
@MockK private lateinit var mockedAccountRepository: AccountRepository
-
@MockK private lateinit var mockedDeviceRepository: DeviceRepository
- @MockK private lateinit var mockedServiceConnectionContainer: ServiceConnectionContainer
-
private lateinit var loginViewModel: LoginViewModel
-
- private val accountCreationTestEvents = MutableSharedFlow<AccountCreationResult>()
private val accountHistoryTestEvents = MutableStateFlow<AccountHistory>(AccountHistory.Missing)
- private val loginTestEvents = MutableSharedFlow<Event.LoginEvent>()
-
- private val serviceConnectionState =
- MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
@Before
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
MockKAnnotations.init(this, relaxUnitFun = true)
- every { mockedAccountRepository.accountCreationEvents } returns accountCreationTestEvents
- every { mockedAccountRepository.accountHistoryEvents } returns accountHistoryTestEvents
- every { mockedAccountRepository.loginEvents } returns loginTestEvents
-
- serviceConnectionState.value =
- ServiceConnectionState.ConnectedReady(mockedServiceConnectionContainer)
+ every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents
loginViewModel =
LoginViewModel(
@@ -64,103 +54,135 @@ class LoginViewModelTest {
}
@Test
- fun testDefaultState() = runBlockingTest {
- loginViewModel.uiState.test {
- assertEquals(LoginViewModel.LoginUiState.Default, awaitItem())
- }
+ fun testDefaultState() = runTest {
+ loginViewModel.uiState.test { assertEquals(LoginUiState.INITIAL, awaitItem()) }
}
@Test
- fun testCreateAccount() = runBlockingTest {
- loginViewModel.uiState.test {
- skipDefaultItem()
- loginViewModel.createAccount()
- assertEquals(LoginViewModel.LoginUiState.CreatingAccount, awaitItem())
- accountCreationTestEvents.emit(AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN))
+ fun testCreateAccount() = runTest {
+ turbineScope {
+ // Arrange
+ val uiStates = loginViewModel.uiState.testIn(backgroundScope)
+ val sideEffects = loginViewModel.viewActions.testIn(backgroundScope)
+ coEvery { mockedAccountRepository.createAccount() } returns
+ AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem())
+ // Act, Assert
+ uiStates.skipDefaultItem()
+ loginViewModel.createAccount()
+ assertEquals(Loading.CreatingAccount, uiStates.awaitItem().loginState)
+ assertEquals(LoginViewAction.NavigateToWelcome, sideEffects.awaitItem())
}
}
@Test
- fun testLoginWithValidAccount() = runBlockingTest {
- loginViewModel.uiState.test {
- skipDefaultItem()
+ fun testLoginWithValidAccount() = runTest {
+ turbineScope {
+ // Arrange
+ val uiStates = loginViewModel.uiState.testIn(backgroundScope)
+ val sideEffects = loginViewModel.viewActions.testIn(backgroundScope)
+ coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok
+
+ // Act, Assert
+ uiStates.skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
- loginTestEvents.emit(Event.LoginEvent(LoginResult.Ok))
- assertEquals(LoginViewModel.LoginUiState.Success(isOutOfTime = false), awaitItem())
+ assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState)
+ assertEquals(Success, uiStates.awaitItem().loginState)
+ assertEquals(LoginViewAction.NavigateToConnect, sideEffects.awaitItem())
}
}
@Test
- fun testLoginWithInvalidAccount() = runBlockingTest {
+ fun testLoginWithInvalidAccount() = runTest {
loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.login(any()) } returns LoginResult.InvalidAccount
+
+ // Act, Assert
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
- loginTestEvents.emit(Event.LoginEvent(LoginResult.InvalidAccount))
- assertEquals(LoginViewModel.LoginUiState.InvalidAccountError, awaitItem())
+ assertEquals(Loading.LoggingIn, awaitItem().loginState)
+ assertEquals(Idle(loginError = LoginError.InvalidCredentials), awaitItem().loginState)
}
}
@Test
- fun testLoginWithTooManyDevicesError() = runBlockingTest {
- coEvery {
- mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout(any(), any(), any(), any())
- } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf())
+ fun testLoginWithTooManyDevicesError() = runTest {
+ turbineScope {
+ // Arrange
+ val uiStates = loginViewModel.uiState.testIn(backgroundScope)
+ val sideEffects = loginViewModel.viewActions.testIn(backgroundScope)
+ coEvery {
+ mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout(
+ any(),
+ any(),
+ any(),
+ any()
+ )
+ } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf())
+ coEvery { mockedAccountRepository.login(any()) } returns LoginResult.MaxDevicesReached
- loginViewModel.uiState.test {
- skipDefaultItem()
+ // Act, Assert
+ uiStates.skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
- loginTestEvents.emit(Event.LoginEvent(LoginResult.MaxDevicesReached))
+ assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState)
assertEquals(
- LoginViewModel.LoginUiState.TooManyDevicesError(DUMMY_ACCOUNT_TOKEN),
- awaitItem()
+ LoginViewAction.TooManyDevices(AccountToken(DUMMY_ACCOUNT_TOKEN)),
+ sideEffects.awaitItem()
)
}
}
@Test
- fun testLoginWithRpcError() = runBlockingTest {
+ fun testLoginWithRpcError() = runTest {
loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.login(any()) } returns LoginResult.RpcError
+
+ // Act, Assert
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
- loginTestEvents.emit(Event.LoginEvent(LoginResult.RpcError))
+ assertEquals(Loading.LoggingIn, awaitItem().loginState)
assertEquals(
- LoginViewModel.LoginUiState.OtherError(EXPECTED_RPC_ERROR_MESSAGE),
- awaitItem()
+ Idle(LoginError.Unknown(EXPECTED_RPC_ERROR_MESSAGE)),
+ awaitItem().loginState
)
}
}
@Test
- fun testLoginWithUnknownError() = runBlockingTest {
+ fun testLoginWithUnknownError() = runTest {
loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.login(any()) } returns LoginResult.OtherError
+
+ // Act, Assert
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
- assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
- loginTestEvents.emit(Event.LoginEvent(LoginResult.OtherError))
+ assertEquals(Loading.LoggingIn, awaitItem().loginState)
assertEquals(
- LoginViewModel.LoginUiState.OtherError(EXPECTED_OTHER_ERROR_MESSAGE),
- awaitItem()
+ Idle(LoginError.Unknown(EXPECTED_OTHER_ERROR_MESSAGE)),
+ awaitItem().loginState
)
}
}
@Test
- fun testAccountHistory() = runBlockingTest {
- loginViewModel.accountHistory.test {
+ fun testAccountHistory() = runTest {
+ loginViewModel.uiState.test {
+ // Act, Assert
skipDefaultItem()
accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN))
- assertEquals(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN), awaitItem())
+ assertEquals(
+ LoginUiState.INITIAL.copy(lastUsedAccount = AccountToken(DUMMY_ACCOUNT_TOKEN)),
+ awaitItem()
+ )
}
}
@Test
- fun testClearingAccountHistory() = runBlockingTest {
+ fun testClearingAccountHistory() = runTest {
+ // Act, Assert
loginViewModel.clearAccountHistory()
verify { mockedAccountRepository.clearAccountHistory() }
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt
new file mode 100644
index 0000000000..2aeca352d0
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.model
+
+@JvmInline value class AccountToken(val value: String)
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
index 7e48417168..e9490d31d4 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
@@ -22,32 +22,13 @@ import net.mullvad.mullvadvpn.lib.theme.typeface.TypeScale
private val MullvadTypography =
Typography(
headlineLarge = TextStyle(fontSize = TypeScale.TextHuge, fontWeight = FontWeight.Bold),
- headlineSmall =
- TextStyle(
- color = MullvadWhite,
- fontSize = TypeScale.TextBig,
- fontWeight = FontWeight.Bold
- ),
+ headlineSmall = TextStyle(fontSize = TypeScale.TextBig, fontWeight = FontWeight.Bold),
+ bodySmall = TextStyle(fontSize = TypeScale.TextSmall),
+ titleSmall = TextStyle(fontSize = TypeScale.TextMedium, fontWeight = FontWeight.SemiBold),
bodyMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.Bold),
- bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall),
- titleSmall =
- TextStyle(
- color = MullvadWhite,
- fontSize = TypeScale.TextMedium,
- fontWeight = FontWeight.SemiBold
- ),
titleMedium =
- TextStyle(
- color = MullvadWhite,
- fontSize = TypeScale.TextMediumPlus,
- fontWeight = FontWeight.SemiBold
- ),
- labelMedium =
- TextStyle(
- color = MullvadWhite60,
- fontSize = TypeScale.TextSmall,
- fontWeight = FontWeight.SemiBold
- ),
+ TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.SemiBold),
+ labelMedium = TextStyle(fontSize = TypeScale.TextSmall, fontWeight = FontWeight.SemiBold),
labelLarge =
TextStyle(
fontWeight = FontWeight.Normal,
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index aeee6593da..23e9910735 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -29,13 +29,16 @@ data class Dimensions(
val loadingSpinnerPadding: Dp = 12.dp,
val loadingSpinnerSize: Dp = 24.dp,
val loadingSpinnerSizeMedium: Dp = 28.dp,
- val loadingSpinnerStrokeWidth: Dp = 3.dp,
+ val loadingSpinnerStrokeWidth: Dp = 6.dp,
+ val loginIconContainerSize: Dp = 60.dp,
+ val smallPadding: Dp = 8.dp,
val mediumPadding: Dp = 16.dp,
+ val largePadding: Dp = 32.dp,
val notificationBannerStartPadding: Dp = 16.dp,
val notificationBannerEndPadding: Dp = 12.dp,
val notificationEndIconPadding: Dp = 4.dp,
val notificationStatusIconSize: Dp = 10.dp,
- val progressIndicatorSize: Dp = 60.dp,
+ val progressIndicatorSize: Dp = 48.dp,
val relayCircleSize: Dp = 16.dp,
val relayRowPadding: Dp = 50.dp,
val screenVerticalMargin: Dp = 22.dp,
@@ -46,7 +49,6 @@ data class Dimensions(
val selectLocationTitlePadding: Dp = 12.dp,
val selectableCellTextMargin: Dp = 12.dp,
val sideMargin: Dp = 22.dp,
- val smallPadding: Dp = 8.dp,
val titleIconSize: Dp = 24.dp,
val topBarHeight: Dp = 64.dp,
val verticalSpace: Dp = 20.dp,