211
This commit is contained in:
72
feature/scan/build.gradle.kts
Normal file
72
feature/scan/build.gradle.kts
Normal file
@@ -0,0 +1,72 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.homebox.lens.feature.scan"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":data"))
|
||||
|
||||
implementation(Libs.coreKtx)
|
||||
implementation(Libs.lifecycleRuntime)
|
||||
implementation(Libs.activityCompose)
|
||||
|
||||
// CameraX
|
||||
// CameraX
|
||||
implementation("androidx.camera:camera-core:1.3.4")
|
||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||
implementation("androidx.camera:camera-view:1.3.4")
|
||||
|
||||
// ML Kit Barcode Scanning
|
||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||
|
||||
// Compose
|
||||
|
||||
|
||||
implementation(Libs.composeUi)
|
||||
implementation(Libs.composeUiGraphics)
|
||||
implementation(Libs.composeUiToolingPreview)
|
||||
implementation(Libs.composeMaterial3)
|
||||
implementation(Libs.navigationCompose)
|
||||
implementation(Libs.hiltNavigationCompose)
|
||||
|
||||
// Hilt
|
||||
implementation(Libs.hiltAndroid)
|
||||
ksp(Libs.hiltCompiler)
|
||||
|
||||
// Logging
|
||||
implementation(Libs.timber)
|
||||
|
||||
// Testing
|
||||
testImplementation(Libs.junit)
|
||||
testImplementation(Libs.kotestRunnerJunit5)
|
||||
testImplementation(Libs.kotestAssertionsCore)
|
||||
testImplementation(Libs.mockk)
|
||||
testImplementation("app.cash.turbine:turbine:1.1.0")
|
||||
androidTestImplementation(Libs.extJunit)
|
||||
androidTestImplementation(Libs.espressoCore)
|
||||
|
||||
androidTestImplementation(Libs.composeUiTestJunit4)
|
||||
debugImplementation(Libs.composeUiTooling)
|
||||
debugImplementation(Libs.composeUiTestManifest)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// [FILE] BarcodeAnalyzer.kt
|
||||
// [SEMANTICS] camera, barcode_scanning, utility
|
||||
|
||||
package com.homebox.lens.feature.scan
|
||||
|
||||
// [IMPORTS]
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: Class('BarcodeAnalyzer')]
|
||||
// [RELATION: Class('BarcodeAnalyzer')] -> [DEPENDS_ON] -> [Class('BarcodeScanning')]
|
||||
// [RELATION: Class('BarcodeAnalyzer')] -> [DEPENDS_ON] -> [Class('InputImage')]
|
||||
/**
|
||||
* @summary Анализатор изображений для обнаружения штрих-кодов с использованием ML Kit.
|
||||
* @param onBarcodeDetected Лямбда-функция, вызываемая при обнаружении штрих-кода.
|
||||
* @description Этот класс реализует [ImageAnalysis.Analyzer] для обработки кадров с камеры и извлечения информации о штрих-кодах.
|
||||
*/
|
||||
class BarcodeAnalyzer(private val onBarcodeDetected: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
|
||||
// [ENTITY: Property('options')]
|
||||
private val options = BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
|
||||
.build()
|
||||
// [END_ENTITY: Property('options')]
|
||||
|
||||
// [ENTITY: Property('scanner')]
|
||||
private val scanner = BarcodeScanning.getClient(options)
|
||||
// [END_ENTITY: Property('scanner')]
|
||||
|
||||
// [ENTITY: Function('analyze')]
|
||||
/**
|
||||
* @summary Анализирует кадр изображения на наличие штрих-кодов.
|
||||
* @param imageProxy Объект [ImageProxy], содержащий данные изображения с камеры.
|
||||
* @sideeffect Вызывает `onBarcodeDetected` при успешном обнаружении штрих-кода.
|
||||
* @precondition `imageProxy.image` не должен быть null.
|
||||
*/
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null) {
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener {
|
||||
if (it.isNotEmpty()) {
|
||||
onBarcodeDetected(it.first().rawValue ?: "")
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
}
|
||||
// [END_ENTITY: Function('analyze')]
|
||||
}
|
||||
// [END_ENTITY: Class('BarcodeAnalyzer')]
|
||||
|
||||
// [END_FILE_BarcodeAnalyzer.kt]
|
||||
@@ -0,0 +1,132 @@
|
||||
// [FILE] ScanScreen.kt
|
||||
// [SEMANTICS] ui, screen, scan, compose, camera, barcode_scanning
|
||||
|
||||
package com.homebox.lens.feature.scan
|
||||
|
||||
// [IMPORTS]
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import java.util.concurrent.Executors
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: Function('ScanScreen')]
|
||||
// [RELATION: Function('ScanScreen')] -> [DEPENDS_ON] -> [ViewModel('ScanViewModel')]
|
||||
/**
|
||||
* @summary Composable-функция для экрана сканирования QR/штрих-кодов.
|
||||
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
|
||||
* @sideeffect Запрашивает разрешение на использование камеры, управляет жизненным циклом камеры.
|
||||
* @invariant Состояние UI отображается в соответствии с `ScanUiState`.
|
||||
*/
|
||||
@Composable
|
||||
fun ScanScreen(
|
||||
viewModel: ScanViewModel = viewModel(),
|
||||
onBarcodeResult: (String) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
|
||||
val requestPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
// Permission granted, set up camera
|
||||
} else {
|
||||
viewModel.onError("Camera permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_CREATE) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Permission already granted, set up camera
|
||||
} else {
|
||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
PreviewView(it).apply {
|
||||
this.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { view ->
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also { preview ->
|
||||
preview.setSurfaceProvider(view.surfaceProvider)
|
||||
}
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(cameraExecutor, BarcodeAnalyzer { barcode ->
|
||||
viewModel.onBarcodeScanned(barcode)
|
||||
onBarcodeResult(barcode)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
|
||||
} catch (e: Exception) {
|
||||
viewModel.onError("Camera initialization failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when (uiState) {
|
||||
is ScanUiState.Success -> Text(text = "Scanned: ${(uiState as ScanUiState.Success).barcode}")
|
||||
is ScanUiState.Error -> Text(text = "Error: ${(uiState as ScanUiState.Error).message}")
|
||||
ScanUiState.Loading -> Text(text = "Scanning...")
|
||||
ScanUiState.Idle -> Text(text = "Waiting to scan...")
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_ENTITY: Function('ScanScreen')]
|
||||
|
||||
// [END_FILE_ScanScreen.kt]
|
||||
@@ -0,0 +1,52 @@
|
||||
// [FILE] ScanUiState.kt
|
||||
// [SEMANTICS] ui, state_management, scan, item_creation
|
||||
|
||||
package com.homebox.lens.feature.scan
|
||||
|
||||
// [ENTITY: SealedInterface('ScanUiState')]
|
||||
/**
|
||||
* @summary Определяет все возможные состояния UI для экрана сканирования.
|
||||
* @description Использование sealed-интерфейса позволяет исчерпывающе обрабатывать все состояния в Composable-функциях.
|
||||
*/
|
||||
sealed interface ScanUiState {
|
||||
// [ENTITY: DataClass('Success')]
|
||||
/**
|
||||
* @summary Состояние успешного сканирования.
|
||||
* @param barcode Обнаруженный штрих-код или QR-код.
|
||||
* @invariant barcode не может быть пустым.
|
||||
*/
|
||||
data class Success(val barcode: String) : ScanUiState {
|
||||
init { require(barcode.isNotBlank()) { "Barcode cannot be blank." } }
|
||||
}
|
||||
// [END_ENTITY: DataClass('Success')]
|
||||
|
||||
// [ENTITY: Object('Loading')]
|
||||
/**
|
||||
* @summary Состояние загрузки/сканирования.
|
||||
* @description Указывает, что процесс сканирования активен.
|
||||
*/
|
||||
object Loading : ScanUiState
|
||||
// [END_ENTITY: Object('Loading')]
|
||||
|
||||
// [ENTITY: DataClass('Error')]
|
||||
/**
|
||||
* @summary Состояние ошибки.
|
||||
* @param message Сообщение об ошибке для отображения пользователю.
|
||||
* @invariant message не может быть пустым.
|
||||
*/
|
||||
data class Error(val message: String) : ScanUiState {
|
||||
init { require(message.isNotBlank()) { "Error message cannot be blank." } }
|
||||
}
|
||||
// [END_ENTITY: DataClass('Error')]
|
||||
|
||||
// [ENTITY: Object('Idle')]
|
||||
/**
|
||||
* @summary Начальное или бездействующее состояние.
|
||||
* @description Указывает, что сканер ожидает начала работы.
|
||||
*/
|
||||
object Idle : ScanUiState
|
||||
// [END_ENTITY: Object('Idle')]
|
||||
}
|
||||
// [END_ENTITY: SealedInterface('ScanUiState')]
|
||||
|
||||
// [END_FILE_ScanUiState.kt]
|
||||
@@ -0,0 +1,75 @@
|
||||
// [FILE] ScanViewModel.kt
|
||||
// [SEMANTICS] ui, viewmodel, state_management, scan
|
||||
|
||||
package com.homebox.lens.feature.scan
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import timber.log.Timber
|
||||
// [END_IMPORTS]
|
||||
|
||||
// [ENTITY: Class('ScanViewModel')]
|
||||
// [RELATION: Class('ScanViewModel')] -> [EMITS_STATE] -> [SealedInterface('ScanUiState')]
|
||||
/**
|
||||
* @summary ViewModel для экрана сканирования.
|
||||
* @description Управляет состоянием UI экрана сканирования, обрабатывая результаты сканирования и ошибки.
|
||||
* @invariant `uiState` всегда является одним из состояний, определенных в `ScanUiState`.
|
||||
*/
|
||||
class ScanViewModel : ViewModel() {
|
||||
|
||||
// [ENTITY: Property('_uiState')]
|
||||
private val _uiState = MutableStateFlow<ScanUiState>(ScanUiState.Idle)
|
||||
// [END_ENTITY: Property('_uiState')]
|
||||
|
||||
// [ENTITY: Property('uiState')]
|
||||
/**
|
||||
* @summary Текущее состояние UI экрана сканирования.
|
||||
* @return [StateFlow] с текущим состоянием UI.
|
||||
*/
|
||||
val uiState: StateFlow<ScanUiState> = _uiState
|
||||
// [END_ENTITY: Property('uiState')]
|
||||
|
||||
// [ENTITY: Function('onBarcodeScanned')]
|
||||
/**
|
||||
* @summary Обрабатывает событие успешного сканирования штрих-кода.
|
||||
* @param barcode Обнаруженный штрих-код или QR-код.
|
||||
* @sideeffect Обновляет `uiState` до [ScanUiState.Success].
|
||||
* @precondition barcode не должен быть пустым.
|
||||
*/
|
||||
fun onBarcodeScanned(barcode: String) {
|
||||
require(barcode.isNotBlank()) { "Scanned barcode cannot be blank." }
|
||||
_uiState.value = ScanUiState.Success(barcode)
|
||||
Timber.i("[INFO][SCAN_EVENT][BARCODE_SCANNED] Barcode: %s. State -> Success.", barcode)
|
||||
}
|
||||
// [END_ENTITY: Function('onBarcodeScanned')]
|
||||
|
||||
// [ENTITY: Function('onError')]
|
||||
/**
|
||||
* @summary Обрабатывает событие ошибки сканирования.
|
||||
* @param message Сообщение об ошибке.
|
||||
* @sideeffect Обновляет `uiState` до [ScanUiState.Error].
|
||||
* @precondition message не должен быть пустым.
|
||||
*/
|
||||
fun onError(message: String) {
|
||||
require(message.isNotBlank()) { "Error message cannot be blank." }
|
||||
_uiState.value = ScanUiState.Error(message)
|
||||
Timber.e("[ERROR][SCAN_EVENT][SCAN_ERROR] Error: %s. State -> Error.", message)
|
||||
}
|
||||
// [END_ENTITY: Function('onError')]
|
||||
|
||||
// [ENTITY: Function('resetState')]
|
||||
/**
|
||||
* @summary Сбрасывает состояние UI к начальному (Idle).
|
||||
* @sideeffect Обновляет `uiState` до [ScanUiState.Idle].
|
||||
*/
|
||||
fun resetState() {
|
||||
_uiState.value = ScanUiState.Idle
|
||||
Timber.i("[INFO][SCAN_EVENT][STATE_RESET] State -> Idle.")
|
||||
}
|
||||
// [END_ENTITY: Function('resetState')]
|
||||
}
|
||||
// [END_ENTITY: Class('ScanViewModel')]
|
||||
|
||||
// [END_FILE_ScanViewModel.kt]
|
||||
Reference in New Issue
Block a user