Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-29 13:50:16 +00:00
commit 6d0b295ca3
219 changed files with 3720 additions and 1687 deletions

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
@ -13,14 +14,16 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningContent
import com.tangem.feature.qrscanning.viewmodel.QrScanningViewModel
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@ -49,12 +52,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()
}
@ -71,7 +81,7 @@ internal class QrScanningFragment : ComposeFragment() {
QrScanningContent(
executor = { cameraExecutor },
analyzer = { analyzer },
uiState = viewModel.uiState,
uiState = viewModel.uiState.collectAsStateWithLifecycle().value,
)
}

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))
}
}