diff options
Diffstat (limited to 'android')
7 files changed, 596 insertions, 0 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt new file mode 100644 index 0000000000..cd63483d45 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt @@ -0,0 +1,87 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.wrapContentWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue + +@Composable +fun BaseCell( + title: @Composable () -> Unit, + bodyView: @Composable () -> Unit, + modifier: Modifier = Modifier, + onCellClicked: () -> Unit = {}, + subtitle: @Composable (() -> Unit)? = null, + subtitleModifier: Modifier = Modifier, + background: Color = MullvadBlue, + startPadding: Dp = dimensionResource(id = R.dimen.cell_left_padding), + endPadding: Dp = dimensionResource(id = R.dimen.cell_right_padding) +) { + val cellHeight = dimensionResource(id = R.dimen.cell_height) + val cellVerticalSpacing = dimensionResource(id = R.dimen.cell_label_vertical_padding) + val subtitleVerticalSpacing = dimensionResource(id = R.dimen.cell_footer_top_padding) + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .background(background) + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .height(cellHeight) + .fillMaxWidth() + .clickable { onCellClicked.invoke() } + .padding(start = startPadding, end = endPadding) + + ) { + title() + + Spacer(modifier = Modifier.weight(1.0f)) + + Column( + modifier = modifier + .wrapContentWidth() + .wrapContentHeight() + ) { + bodyView() + } + } + + if (subtitle != null) { + Row( + modifier = subtitleModifier + .background(MullvadDarkBlue) + .padding( + start = startPadding, + top = subtitleVerticalSpacing, + end = endPadding, + bottom = cellVerticalSpacing + ) + .fillMaxWidth() + .wrapContentHeight() + ) { + subtitle() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt new file mode 100644 index 0000000000..b117f7cbf3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt @@ -0,0 +1,126 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.wrapContentWidth +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 + +@Preview +@Composable +private fun PreviewTopBar() { + CollapsingTopBar( + backgroundColor = MullvadDarkBlue, + onBackClicked = {}, + title = "View title", + progress = 1.0f, + backTitle = "Back", + modifier = Modifier.height(102.dp) + ) +} + +@Composable +fun CollapsingTopBar( + backgroundColor: Color, + onBackClicked: () -> Unit, + title: String, + progress: Float, + backTitle: String, + modifier: Modifier +) { + val expandedToolbarHeight = dimensionResource(id = R.dimen.expanded_toolbar_height) + val iconSize = dimensionResource(id = R.dimen.icon_size) + val iconPadding = dimensionResource(id = R.dimen.small_padding) + val sideMargin = dimensionResource(id = R.dimen.side_margin) + val verticalMargin = dimensionResource(id = R.dimen.cell_label_vertical_padding) + val textSize = dimensionResource(id = R.dimen.text_small).value.sp + val maxTopPadding = 48 + val minTopPadding = 14 + val maxTitleSize = 30 + val minTitleSize = 20 + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(expandedToolbarHeight) + .background(backgroundColor) + ) + + Button( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + onClick = onBackClicked, + colors = ButtonDefaults.buttonColors( + contentColor = Color.White, + backgroundColor = MullvadDarkBlue + ) + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = stringResource(id = R.string.back), + modifier = Modifier + .width(iconSize) + .height(iconSize) + ) + Spacer( + modifier = Modifier + .width(iconPadding) + .fillMaxHeight() + ) + Text( + text = backTitle, + color = MullvadWhite60, + fontWeight = FontWeight.Bold, + fontSize = textSize + ) + } + + Text( + text = title, + style = TextStyle( + color = Color.White, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End + ), + modifier = modifier + .padding( + start = sideMargin, + end = sideMargin, + top = (minTopPadding + (maxTopPadding - minTopPadding) * progress).dp, + bottom = verticalMargin + ), + fontSize = topBarSize( + progress = progress, + minTitleSize = minTitleSize, + maxTitleSize = maxTitleSize + ).sp + ) +} + +private fun topBarSize(progress: Float, minTitleSize: Int, maxTitleSize: Int): Float { + return (minTitleSize + ((maxTitleSize - minTitleSize) * progress)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 05cd60cba1..c6d32a4df9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -1,10 +1,25 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import com.google.accompanist.systemuicontroller.rememberSystemUiController +import me.onebone.toolbar.CollapsingToolbarScaffold +import me.onebone.toolbar.CollapsingToolbarScaffoldScope +import me.onebone.toolbar.CollapsingToolbarScaffoldState +import me.onebone.toolbar.CollapsingToolbarScope +import me.onebone.toolbar.ExperimentalToolbarApi +import me.onebone.toolbar.ScrollStrategy @Composable fun ScaffoldWithTopBar( @@ -30,3 +45,48 @@ fun ScaffoldWithTopBar( content = content ) } + +@Composable +@OptIn(ExperimentalToolbarApi::class) +fun CollapsableAwareToolbarScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + isEnabledWhenCollapsable: Boolean = true, + toolbarModifier: Modifier = Modifier, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + var isCollapsable by remember { mutableStateOf(false) } + + LaunchedEffect(isCollapsable) { + if (!isCollapsable) { + state.toolbarState.expand() + } + } + + CollapsingToolbarScaffold( + modifier = modifier, + state = state, + scrollStrategy = scrollStrategy, + enabled = isEnabledWhenCollapsable && isCollapsable, + toolbarModifier = toolbarModifier, + toolbar = toolbar, + body = { + var bodyHeight by remember { mutableStateOf(0) } + + BoxWithConstraints( + modifier = Modifier.onGloballyPositioned { bodyHeight = it.size.height } + ) { + val minMaxToolbarHeightDiff = with(state) { + toolbarState.maxHeight - toolbarState.minHeight + } + val isContentHigherThanCollapseThreshold = with(LocalDensity.current) { + bodyHeight > maxHeight.toPx() - minMaxToolbarHeightDiff + } + isCollapsable = isContentHigherThanCollapseThreshold + body() + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt new file mode 100644 index 0000000000..b30ca26f22 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt @@ -0,0 +1,99 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.compose.theme.MullvadGreen +import net.mullvad.mullvadvpn.compose.theme.MullvadRed +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite + +@Preview +@Composable +private fun PreviewSwitch() { + CellSwitch( + isChecked = false, + onCheckedChange = null + ) +} + +@Composable +fun CellSwitch( + isChecked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + scale: Float = 1f, + thumbCheckedTrackColor: Color = MullvadGreen, + thumbUncheckedTrackColor: Color = MullvadRed, + thumbColor: Color = MullvadWhite +) { + val gapBetweenThumbAndTrackEdge: Dp = 2.dp + val width: Dp = 46.dp + val height: Dp = 28.dp + val thumbRadius = 11.dp + + // To move the thumb, we need to calculate the position (along x axis) + val animatePosition = animateFloatAsState( + targetValue = if (isChecked) + with(LocalDensity.current) { + (width - thumbRadius - gapBetweenThumbAndTrackEdge - 1.dp).toPx() + } + else + with(LocalDensity.current) { (thumbRadius + gapBetweenThumbAndTrackEdge + 1.dp).toPx() } + ) + + Canvas( + modifier = modifier + .padding(1.dp) + .size(width = width, height = height) + .scale(scale = scale) + .pointerInput(Unit) { + if (onCheckedChange != null) { + detectTapGestures( + onTap = { + onCheckedChange(!isChecked) + } + ) + } + } + ) { + // Track + drawRoundRect( + color = thumbColor, + cornerRadius = CornerRadius(x = 15.dp.toPx(), y = 15.dp.toPx()), + style = Stroke( + width = 2.dp.toPx(), + miter = 6.dp.toPx(), + cap = StrokeCap.Square, + ), + ) + + // Thumb + drawCircle( + color = if (isChecked) thumbCheckedTrackColor else thumbUncheckedTrackColor, + radius = thumbRadius.toPx(), + center = Offset( + x = animatePosition.value, + y = size.height / 2 + ) + ) + } + + Spacer(modifier = Modifier.height(18.dp)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt new file mode 100644 index 0000000000..3a263a6886 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -0,0 +1,172 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import android.text.TextUtils +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite10 + +private const val EMPTY_STRING = "" +private const val NEWLINE_STRING = "\n" + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun CustomTextField( + value: String, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + onFocusChange: (Boolean) -> Unit, + onSubmit: (String) -> Unit, + isEnabled: Boolean = true, + placeholderText: String = "", + placeHolderColor: Color = MullvadBlue, + maxCharLength: Int = Int.MAX_VALUE, + isValidValue: Boolean, + isDigitsOnlyAllowed: Boolean, + defaultTextColor: Color = Color.White, + textAlign: TextAlign = TextAlign.Start +) { + val fontSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + val shape = RoundedCornerShape(4.dp) + val textFieldHeight = 44.dp + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + var isFocused by remember { mutableStateOf(false) } + + val textColor = when { + isValidValue.not() -> Color.Red + isFocused -> MullvadBlue + else -> defaultTextColor + } + + val placeholderTextColor = if (isFocused) { + placeHolderColor + } else { + Color.White + } + + val backgroundColor = if (isFocused) { + Color.White + } else { + MullvadWhite10 + } + + fun triggerSubmit() { + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Previous) + onSubmit(value) + } + + BasicTextField( + value = value, + onValueChange = { input -> + val isValidInput = if (isDigitsOnlyAllowed) TextUtils.isDigitsOnly(input) else true + if (input.length <= maxCharLength && isValidInput) { + // Remove any newline chars added by enter key clicks + onValueChanged(input.replace(NEWLINE_STRING, EMPTY_STRING)) + } + }, + textStyle = TextStyle( + color = textColor, + fontSize = fontSize, + textAlign = textAlign + ), + enabled = isEnabled, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + autoCorrect = false, + ), + keyboardActions = KeyboardActions( + onDone = { triggerSubmit() } + ), + decorationBox = { decorationBox -> + Box( + modifier = Modifier + .padding(PaddingValues(12.dp, 10.dp)) + .fillMaxWidth() + ) { + if (value.isBlank()) { + Text( + text = placeholderText, + color = placeholderTextColor, + fontSize = fontSize, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth() + ) + } + decorationBox() + } + }, + cursorBrush = SolidColor(MullvadBlue), + modifier = modifier + .background(backgroundColor) + .clip(shape) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + onFocusChange(focusState.isFocused) + } + .height(textFieldHeight) + .onKeyEvent { keyEvent -> + return@onKeyEvent when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_ENTER -> { + triggerSubmit() + true + } + KeyEvent.KEYCODE_ESCAPE -> { + focusManager.clearFocus(force = true) + keyboardController?.hide() + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + focusManager.moveFocus(FocusDirection.Down) + true + } + KeyEvent.KEYCODE_DPAD_UP -> { + focusManager.moveFocus(FocusDirection.Up) + true + } + else -> { + false + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt new file mode 100644 index 0000000000..2c542951ab --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.compose.theme + +import androidx.compose.ui.graphics.Color + +val MullvadBeige = Color(0xFFFFCD86) +val MullvadBlue = Color(0xFF294D73) +val MullvadBlue60 = Color(0x99294D73) +val MullvadBlue20 = Color(0x33294D73) +val MullvadBrown = Color(0xFFD2943B) +val MullvadDarkBlue = Color(0xFF192E45) +val MullvadGreen = Color(0xFF44AD4D) +val MullvadRed = Color(0xFFE34039) +val MullvadYellow = Color(0xFFFFD524) +val MullvadHelmetYellow = Color(0xFFFFD524) +val MullvadWhite = Color(0xFFFFFFFF) +val MullvadWhite10 = Color(0x1AFFFFFF) +val MullvadWhite20 = Color(0x33FFFFFF) +val MullvadWhite40 = Color(0x66FFFFFF) +val MullvadWhite60 = Color(0x99FFFFFF) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt new file mode 100644 index 0000000000..95e6d312a0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.compose.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp + +private val MullvadColorPalette = lightColors( + primary = MullvadBlue, + primaryVariant = MullvadDarkBlue, + secondary = MullvadRed +) + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) + +@Composable +fun CollapsingToolbarTheme( + content: @Composable () -> Unit +) { + val colors = MullvadColorPalette + + MaterialTheme( + colors = colors, + shapes = Shapes, + content = content + ) +} |
