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:
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.
|
||||
*/
|
||||
|
||||
@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
|
||||
{
|
||||
|
||||
+8
-5
@@ -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
|
||||
{
|
||||
|
||||
+8
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
-4
@@ -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
|
||||
|
||||
+31
-6
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user