Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-20 10:27:01 +03:00
commit a0132aea5a
16 changed files with 316 additions and 61 deletions

View file

@ -393,6 +393,10 @@
<string name="organize_tokens_sort_by_balance">By balance</string>
<string name="organize_tokens_title">Organize tokens</string>
<string name="organize_tokens_ungroup">Ungroup</string>
<string name="qr_scanner_camera_denied_gallery_button">Select from the gallery</string>
<string name="qr_scanner_camera_denied_settings_button">Settings</string>
<string name="qr_scanner_camera_denied_title">Camera access denied</string>
<string name="qr_scanner_camera_denied_text">You have not given access to your camera</string>
<string name="receive_bottom_sheet_title">%1$s %2$s address on %3$s network</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) on %3$s network</string>
<string name="receive_bottom_sheet_warning_message_full">Send only %s to this address. Sending any other currency will result in its irreversible loss.</string>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,8C13.061,8 14.078,8.421 14.828,9.172C15.578,9.922 16,10.939 16,12C16,13.061 15.578,14.078 14.828,14.828C14.078,15.579 13.061,16 12,16C10.939,16 9.922,15.579 9.172,14.828C8.421,14.078 8,13.061 8,12C8,10.939 8.421,9.922 9.172,9.172C9.922,8.421 10.939,8 12,8ZM12,10C11.469,10 10.961,10.211 10.586,10.586C10.211,10.961 10,11.47 10,12C10,12.53 10.211,13.039 10.586,13.414C10.961,13.789 11.469,14 12,14C12.53,14 13.039,13.789 13.414,13.414C13.789,13.039 14,12.53 14,12C14,11.47 13.789,10.961 13.414,10.586C13.039,10.211 12.53,10 12,10ZM10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10ZM11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z"
android:fillColor="#1E1E1E"/>
</vector>

View file

@ -20,6 +20,7 @@ dependencies {
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Camera */
implementation(deps.camera.camera2)

View file

@ -2,6 +2,7 @@ package com.tangem.feature.qrscanning
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
@ -10,6 +11,8 @@ import androidx.compose.ui.graphics.Color
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
@ -45,12 +48,19 @@ internal class QrScanningFragment : ComposeFragment() {
}
private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) parentFragmentManager.popBackStack()
if (!it) viewModel.onCameraDeniedState()
}
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(requireContext(), selectedImage)
analyzer.analyze(image)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.router = innerRouter
viewModel.setRouter(innerRouter, galleryLauncher)
cameraExecutor = Executors.newSingleThreadExecutor()
requestCameraPermission()
}
@ -70,7 +80,7 @@ internal class QrScanningFragment : ComposeFragment() {
QrScanningContent(
executor = { cameraExecutor },
analyzer = { analyzer },
uiState = viewModel.uiState,
uiState = viewModel.uiState.collectAsStateWithLifecycle().value,
)
setFitSystemWindows(fit = false)
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class CameraDeniedBottomSheetConfig(
val onGalleryClick: () -> Unit,
val onCancelClick: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,82 @@
package com.tangem.feature.qrscanning.presentation
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.qrscanning.impl.R
@Composable
fun CameraDeniedBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: CameraDeniedBottomSheetConfig ->
CameraDeniedBottomSheet(content = content)
}
}
@Composable
private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) {
val context = LocalContext.current
Column {
CameraDeniedBottomSheetHeader()
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_settings_button),
icon = R.drawable.ic_settings_24,
onItemsClick = {
val intent: Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ContextCompat.startActivity(context, intent, null)
},
)
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_gallery_button),
icon = R.drawable.ic_gallery_24,
onItemsClick = content.onGalleryClick,
)
SimpleSettingsRow(
title = stringResource(id = R.string.common_close),
icon = R.drawable.ic_close,
onItemsClick = content.onCancelClick,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun CameraDeniedBottomSheetHeader() {
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing16,
),
)
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_text),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing3,
bottom = TangemTheme.dimens.spacing16,
),
)
}

View file

