This commit is contained in:
2025-09-26 10:30:59 +03:00
parent aa69776807
commit 394e0040de
82 changed files with 5324 additions and 1998 deletions

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

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]