android: profiles list-detail on expanded width with in-pane profile form
This commit is contained in:
1 parent
7cc6343ba1
commit
564b8aaa8d
6 files changed
+161
-1
No files matched your search
@@ -165,6 +165,9 @@ dependencies {
|
||||
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
|
||||
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
|
||||
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
||||
/* list-detail layout for Profiles on expanded widths */
|
||||
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.3.0'
|
||||
implementation 'androidx.compose.material3.adaptive:adaptive-navigation:1.3.0'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
/* home screen widget (status + connect button) */
|
||||
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.ui.ProfileFormViewModel
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/**
|
||||
* The profile form hosted inside the detail pane of the Profiles list-detail layout
|
||||
* (expanded width). Same save logic as VpnProfileDetailActivity, which is still used
|
||||
* on phones.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileEditorPane(
|
||||
profile: VpnProfile?,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val dataSource = remember { VpnProfileSource(context).open() as VpnProfileDataSource }
|
||||
DisposableEffect(dataSource) { onDispose { dataSource.close() } }
|
||||
val messages = remember { MutableSharedFlow<Int>(extraBufferCapacity = 1) }
|
||||
val key = profile?.getUUID()?.toString() ?: "new"
|
||||
val formViewModel: ProfileFormViewModel = viewModel(
|
||||
key = "profile-editor-$key",
|
||||
factory = object : ViewModelProvider.Factory
|
||||
{
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
ProfileFormViewModel(profile?.toFormState() ?: ProfileFormState()) as T
|
||||
},
|
||||
)
|
||||
ProfileFormScreen(
|
||||
title = profile?.name ?: stringResource(R.string.add_profile),
|
||||
state = formViewModel.state,
|
||||
onChange = { formViewModel.state = it },
|
||||
onCancel = onClose,
|
||||
onSave = {
|
||||
val checked = formViewModel.state.validate(context)
|
||||
formViewModel.state = checked
|
||||
if (!checked.isValid())
|
||||
{
|
||||
messages.tryEmit(R.string.alert_text_no_input_gateway)
|
||||
return@ProfileFormScreen
|
||||
}
|
||||
val saved = checked.toProfile(profile)
|
||||
if (profile != null) dataSource.updateVpnProfile(saved)
|
||||
else dataSource.insertProfile(saved)
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
onClose()
|
||||
},
|
||||
messages = messages,
|
||||
)
|
||||
}
|
||||
+75
-1
@@ -62,6 +62,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.VpnStateService.State
|
||||
@@ -69,7 +77,11 @@ import org.strongswan.android.ui.compose.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/**
|
||||
* Profiles tab. On phones it is just the list (the form opens as its own screen);
|
||||
* on expanded widths it becomes a list-detail layout with the profile form in the detail pane.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ProfilesScreen(
|
||||
state: HomeUiState,
|
||||
@@ -81,6 +93,68 @@ fun ProfilesScreen(
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
)
|
||||
{
|
||||
if (LocalConfiguration.current.screenWidthDp < 840)
|
||||
{
|
||||
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd)
|
||||
return
|
||||
}
|
||||
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
|
||||
var editing by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
|
||||
LaunchedEffect(editing)
|
||||
{
|
||||
navigator.navigateTo(if (editing != null) ListDetailPaneScaffoldRole.Detail else ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
ProfilesList(
|
||||
state = state,
|
||||
onConnect = onConnect,
|
||||
onDisconnect = onDisconnect,
|
||||
onEdit = { editing = it.getUUID().toString() },
|
||||
onDelete = onDelete,
|
||||
onRestore = onRestore,
|
||||
onShare = onShare,
|
||||
onAdd = { editing = "" },
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
val target = editing
|
||||
if (target != null)
|
||||
{
|
||||
val profile = state.profiles.firstOrNull { it.getUUID().toString() == target }
|
||||
ProfileEditorPane(profile = profile, onClose = { editing = null })
|
||||
}
|
||||
else
|
||||
{
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
|
||||
{
|
||||
ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProfilesList(
|
||||
state: HomeUiState,
|
||||
onConnect: (VpnProfile) -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onEdit: (VpnProfile) -> Unit,
|
||||
onDelete: (VpnProfile) -> Unit,
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -504,4 +504,5 @@
|
||||
<string name="widget_label">SHX VPN</string>
|
||||
<string name="widget_description">Schutzstatus und Verbinden mit einem Tipp</string>
|
||||
|
||||
<string name="profile_detail_placeholder">Profil auswählen oder hinzufügen</string>
|
||||
</resources>
|
||||
@@ -501,4 +501,5 @@
|
||||
<string name="theme_dark">Тёмная</string>
|
||||
<string name="widget_label">SHX VPN</string>
|
||||
<string name="widget_description">Статус защиты и подключение одним касанием</string>
|
||||
<string name="profile_detail_placeholder">Выберите или добавьте профиль</string>
|
||||
</resources>
|
||||
@@ -506,4 +506,5 @@
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="widget_label">SHX VPN</string>
|
||||
<string name="widget_description">Protection status and one-tap connect</string>
|
||||
<string name="profile_detail_placeholder">Select or add a profile</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user