diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index a80f4ba43..dca414c0f 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -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' diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt new file mode 100644 index 000000000..bcbd938c8 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileEditorPane.kt @@ -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(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 create(modelClass: Class): 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, + ) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt index e7c99f3dc..2105f8835 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt @@ -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(null) } + val navigator = rememberListDetailPaneScaffoldNavigator() + 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() } diff --git a/src/frontends/android/app/src/main/res/values-de/strings.xml b/src/frontends/android/app/src/main/res/values-de/strings.xml index 226c50e6f..e069d3463 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings.xml @@ -504,4 +504,5 @@ SHX VPN Schutzstatus und Verbinden mit einem Tipp + Profil auswählen oder hinzufügen diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 860d12604..b477a0b3c 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -501,4 +501,5 @@ Тёмная SHX VPN Статус защиты и подключение одним касанием + Выберите или добавьте профиль diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index a627a1b64..7cc10c527 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -506,4 +506,5 @@ Dark SHX VPN Protection status and one-tap connect + Select or add a profile