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 }