@ -1,9 +1,10 @@
package com.tangem.feature.qrscanning.presentation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -18,14 +19,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIconContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -44,15 +43,9 @@ internal fun QrScanningContent(
analyzer: () -> MLKitBarcodeAnalyzer,
uiState: QrScanningState,
) {
val context = LocalContext.current
var isFlash by remember { mutableStateOf(false) }
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(context, selectedImage)
analyzer().analyze(image)
}
}
BackHandler(onBack = uiState.onBackClick)
Box(
modifier = Modifier
@ -101,13 +94,16 @@ internal fun QrScanningContent(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { galleryLauncher.launch("image/*") },
onClick = uiState.onGalleryClick,
),
tint = TangemColorPalette.White,
)
}
},
)
if (uiState.bottomSheetConfig != null) {
CameraDeniedBottomSheet(uiState.bottomSheetConfig)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.qrscanning.presentation
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -8,5 +9,6 @@ data class QrScanningState(
val message: TextReference?,
val onQrScanned: (String) -> Unit,
val onBackClick: () -> Unit,
val onGalleryClicked: () -> Unit,
val onGalleryClick: () -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
)

View file

@ -0,0 +1,35 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.feature.qrscanning.presentation.transformers.QrScanningTransformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class QrScanningStateController @Inject constructor() {
val uiState: StateFlow<QrScanningState> get() = mutableUiState
val value: QrScanningState get() = uiState.value
private val mutableUiState: MutableStateFlow<QrScanningState> = MutableStateFlow(value = getInitialState())
fun update(function: (QrScanningState) -> QrScanningState) {
mutableUiState.update(function = function)
}
fun update(transformer: QrScanningTransformer) {
mutableUiState.update(function = transformer::transform)
}
private fun getInitialState(): QrScanningState {
return QrScanningState(
message = null,
onBackClick = {},
onQrScanned = {},
onGalleryClick = {},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal class DismissBottomSheetTransformer : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = null,
)
}
}

View file

@ -1,16 +1,19 @@
package com.tangem.feature.qrscanning.presentation
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class QrScanningStateFactory(
val clickIntents: QrScanningClickIntents,
) {
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,
private val source: SourceType,
private val network: String?,
) : QrScanningTransformer {
fun getInitialState(source: SourceType, network: String?): QrScanningState {
override fun transform(prevState: QrScanningState): QrScanningState {
val message = when (source) {
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
else -> null
@ -20,7 +23,7 @@ internal class QrScanningStateFactory(
message = message,
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onGalleryClicked = clickIntents::onGalleryClicked,
onGalleryClick = clickIntents::onGalleryClicked,
)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal interface QrScanningTransformer {
fun transform(prevState: QrScanningState): QrScanningState
}

View file

@ -0,0 +1,24 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.CameraDeniedBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class ShowCameraDeniedBottomSheetTransformer(
private val clickIntents: QrScanningClickIntents,
) : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = clickIntents::onBackClick,
content = CameraDeniedBottomSheetConfig(
onCancelClick = clickIntents::onBackClick,
onGalleryClick = clickIntents::onGalleryClicked,
),
),
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.activity.result.ActivityResultLauncher
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates
internal open class BaseQrScanningClickIntents {
protected val router: QrScanningInnerRouter get() = _router
protected val viewModelScope: CoroutineScope get() = _viewModelScope
protected val source: SourceType get() = _source
protected val galleryLauncher: ActivityResultLauncher<String> get() = _galleryLauncher
private var _router: QrScanningInnerRouter by Delegates.notNull()
private var _viewModelScope: CoroutineScope by Delegates.notNull()
private var _source: SourceType by Delegates.notNull()
private var _galleryLauncher: ActivityResultLauncher<String> by Delegates.notNull()
open fun initialize(
router: QrScanningInnerRouter,
source: SourceType,
galleryLauncher: ActivityResultLauncher<String>,
coroutineScope: CoroutineScope,
) {
_router = router
_viewModelScope = coroutineScope
_source = source
_galleryLauncher = galleryLauncher
}
}

View file

@ -1,5 +1,13 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.launch
import javax.inject.Inject
interface QrScanningClickIntents {
fun onBackClick()
@ -7,4 +15,39 @@ interface QrScanningClickIntents {
fun onQrScanned(qrCode: String)
fun onGalleryClicked()
}
@ViewModelScoped
internal class QrScanningClickIntentsImplementor @Inject constructor(
private val stateHolder: QrScanningStateController,
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
) : BaseQrScanningClickIntents(), QrScanningClickIntents {
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
}
override fun onGalleryClicked() {
galleryLauncher.launch(GALLERY_IMAGE_FILTER)
if (stateHolder.value.bottomSheetConfig != null) {
stateHolder.update(DismissBottomSheetTransformer())
}
}
companion object {
private const val GALLERY_IMAGE_FILTER = "image/*"
}
}

View file

@ -1,8 +1,6 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -11,50 +9,38 @@ import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateFactory
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer
import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import kotlin.properties.Delegates
@HiltViewModel
internal class QrScanningViewModel @Inject constructor(
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
private val stateHolder: QrScanningStateController,
private val clickIntents: QrScanningClickIntentsImplementor,
savedStateHandle: SavedStateHandle,
) : ViewModel(), QrScanningClickIntents {
) : ViewModel() {
private val source: SourceType = savedStateHandle[SOURCE_KEY] ?: error("Source is mandatory")
private val network: String? = savedStateHandle[NETWORK_KEY]
private val factory = QrScanningStateFactory(
clickIntents = this,
)
val uiState: StateFlow<QrScanningState> = stateHolder.uiState
var router: QrScanningInnerRouter by Delegates.notNull()
var uiState: QrScanningState by mutableStateOf(factory.getInitialState(source, network))
private set
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
fun setRouter(router: QrScanningInnerRouter, galleryLauncher: ActivityResultLauncher<String>) {
clickIntents.initialize(
router = router,
source = source,
galleryLauncher = galleryLauncher,
coroutineScope = viewModelScope,
)
stateHolder.update(InitializeQrScanningStateTransformer(clickIntents, source, network))
}
override fun onGalleryClicked() {
// [REDACTED_JIRA]
fun onQrScanned(qrCode: String) = clickIntents.onQrScanned(qrCode)
fun onCameraDeniedState() {
stateHolder.update(ShowCameraDeniedBottomSheetTransformer(clickIntents))
}
}