android: import preview/error sheet and profile form as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
200697216d
commit
fecef1135c
6 files changed
+697
-477
No files matched your search
+33
-11
@@ -179,7 +179,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
|
|
||||||
ImportScreenKt.installImportScreen(this, mUi,
|
ImportScreenKt.installImportScreen(this, mUi,
|
||||||
this::saveProfile, this::finish,
|
this::saveProfile, this::finish,
|
||||||
this::importPkcs12, this::chooseUserCertificate);
|
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
|
||||||
|
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
String action = intent.getAction();
|
String action = intent.getAction();
|
||||||
@@ -246,13 +246,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
mImportPKCS12.launch(intent);
|
mImportPKCS12.launch(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Uri mLastUri;
|
||||||
|
|
||||||
private void loadProfile(Uri uri)
|
private void loadProfile(Uri uri)
|
||||||
{
|
{
|
||||||
mUi.setLoading(true);
|
mUi.setLoading(true);
|
||||||
|
|
||||||
|
/* shown in the preview: "link" for downloads, "file" otherwise */
|
||||||
|
mUi.setSource(uri.getScheme());
|
||||||
|
mUi.setSourceUri(uri.toString());
|
||||||
|
mUi.setLoadError(null);
|
||||||
|
mLastUri = uri;
|
||||||
Bundle args = new Bundle();
|
Bundle args = new Bundle();
|
||||||
args.putParcelable(PROFILE_URI, uri);
|
args.putParcelable(PROFILE_URI, uri);
|
||||||
LoaderManager.getInstance(this).initLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
LoaderManager.getInstance(this).restartLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Try again» after a failed download. */
|
||||||
|
private void retryLoad()
|
||||||
|
{
|
||||||
|
if (mLastUri != null)
|
||||||
|
{
|
||||||
|
loadProfile(mLastUri);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleProfile(ProfileLoadResult data)
|
public void handleProfile(ProfileLoadResult data)
|
||||||
@@ -300,15 +316,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (error != null)
|
/* the sheet shows the failure with the likely reasons and a retry */
|
||||||
{
|
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
|
||||||
Toast.makeText(this, getString(R.string.profile_import_failed_detail, error), Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
finish();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
||||||
@@ -666,6 +675,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
intent = new Intent(this, MainActivity.class);
|
intent = new Intent(this, MainActivity.class);
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
|
if (mUi.getConnectNow())
|
||||||
|
{ /* «Connect right away» in the preview */
|
||||||
|
Intent start = new Intent(this, VpnProfileControlActivity.class);
|
||||||
|
start.setAction(VpnProfileControlActivity.START_PROFILE);
|
||||||
|
start.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, mProfile.getUUID().toString());
|
||||||
|
startActivity(start);
|
||||||
|
}
|
||||||
|
|
||||||
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
|
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
|
||||||
finish();
|
finish();
|
||||||
@@ -701,6 +717,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
*/
|
*/
|
||||||
private void updateProfileData()
|
private void updateProfileData()
|
||||||
{
|
{
|
||||||
|
/* the name can be changed in the preview */
|
||||||
|
String name = mUi.getName().trim();
|
||||||
|
if (!name.isEmpty())
|
||||||
|
{
|
||||||
|
mProfile.setName(name);
|
||||||
|
}
|
||||||
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
||||||
{
|
{
|
||||||
mProfile.setUsername(mUi.getUsername().trim());
|
mProfile.setUsername(mUi.getUsername().trim());
|
||||||
|
|||||||
+243
-210
@@ -11,53 +11,59 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui.compose
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.rounded.Close
|
|
||||||
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.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
import org.strongswan.android.ui.LogActivity
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observable UI state of the profile import screen. Owned by the (Java)
|
* Observable UI state of the profile import screen. Owned by the (Java)
|
||||||
@@ -89,21 +95,28 @@ class ImportUiModel
|
|||||||
var remoteCertPrimary by mutableStateOf<String?>(null)
|
var remoteCertPrimary by mutableStateOf<String?>(null)
|
||||||
var remoteCertSecondary by mutableStateOf<String?>(null)
|
var remoteCertSecondary by mutableStateOf<String?>(null)
|
||||||
var hideImport by mutableStateOf(false)
|
var hideImport by mutableStateOf(false)
|
||||||
|
/** uri scheme of the profile source (https, content, file) */
|
||||||
|
var source by mutableStateOf<String?>(null)
|
||||||
|
var sourceUri by mutableStateOf<String?>(null)
|
||||||
|
/** set when the profile could not be read; the sheet shows it with a retry */
|
||||||
|
var loadError by mutableStateOf<String?>(null)
|
||||||
|
/** «Connect right away» after adding */
|
||||||
|
var connectNow by mutableStateOf(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the import UI as a bottom sheet in the transparent import activity. */
|
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
fun ComponentActivity.installImportScreen(
|
||||||
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
|
model: ImportUiModel,
|
||||||
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
|
onImport: Runnable,
|
||||||
|
onClose: Runnable,
|
||||||
|
onImportPkcs12: Runnable,
|
||||||
|
onSelectUserCert: Runnable,
|
||||||
|
onRetry: Runnable,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
|
ShxSheet(onDismiss = onClose::run)
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onClose::run,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
model = model,
|
model = model,
|
||||||
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
|
|||||||
onClose = onClose::run,
|
onClose = onClose::run,
|
||||||
onImportPkcs12 = onImportPkcs12::run,
|
onImportPkcs12 = onImportPkcs12::run,
|
||||||
onSelectUserCert = onSelectUserCert::run,
|
onSelectUserCert = onSelectUserCert::run,
|
||||||
|
onRetry = onRetry::run,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
model: ImportUiModel,
|
model: ImportUiModel,
|
||||||
@@ -125,221 +138,241 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onImportPkcs12: () -> Unit,
|
onImportPkcs12: () -> Unit,
|
||||||
onSelectUserCert: () -> Unit,
|
onSelectUserCert: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Column(Modifier.fillMaxSize())
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
when
|
||||||
{
|
{
|
||||||
TopAppBar(
|
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
|
||||||
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onClose) {
|
|
||||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (model.showForm && !model.hideImport)
|
|
||||||
{
|
{
|
||||||
TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) }
|
/* F_ErrImport */
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(c.errorContainer))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp))
|
||||||
}
|
}
|
||||||
},
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
{
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
Text(
|
||||||
),
|
stringResource(if (model.source?.startsWith("http") == true) R.string.import_failed_download else R.string.import_failed_read),
|
||||||
|
style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text,
|
||||||
)
|
)
|
||||||
if (model.loading)
|
model.sourceUri?.let {
|
||||||
{
|
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
.fillMaxSize()
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = ShxSpacing.lg)
|
|
||||||
.padding(bottom = ShxSpacing.xl),
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (model.existsWarning != null)
|
Text(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
val reasons = if (model.source?.startsWith("http") == true) listOf(
|
||||||
|
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
|
||||||
|
R.string.import_reason_offline to R.string.import_reason_offline_d,
|
||||||
|
R.string.import_reason_tls to R.string.import_reason_tls_d,
|
||||||
|
) else listOf(R.string.import_reason_format to R.string.import_reason_format_d)
|
||||||
|
reasons.forEach { (t, d) ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
{
|
{
|
||||||
ImportBanner(
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
|
||||||
text = model.existsWarning ?: "",
|
Text(
|
||||||
container = MaterialTheme.colorScheme.errorContainer,
|
buildAnnotatedString {
|
||||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
|
|
||||||
{
|
|
||||||
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
|
|
||||||
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
|
|
||||||
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
|
|
||||||
}
|
}
|
||||||
|
(model.loadError ?: model.existsWarning)?.let {
|
||||||
|
Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.import_ask_admin))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
if (model.source?.startsWith("http") == true)
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_support_details),
|
||||||
|
style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(role = Role.Button) { context.startActivity(Intent(context, LogActivity::class.java)) }.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.loading ->
|
||||||
|
{
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp))
|
||||||
|
{
|
||||||
|
LoadingIndicator(Modifier.size(48.dp))
|
||||||
|
Text(stringResource(R.string.import_reading), style = ShxType.body, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
/* F_ImportPreview */
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accentContainer))
|
||||||
|
{
|
||||||
|
Text(model.name.filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }, style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.import_add_title), style = ShxType.sheetTitle, color = c.text)
|
||||||
|
Text(stringResource(R.string.import_read_ok), style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
|
||||||
|
{
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.import_name), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||||
|
BasicTextField(
|
||||||
|
value = model.name,
|
||||||
|
onValueChange = { model.name = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.rowTitle.copy(color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val rows = buildList {
|
||||||
|
add(stringResource(R.string.import_server) to (model.gateway to c.text))
|
||||||
|
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
|
||||||
|
add(stringResource(R.string.import_login) to ((when
|
||||||
|
{
|
||||||
|
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
|
||||||
|
model.requireUserCert -> stringResource(R.string.import_login_cert)
|
||||||
|
else -> stringResource(R.string.import_login_password)
|
||||||
|
}) to c.text))
|
||||||
|
add(stringResource(R.string.import_source) to (stringResource(if (model.source?.startsWith("http") == true) R.string.import_source_link else R.string.import_source_file) to c.text))
|
||||||
|
if (model.showRemoteCert)
|
||||||
|
{
|
||||||
|
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.forEach { (k, v) ->
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
|
||||||
|
{
|
||||||
|
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
|
||||||
|
Text(
|
||||||
|
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (model.requireUserPass)
|
if (model.requireUserPass)
|
||||||
{
|
{
|
||||||
Text(
|
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
|
||||||
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
|
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
ShxCard {
|
|
||||||
Column(Modifier.padding(ShxSpacing.lg)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model.username,
|
|
||||||
onValueChange = { model.username = it },
|
|
||||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = model.usernameEditable,
|
|
||||||
isError = model.usernameError,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(ShxSpacing.md))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model.password,
|
|
||||||
onValueChange = { model.password = it },
|
|
||||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = model.passwordEditable,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (model.sharedSecretWarning)
|
if (model.sharedSecretWarning)
|
||||||
{
|
{
|
||||||
ImportBanner(
|
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
|
||||||
text = stringResource(R.string.profile_import_shared_secret),
|
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.requireUserCert)
|
if (model.requireUserCert)
|
||||||
{
|
{
|
||||||
Text(
|
val certShape = RoundedCornerShape(24.dp)
|
||||||
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
|
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
|
||||||
style = MaterialTheme.typography.labelMedium,
|
{
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
ShxCard {
|
|
||||||
Column {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.clickable(onClick = onSelectUserCert)
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (model.userCertError) MaterialTheme.colorScheme.error
|
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
model.userCertSubject
|
|
||||||
?: stringResource(R.string.profile_user_select_certificate),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (model.userCertLoading)
|
|
||||||
{
|
{
|
||||||
LoadingIndicator(Modifier.size(18.dp))
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (model.userCertError) c.error else c.text)
|
||||||
|
Text(model.userCertSubject ?: stringResource(R.string.profile_user_select_certificate), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
}
|
}
|
||||||
|
if (model.userCertLoading) LoadingIndicator(Modifier.size(20.dp))
|
||||||
|
else Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
}
|
}
|
||||||
if (model.showImportUserCert)
|
if (model.showImportUserCert)
|
||||||
{
|
{
|
||||||
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.sm))
|
|
||||||
Text(stringResource(R.string.profile_cert_import))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.showRemoteCert)
|
|
||||||
{
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
|
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
ShxCard {
|
|
||||||
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ImportBanner(
|
|
||||||
text: String,
|
|
||||||
container: androidx.compose.ui.graphics.Color,
|
|
||||||
content: androidx.compose.ui.graphics.Color,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = container,
|
|
||||||
contentColor = content,
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = ShxSpacing.sm),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Composable
|
val toggleShape = RoundedCornerShape(24.dp)
|
||||||
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
|
|
||||||
{
|
|
||||||
Column {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth().clip(toggleShape).background(c.card).border(1.dp, c.cardBorder, toggleShape).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
Column(Modifier.padding(start = ShxSpacing.lg)) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(value, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (divider)
|
|
||||||
{
|
{
|
||||||
androidx.compose.material3.HorizontalDivider(
|
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
|
||||||
modifier = Modifier.padding(start = 52.dp),
|
}
|
||||||
|
if (model.existsWarning != null)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
|
||||||
|
append(" ")
|
||||||
|
append(stringResource(R.string.import_exists_text))
|
||||||
|
},
|
||||||
|
style = ShxType.note, color = c.textTonal,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onClose, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(if (model.existsWarning != null) R.string.import_update_server else R.string.import_add_server),
|
||||||
|
onImport, modifier = Modifier.weight(2f), enabled = !model.hideImport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */
|
||||||
|
@Composable
|
||||||
|
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(if (error) 2.dp else 1.dp, if (error) c.error else c.cardBorder, shape).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onChange,
|
||||||
|
enabled = editable,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 16.sp, color = if (editable) c.text else c.textSub),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
visualTransformation = if (secret) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+220
-255
@@ -86,6 +86,27 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.data.VpnType
|
import org.strongswan.android.data.VpnType
|
||||||
@@ -282,6 +303,9 @@ fun ProfileFormState.isValid(): Boolean
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The three sign-in variants of the mockup tabs; the rarer types stay in «More». */
|
||||||
|
private val LOGIN_TYPES = listOf(VpnType.IKEV2_EAP, VpnType.IKEV2_CERT, VpnType.IKEV2_CERT_EAP)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileFormScreen(
|
fun ProfileFormScreen(
|
||||||
@@ -295,6 +319,7 @@ fun ProfileFormScreen(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||||
androidx.compose.runtime.LaunchedEffect(messages)
|
androidx.compose.runtime.LaunchedEffect(messages)
|
||||||
{
|
{
|
||||||
@@ -313,103 +338,147 @@ fun ProfileFormScreen(
|
|||||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Scaffold(
|
Box(Modifier.fillMaxSize().background(c.background))
|
||||||
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
|
||||||
modifier = Modifier.windowInsetsPadding(
|
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
|
||||||
),
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
|
|
||||||
),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(title, maxLines = 1) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onCancel) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
if (enabled)
|
|
||||||
{
|
{
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
modifier = Modifier.windowInsetsPadding(
|
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 20.dp, vertical = ShxSpacing.md),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onCancel,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(48.dp),
|
|
||||||
) { Text(stringResource(R.string.profile_edit_cancel)) }
|
|
||||||
Button(
|
|
||||||
onClick = onSave,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(2f)
|
|
||||||
.height(48.dp),
|
|
||||||
) { Text(stringResource(R.string.profile_edit_save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState()),
|
||||||
.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onCancel))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text, maxLines = 1, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
{
|
{
|
||||||
if (state.readOnly)
|
if (state.readOnly)
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
|
ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR)
|
||||||
}
|
}
|
||||||
ShxSectionCard(title = stringResource(R.string.profile_section_basic))
|
ShxFormField(
|
||||||
{
|
label = stringResource(R.string.profile_name_label).trimEnd(':'),
|
||||||
OutlinedTextField(
|
value = state.name,
|
||||||
|
onValueChange = { onChange(state.copy(name = it)) },
|
||||||
|
placeholder = stringResource(R.string.form_name_placeholder),
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_gateway_label).trimEnd(':'),
|
||||||
value = state.gateway,
|
value = state.gateway,
|
||||||
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
||||||
label = { Text(stringResource(R.string.profile_gateway_label)) },
|
placeholder = stringResource(R.string.form_gateway_placeholder),
|
||||||
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
|
error = state.gatewayError,
|
||||||
isError = state.gatewayError != null,
|
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
|
ShxSectionHeader(stringResource(R.string.form_login))
|
||||||
OutlinedTextField(
|
if (state.vpnType in LOGIN_TYPES)
|
||||||
value = types.getOrElse(state.vpnType.ordinal) { "" },
|
{
|
||||||
onValueChange = {},
|
ShxTabs(
|
||||||
readOnly = true,
|
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
|
||||||
|
selected = LOGIN_TYPES.indexOf(state.vpnType),
|
||||||
|
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
|
||||||
|
}
|
||||||
|
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
||||||
|
{
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_username_label).trimEnd(':'),
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
||||||
|
error = state.usernameError,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_password_label).trimEnd(':'),
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = { onChange(state.copy(password = it)) },
|
||||||
|
supporting = stringResource(R.string.profile_password_hint),
|
||||||
|
enabled = enabled,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailing = {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
|
||||||
|
{
|
||||||
|
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null, tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
||||||
|
{
|
||||||
|
FormPickRow(
|
||||||
|
title = state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate),
|
||||||
|
subtitle = stringResource(R.string.profile_user_certificate_label).trimEnd(':'),
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
||||||
|
onChange(latest.value.copy(userCertAlias = alias))
|
||||||
|
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
|
||||||
|
if (!state.caAuto)
|
||||||
|
{
|
||||||
|
FormPickRow(
|
||||||
|
title = state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) },
|
||||||
|
subtitle = stringResource(R.string.profile_ca_label).trimEnd(':'),
|
||||||
|
enabled = enabled,
|
||||||
|
bordered = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
||||||
|
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
||||||
|
pickCa.launch(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* «More: port, MTU, DNS, identities» */
|
||||||
|
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
|
||||||
|
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.form_more), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = stringResource(R.string.profile_show_advanced_label), tint = c.iconMuted, modifier = Modifier.size(18.dp).rotate(chevron))
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.advanced,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
|
||||||
|
{
|
||||||
|
FormPickRow(
|
||||||
|
title = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||||
|
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
|
) { typeExpanded = true }
|
||||||
|
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
|
||||||
|
{
|
||||||
VpnType.values().forEachIndexed { index, type ->
|
VpnType.values().forEachIndexed { index, type ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(types.getOrElse(index) { type.identifier }) },
|
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onChange(state.copy(vpnType = type))
|
onChange(state.copy(vpnType = type))
|
||||||
typeExpanded = false
|
typeExpanded = false
|
||||||
@@ -418,199 +487,95 @@ fun ProfileFormScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||||
|
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
|
||||||
|
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
|
||||||
|
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
|
||||||
|
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
|
||||||
|
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
{
|
{
|
||||||
OutlinedTextField(
|
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
|
||||||
value = state.username,
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||||
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
|
||||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||||
isError = state.usernameError != null,
|
|
||||||
supportingText = state.usernameError?.let { { Text(it) } },
|
|
||||||
enabled = enabled,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.password,
|
|
||||||
onValueChange = { onChange(state.copy(password = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
|
||||||
enabled = enabled,
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(onClick = { showPassword = !showPassword }) {
|
|
||||||
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
|
||||||
}
|
}
|
||||||
},
|
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||||
modifier = Modifier.fillMaxWidth(),
|
ShxPanel()
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
|
||||||
{
|
{
|
||||||
TextButton(onClick = {
|
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
|
||||||
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
|
||||||
onChange(latest.value.copy(userCertAlias = alias))
|
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
|
||||||
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
|
||||||
}, enabled = enabled) {
|
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
|
||||||
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
|
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
|
||||||
|
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
|
||||||
|
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
|
||||||
|
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
|
||||||
}
|
}
|
||||||
}
|
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||||
Checkbox(
|
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||||
checked = state.caAuto,
|
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||||
onCheckedChange = { onChange(state.copy(caAuto = it)) },
|
ShxPanel()
|
||||||
enabled = enabled,
|
|
||||||
)
|
|
||||||
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
|
|
||||||
onChange(state.copy(caAuto = !state.caAuto))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!state.caAuto)
|
|
||||||
{
|
{
|
||||||
TextButton(onClick = {
|
ShxRow(
|
||||||
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
|
||||||
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
|
||||||
pickCa.launch(intent)
|
|
||||||
}, enabled = enabled) {
|
|
||||||
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = { onChange(state.copy(name = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_name_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
|
|
||||||
enabled = enabled,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
|
|
||||||
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ExpandMore,
|
|
||||||
contentDescription = stringResource(R.string.profile_show_advanced_label),
|
|
||||||
modifier = Modifier.rotate(chevron),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onChange(state.copy(advanced = !state.advanced)) },
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = state.advanced,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
|
||||||
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_remote_id_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
|
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_local_id_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
|
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
|
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_mtu_label)) },
|
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_port_label)) },
|
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
|
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
|
|
||||||
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
|
|
||||||
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
|
|
||||||
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
|
|
||||||
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
|
|
||||||
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
|
|
||||||
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
|
|
||||||
isError = state.includedSubnetsError != null,
|
|
||||||
supportingText = { state.includedSubnetsError?.let { Text(it) } },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
|
|
||||||
isError = state.excludedSubnetsError != null,
|
|
||||||
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
SwitchRow(
|
|
||||||
label = stringResource(R.string.profile_split_tunnelingv4_title),
|
|
||||||
checked = state.blockIpv4,
|
|
||||||
enabled = enabled,
|
|
||||||
) { onChange(state.copy(blockIpv4 = it)) }
|
|
||||||
SwitchRow(
|
|
||||||
label = stringResource(R.string.profile_split_tunnelingv6_title),
|
|
||||||
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
|
|
||||||
checked = state.blockIpv6,
|
|
||||||
enabled = enabled,
|
|
||||||
) { onChange(state.copy(blockIpv6 = it)) }
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.AltRoute,
|
|
||||||
title = stringResource(R.string.profile_routing_link_title),
|
title = stringResource(R.string.profile_routing_link_title),
|
||||||
subtitle = stringResource(R.string.profile_routing_link_summary),
|
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||||
onClick = {
|
icon = ShxIcons.Route,
|
||||||
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
chevron = true,
|
||||||
},
|
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
|
||||||
)
|
)
|
||||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
|
||||||
isError = state.ikeProposalError != null,
|
|
||||||
supportingText = { state.ikeProposalError?.let { Text(it) } },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
|
|
||||||
isError = state.espProposalError != null,
|
|
||||||
supportingText = { state.espProposalError?.let { Text(it) } },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
|
|
||||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
}
|
||||||
|
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||||
|
error = state.ikeProposalError, enabled = enabled, mono = true)
|
||||||
|
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||||
|
error = state.espProposalError, enabled = enabled, mono = true)
|
||||||
|
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
|
||||||
|
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
||||||
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||||
|
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.profile_edit_save), onSave, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** White row that opens a picker: 12 label on top, 15/700 value, chevron. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(
|
private fun FormPickRow(
|
||||||
label: String,
|
title: String,
|
||||||
checked: Boolean,
|
subtitle: String,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
supporting: String? = null,
|
modifier: Modifier = Modifier,
|
||||||
onChange: (Boolean) -> Unit,
|
bordered: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ListItem(
|
val c = LocalShxColors.current
|
||||||
headlineContent = { Text(label) },
|
val shape = RoundedCornerShape(16.dp)
|
||||||
supportingContent = supporting?.let { { Text(it) } },
|
Row(
|
||||||
trailingContent = {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
},
|
modifier = modifier
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.then(if (bordered) Modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape) else Modifier)
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||||
|
Text(title, style = ShxType.body.copy(fontSize = 16.sp), color = if (enabled) c.text else c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+121
-1
@@ -36,6 +36,7 @@ import androidx.compose.foundation.selection.toggleable
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -48,9 +49,11 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
@@ -573,7 +576,7 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
|||||||
{
|
{
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -617,3 +620,120 @@ fun ShxIntro(text: String, modifier: Modifier = Modifier)
|
|||||||
{
|
{
|
||||||
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.textSub, modifier = modifier.padding(horizontal = 4.dp))
|
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.textSub, modifier = modifier.padding(horizontal = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field of the mockup (F_ManualAdd): white box, radius 16, label 12/700
|
||||||
|
* inside, value 16/600; 2px purple border while focused, red on error.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxFormField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
supporting: String? = null,
|
||||||
|
error: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
mono: Boolean = false,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
var focused by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
|
val borderColor = when
|
||||||
|
{
|
||||||
|
error != null -> c.error
|
||||||
|
focused -> c.accent
|
||||||
|
else -> c.cardBorder
|
||||||
|
}
|
||||||
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(c.card)
|
||||||
|
.border(if (focused || error != null) 2.dp else 1.dp, borderColor, shape)
|
||||||
|
.padding(start = 14.dp, end = if (trailing != null) 4.dp else 14.dp, top = 10.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = when
|
||||||
|
{
|
||||||
|
error != null -> c.error
|
||||||
|
focused -> c.accentText
|
||||||
|
else -> c.textSub
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Box()
|
||||||
|
{
|
||||||
|
if (value.isEmpty() && placeholder != null)
|
||||||
|
{
|
||||||
|
Text(placeholder, style = ShxType.body.copy(fontSize = 16.sp), color = c.iconMuted, maxLines = 1)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = singleLine,
|
||||||
|
textStyle = (if (mono) ShxType.mono.copy(fontSize = 16.sp) else ShxType.body.copy(fontSize = 16.sp)).copy(color = if (enabled) c.text else c.textSub),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
val note = error ?: supporting
|
||||||
|
if (note != null)
|
||||||
|
{
|
||||||
|
Text(note, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = if (error != null) c.error else c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle row inside a white card: 15/700 title, optional 12 sub, the mockup switch. */
|
||||||
|
@Composable
|
||||||
|
fun ShxToggleRow(title: String, checked: Boolean, onChange: (Boolean) -> Unit, subtitle: String? = null, enabled: Boolean = true, divider: Boolean = false)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column()
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 58.dp)
|
||||||
|
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onChange)
|
||||||
|
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (enabled) c.text else c.textSub)
|
||||||
|
if (subtitle != null)
|
||||||
|
{
|
||||||
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -918,4 +918,44 @@
|
|||||||
<string name="share_send_file">Отправить файлом</string>
|
<string name="share_send_file">Отправить файлом</string>
|
||||||
<string name="share_copy">Скопировать текст</string>
|
<string name="share_copy">Скопировать текст</string>
|
||||||
<string name="share_copied">Профиль скопирован</string>
|
<string name="share_copied">Профиль скопирован</string>
|
||||||
|
<string name="import_failed_download">Не удалось скачать профиль</string>
|
||||||
|
<string name="import_failed_read">Не удалось прочитать профиль</string>
|
||||||
|
<string name="import_reasons">Возможные причины</string>
|
||||||
|
<string name="import_reason_blocked">Сайт заблокирован</string>
|
||||||
|
<string name="import_reason_blocked_d">ссылка не открывается без VPN</string>
|
||||||
|
<string name="import_reason_offline">Нет интернета</string>
|
||||||
|
<string name="import_reason_offline_d">проверьте мобильную сеть или Wi-Fi</string>
|
||||||
|
<string name="import_reason_tls">Ошибка сертификата сайта</string>
|
||||||
|
<string name="import_reason_tls_d">HTTPS-соединение не прошло проверку</string>
|
||||||
|
<string name="import_reason_format">Это не профиль</string>
|
||||||
|
<string name="import_reason_format_d">файл повреждён или это не профиль .sswan</string>
|
||||||
|
<string name="import_ask_admin">Попросите у администратора файл .sswan или QR — их можно добавить без интернета.</string>
|
||||||
|
<string name="import_support_details">Подробности для поддержки</string>
|
||||||
|
<string name="import_reading">Читаем профиль…</string>
|
||||||
|
<string name="import_add_title">Добавить сервер?</string>
|
||||||
|
<string name="import_read_ok">Профиль прочитан, всё в порядке</string>
|
||||||
|
<string name="import_name">Название</string>
|
||||||
|
<string name="import_server">Сервер</string>
|
||||||
|
<string name="import_type">Тип</string>
|
||||||
|
<string name="import_login">Вход</string>
|
||||||
|
<string name="import_login_both">Сертификат и пароль</string>
|
||||||
|
<string name="import_login_cert">Сертификат</string>
|
||||||
|
<string name="import_login_password">Логин и пароль</string>
|
||||||
|
<string name="import_source">Источник</string>
|
||||||
|
<string name="import_source_link">ссылка</string>
|
||||||
|
<string name="import_source_file">файл</string>
|
||||||
|
<string name="import_ca">Сертификат CA</string>
|
||||||
|
<string name="import_ca_included">входит в профиль</string>
|
||||||
|
<string name="import_connect_now">Подключиться сразу</string>
|
||||||
|
<string name="import_exists_title">Такой сервер уже есть.</string>
|
||||||
|
<string name="import_exists_text">Новый профиль заменит его, настройки сервера сохранятся.</string>
|
||||||
|
<string name="import_update_server">Обновить сервер</string>
|
||||||
|
<string name="import_add_server">Добавить сервер</string>
|
||||||
|
<string name="form_name_placeholder">Амстердам</string>
|
||||||
|
<string name="form_gateway_placeholder">vpn.example.com</string>
|
||||||
|
<string name="form_login">Вход</string>
|
||||||
|
<string name="form_login_password">Логин</string>
|
||||||
|
<string name="form_login_cert">Сертификат</string>
|
||||||
|
<string name="form_login_both">Оба</string>
|
||||||
|
<string name="form_more">Дополнительно: порт, MTU, DNS, идентификаторы</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -903,4 +903,44 @@
|
|||||||
<string name="share_send_file">Send as a file</string>
|
<string name="share_send_file">Send as a file</string>
|
||||||
<string name="share_copy">Copy text</string>
|
<string name="share_copy">Copy text</string>
|
||||||
<string name="share_copied">Profile copied</string>
|
<string name="share_copied">Profile copied</string>
|
||||||
|
<string name="import_failed_download">Could not download the profile</string>
|
||||||
|
<string name="import_failed_read">Could not read the profile</string>
|
||||||
|
<string name="import_reasons">Possible reasons</string>
|
||||||
|
<string name="import_reason_blocked">The site is blocked</string>
|
||||||
|
<string name="import_reason_blocked_d">the link does not open without the VPN</string>
|
||||||
|
<string name="import_reason_offline">No internet</string>
|
||||||
|
<string name="import_reason_offline_d">check mobile data or Wi-Fi</string>
|
||||||
|
<string name="import_reason_tls">Site certificate error</string>
|
||||||
|
<string name="import_reason_tls_d">the HTTPS connection did not pass the check</string>
|
||||||
|
<string name="import_reason_format">Not a profile</string>
|
||||||
|
<string name="import_reason_format_d">the file is damaged or is not an .sswan profile</string>
|
||||||
|
<string name="import_ask_admin">Ask the admin for an .sswan file or a QR code: they can be added without the internet.</string>
|
||||||
|
<string name="import_support_details">Details for support</string>
|
||||||
|
<string name="import_reading">Reading the profile…</string>
|
||||||
|
<string name="import_add_title">Add the server?</string>
|
||||||
|
<string name="import_read_ok">The profile is read, all is fine</string>
|
||||||
|
<string name="import_name">Name</string>
|
||||||
|
<string name="import_server">Server</string>
|
||||||
|
<string name="import_type">Type</string>
|
||||||
|
<string name="import_login">Sign-in</string>
|
||||||
|
<string name="import_login_both">Certificate and password</string>
|
||||||
|
<string name="import_login_cert">Certificate</string>
|
||||||
|
<string name="import_login_password">Login and password</string>
|
||||||
|
<string name="import_source">Source</string>
|
||||||
|
<string name="import_source_link">link</string>
|
||||||
|
<string name="import_source_file">file</string>
|
||||||
|
<string name="import_ca">CA certificate</string>
|
||||||
|
<string name="import_ca_included">included in the profile</string>
|
||||||
|
<string name="import_connect_now">Connect right away</string>
|
||||||
|
<string name="import_exists_title">This server already exists.</string>
|
||||||
|
<string name="import_exists_text">The new profile replaces it; the settings of the server stay.</string>
|
||||||
|
<string name="import_update_server">Update the server</string>
|
||||||
|
<string name="import_add_server">Add the server</string>
|
||||||
|
<string name="form_name_placeholder">Amsterdam</string>
|
||||||
|
<string name="form_gateway_placeholder">vpn.example.com</string>
|
||||||
|
<string name="form_login">Sign-in</string>
|
||||||
|
<string name="form_login_password">Login</string>
|
||||||
|
<string name="form_login_cert">Certificate</string>
|
||||||
|
<string name="form_login_both">Both</string>
|
||||||
|
<string name="form_more">More: port, MTU, DNS, identities</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user