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
+696
-476
No files matched your search
+33
-11
@@ -179,7 +179,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
|
||||
ImportScreenKt.installImportScreen(this, mUi,
|
||||
this::saveProfile, this::finish,
|
||||
this::importPkcs12, this::chooseUserCertificate);
|
||||
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
|
||||
|
||||
Intent intent = getIntent();
|
||||
String action = intent.getAction();
|
||||
@@ -246,13 +246,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
mImportPKCS12.launch(intent);
|
||||
}
|
||||
|
||||
private Uri mLastUri;
|
||||
|
||||
private void loadProfile(Uri uri)
|
||||
{
|
||||
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();
|
||||
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)
|
||||
@@ -300,15 +316,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (error != null)
|
||||
{
|
||||
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();
|
||||
/* the sheet shows the failure with the likely reasons and a retry */
|
||||
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
|
||||
return;
|
||||
}
|
||||
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
||||
@@ -666,6 +675,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
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()));
|
||||
finish();
|
||||
@@ -701,6 +717,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
*/
|
||||
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))
|
||||
{
|
||||
mProfile.setUsername(mUi.getUsername().trim());
|
||||
|
||||
+242
-209
@@ -11,53 +11,59 @@
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.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.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
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.ShxType
|
||||
|
||||
/**
|
||||
* 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 remoteCertSecondary by mutableStateOf<String?>(null)
|
||||
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. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
|
||||
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
|
||||
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
|
||||
fun ComponentActivity.installImportScreen(
|
||||
model: ImportUiModel,
|
||||
onImport: Runnable,
|
||||
onClose: Runnable,
|
||||
onImportPkcs12: Runnable,
|
||||
onSelectUserCert: Runnable,
|
||||
onRetry: Runnable,
|
||||
)
|
||||
{
|
||||
setContent {
|
||||
ShxTheme {
|
||||
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose::run,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
)
|
||||
ShxSheet(onDismiss = onClose::run)
|
||||
{
|
||||
ImportScreen(
|
||||
model = model,
|
||||
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
|
||||
onClose = onClose::run,
|
||||
onImportPkcs12 = onImportPkcs12::run,
|
||||
onSelectUserCert = onSelectUserCert::run,
|
||||
onRetry = onRetry::run,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
model: ImportUiModel,
|
||||
@@ -125,221 +138,241 @@ fun ImportScreen(
|
||||
onClose: () -> Unit,
|
||||
onImportPkcs12: () -> Unit,
|
||||
onSelectUserCert: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
)
|
||||
{
|
||||
Column(Modifier.fillMaxSize())
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
when
|
||||
{
|
||||
TopAppBar(
|
||||
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)
|
||||
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
|
||||
{
|
||||
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))
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
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)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LoadingIndicator()
|
||||
model.sourceUri?.let {
|
||||
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ShxSpacing.lg)
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
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(
|
||||
text = model.existsWarning ?: "",
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
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)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
|
||||
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
|
||||
if (model.sharedSecretWarning)
|
||||
{
|
||||
ImportBanner(
|
||||
text = stringResource(R.string.profile_import_shared_secret),
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||
)
|
||||
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
|
||||
}
|
||||
}
|
||||
|
||||
if (model.requireUserCert)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
||||
)
|
||||
ShxCard {
|
||||
Column {
|
||||
val certShape = RoundedCornerShape(24.dp)
|
||||
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
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)
|
||||
{
|
||||
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
|
||||
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)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
Text(
|
||||
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
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),
|
||||
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
|
||||
{
|
||||
Column {
|
||||
}
|
||||
val toggleShape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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),
|
||||
)
|
||||
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(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = 52.dp),
|
||||
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
|
||||
}
|
||||
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.unit.dp
|
||||
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.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
@@ -282,6 +303,9 @@ fun ProfileFormState.isValid(): Boolean
|
||||
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)
|
||||
@Composable
|
||||
fun ProfileFormScreen(
|
||||
@@ -295,6 +319,7 @@ fun ProfileFormScreen(
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
androidx.compose.runtime.LaunchedEffect(messages)
|
||||
{
|
||||
@@ -313,103 +338,147 @@ fun ProfileFormScreen(
|
||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
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)
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
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(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_name_label).trimEnd(':'),
|
||||
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,
|
||||
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_gateway_label)) },
|
||||
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
|
||||
isError = state.gatewayError != null,
|
||||
placeholder = stringResource(R.string.form_gateway_placeholder),
|
||||
error = state.gatewayError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
ShxSectionHeader(stringResource(R.string.form_login))
|
||||
if (state.vpnType in LOGIN_TYPES)
|
||||
{
|
||||
ShxTabs(
|
||||
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,
|
||||
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 ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(types.getOrElse(index) { type.identifier }) },
|
||||
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
|
||||
onClick = {
|
||||
onChange(state.copy(vpnType = type))
|
||||
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(
|
||||
value = state.username,
|
||||
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
||||
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_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
||||
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
ShxPanel()
|
||||
{
|
||||
TextButton(onClick = {
|
||||
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
||||
onChange(latest.value.copy(userCertAlias = alias))
|
||||
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
||||
}, enabled = enabled) {
|
||||
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
|
||||
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.caAuto,
|
||||
onCheckedChange = { onChange(state.copy(caAuto = it)) },
|
||||
enabled = enabled,
|
||||
)
|
||||
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
|
||||
onChange(state.copy(caAuto = !state.caAuto))
|
||||
})
|
||||
}
|
||||
if (!state.caAuto)
|
||||
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxPanel()
|
||||
{
|
||||
TextButton(onClick = {
|
||||
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,
|
||||
ShxRow(
|
||||
title = stringResource(R.string.profile_routing_link_title),
|
||||
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||
onClick = {
|
||||
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||
},
|
||||
icon = ShxIcons.Route,
|
||||
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
|
||||
private fun SwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
private fun FormPickRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
enabled: Boolean,
|
||||
supporting: String? = null,
|
||||
onChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bordered: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
supportingContent = supporting?.let { { Text(it) } },
|
||||
trailingContent = {
|
||||
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -48,9 +49,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
@@ -573,7 +576,7 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
||||
{
|
||||
Column(
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_copy">Скопировать текст</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>
|
||||
@@ -903,4 +903,44 @@
|
||||
<string name="share_send_file">Send as a file</string>
|
||||
<string name="share_copy">Copy text</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>
|
||||
Reference in new issue
Block a user