android/ui: add game of life to show progress when connecting (ENG-2860) (#244)

Fixes ENG-2860

Adds a game of life animation with the Tailscale logo when launching the app and waiting for the VPN tunnel to be established.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/243/head
Andrea Gottardo 2 months ago committed by GitHub
parent bf74edd551
commit 3fea68ef2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -120,6 +120,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
} }
Ipn.State.NoState,
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
else -> else ->
ConnectView( ConnectView(
@ -208,15 +209,11 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
@Composable @Composable
fun StartingView() { fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column( Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer), modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
Text( TailscaleLogoView(animated = true, Modifier.size(72.dp))
text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
} }
} }
@ -266,7 +263,7 @@ fun ConnectView(
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize)
} }
} else { } else {
TailscaleLogoView(Modifier.size(50.dp)) TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
Text( Text(
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.welcome_to_tailscale),

@ -11,53 +11,126 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.timer
// TODO(angott): // DotsMatrix represents the state of the progress indicator.
// - Implement game-of-life animation for progress indicator. typealias DotsMatrix = List<List<Boolean>>
// - Remove hardcoded dots, use a for-each and make it dynamically
// use the space available instead of unit = 10.dp // The initial DotsMatrix that represents the Tailscale logo (T-shaped).
val logoDotsMatrix: DotsMatrix =
listOf(
listOf(false, false, false),
listOf(true, true, true),
listOf(false, true, false),
)
@Composable @Composable
fun TailscaleLogoView(modifier: Modifier) { fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) {
val primaryColor: Color = MaterialTheme.colorScheme.primary
val secondaryColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) val primaryColor: Color = MaterialTheme.colorScheme.secondary
val secondaryColor: Color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f)
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
var currentDotsMatrixIndex = 0
fun advanceToNextMatrix() {
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
val newMatrix =
if (animated) {
gameOfLife[currentDotsMatrixIndex]
} else {
logoDotsMatrix
}
currentDotsMatrix.set(newMatrix)
}
if (animated) {
timer(period = 300L) { advanceToNextMatrix() }
}
@Composable
fun EnabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
}
@Composable
fun DisabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
}
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
val currentMatrix = currentDotsMatrix.collectAsState().value
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) { for (y in 0..2) {
Canvas( Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), for (x in 0..2) {
onDraw = { drawCircle(color = secondaryColor) }) if (currentMatrix[y][x]) {
Canvas( EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), } else {
onDraw = { drawCircle(color = secondaryColor) }) DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
Canvas( }
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)), }
onDraw = { drawCircle(color = secondaryColor) }) }
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
}
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = secondaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = primaryColor) })
Canvas(
modifier = Modifier.size(this@BoxWithConstraints.maxWidth.div(4)),
onDraw = { drawCircle(color = secondaryColor) })
} }
} }
} }
} }
val gameOfLife: List<DotsMatrix> =
listOf(
listOf(
listOf(false, true, true),
listOf(true, false, true),
listOf(false, false, true),
),
listOf(
listOf(false, true, true),
listOf(false, false, true),
listOf(false, true, false),
),
listOf(
listOf(false, true, true),
listOf(false, false, false),
listOf(false, false, true),
),
listOf(
listOf(false, false, true),
listOf(false, true, false),
listOf(false, false, false),
),
listOf(
listOf(false, true, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, true),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, true),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(true, false, false),
),
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))

Loading…
Cancel
Save