taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

ConfigFragment.kt (29163B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.merchantpos.config
     18 
     19 import android.Manifest
     20 import android.content.Intent
     21 import android.content.pm.PackageManager
     22 import android.media.Image
     23 import android.os.Bundle
     24 import android.util.Log
     25 import android.widget.Toast
     26 import androidx.activity.result.contract.ActivityResultContracts
     27 import androidx.annotation.OptIn
     28 import androidx.camera.core.CameraSelector
     29 import androidx.camera.core.ExperimentalGetImage
     30 import androidx.camera.core.ImageAnalysis
     31 import androidx.camera.core.Preview
     32 import androidx.camera.lifecycle.ProcessCameraProvider
     33 import androidx.camera.view.PreviewView
     34 import androidx.compose.foundation.layout.Arrangement
     35 import androidx.compose.foundation.layout.Box
     36 import androidx.compose.foundation.layout.BoxWithConstraints
     37 import androidx.compose.foundation.layout.Column
     38 import androidx.compose.foundation.layout.Row
     39 import androidx.compose.foundation.layout.fillMaxSize
     40 import androidx.compose.foundation.layout.fillMaxWidth
     41 import androidx.compose.foundation.layout.padding
     42 import androidx.compose.foundation.layout.size
     43 import androidx.compose.foundation.layout.systemBarsPadding
     44 import androidx.compose.foundation.layout.width
     45 import androidx.compose.foundation.lazy.LazyColumn
     46 import androidx.compose.foundation.lazy.items
     47 import androidx.compose.foundation.lazy.rememberLazyListState
     48 import androidx.compose.material3.Button
     49 import androidx.compose.material3.Checkbox
     50 import androidx.compose.material3.CircularProgressIndicator
     51 import androidx.compose.material3.MaterialTheme
     52 import androidx.compose.material3.OutlinedButton
     53 import androidx.compose.material3.OutlinedTextField
     54 import androidx.compose.material3.SingleChoiceSegmentedButtonRow
     55 import androidx.compose.material3.SegmentedButton
     56 import androidx.compose.material3.SegmentedButtonDefaults
     57 import androidx.compose.material3.Surface
     58 import androidx.compose.material3.Text
     59 import androidx.compose.runtime.Composable
     60 import androidx.compose.runtime.getValue
     61 import androidx.compose.runtime.mutableStateOf
     62 import androidx.compose.runtime.setValue
     63 import androidx.compose.ui.Modifier
     64 import androidx.compose.ui.Alignment
     65 import androidx.compose.ui.draw.clipToBounds
     66 import androidx.compose.ui.focus.FocusDirection
     67 import androidx.compose.ui.platform.LocalFocusManager
     68 import androidx.compose.ui.platform.LocalSoftwareKeyboardController
     69 import androidx.compose.ui.platform.ComposeView
     70 import androidx.compose.ui.platform.LocalConfiguration
     71 import androidx.compose.ui.platform.ViewCompositionStrategy
     72 import androidx.compose.ui.res.stringResource
     73 import androidx.compose.ui.unit.dp
     74 import androidx.compose.ui.viewinterop.AndroidView
     75 import androidx.compose.foundation.text.KeyboardActions
     76 import androidx.compose.foundation.text.KeyboardOptions
     77 import androidx.compose.ui.window.Dialog
     78 import androidx.core.content.ContextCompat
     79 import androidx.core.net.toUri
     80 import androidx.fragment.app.Fragment
     81 import androidx.fragment.app.activityViewModels
     82 import androidx.lifecycle.lifecycleScope
     83 import com.google.zxing.BarcodeFormat
     84 import com.google.zxing.BinaryBitmap
     85 import com.google.zxing.DecodeHintType
     86 import com.google.zxing.MultiFormatReader
     87 import com.google.zxing.NotFoundException
     88 import com.google.zxing.PlanarYUVLuminanceSource
     89 import com.google.zxing.common.HybridBinarizer
     90 import kotlinx.coroutines.Dispatchers
     91 import kotlinx.coroutines.launch
     92 import kotlinx.coroutines.withContext
     93 import net.taler.common.TokenDuration
     94 import net.taler.lib.android.ChallengeCancelledException
     95 import net.taler.lib.android.handleChallengeResponse
     96 import net.taler.merchantpos.MainActivity
     97 import net.taler.merchantpos.MainViewModel
     98 import net.taler.merchantpos.R
     99 import net.taler.merchantpos.compose.PosTheme
    100 import net.taler.merchantpos.showPosError
    101 import androidx.compose.ui.text.input.ImeAction
    102 import androidx.compose.ui.text.input.KeyboardType
    103 import androidx.compose.ui.text.input.PasswordVisualTransformation
    104 import androidx.compose.ui.text.input.VisualTransformation
    105 import androidx.compose.material.icons.Icons
    106 import androidx.compose.material.icons.filled.Visibility
    107 import androidx.compose.material.icons.filled.VisibilityOff
    108 import androidx.compose.material3.Icon
    109 import androidx.compose.material3.IconButton
    110 import androidx.compose.runtime.remember
    111 import io.ktor.client.plugins.ClientRequestException
    112 import io.ktor.http.HttpStatusCode.Companion.Unauthorized
    113 
    114 private enum class ConfigMode { Manual, Qr }
    115 
    116 class ConfigFragment : Fragment() {
    117 
    118     private val model: MainViewModel by activityViewModels()
    119     private val configManager by lazy { model.configManager }
    120 
    121     private var awaitingConfigUpdate = false
    122     private var mode by mutableStateOf(ConfigMode.Manual)
    123     private var merchantUrlText by mutableStateOf("")
    124     private var usernameText by mutableStateOf("")
    125     private var tokenText by mutableStateOf("")
    126     private var saveToken by mutableStateOf(true)
    127     private var isSubmitting by mutableStateOf(false)
    128     private var isQrLoading by mutableStateOf(false)
    129     private var previewView: PreviewView? = null
    130 
    131     private val cameraExecutor by lazy {
    132         ContextCompat.getMainExecutor(requireContext())
    133     }
    134 
    135     private val qrReader = MultiFormatReader().apply {
    136         setHints(
    137             mapOf(
    138                 DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
    139                 DecodeHintType.CHARACTER_SET to "UTF-8",
    140             ),
    141         )
    142     }
    143 
    144     override fun onCreateView(
    145         inflater: android.view.LayoutInflater,
    146         container: android.view.ViewGroup?,
    147         savedInstanceState: Bundle?,
    148     ) = ComposeView(requireContext()).apply {
    149         setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    150         initializeState(savedInstanceState == null)
    151         setContent {
    152             ConfigScreen(
    153                 mode = mode,
    154                 merchantUrl = merchantUrlText,
    155                 username = usernameText,
    156                 token = tokenText,
    157                 saveToken = saveToken,
    158                 isSubmitting = isSubmitting,
    159                 isQrLoading = isQrLoading,
    160                 onModeChanged = {
    161                     mode = it
    162                     if (it == ConfigMode.Qr) requestCameraIfNeeded() else stopCamera()
    163                 },
    164                 onMerchantUrlChanged = { merchantUrlText = it },
    165                 onUsernameChanged = { usernameText = it },
    166                 onTokenChanged = { tokenText = it },
    167                 onSaveTokenChanged = { saveToken = it },
    168                 previewContent = {
    169                     AndroidView(
    170                         modifier = Modifier.fillMaxSize(),
    171                         factory = { context ->
    172                             PreviewView(context).also {
    173                                 it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
    174                                 it.scaleType = PreviewView.ScaleType.FIT_CENTER
    175                                 previewView = it
    176                                 if (mode == ConfigMode.Qr) {
    177                                     requestCameraIfNeeded()
    178                                 }
    179                             }
    180                         },
    181                         update = { view ->
    182                             view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
    183                             view.scaleType = PreviewView.ScaleType.FIT_CENTER
    184                             previewView = view
    185                             if (mode == ConfigMode.Qr) {
    186                                 requestCameraIfNeeded()
    187                             }
    188                         },
    189                     )
    190                 },
    191                 onConnect = ::submitManualConfig,
    192             )
    193         }
    194     }
    195 
    196     override fun onViewCreated(view: android.view.View, savedInstanceState: Bundle?) {
    197         configManager.configUpdateResult.observe(viewLifecycleOwner) { result ->
    198             onConfigUpdate(result)
    199         }
    200     }
    201 
    202     override fun onResume() {
    203         super.onResume()
    204         if (mode == ConfigMode.Qr) {
    205             requestCameraIfNeeded()
    206         }
    207     }
    208 
    209     override fun onDestroyView() {
    210         stopCamera()
    211         previewView = null
    212         super.onDestroyView()
    213     }
    214 
    215     private fun initializeState(isInitialization: Boolean) {
    216         val cfg = configManager.config
    217         if (isInitialization) {
    218             merchantUrlText = NEW_CONFIG_URL_DEMO
    219             saveToken = cfg.savePassword()
    220             if (cfg is Config.New && cfg.merchantUrl.isNotBlank()) {
    221                 merchantUrlText = cfg.merchantUrl
    222                 sanitizeMerchantUrlAndUpdateFields()
    223             }
    224         }
    225     }
    226 
    227     private fun submitManualConfig() {
    228         lifecycleScope.launch {
    229             isSubmitting = true
    230             val baseUrl = sanitizeMerchantUrlAndUpdateFields()
    231             val username = usernameText.trim()
    232             val initialSecret = tokenText.trim()
    233             val duration = TokenDuration.Forever
    234 
    235             val limitedToken = try {
    236                 fetchLimitedAccessTokenWithMfa(baseUrl, username, initialSecret, duration)
    237             } catch (_: ChallengeCancelledException) {
    238                 isSubmitting = false
    239                 return@launch
    240             } catch (e: Exception) {
    241                 isSubmitting = false
    242                 Log.e("ConfigFragment", "Error fetching limited token: ${e.message}")
    243                 val errorRes = if (e is ClientRequestException && e.response.status == Unauthorized) {
    244                     R.string.config_auth_error
    245                 } else {
    246                     R.string.config_error_network
    247                 }
    248                 requireActivity().showPosError(errorRes)
    249                 return@launch
    250             }
    251 
    252             val configUrl = "$baseUrl/instances/$username"
    253             val config = Config.New(
    254                 merchantUrl = configUrl,
    255                 accessToken = limitedToken,
    256                 savePassword = saveToken,
    257             )
    258             awaitingConfigUpdate = true
    259             configManager.fetchConfig(config, true)
    260         }
    261     }
    262 
    263     private fun sanitizeMerchantUrlAndUpdateFields(): String {
    264         val rawInput = merchantUrlText.trim()
    265         if (rawInput.isEmpty()) return ""
    266 
    267         val normalizedInput = if (rawInput.startsWith("http://") || rawInput.startsWith("https://")) {
    268             rawInput
    269         } else {
    270             "https://$rawInput"
    271         }
    272 
    273         val uri = normalizedInput.toUri()
    274         val host = uri.host.orEmpty()
    275         val port = if (uri.port != -1) ":${uri.port}" else ""
    276         val baseHost = "$host$port"
    277         val segments = uri.pathSegments
    278         if (segments.size >= 2 && segments[0].equals("instances", true)) {
    279             usernameText = segments[1]
    280         }
    281         merchantUrlText = baseHost
    282         return if (baseHost.isBlank()) "" else "https://$baseHost"
    283     }
    284 
    285     private fun onConfigUpdate(result: ConfigUpdateResult?) {
    286         if (!awaitingConfigUpdate) return
    287         when (result) {
    288             null -> Unit
    289             is ConfigUpdateResult.Error -> {
    290                 awaitingConfigUpdate = false
    291                 isSubmitting = false
    292                 requireActivity().showPosError(result.msg)
    293             }
    294 
    295             is ConfigUpdateResult.Success -> {
    296                 awaitingConfigUpdate = false
    297                 isSubmitting = false
    298                 Toast.makeText(
    299                     requireContext(),
    300                     getString(R.string.config_changed, result.currency),
    301                     Toast.LENGTH_LONG,
    302                 ).show()
    303                 (requireActivity() as MainActivity).navigateToInitialOrderScreen()
    304             }
    305         }
    306     }
    307 
    308     private suspend fun fetchLimitedAccessTokenWithMfa(
    309         baseUrl: String,
    310         username: String,
    311         initialSecret: String,
    312         duration: TokenDuration,
    313     ): String {
    314         var challengeIds: List<String> = emptyList()
    315         while (true) {
    316             try {
    317                 return withContext(Dispatchers.IO) {
    318                     configManager.fetchLimitedAccessToken(
    319                         baseUrl,
    320                         username,
    321                         initialSecret,
    322                         duration,
    323                         challengeIds,
    324                     )
    325                 }
    326             } catch (e: ChallengeRequiredException) {
    327                 val solvedIds = handleChallengeResponse(
    328                     e.challengeResponse.challenges,
    329                     e.challengeResponse.combiAnd,
    330                     onRequestChallenge = { challengeId ->
    331                         withContext(Dispatchers.IO) {
    332                             configManager.requestChallenge(baseUrl, username, challengeId)
    333                         }
    334                     },
    335                     onConfirmChallenge = { challengeId, tan ->
    336                         withContext(Dispatchers.IO) {
    337                             configManager.confirmChallenge(baseUrl, username, challengeId, tan)
    338                         }
    339                     },
    340                 )
    341                 if (solvedIds.isEmpty()) throw ChallengeCancelledException()
    342                 challengeIds = solvedIds
    343             }
    344         }
    345     }
    346 
    347     private val requestCameraPerm =
    348         registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
    349             if (granted) startCamera()
    350             else Toast.makeText(
    351                 requireContext(),
    352                 R.string.config_fragment_camera_needed_text,
    353                 Toast.LENGTH_SHORT,
    354             ).show()
    355         }
    356 
    357     private fun requestCameraIfNeeded() {
    358         if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) ==
    359             PackageManager.PERMISSION_GRANTED
    360         ) {
    361             startCamera()
    362         } else {
    363             requestCameraPerm.launch(Manifest.permission.CAMERA)
    364         }
    365     }
    366 
    367     @OptIn(ExperimentalGetImage::class)
    368     private fun startCamera() {
    369         val previewTarget = previewView ?: return
    370         val providerFuture = ProcessCameraProvider.getInstance(requireContext())
    371         providerFuture.addListener({
    372             val provider = providerFuture.get()
    373             val preview = Preview.Builder().build().also {
    374                 it.setSurfaceProvider(previewTarget.surfaceProvider)
    375             }
    376             val analysis = ImageAnalysis.Builder()
    377                 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    378                 .build().also { useCase ->
    379                     useCase.setAnalyzer(cameraExecutor) { proxy ->
    380                         val mediaImage = proxy.image
    381                         if (mediaImage != null) {
    382                             val nv21 = yuv420888ToNv21(mediaImage)
    383                             val width = mediaImage.width
    384                             val height = mediaImage.height
    385                             val source = PlanarYUVLuminanceSource(
    386                                 nv21,
    387                                 width,
    388                                 height,
    389                                 0,
    390                                 0,
    391                                 width,
    392                                 height,
    393                                 false,
    394                             )
    395                             val rotated = when (proxy.imageInfo.rotationDegrees) {
    396                                 90, 270 -> source.rotateCounterClockwise()
    397                                 else -> source
    398                             }
    399                             val bitmap = BinaryBitmap(HybridBinarizer(rotated))
    400                             try {
    401                                 val result = qrReader.decodeWithState(bitmap)
    402                                 onQrDecoded(result.text)
    403                             } catch (_: NotFoundException) {
    404                             } finally {
    405                                 proxy.close()
    406                             }
    407                         } else {
    408                             proxy.close()
    409                         }
    410                     }
    411                 }
    412             provider.unbindAll()
    413             provider.bindToLifecycle(
    414                 viewLifecycleOwner,
    415                 CameraSelector.DEFAULT_BACK_CAMERA,
    416                 preview,
    417                 analysis,
    418             )
    419         }, cameraExecutor)
    420     }
    421 
    422     private fun yuv420888ToNv21(image: Image): ByteArray {
    423         val yPlane = image.planes[0].buffer
    424         val uPlane = image.planes[1].buffer
    425         val vPlane = image.planes[2].buffer
    426         val ySize = yPlane.remaining()
    427         val uSize = uPlane.remaining()
    428         val vSize = vPlane.remaining()
    429         val nv21 = ByteArray(ySize + uSize + vSize)
    430         yPlane.get(nv21, 0, ySize)
    431         vPlane.get(nv21, ySize, vSize)
    432         uPlane.get(nv21, ySize + vSize, uSize)
    433         return nv21
    434     }
    435 
    436     private fun onQrDecoded(raw: String) {
    437         if (!raw.startsWith("taler-pos://")) return
    438         stopCamera()
    439         isQrLoading = true
    440         val intent = Intent(Intent.ACTION_VIEW, raw.toUri())
    441         (requireActivity() as MainActivity).handleSetupIntent(intent)
    442     }
    443 
    444     private fun stopCamera() {
    445         try {
    446             ProcessCameraProvider.getInstance(requireContext()).get().unbindAll()
    447         } catch (_: Exception) {
    448         }
    449     }
    450 }
    451 
    452 @Composable
    453 private fun ConfigScreen(
    454     mode: ConfigMode,
    455     merchantUrl: String,
    456     username: String,
    457     token: String,
    458     saveToken: Boolean,
    459     isSubmitting: Boolean,
    460     isQrLoading: Boolean,
    461     onModeChanged: (ConfigMode) -> Unit,
    462     onMerchantUrlChanged: (String) -> Unit,
    463     onUsernameChanged: (String) -> Unit,
    464     onTokenChanged: (String) -> Unit,
    465     onSaveTokenChanged: (Boolean) -> Unit,
    466     previewContent: @Composable () -> Unit,
    467     onConnect: () -> Unit,
    468     initialPasswordVisible: Boolean = false,
    469 ) {
    470     PosTheme {
    471         val isTabletLayout = LocalConfiguration.current.smallestScreenWidthDp >= 720
    472         val focusManager = LocalFocusManager.current
    473         val keyboardController = LocalSoftwareKeyboardController.current
    474         val formListState = rememberLazyListState()
    475         Box(
    476             modifier = Modifier
    477                 .fillMaxSize()
    478                 .systemBarsPadding(),
    479         ) {
    480             Column(
    481                 modifier = Modifier
    482                     .fillMaxSize()
    483                     .padding(20.dp),
    484                 verticalArrangement = Arrangement.spacedBy(16.dp),
    485             ) {
    486                 RowButtons(
    487                     mode = mode,
    488                     onManual = { onModeChanged(ConfigMode.Manual) },
    489                     onQr = { onModeChanged(ConfigMode.Qr) },
    490                 )
    491                 if (mode == ConfigMode.Manual) {
    492                     ManualConfigScreen(
    493                         modifier = Modifier
    494                             .fillMaxWidth()
    495                             .weight(1f, fill = false),
    496                         merchantUrl = merchantUrl,
    497                         username = username,
    498                         token = token,
    499                         saveToken = saveToken,
    500                         isSubmitting = isSubmitting,
    501                         formListState = formListState,
    502                         onMerchantUrlChanged = onMerchantUrlChanged,
    503                         onUsernameChanged = onUsernameChanged,
    504                         onTokenChanged = onTokenChanged,
    505                         onSaveTokenChanged = onSaveTokenChanged,
    506                         onConnect = onConnect,
    507                         focusManager = focusManager,
    508                         keyboardController = keyboardController,
    509                         initialPasswordVisible = initialPasswordVisible,
    510                     )
    511                 } else {
    512                     if (isTabletLayout) {
    513                         TabletQrConfigScreen(
    514                             modifier = Modifier
    515                                 .fillMaxWidth()
    516                                 .weight(1f),
    517                             isQrLoading = isQrLoading,
    518                             previewContent = previewContent,
    519                         )
    520                     } else {
    521                         PhoneQrConfigScreen(
    522                             modifier = Modifier
    523                                 .fillMaxWidth()
    524                                 .weight(1f),
    525                             isQrLoading = isQrLoading,
    526                             previewContent = previewContent,
    527                         )
    528                     }
    529                 }
    530             }
    531 
    532             if (isSubmitting) {
    533                 Dialog(onDismissRequest = {}) {
    534                     Surface(
    535                         shape = MaterialTheme.shapes.large,
    536                         tonalElevation = 6.dp,
    537                     ) {
    538                         Box(
    539                             modifier = Modifier.padding(24.dp),
    540                             contentAlignment = Alignment.Center,
    541                         ) {
    542                             CircularProgressIndicator()
    543                         }
    544                     }
    545                 }
    546             }
    547         }
    548     }
    549 }
    550 
    551 @Composable
    552 private fun ManualConfigScreen(
    553     modifier: Modifier,
    554     merchantUrl: String,
    555     username: String,
    556     token: String,
    557     saveToken: Boolean,
    558     isSubmitting: Boolean,
    559     formListState: androidx.compose.foundation.lazy.LazyListState,
    560     onMerchantUrlChanged: (String) -> Unit,
    561     onUsernameChanged: (String) -> Unit,
    562     onTokenChanged: (String) -> Unit,
    563     onSaveTokenChanged: (Boolean) -> Unit,
    564     onConnect: () -> Unit,
    565     focusManager: androidx.compose.ui.focus.FocusManager,
    566     keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?,
    567     initialPasswordVisible: Boolean = false,
    568 ) {
    569     LazyColumn(
    570         modifier = modifier,
    571         state = formListState,
    572         verticalArrangement = Arrangement.spacedBy(16.dp),
    573     ) {
    574         item {
    575             OutlinedTextField(
    576                 value = merchantUrl,
    577                 onValueChange = onMerchantUrlChanged,
    578                 modifier = Modifier.fillMaxWidth(),
    579                 label = { Text(stringResource(R.string.config_merchant_url)) },
    580                 prefix = { Text("https://") },
    581                 keyboardOptions = KeyboardOptions(
    582                     keyboardType = KeyboardType.Uri,
    583                     imeAction = ImeAction.Next,
    584                 ),
    585                 keyboardActions = KeyboardActions(
    586                     onNext = { focusManager.moveFocus(FocusDirection.Down) },
    587                 ),
    588             )
    589         }
    590         item {
    591             OutlinedTextField(
    592                 value = username,
    593                 onValueChange = onUsernameChanged,
    594                 modifier = Modifier.fillMaxWidth(),
    595                 label = { Text(stringResource(R.string.config_username)) },
    596                 keyboardOptions = KeyboardOptions(
    597                     imeAction = ImeAction.Next,
    598                 ),
    599                 keyboardActions = KeyboardActions(
    600                     onNext = { focusManager.moveFocus(FocusDirection.Down) },
    601                 ),
    602             )
    603         }
    604         item {
    605             var passwordVisible by remember { mutableStateOf(initialPasswordVisible) }
    606             OutlinedTextField(
    607                 value = token,
    608                 onValueChange = onTokenChanged,
    609                 modifier = Modifier.fillMaxWidth(),
    610                 label = { Text(stringResource(R.string.config_password)) },
    611                 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
    612                 trailingIcon = {
    613                     IconButton(
    614                         onClick = { passwordVisible = !passwordVisible },
    615                     ) {
    616                         Icon(
    617                             imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
    618                             contentDescription = null,
    619                         )
    620                     }
    621                 },
    622                 keyboardOptions = KeyboardOptions(
    623                     keyboardType = KeyboardType.Password,
    624                     imeAction = ImeAction.Done,
    625                 ),
    626                 keyboardActions = KeyboardActions(
    627                     onDone = {
    628                         focusManager.clearFocus()
    629                         keyboardController?.hide()
    630                     },
    631                 ),
    632             )
    633         }
    634         item {
    635             Row(
    636                 modifier = Modifier.fillMaxWidth(),
    637                 horizontalArrangement = Arrangement.SpaceBetween,
    638                 verticalAlignment = Alignment.CenterVertically,
    639             ) {
    640                 Row(
    641                     verticalAlignment = Alignment.CenterVertically,
    642                 ) {
    643                     Checkbox(
    644                         checked = saveToken,
    645                         onCheckedChange = { onSaveTokenChanged(it) },
    646                     )
    647                     Text(stringResource(R.string.config_save_password))
    648                 }
    649                 Button(
    650                     onClick = onConnect,
    651                     enabled = !isSubmitting,
    652                 ) {
    653                     Text(stringResource(R.string.config_ok))
    654                 }
    655             }
    656         }
    657     }
    658 }
    659 
    660 @Composable
    661 private fun TabletQrConfigScreen(
    662     modifier: Modifier,
    663     isQrLoading: Boolean,
    664     previewContent: @Composable () -> Unit,
    665 ) {
    666     BoxWithConstraints(
    667         modifier = modifier,
    668         contentAlignment = Alignment.Center,
    669     ) {
    670         val previewSize = minOf(maxWidth * 0.7f, maxHeight * 0.7f)
    671         Column(
    672             horizontalAlignment = Alignment.CenterHorizontally,
    673             verticalArrangement = Arrangement.spacedBy(16.dp),
    674         ) {
    675             Box(
    676                 modifier = Modifier
    677                     .size(previewSize)
    678                     .clipToBounds(),
    679             ) {
    680                 previewContent()
    681             }
    682             Text(stringResource(R.string.scan_qr_hint))
    683             if (isQrLoading) {
    684                 CircularProgressIndicator()
    685             }
    686         }
    687     }
    688 }
    689 
    690 @Composable
    691 private fun PhoneQrConfigScreen(
    692     modifier: Modifier,
    693     isQrLoading: Boolean,
    694     previewContent: @Composable () -> Unit,
    695 ) {
    696     BoxWithConstraints(
    697         modifier = modifier,
    698     ) {
    699         val previewSize = minOf(maxWidth * 0.8f, maxHeight * 0.6f)
    700 
    701         Column(
    702             modifier = Modifier
    703                 .fillMaxWidth()
    704                 .padding(top = 12.dp),
    705             horizontalAlignment = Alignment.CenterHorizontally,
    706             verticalArrangement = Arrangement.spacedBy(16.dp),
    707         ) {
    708             Box(
    709                 modifier = Modifier
    710                     .size(previewSize)
    711                     .clipToBounds(),
    712             ) {
    713                 previewContent()
    714             }
    715             Text(stringResource(R.string.scan_qr_hint))
    716             if (isQrLoading) {
    717                 CircularProgressIndicator()
    718             }
    719         }
    720     }
    721 }
    722 
    723 @Composable
    724 private fun RowButtons(
    725     mode: ConfigMode,
    726     onManual: () -> Unit,
    727     onQr: () -> Unit,
    728 ) {
    729     SingleChoiceSegmentedButtonRow(
    730         modifier = Modifier.fillMaxWidth(),
    731     ) {
    732         SegmentedButton(
    733             selected = mode == ConfigMode.Manual,
    734             onClick = onManual,
    735             icon = {},
    736             shape = SegmentedButtonDefaults.itemShape(
    737                 index = 0,
    738                 count = 2,
    739             ),
    740             modifier = Modifier.weight(1f),
    741         ) {
    742             Text(stringResource(R.string.config_manual_label))
    743         }
    744         SegmentedButton(
    745             selected = mode == ConfigMode.Qr,
    746             onClick = onQr,
    747             icon = {},
    748             shape = SegmentedButtonDefaults.itemShape(
    749                 index = 1,
    750                 count = 2,
    751             ),
    752             modifier = Modifier.weight(1f),
    753         ) {
    754             Text(stringResource(R.string.config_qr_label))
    755         }
    756     }
    757 }
    758 
    759 @Composable
    760 internal fun ConfigScreenContent(
    761     merchantUrl: String,
    762     username: String,
    763     token: String,
    764     passwordVisible: Boolean = false,
    765 ) {
    766     ConfigScreen(
    767         mode = ConfigMode.Manual,
    768         merchantUrl = merchantUrl,
    769         username = username,
    770         token = token,
    771         saveToken = false,
    772         isSubmitting = false,
    773         isQrLoading = false,
    774         onModeChanged = {},
    775         onMerchantUrlChanged = {},
    776         onUsernameChanged = {},
    777         onTokenChanged = {},
    778         onSaveTokenChanged = {},
    779         previewContent = {},
    780         onConnect = {},
    781         initialPasswordVisible = passwordVisible,
    782     )
    783 }