diff options
| author | Albin <albin@mullvad.net> | 2022-06-17 08:28:03 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2022-06-22 11:57:30 +0200 |
| commit | e87aea2ce5baa7a9c7466e345dad7bc0739445a6 (patch) | |
| tree | 5bd630b64252f4de2f334269d9afe1af214c6e63 /android | |
| parent | 319c524f55729a14c62bb6d8a8985e4d87030fb6 (diff) | |
| download | mullvadvpn-e87aea2ce5baa7a9c7466e345dad7bc0739445a6.tar.xz mullvadvpn-e87aea2ce5baa7a9c7466e345dad7bc0739445a6.zip | |
Add device list ui
Diffstat (limited to 'android')
10 files changed, 457 insertions, 9 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt new file mode 100644 index 0000000000..fdadbd5b29 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt @@ -0,0 +1,125 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusOrder +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.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.Device +import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel + +@Composable +fun ShowDeviceRemovalDialog(viewModel: DeviceListViewModel, device: Device) { + AlertDialog( + onDismissRequest = { + viewModel.clearStagedDevice() + }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(top = 0.dp) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = "Remove", + modifier = Modifier + .width(50.dp) + .height(50.dp) + ) + } + }, + text = { + val htmlFormattedDialogText = textResource( + id = R.string.max_devices_confirm_removal_description, + device.name.capitalizeFirstCharOfEachWord() + ).let { introText -> + if (device.ports.isNotEmpty()) { + introText.plus(" " + stringResource(id = R.string.port_removal_notice)) + } else { + introText + } + } + + HtmlText( + htmlFormattedString = htmlFormattedDialogText, + textSize = 16.sp.value + ) + }, + buttons = { + Column( + Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Button( + modifier = Modifier + .height(dimensionResource(id = R.dimen.button_height)) + .defaultMinSize( + minWidth = 0.dp, + minHeight = dimensionResource(id = R.dimen.button_height) + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(id = R.color.red), + contentColor = Color.White + ), + onClick = { + viewModel.confirmRemoval() + } + ) { + Text( + text = stringResource(id = R.string.confirm_removal), + fontSize = 18.sp + ) + } + Button( + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .focusOrder(FocusRequester()) + .padding(top = 16.dp) + .height(dimensionResource(id = R.dimen.button_height)) + .defaultMinSize( + minWidth = 0.dp, + minHeight = dimensionResource(id = R.dimen.button_height) + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = { + viewModel.clearStagedDevice() + } + ) { + Text( + text = stringResource(id = R.string.back), + fontSize = 18.sp + ) + } + } + }, + backgroundColor = colorResource(id = R.color.darkBlue) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt index 81d212df84..545d724228 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt @@ -1,15 +1,15 @@ package net.mullvad.mullvadvpn.compose.component -import android.text.Spanned import android.util.TypedValue import android.widget.TextView import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat @Composable fun HtmlText( - htmlFormattedText: Spanned, + htmlFormattedString: String, textSize: Float, modifier: Modifier = Modifier ) { @@ -20,6 +20,8 @@ fun HtmlText( setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize) } }, - update = { it.text = htmlFormattedText } + update = { + it.text = HtmlCompat.fromHtml(htmlFormattedString, HtmlCompat.FROM_HTML_MODE_COMPACT) + } ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt index af685e5e5c..8fe7d44c75 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt @@ -1,16 +1,12 @@ package net.mullvad.mullvadvpn.compose.component -import android.text.Spanned import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.platform.LocalContext -import androidx.core.text.HtmlCompat @Composable @ReadOnlyComposable -fun textResource(@StringRes id: Int, vararg formatArgs: Any): Spanned { - return LocalContext.current.resources.getString(id, *formatArgs).let { text -> - HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) - } +fun textResource(@StringRes id: Int, vararg formatArgs: Any): String { + return LocalContext.current.resources.getString(id, *formatArgs) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt new file mode 100644 index 0000000000..b9dcae7e8e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -0,0 +1,176 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ActionButton +import net.mullvad.mullvadvpn.compose.component.ItemList +import net.mullvad.mullvadvpn.compose.component.ShowDeviceRemovalDialog +import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel + +@Composable +fun DeviceListScreen( + viewModel: DeviceListViewModel, + onBackClick: () -> Unit, + onContinueWithLogin: () -> Unit +) { + val state = viewModel.uiState.collectAsState().value + + if (state.deviceStagedForRemoval != null) { + ShowDeviceRemovalDialog( + viewModel = viewModel, + device = state.deviceStagedForRemoval + ) + } + + ConstraintLayout( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .background(colorResource(id = R.color.darkBlue)) + ) { + val (icon, message, list, actionButtons) = createRefs() + + Image( + painter = painterResource( + id = if (state.hasTooManyDevices) { + R.drawable.icon_fail + } else { + R.drawable.icon_success + } + ), + contentDescription = null, // No meaningful user info or action. + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top, margin = 30.dp) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .width(64.dp) + .height(64.dp) + ) + + Column( + modifier = Modifier + .constrainAs(message) { + top.linkTo(icon.bottom, margin = 16.dp) + start.linkTo(parent.start, margin = 22.dp) + end.linkTo(parent.end, margin = 22.dp) + width = Dimension.fillToConstraints + }, + ) { + Text( + text = stringResource( + id = if (state.hasTooManyDevices) { + R.string.max_devices_warning_title + } else { + R.string.max_devices_resolved_title + } + ), + fontSize = 24.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource( + id = if (state.hasTooManyDevices) { + R.string.max_devices_warning_description + } else { + R.string.max_devices_resolved_description + } + ), + color = Color.White, + fontSize = 14.sp, + modifier = Modifier + .wrapContentHeight() + .animateContentSize() + .padding(top = 8.dp) + ) + } + + Box( + modifier = Modifier + .constrainAs(list) { + top.linkTo(message.bottom, margin = 20.dp) + bottom.linkTo(actionButtons.top, margin = 5.dp) + height = Dimension.fillToConstraints + width = Dimension.matchParent + } + ) { + if (state.isLoading) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 8.dp, + modifier = Modifier.align(Alignment.Center) + ) + } else { + ItemList( + state.devices, + itemText = { it.name.capitalizeFirstCharOfEachWord() }, + onItemClicked = { + viewModel.stageDeviceForRemoval(it) + }, + itemPainter = painterResource(id = R.drawable.icon_close) + ) + } + } + + Column( + modifier = Modifier + .constrainAs(actionButtons) { + bottom.linkTo(parent.bottom, margin = 22.dp) + start.linkTo(parent.start, margin = 22.dp) + end.linkTo(parent.end, margin = 22.dp) + width = Dimension.fillToConstraints + } + ) { + ActionButton( + text = stringResource(id = R.string.continue_login), + onClick = onContinueWithLogin, + isEnabled = state.hasTooManyDevices.not() && state.isLoading.not(), + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(id = R.color.green), + disabledBackgroundColor = colorResource(id = R.color.green40), + disabledContentColor = colorResource(id = R.color.white80), + contentColor = Color.White + ) + ) + ActionButton( + text = stringResource(id = R.string.back), + onClick = onBackClick, + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + modifier = Modifier + .padding(top = 16.dp) + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index fc58c2685f..ce92ef72aa 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -101,6 +101,7 @@ fun DeviceRevokedScreen( text = stringResource(id = R.string.go_to_login), onClick = { deviceRevokedViewModel.onGoToLoginClicked() }, colors = ButtonDefaults.buttonColors( + contentColor = Color.White, backgroundColor = colorResource( if (state == DeviceRevokedUiState.SECURED) { R.color.red60 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt new file mode 100644 index 0000000000..9e048c2926 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.Device + +data class DeviceListUiState( + val devices: List<Device>, + val isLoading: Boolean, + val deviceStagedForRemoval: Device? +) { + val hasTooManyDevices = devices.count() >= 5 + + companion object { + val INITIAL = DeviceListUiState( + devices = listOf(), + isLoading = true, + deviceStagedForRemoval = null + ) + } +} 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 f86a8d69e2..8f939487e5 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 @@ -9,6 +9,7 @@ import net.mullvad.mullvadvpn.ipc.EventDispatcher import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel @@ -39,6 +40,7 @@ val uiModule = module { single { DeviceRepository(get()) } viewModel { LoginViewModel(get()) } viewModel { DeviceRevokedViewModel(get()) } + viewModel { DeviceListViewModel(get()) } } const val APPS_SCOPE = "APPS_SCOPE" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt new file mode 100644 index 0000000000..11ef615ad8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt @@ -0,0 +1,76 @@ +package net.mullvad.mullvadvpn.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource +import androidx.fragment.app.Fragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.screen.DeviceListScreen +import net.mullvad.mullvadvpn.ui.LoginFragment +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class DeviceListFragment : Fragment() { + + private val deviceListViewModel by viewModel<DeviceListViewModel>() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + deviceListViewModel.accountToken = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) + + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + val topColor = colorResource(R.color.blue) + ScaffoldWithTopBar( + topBarColor = topColor, + statusBarColor = topColor, + navigationBarColor = colorResource(id = R.color.darkBlue), + onSettingsClicked = this@DeviceListFragment::openSettings, + content = { + DeviceListScreen( + viewModel = deviceListViewModel, + onBackClick = this@DeviceListFragment::goBack, + onContinueWithLogin = this@DeviceListFragment::openLoginView + ) + } + ) + } + } + } + + private fun openLoginView() { + val loginFragment = LoginFragment().apply { + if (deviceListViewModel.accountToken != null) { + arguments = Bundle().apply { + putString( + ACCOUNT_TOKEN_ARGUMENT_KEY, + deviceListViewModel.accountToken + ) + } + } + } + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, loginFragment) + addToBackStack(null) + commit() + } + } + + private fun goBack() { + parentActivity()?.onBackPressed() + } + + private fun parentActivity(): MainActivity? { + return (context as? MainActivity) + } + + private fun openSettings() = parentActivity()?.openSettings() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt new file mode 100644 index 0000000000..027520d293 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt @@ -0,0 +1,50 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.DeviceListUiState +import net.mullvad.mullvadvpn.model.Device +import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository +import net.mullvad.mullvadvpn.util.safeLet + +class DeviceListViewModel( + private val deviceRepository: DeviceRepository +) : ViewModel() { + private val _stagedForRemoval = MutableStateFlow<Device?>(null) + var accountToken: String? = null + + val uiState = deviceRepository.deviceList + .combine(_stagedForRemoval) { deviceList, deviceStagedForRemoval -> + DeviceListUiState( + devices = deviceList, + isLoading = false, + deviceStagedForRemoval = deviceStagedForRemoval + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL) + + fun stageDeviceForRemoval(device: Device) { + _stagedForRemoval.value = device + } + + fun clearStagedDevice() { + _stagedForRemoval.value = null + } + + fun confirmRemoval() { + safeLet(accountToken, _stagedForRemoval.value) { token, device -> + deviceRepository.removeDevice(token, device.id) + _stagedForRemoval.value = null + } + } + + fun refreshDeviceState() = deviceRepository.refreshDeviceState() + + fun refreshDeviceList() = accountToken?.let { token -> + deviceRepository.refreshDeviceList(token) + } +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 8f53508029..de78bd2b7a 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -15,6 +15,7 @@ <color name="green">#44AD4D</color> <color name="green90">#E644AD4D</color> <color name="green80">#CC44AD4D</color> + <color name="green40">#6644AD4D</color> <color name="red">#FFE34039</color> <color name="red95">#F2E34039</color> <color name="red80">#CCE34039</color> |
