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'
|
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
|
||||||
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
|
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
|
||||||
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
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'
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
/* home screen widget (status + connect button) */
|
/* home screen widget (status + connect button) */
|
||||||
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import kotlinx.coroutines.launch
|
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.R
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
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.components.ShxEmptyState
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
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
|
@Composable
|
||||||
fun ProfilesScreen(
|
fun ProfilesScreen(
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
@@ -81,6 +93,68 @@ fun ProfilesScreen(
|
|||||||
onShare: (VpnProfile) -> Unit,
|
onShare: (VpnProfile) -> Unit,
|
||||||
onAdd: () -> 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 context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|||||||
@@ -504,4 +504,5 @@
|
|||||||
<string name="widget_label">SHX VPN</string>
|
<string name="widget_label">SHX VPN</string>
|
||||||
<string name="widget_description">Schutzstatus und Verbinden mit einem Tipp</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>
|
</resources>
|
||||||
@@ -501,4 +501,5 @@
|
|||||||
<string name="theme_dark">Тёмная</string>
|
<string name="theme_dark">Тёмная</string>
|
||||||
<string name="widget_label">SHX VPN</string>
|
<string name="widget_label">SHX VPN</string>
|
||||||
<string name="widget_description">Статус защиты и подключение одним касанием</string>
|
<string name="widget_description">Статус защиты и подключение одним касанием</string>
|
||||||
|
<string name="profile_detail_placeholder">Выберите или добавьте профиль</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -506,4 +506,5 @@
|
|||||||
<string name="theme_dark">Dark</string>
|
<string name="theme_dark">Dark</string>
|
||||||
<string name="widget_label">SHX VPN</string>
|
<string name="widget_label">SHX VPN</string>
|
||||||
<string name="widget_description">Protection status and one-tap connect</string>
|
<string name="widget_description">Protection status and one-tap connect</string>
|
||||||
|
<string name="profile_detail_placeholder">Select or add a profile</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user