summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
authorsaber safavi <saber.safavi@codic.se>2023-07-25 12:46:17 +0200
committersaber safavi <saber.safavi@codic.se>2023-07-28 16:54:15 +0200
commit44424f8528129369dbaa7eac6ea8d3ca34742f9c (patch)
tree9a843a5a5fb064f922df10dfe6480e14c2f8f4f0 /android/app/src
parent73359230e0b56881227a993fd9e0f15593d44435 (diff)
downloadmullvadvpn-44424f8528129369dbaa7eac6ea8d3ca34742f9c.tar.xz
mullvadvpn-44424f8528129369dbaa7eac6ea8d3ca34742f9c.zip
Add account screen
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt199
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt267
2 files changed, 222 insertions, 244 deletions
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
new file mode 100644
index 0000000000..1e8b677e32
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
@@ -0,0 +1,199 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+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.CollapsableAwareToolbarScaffold
+import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView
+import net.mullvad.mullvadvpn.compose.component.InformationView
+import net.mullvad.mullvadvpn.compose.component.MissingPolicy
+import net.mullvad.mullvadvpn.compose.state.AccountUiState
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord
+import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
+import net.mullvad.mullvadvpn.util.toExpiryDateString
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+private fun PreviewAccountScreen() {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = "Test Name",
+ accountNumber = "1234123412341234",
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow(),
+ )
+}
+
+@ExperimentalMaterial3Api
+@Composable
+fun AccountScreen(
+ uiState: AccountUiState,
+ viewActions: SharedFlow<AccountViewModel.ViewAction>,
+ onRedeemVoucherClick: () -> Unit = {},
+ onManageAccountClick: () -> Unit = {},
+ onLogoutClick: () -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+
+ CollapsableAwareToolbarScaffold(
+ backgroundColor = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
+ isEnabledWhenCollapsable = true,
+ toolbar = {
+ val scaffoldModifier =
+ Modifier.road(
+ whenCollapsed = Alignment.TopCenter,
+ whenExpanded = Alignment.BottomStart
+ )
+ CollapsingTopBar(
+ backgroundColor = MaterialTheme.colorScheme.secondary,
+ onBackClicked = { onBackClick() },
+ title = stringResource(id = R.string.settings_account),
+ progress = progress,
+ modifier = scaffoldModifier,
+ backTitle = String(),
+ shouldRotateBackButtonDown = true
+ )
+ },
+ ) {
+ LaunchedEffect(Unit) {
+ viewActions.collect { viewAction ->
+ if (viewAction is AccountViewModel.ViewAction.OpenAccountView) {
+ context.openAccountPageInBrowser(viewAction.token)
+ }
+ }
+ }
+ Column(
+ verticalArrangement = Arrangement.Bottom,
+ horizontalAlignment = Alignment.Start,
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.background)
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .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),
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding
+ )
+ )
+
+ CopyableObfuscationView(content = uiState.accountNumber)
+
+ 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(1.0f))
+
+ ActionButton(
+ text = stringResource(id = R.string.manage_account),
+ onClick = { onManageAccountClick() },
+ 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
+ )
+ )
+
+ Spacer(modifier = Modifier.height(Dimens.cellHeight))
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
index bf6dc71b22..4349e8ae64 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
@@ -1,271 +1,50 @@
package net.mullvad.mullvadvpn.ui.fragment
-import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.core.view.isVisible
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import java.text.DateFormat
-import kotlin.properties.Delegates.observable
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.BuildConfig
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes
-import net.mullvad.mullvadvpn.lib.common.util.JobTracker
-import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord
-import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
-import net.mullvad.mullvadvpn.model.TunnelState
-import net.mullvad.mullvadvpn.repository.AccountRepository
-import net.mullvad.mullvadvpn.repository.DeviceRepository
-import net.mullvad.mullvadvpn.ui.CollapsibleTitleController
-import net.mullvad.mullvadvpn.ui.GroupedPasswordTransformationMethod
-import net.mullvad.mullvadvpn.ui.GroupedTransformationMethod
+import net.mullvad.mullvadvpn.compose.screen.AccountScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.ui.NavigationBarPainter
import net.mullvad.mullvadvpn.ui.StatusBarPainter
-import net.mullvad.mullvadvpn.ui.extension.requireMainActivity
-import net.mullvad.mullvadvpn.ui.paintNavigationBar
-import net.mullvad.mullvadvpn.ui.paintStatusBar
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
-import net.mullvad.mullvadvpn.ui.widget.AccountManagementButton
-import net.mullvad.mullvadvpn.ui.widget.Button
-import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView
-import net.mullvad.mullvadvpn.ui.widget.InformationView
-import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton
-import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS
-import net.mullvad.mullvadvpn.util.addDebounceForUnknownState
-import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
-import net.mullvad.talpid.tunnel.ErrorStateCause
-import org.joda.time.DateTime
-import org.koin.android.ext.android.inject
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class AccountFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter {
+ private val vm by viewModel<AccountViewModel>()
- // Injected dependencies
- private val accountRepository: AccountRepository by inject()
- private val deviceRepository: DeviceRepository by inject()
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private val dateStyle = DateFormat.MEDIUM
- private val timeStyle = DateFormat.SHORT
- private val expiryFormatter = DateFormat.getDateTimeInstance(dateStyle, timeStyle)
-
- private var oldAccountExpiry: DateTime? = null
-
- private var currentAccountExpiry: DateTime? = null
- set(value) {
- field = value
-
- synchronized(this) {
- if (value != oldAccountExpiry) {
- oldAccountExpiry = null
- }
- }
- }
-
- private var hasConnectivity = true
- set(value) {
- field = value
- accountManagementButton.isEnabled = value
- }
-
- private var isOffline = true
- set(value) {
- field = value
- redeemVoucherButton.setEnabled(!value)
- }
-
- private var isAccountNumberShown by
- observable(false) { _, _, doShow ->
- accountNumberView.informationState =
- if (doShow) {
- InformationView.Masking.Show(GroupedTransformationMethod())
- } else {
- InformationView.Masking.Hide(GroupedPasswordTransformationMethod())
- }
- }
-
- private lateinit var accountExpiryView: InformationView
- private lateinit var accountNumberView: CopyableInformationView
- private lateinit var deviceNameView: InformationView
- private lateinit var accountManagementButton: AccountManagementButton
- private lateinit var redeemVoucherButton: RedeemVoucherButton
- private lateinit var titleController: CollapsibleTitleController
-
- @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker()
-
- override fun onAttach(activity: Activity) {
- super.onAttach(activity)
- requireMainActivity().enterSecureScreen(this)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- lifecycleScope.launchUiSubscriptionsOnResume()
- }
-
+ @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View {
- val view = inflater.inflate(R.layout.account, container, false)
-
- view.findViewById<View>(R.id.close).setOnClickListener {
- requireMainActivity().onBackPressed()
- }
-
- accountManagementButton =
- view.findViewById<AccountManagementButton>(R.id.account_management).apply {
- setOnClickAction("openAccountPageInBrowser", jobTracker) {
- isEnabled = false
- serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token ->
- context.openAccountPageInBrowser(token)
+ ): View? {
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = vm.uiState.collectAsState().value
+ AccountScreen(
+ uiState = state,
+ viewActions = vm.viewActions,
+ onRedeemVoucherClick = { openRedeemVoucherFragment() },
+ onManageAccountClick = vm::onManageAccountClick,
+ onLogoutClick = vm::onLogoutClick
+ ) {
+ activity?.onBackPressed()
}
- isEnabled = true
- checkForAddedTime()
}
}
- accountManagementButton.isVisible = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE
-
- redeemVoucherButton =
- view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply {
- prepare(parentFragmentManager, jobTracker)
- }
-
- view.findViewById<Button>(R.id.logout).setOnClickAction("logout", jobTracker) {
- accountRepository.logout()
}
-
- accountNumberView =
- view.findViewById<CopyableInformationView>(R.id.account_number).apply {
- informationState =
- InformationView.Masking.Hide(GroupedPasswordTransformationMethod())
- onToggleMaskingClicked = { isAccountNumberShown = isAccountNumberShown.not() }
- }
-
- accountExpiryView = view.findViewById(R.id.account_expiry)
- deviceNameView = view.findViewById(R.id.device_name)
- titleController = CollapsibleTitleController(view)
-
- return view
- }
-
- override fun onResume() {
- super.onResume()
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- override fun onStop() {
- jobTracker.cancelAllJobs()
- super.onStop()
}
- override fun onDestroyView() {
- titleController.onDestroy()
- super.onDestroyView()
- }
-
- override fun onDetach() {
- requireMainActivity().leaveSecureScreen(this)
- super.onDetach()
- }
-
- private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch {
- repeatOnLifecycle(Lifecycle.State.RESUMED) {
- launchUpdateTextOnDeviceChanges()
- launchUpdateTextOnExpiryChanges()
- launchTunnelStateSubscription()
- launchRefreshDeviceStateAfterAnimation()
- launchPaintStatusBarAfterTransition()
- }
- }
-
- private fun CoroutineScope.launchUpdateTextOnDeviceChanges() {
- launch {
- deviceRepository.deviceState
- .debounce {
- it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
- }
- .collect { state ->
- accountNumberView.information = state.token()
- deviceNameView.information = state.deviceName()?.capitalizeFirstCharOfEachWord()
- }
- }
- }
-
- private fun CoroutineScope.launchUpdateTextOnExpiryChanges() {
- launch {
- accountRepository.accountExpiryState
- .map { state -> state.date() }
- .collect { expiryDate ->
- currentAccountExpiry = expiryDate
- updateAccountExpiry(expiryDate)
- }
- }
- }
-
- private fun CoroutineScope.launchTunnelStateSubscription() {
- launch {
- serviceConnectionManager.connectionState
- .flatMapLatest { state ->
- if (state is ServiceConnectionState.ConnectedReady) {
- callbackFlowFromNotifier(state.container.connectionProxy.onUiStateChange)
- } else {
- emptyFlow()
- }
- }
- .collect { uiState ->
- hasConnectivity =
- uiState is TunnelState.Connected ||
- uiState is TunnelState.Disconnected ||
- (uiState is TunnelState.Error && !uiState.errorState.isBlocking)
- isOffline =
- uiState is TunnelState.Error &&
- uiState.errorState.cause is ErrorStateCause.IsOffline
- }
- }
- }
-
- private fun CoroutineScope.launchPaintStatusBarAfterTransition() = launch {
- transitionFinishedFlow.collect {
- paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
- }
-
- private fun CoroutineScope.launchRefreshDeviceStateAfterAnimation() = launch {
- transitionFinishedFlow.collect { deviceRepository.refreshDeviceState() }
- }
-
- private fun checkForAddedTime() {
- currentAccountExpiry?.let { expiry -> oldAccountExpiry = expiry }
- }
-
- private fun updateAccountExpiry(accountExpiry: DateTime?) {
- if (accountExpiry != null) {
- accountExpiryView.information = expiryFormatter.format(accountExpiry.toDate())
- } else {
- accountExpiryView.information = null
- accountRepository.fetchAccountExpiry()
- }
- }
-
- private fun showRedeemVoucherDialog() {
+ private fun openRedeemVoucherFragment() {
val transaction = parentFragmentManager.beginTransaction()
-
transaction.addToBackStack(null)
-
RedeemVoucherDialogFragment().show(transaction, null)
}
}