android: expressive motion - fade-through tabs, back to Connect, LoadingIndicator, wavy progress, snackbar messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 00:01:17 +07:00
1 parent 6fd03f37f7
commit 3f7bd94a69
6 files changed
+65 -22

No files matched your search

@@ -7,6 +7,8 @@
* option) any later version. * option) any later version.
*/ */
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui package org.strongswan.android.ui
import android.Manifest import android.Manifest
@@ -25,7 +27,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -162,7 +165,7 @@ private fun SelectedAppsScreen(
) )
if (loading) if (loading)
{ {
CircularProgressIndicator(Modifier.padding(24.dp).size(32.dp)) LoadingIndicator(Modifier.padding(24.dp).size(32.dp))
} }
else else
{ {
@@ -7,6 +7,8 @@
* option) any later version. * option) any later version.
*/ */
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose package org.strongswan.android.ui.compose
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
@@ -72,7 +74,8 @@ import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -622,7 +625,7 @@ private fun ConnectionCard(
content = MaterialTheme.colorScheme.onErrorContainer, content = MaterialTheme.colorScheme.onErrorContainer,
) { Text(stringResource(R.string.disconnect), fontSize = 16.sp) } ) { Text(stringResource(R.string.disconnect), fontSize = 16.sp) }
State.CONNECTING, State.DISCONNECTING -> HeroButton(onClick = onCancel) { State.CONNECTING, State.DISCONNECTING -> HeroButton(onClick = onCancel) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) LoadingIndicator(Modifier.size(18.dp))
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Text(stringResource(android.R.string.cancel), fontSize = 16.sp) Text(stringResource(android.R.string.cancel), fontSize = 16.sp)
} }
@@ -885,7 +888,7 @@ private fun AvailabilityCard(
) )
if (probing) if (probing)
{ {
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
} }
IconButton(onClick = onRefresh, enabled = !probing) { IconButton(onClick = onRefresh, enabled = !probing) {
@@ -906,7 +909,7 @@ private fun AvailabilityCard(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
{ {
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) LoadingIndicator(Modifier.size(14.dp))
Text( Text(
text = stringResource(R.string.availability_measuring), text = stringResource(R.string.availability_measuring),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -1159,7 +1162,7 @@ private fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<Prob
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
if (result.ok && result.rttMs == null && measuring) if (result.ok && result.rttMs == null && measuring)
{ {
CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 1.5.dp) LoadingIndicator(Modifier.size(12.dp))
} }
else else
{ {
@@ -37,7 +37,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -125,6 +127,10 @@ data class HomeUiState(
class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.VpnStateListener class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.VpnStateListener
{ {
/* one-shot user messages (string resource ids), shown as snackbars by ShxApp */
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages
private val _ui = MutableStateFlow(HomeUiState()) private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui val ui: StateFlow<HomeUiState> = _ui
@@ -379,7 +385,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (uri == null) if (uri == null)
{ {
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show() _messages.tryEmit(R.string.share_profile_failed)
return@withContext return@withContext
} }
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
@@ -391,7 +397,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
} }
catch (_: ActivityNotFoundException) catch (_: ActivityNotFoundException)
{ {
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show() _messages.tryEmit(R.string.share_profile_failed)
} }
} }
} }
@@ -7,6 +7,8 @@
* option) any later version. * option) any later version.
*/ */
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose package org.strongswan.android.ui.compose
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -29,7 +31,8 @@ import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.FileDownload import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.VpnKey import androidx.compose.material.icons.rounded.VpnKey
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -138,7 +141,7 @@ fun ImportScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator() LoadingIndicator()
} }
return return
} }
@@ -240,7 +243,7 @@ fun ImportScreen(
} }
if (model.userCertLoading) if (model.userCertLoading)
{ {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) LoadingIndicator(Modifier.size(18.dp))
} }
} }
if (model.showImportUserCert) if (model.showImportUserCert)
@@ -7,6 +7,8 @@
* option) any later version. * option) any later version.
*/ */
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSnackbarHost import org.strongswan.android.ui.compose.components.ShxSnackbarHost
@@ -53,11 +55,12 @@ import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -328,7 +331,7 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
) )
if (ruState.status == RuIpListStatus.UPDATING) if (ruState.status == RuIpListStatus.UPDATING)
{ {
LinearProgressIndicator( LinearWavyProgressIndicator(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
@@ -586,7 +589,7 @@ private fun RuToggleRow(
} }
if (updating) if (updating)
{ {
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
} }
else else
@@ -11,14 +11,14 @@ package org.strongswan.android.ui.compose
import android.content.Intent import android.content.Intent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -44,7 +44,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Box
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
@@ -105,6 +109,12 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) } var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it } val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel)
{
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* the home list keeps its scroll state across tab switches */ /* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState() val homeListState = rememberLazyListState()
@@ -112,6 +122,13 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
* and on tablets (Stage 3 of ui-ux-plan). Item colors are built here in a * and on tablets (Stage 3 of ui-ux-plan). Item colors are built here in a
* composable context - the items lambda itself does not compose. */ * composable context - the items lambda itself does not compose. */
val navItemColors = shxNavigationItemColors() val navItemColors = shxNavigationItemColors()
val fadeSpec = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val scaleSpec = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
/* from any other tab "back" returns to Connect instead of leaving the app */
BackHandler(enabled = state.tab != ShxTab.HOME) {
viewModel.setTab(ShxTab.HOME)
}
NavigationSuiteScaffold( NavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors) ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
@@ -122,13 +139,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
Box(Modifier.fillMaxSize())
{
AnimatedContent( AnimatedContent(
targetState = state.tab, targetState = state.tab,
transitionSpec = { transitionSpec = {
val forward = targetState.ordinal >= initialState.ordinal /* tabs are not spatially ordered: fade-through (outgoing fades out
val direction = if (forward) 1 else -1 * first, incoming fades in with a slight scale), driven by the
(slideInHorizontally(tween(240)) { it / 6 * direction } + fadeIn(tween(240))) togetherWith * expressive motion scheme */
(slideOutHorizontally(tween(180)) { -it / 6 * direction } + fadeOut(tween(160))) (fadeIn(fadeSpec) + scaleIn(scaleSpec, initialScale = 0.96f)) togetherWith
fadeOut(tween(90))
}, },
label = "tabContent", label = "tabContent",
) { tab -> ) { tab ->
@@ -159,6 +179,11 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
ShxTab.ROUTING -> RoutingScreen() ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles) ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
} }
}
ShxSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
} }
pendingShare?.let { profile -> pendingShare?.let { profile ->
ShareProfileDialog( ShareProfileDialog(