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:
1 parent
6fd03f37f7
commit
3f7bd94a69
6 files changed
+65
-22
No files matched your search
+5
-2
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-5
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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
-4
@@ -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
|
||||||
|
|||||||
+31
-6
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user