ConfigFragment.kt
/*
* This file is part of GNU Taler
* (C) 2020 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.config
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.ReaderException
import com.google.zxing.common.HybridBinarizer
import net.taler.lib.android.MfaCodeContent
import net.taler.lib.android.MfaMethodContent
import net.taler.merchantpos.MainActivity
import net.taler.merchantpos.MainViewModel
import net.taler.merchantpos.R
import net.taler.merchantpos.compose.PosTheme
import net.taler.merchantpos.showPosError
internal enum class ConfigMode
{
Manual,
Qr
}
private const val DEMO_MERCHANT_HOST = "backend.demo.taler.net"
private const val DEMO_INSTANCE = "sandbox"
private const val DEMO_PASSWORD = "sandbox"
private const val INSTANCE_SETUP_TUTORIAL_URL =
"https://tutorials.taler.net/merchant/merchant-backoffice/obtain-instance/index"
class ConfigFragment : Fragment()
{
private val model: MainViewModel by activityViewModels()
private val draft: ConfigDraftViewModel by activityViewModels()
private val configManager by lazy { model.configManager }
private var mode by mutableStateOf(ConfigMode.Manual)
private var merchantUrlText by mutableStateOf("")
private var usernameText by mutableStateOf("")
private var tokenText by mutableStateOf("")
private var saveToken by mutableStateOf(true)
private var isQrLoading by mutableStateOf(false)
private var previewView: PreviewView? = null
private var cameraGranted by mutableStateOf(false)
private var cameraDialogVisible by mutableStateOf(false)
private var cameraNeedsSettings by mutableStateOf(false)
private var cameraRequestInFlight = false
private var cameraBindingTarget: PreviewView? = null
private var cameraGeneration = 0
private val cameraExecutor by lazy {
ContextCompat.getMainExecutor(requireContext())
}
private val qrReader =
MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.CHARACTER_SET to "UTF-8",
),
)
}
private val requestCameraPerm =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
cameraRequestInFlight = false
if (isAdded) refreshCameraPermission()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
initializeState(savedInstanceState)
setContent {
ConfigScreen(
mode = mode,
merchantUrl = merchantUrlText,
username = usernameText,
token = tokenText,
saveToken = saveToken,
isSubmitting = draft.isSubmitting,
onCancelLogin = {
configManager.cancelConfigFetch()
draft.cancelLogin()
},
connectionPermissionPending = model.localNetworkAccess.host != null,
mfaPrompt = draft.prompt,
isQrLoading = isQrLoading,
onModeChanged = ::changeMode,
cameraDialogVisible = cameraDialogVisible,
cameraNeedsSettings = cameraNeedsSettings,
onCameraPermissionConfirm = ::confirmCameraPermission,
onCameraPermissionDismiss = { changeMode(ConfigMode.Manual) },
onMerchantUrlChanged = {
merchantUrlText = it
draft.merchantUrl = it
},
onUsernameChanged = {
usernameText = it
draft.username = it
},
onTokenChanged = {
tokenText = it
draft.token = it
},
onSaveTokenChanged = {
saveToken = it
draft.saveToken = it
},
previewContent = {
if (cameraGranted)
{
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
PreviewView(context).also {
it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
it.scaleType = PreviewView.ScaleType.FIT_CENTER
previewView = it
if (mode == ConfigMode.Qr)
{
startCamera()
}
}
},
update = { view ->
view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
view.scaleType = PreviewView.ScaleType.FIT_CENTER
previewView = view
},
)
}
else
{
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
OutlinedButton(onClick = ::refreshCameraPermission) {
Text(stringResource(R.string.config_camera_allow))
}
}
}
},
onConnect = ::submitManualConfig,
)
}
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
)
{
draft.loginError.observe(viewLifecycleOwner) { error ->
if (error != null)
{
draft.loginError.value = null
requireActivity().showPosError(error)
}
}
configManager.configUpdateResult.observe(viewLifecycleOwner) { result ->
onConfigUpdate(result)
}
}
override fun onSaveInstanceState(outState: Bundle)
{
super.onSaveInstanceState(outState)
// Keep the sign-in draft useful across rotation without writing the credential to disk.
outState.putString(STATE_MODE, mode.name)
outState.putString(STATE_MERCHANT_URL, merchantUrlText)
outState.putString(STATE_USERNAME, usernameText)
outState.putBoolean(STATE_SAVE_TOKEN, saveToken)
outState.putBoolean(STATE_CAMERA_REQUEST, cameraRequestInFlight)
}
override fun onResume()
{
super.onResume()
if (mode == ConfigMode.Qr)
{
refreshCameraPermission()
}
}
override fun onDestroyView()
{
stopCamera()
previewView = null
// The Compose host also detaches/reattaches this view after rotation.
// Only an actual removal means the user is leaving login.
if (isRemoving &&
activity?.isChangingConfigurations != true)
{
draft.clear()
}
super.onDestroyView()
}
override fun onDestroy()
{
if (activity?.isChangingConfigurations != true) draft.clear()
super.onDestroy()
}
private fun initializeState(savedInstanceState: Bundle?)
{
val cfg = configManager.config
if (savedInstanceState != null)
{
cameraRequestInFlight = savedInstanceState.getBoolean(STATE_CAMERA_REQUEST)
mode = draft.mode ?: savedInstanceState
.getString(STATE_MODE)
?.let { runCatching { ConfigMode.valueOf(it) }.getOrNull() } ?: ConfigMode.Manual
merchantUrlText = draft.merchantUrl ?: savedInstanceState.getString(STATE_MERCHANT_URL).orEmpty()
usernameText = draft.username ?: savedInstanceState.getString(STATE_USERNAME).orEmpty()
tokenText = draft.token.orEmpty() // Activity ViewModel only; never copied into saved state.
saveToken = draft.saveToken ?: savedInstanceState.getBoolean(STATE_SAVE_TOKEN, cfg.savePassword())
}
else
{
if (!draft.initialized)
{
merchantUrlText = NEW_CONFIG_URL_DEMO
saveToken = cfg.savePassword()
if (cfg is Config.New &&
cfg.merchantUrl.isNotBlank())
{
merchantUrlText = cfg.merchantUrl
sanitizeMerchantUrlAndUpdateFields()
}
draft.initialized = true
draft.mode = mode
draft.merchantUrl = merchantUrlText
draft.username = usernameText
draft.saveToken = saveToken
}
else
{
mode = draft.mode ?: ConfigMode.Manual
merchantUrlText = draft.merchantUrl.orEmpty()
usernameText = draft.username.orEmpty()
tokenText = draft.token.orEmpty()
saveToken = draft.saveToken ?: cfg.savePassword()
}
}
draft.initialized = true
draft.mode = mode
draft.merchantUrl = merchantUrlText
draft.username = usernameText
draft.saveToken = saveToken
}
private fun submitManualConfig()
{
if (draft.isSubmitting) return
model.localNetworkAccess.retry()
val baseUrl = sanitizeMerchantUrlAndUpdateFields()
draft.startLogin(
MerchantLoginBackend(configManager, baseUrl, usernameText.trim(), tokenText.trim(), saveToken),
)
}
private fun sanitizeMerchantUrlAndUpdateFields(): String
{
val rawInput = merchantUrlText.trim()
if (rawInput.isEmpty()) return ""
val normalizedInput =
if (rawInput.startsWith("http://") ||
rawInput.startsWith("https://"))
{
rawInput
}
else
{
"https://$rawInput"
}
val uri = normalizedInput.toUri()
val host = uri.host.orEmpty()
val port = if (uri.port != -1) ":${uri.port}" else ""
val baseHost = "$host$port"
val segments = uri.pathSegments
if (segments.size >= 2 &&
segments[0].equals("instances", true))
{
usernameText = segments[1]
}
merchantUrlText = baseHost
draft.merchantUrl = merchantUrlText
draft.username = usernameText
return if (baseHost.isBlank()) "" else "https://$baseHost"
}
private fun onConfigUpdate(result: ConfigUpdateResult?)
{
if (!draft.awaitingConfigUpdate) return
when (result)
{
null ->
{
Unit
}
is ConfigUpdateResult.Error ->
{
draft.finishConfigUpdate()
requireActivity().showPosError(result.msg)
}
is ConfigUpdateResult.Success ->
{
draft.finishConfigUpdate()
Toast
.makeText(
requireContext(),
getString(R.string.config_changed, result.currency),
Toast.LENGTH_LONG,
).show()
draft.clear()
(requireActivity() as MainActivity).navigateToInitialOrderScreen()
}
}
}
private fun changeMode(next: ConfigMode)
{
mode = next
draft.mode = next
cameraDialogVisible = false
if (next == ConfigMode.Qr) refreshCameraPermission() else stopCamera()
}
private fun refreshCameraPermission()
{
cameraGranted = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (mode != ConfigMode.Qr) return
cameraNeedsSettings =
requireContext()
.getSharedPreferences(CAMERA_PREFS, Context.MODE_PRIVATE)
.getBoolean(CAMERA_REQUESTED, false) &&
!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)
cameraDialogVisible = !cameraGranted && !cameraRequestInFlight
if (cameraGranted)
{
startCamera()
}
else
{
stopCamera()
}
}
private fun confirmCameraPermission()
{
if (cameraRequestInFlight || mode != ConfigMode.Qr) return
cameraDialogVisible = false
if (cameraNeedsSettings)
{
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${requireContext().packageName}".toUri()),
)
}
else
{
requireContext()
.getSharedPreferences(CAMERA_PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(CAMERA_REQUESTED, true)
.apply()
cameraRequestInFlight = true
requestCameraPerm.launch(Manifest.permission.CAMERA)
}
}
private fun startCamera()
{
val previewTarget = previewView ?: return
if (!cameraGranted || mode != ConfigMode.Qr || isQrLoading || cameraBindingTarget === previewTarget) return
cameraBindingTarget = previewTarget
val generation = cameraGeneration
val providerFuture = ProcessCameraProvider.getInstance(requireContext())
providerFuture.addListener(
{
if (generation != cameraGeneration || previewView !== previewTarget ||
mode != ConfigMode.Qr || view == null || !cameraGranted) {
return@addListener
}
val provider = providerFuture.get()
val preview =
Preview.Builder().build().also {
it.setSurfaceProvider(previewTarget.surfaceProvider)
}
val analysis =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { useCase ->
useCase.setAnalyzer(cameraExecutor) { proxy ->
try
{
val plane = proxy.planes.firstOrNull() ?: return@setAnalyzer
val source =
qrLuminance(
plane.buffer,
proxy.width,
proxy.height,
plane.rowStride,
plane.pixelStride,
proxy.imageInfo.rotationDegrees,
)
val result = qrReader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
onQrDecoded(result.text)
}
catch (_: ReaderException)
{
// Most camera frames do not contain a complete, readable QR code.
}
catch (_: IllegalArgumentException)
{
// Ignore an incomplete camera frame and try the next one.
}
finally
{
qrReader.reset()
proxy.close()
}
}
}
provider.unbindAll()
provider.bindToLifecycle(
viewLifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
},
cameraExecutor
)
}
private fun onQrDecoded(raw: String)
{
if (merchantSetupConfig(raw.toUri()) == null) return
stopCamera()
isQrLoading = true
val intent = Intent(Intent.ACTION_VIEW, raw.toUri())
(requireActivity() as MainActivity).handleSetupIntent(intent)
}
private fun stopCamera()
{
cameraGeneration++
if (cameraBindingTarget == null) return
cameraBindingTarget = null
try
{
ProcessCameraProvider.getInstance(requireContext()).get().unbindAll()
}
catch (_: Exception)
{
}
}
companion object
{
private const val STATE_MODE = "config.mode"
private const val STATE_MERCHANT_URL = "config.merchantUrl"
private const val STATE_USERNAME = "config.username"
private const val STATE_SAVE_TOKEN = "config.saveToken"
private const val STATE_CAMERA_REQUEST = "config.cameraRequest"
private const val CAMERA_PREFS = "config.cameraPermission"
private const val CAMERA_REQUESTED = "requested"
}
}
@Composable
private fun ConfigScreen(
mode: ConfigMode,
merchantUrl: String,
username: String,
token: String,
saveToken: Boolean,
isSubmitting: Boolean,
isQrLoading: Boolean,
onModeChanged: (ConfigMode) -> Unit,
onMerchantUrlChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
onTokenChanged: (String) -> Unit,
onSaveTokenChanged: (Boolean) -> Unit,
previewContent: @Composable () -> Unit,
onConnect: () -> Unit,
onCancelLogin: () -> Unit = {},
initialPasswordVisible: Boolean = false,
mfaPrompt: LoginPrompt? = null,
connectionPermissionPending: Boolean = false,
cameraDialogVisible: Boolean = false,
cameraNeedsSettings: Boolean = false,
onCameraPermissionConfirm: () -> Unit = {},
onCameraPermissionDismiss: () -> Unit = {},
)
{
PosTheme {
if (cameraDialogVisible)
{
AlertDialog(
onDismissRequest = onCameraPermissionDismiss,
title = { Text(stringResource(R.string.config_camera_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.config_camera_explanation))
if (cameraNeedsSettings) Text(stringResource(R.string.config_camera_settings_instructions))
}
},
confirmButton = {
TextButton(onClick = onCameraPermissionConfirm) {
Text(
stringResource(
if (cameraNeedsSettings)
{
R.string.config_camera_open_settings
}
else
{
R.string.config_camera_allow
},
),
)
}
},
dismissButton = {
TextButton(onClick = onCameraPermissionDismiss) {
Text(stringResource(R.string.config_camera_manual))
}
},
)
}
val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val formListState = rememberLazyListState()
Box(
modifier =
Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier =
Modifier
.widthIn(max = if (isTabletLayout) 960.dp else 640.dp)
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RowButtons(
mode = mode,
onManual = { onModeChanged(ConfigMode.Manual) },
onQr = { onModeChanged(ConfigMode.Qr) },
)
if (mode == ConfigMode.Manual)
{
ManualConfigScreen(
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
merchantUrl = merchantUrl,
username = username,
token = token,
saveToken = saveToken,
isSubmitting = isSubmitting,
formListState = formListState,
onMerchantUrlChanged = onMerchantUrlChanged,
onUsernameChanged = onUsernameChanged,
onTokenChanged = onTokenChanged,
onSaveTokenChanged = onSaveTokenChanged,
onConnect = onConnect,
focusManager = focusManager,
keyboardController = keyboardController,
initialPasswordVisible = initialPasswordVisible,
)
}
else
{
if (isTabletLayout)
{
TabletQrConfigScreen(
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
isQrLoading = isQrLoading,
previewContent = previewContent,
)
}
else
{
PhoneQrConfigScreen(
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
isQrLoading = isQrLoading,
previewContent = previewContent,
)
}
}
}
if (mfaPrompt != null && !connectionPermissionPending)
{
Dialog(
onDismissRequest = mfaPrompt::cancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
when (mfaPrompt)
{
is LoginPrompt.Method ->
{
MfaMethodContent(
mfaPrompt.challenges,
mfaPrompt::select,
mfaPrompt::cancel,
)
}
is LoginPrompt.Code ->
{
MfaCodeContent(
mfaPrompt.challenge,
mfaPrompt.code,
mfaPrompt.error,
mfaPrompt::updateCode,
mfaPrompt::submit,
mfaPrompt::cancel,
)
}
}
}
}
else if (isSubmitting && !connectionPermissionPending)
{
Dialog(onDismissRequest = onCancelLogin) {
Surface(
shape = MaterialTheme.shapes.large,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(stringResource(R.string.config_connecting))
TextButton(onClick = onCancelLogin) {
Text(stringResource(android.R.string.cancel))
}
}
}
}
}
}
}
}
@Composable
private fun ManualConfigScreen(
modifier: Modifier,
merchantUrl: String,
username: String,
token: String,
saveToken: Boolean,
isSubmitting: Boolean,
formListState: LazyListState,
onMerchantUrlChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
onTokenChanged: (String) -> Unit,
onSaveTokenChanged: (Boolean) -> Unit,
onConnect: () -> Unit,
focusManager: FocusManager,
keyboardController: SoftwareKeyboardController?,
initialPasswordVisible: Boolean = false,
)
{
LazyColumn(
modifier = modifier,
state = formListState,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
OutlinedTextField(
value = merchantUrl,
onValueChange = onMerchantUrlChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.config_merchant_url)) },
prefix = {
Text(
text = "https://",
modifier = Modifier.padding(end = 8.dp),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
singleLine = true,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
),
)
}
item {
OutlinedTextField(
value = username,
onValueChange = onUsernameChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.config_username)) },
keyboardOptions =
KeyboardOptions(
imeAction = ImeAction.Next,
),
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
),
)
}
item {
var passwordVisible by remember { mutableStateOf(initialPasswordVisible) }
OutlinedTextField(
value = token,
onValueChange = onTokenChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.config_password)) },
visualTransformation =
if (passwordVisible)
{
VisualTransformation.None
}
else
{
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(
onClick = { passwordVisible = !passwordVisible },
) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription =
stringResource(
if (passwordVisible) R.string.password_hide else R.string.password_show,
),
)
}
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions =
KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
),
)
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier =
Modifier.fillMaxWidth().toggleable(
value = saveToken,
role = Role.Checkbox,
onValueChange = onSaveTokenChanged,
),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = saveToken, onCheckedChange = null)
Text(stringResource(R.string.config_save_password), modifier = Modifier.weight(1f))
}
Button(
onClick = onConnect,
enabled = !isSubmitting,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.config_ok))
}
}
}
item {
InstanceHelpCard(
onOpenTutorial = {
focusManager.clearFocus()
keyboardController?.hide()
},
onFillDemoCredentials = {
focusManager.clearFocus()
keyboardController?.hide()
onMerchantUrlChanged(DEMO_MERCHANT_HOST)
onUsernameChanged(DEMO_INSTANCE)
onTokenChanged(DEMO_PASSWORD)
},
onFillReviewUrl = {
focusManager.clearFocus()
keyboardController?.hide()
onMerchantUrlChanged(DEMO_MERCHANT_HOST)
onUsernameChanged("")
onTokenChanged("")
},
)
}
}
}
@Composable
private fun InstanceHelpCard(
onOpenTutorial: () -> Unit,
onFillDemoCredentials: () -> Unit,
onFillReviewUrl: () -> Unit,
)
{
var expanded by rememberSaveable { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.config_no_instance),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
AnimatedVisibility(visible = expanded) {
Column {
HorizontalDivider()
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column {
Text(
text = stringResource(R.string.config_instance_tutorial_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = {
onOpenTutorial()
uriHandler.openUri(INSTANCE_SETUP_TUTORIAL_URL)
},
) {
Text(stringResource(R.string.config_instance_tutorial_link))
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.config_instance_demo_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onFillDemoCredentials) {
Text(stringResource(R.string.config_fill_demo_credentials))
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.config_instance_review_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onFillReviewUrl) {
Text(stringResource(R.string.config_fill_review_url))
}
}
}
}
}
}
}
}
@Composable
private fun TabletQrConfigScreen(
modifier: Modifier,
isQrLoading: Boolean,
previewContent: @Composable () -> Unit,
)
{
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
val previewSize = minOf(maxWidth * 0.7f, maxHeight * 0.7f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier =
Modifier
.size(previewSize)
.clipToBounds(),
) {
previewContent()
}
Text(stringResource(R.string.scan_qr_hint))
if (isQrLoading)
{
CircularProgressIndicator()
}
}
}
}
@Composable
private fun PhoneQrConfigScreen(
modifier: Modifier,
isQrLoading: Boolean,
previewContent: @Composable () -> Unit,
)
{
BoxWithConstraints(
modifier = modifier,
) {
val previewSize = minOf(maxWidth * 0.8f, maxHeight * 0.6f)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier =
Modifier
.size(previewSize)
.clipToBounds(),
) {
previewContent()
}
Text(stringResource(R.string.scan_qr_hint))
if (isQrLoading)
{
CircularProgressIndicator()
}
}
}
}
@Composable
private fun RowButtons(
mode: ConfigMode,
onManual: () -> Unit,
onQr: () -> Unit,
)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
) {
SegmentedButton(
selected = mode == ConfigMode.Manual,
onClick = onManual,
icon = {},
shape =
SegmentedButtonDefaults.itemShape(
index = 0,
count = 2,
),
modifier = Modifier.weight(1f).fillMaxHeight(),
) {
Text(stringResource(R.string.config_manual_label))
}
SegmentedButton(
selected = mode == ConfigMode.Qr,
onClick = onQr,
icon = {},
shape =
SegmentedButtonDefaults.itemShape(
index = 1,
count = 2,
),
modifier = Modifier.weight(1f).fillMaxHeight(),
) {
Text(stringResource(R.string.config_qr_label))
}
}
}
@Composable
internal fun ConfigScreenContent(
merchantUrl: String,
username: String,
token: String,
passwordVisible: Boolean = false,
mode: ConfigMode = ConfigMode.Manual,
cameraDialogVisible: Boolean = false,
cameraNeedsSettings: Boolean = false,
)
{
ConfigScreen(
mode = mode,
merchantUrl = merchantUrl,
username = username,
token = token,
saveToken = false,
isSubmitting = false,
isQrLoading = false,
onModeChanged = {},
onMerchantUrlChanged = {},
onUsernameChanged = {},
onTokenChanged = {},
onSaveTokenChanged = {},
previewContent = {},
onConnect = {},
initialPasswordVisible = passwordVisible,
cameraDialogVisible = cameraDialogVisible,
cameraNeedsSettings = cameraNeedsSettings,
)
}