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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui
import android.Manifest
@@ -25,7 +27,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.OutlinedTextField
import androidx.compose.material3.Text
@@ -162,7 +165,7 @@ private fun SelectedAppsScreen(
)
if (loading)
{
CircularProgressIndicator(Modifier.padding(24.dp).size(32.dp))
LoadingIndicator(Modifier.padding(24.dp).size(32.dp))
}
else
{
@@ -7,6 +7,8 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
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.Button
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.FilledTonalButton
import androidx.compose.material3.Icon
@@ -622,7 +625,7 @@ private fun ConnectionCard(
content = MaterialTheme.colorScheme.onErrorContainer,
) { Text(stringResource(R.string.disconnect), fontSize = 16.sp) }
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))
Text(stringResource(android.R.string.cancel), fontSize = 16.sp)
}
@@ -885,7 +888,7 @@ private fun AvailabilityCard(
)
if (probing)
{
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.size(12.dp))
}
IconButton(onClick = onRefresh, enabled = !probing) {
@@ -906,7 +909,7 @@ private fun AvailabilityCard(
modifier = Modifier.padding(top = 4.dp),
)
{
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
LoadingIndicator(Modifier.size(14.dp))
Text(
text = stringResource(R.string.availability_measuring),
style = MaterialTheme.typography.bodySmall,
@@ -1159,7 +1162,7 @@ private fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<Prob
Spacer(Modifier.size(8.dp))
if (result.ok && result.rttMs == null && measuring)
{
CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 1.5.dp)
LoadingIndicator(Modifier.size(12.dp))
}
else
{
@@ -37,7 +37,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@@ -125,6 +127,10 @@ data class HomeUiState(
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())
val ui: StateFlow<HomeUiState> = _ui
@@ -379,7 +385,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
withContext(Dispatchers.Main) {
if (uri == null)
{
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show()
_messages.tryEmit(R.string.share_profile_failed)
return@withContext
}
val intent = Intent(Intent.ACTION_SEND)
@@ -391,7 +397,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
}
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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
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.Language
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.Icon
import androidx.compose.material3.IconButton
@@ -138,7 +141,7 @@ fun ImportScreen(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
LoadingIndicator()
}
return
}
@@ -240,7 +243,7 @@ fun ImportScreen(
}
if (model.userCertLoading)
{
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
LoadingIndicator(Modifier.size(18.dp))
}
}
if (model.showImportUserCert)
@@ -7,6 +7,8 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
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.Rule
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -328,7 +331,7 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
)
if (ruState.status == RuIpListStatus.UPDATING)
{
LinearProgressIndicator(
LinearWavyProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
@@ -586,7 +589,7 @@ private fun RuToggleRow(
}
if (updating)
{
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(12.dp))
}
else
@@ -11,14 +11,14 @@ package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -44,7 +44,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarHostState
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.TextButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
@@ -105,6 +109,12 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
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 */
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
* composable context - the items lambda itself does not compose. */
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(
navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
@@ -122,13 +139,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(Modifier.fillMaxSize())
{
AnimatedContent(
targetState = state.tab,
transitionSpec = {
val forward = targetState.ordinal >= initialState.ordinal
val direction = if (forward) 1 else -1
(slideInHorizontally(tween(240)) { it / 6 * direction } + fadeIn(tween(240))) togetherWith
(slideOutHorizontally(tween(180)) { -it / 6 * direction } + fadeOut(tween(160)))
/* tabs are not spatially ordered: fade-through (outgoing fades out
* first, incoming fades in with a slight scale), driven by the
* expressive motion scheme */
(fadeIn(fadeSpec) + scaleIn(scaleSpec, initialScale = 0.96f)) togetherWith
fadeOut(tween(90))
},
label = "tabContent",
) { tab ->
@@ -159,6 +179,11 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
}
}
ShxSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
pendingShare?.let { profile ->
ShareProfileDialog(