android: profiles list-detail on expanded width with in-pane profile form

This commit is contained in:
Denozordec committed 2026-10-10 00:28:47 +07:00
1 parent 7cc6343ba1
commit 564b8aaa8d
6 files changed
+161 -1

No files matched your search

+3
View File
@@ -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'
@@ -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,
)
}
@@ -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>