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 000000000000..ea953bfadc9e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -0,0 +1,196 @@ +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 androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +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.CopyableInformationView +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.ui.extension.openAccountPageInBrowser +import net.mullvad.mullvadvpn.viewmodel.AccountViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewAccountScreen() { + AccountScreen( + uiState = + AccountUiState( + _deviceName = "Test Name", + accountNumber = "1234123412341234", + accountExpiry = null + ), + viewActionSharedFlow = MutableSharedFlow().asSharedFlow(), + ) +} + +@ExperimentalMaterial3Api +@Composable +fun AccountScreen( + uiState: AccountUiState, + viewActionSharedFlow: SharedFlow, + 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) { + viewActionSharedFlow.distinctUntilChanged().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, 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 + ) + ) + + CopyableInformationView(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.expiryString, + 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(50.dp)) + } + } +} 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 06fdb37b7906..339ffea7128c 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.constant.BuildTypes -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.openAccountPageInBrowser -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.JobTracker -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.mullvadvpn.util.capitalizeFirstCharOfEachWord -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() - // 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(R.id.close).setOnClickListener { - requireMainActivity().onBackPressed() - } - - accountManagementButton = - view.findViewById(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(R.id.compose_view).setContent { + AppTheme { + val state = vm.uiState.collectAsState().value + AccountScreen( + uiState = state, + viewActionSharedFlow = 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(R.id.redeem_voucher).apply { - prepare(parentFragmentManager, jobTracker) - } - - view.findViewById