diff --git a/app-android/src/main/java/dev/sdkforge/camera/android/MainActivity.kt b/app-android/src/main/java/dev/sdkforge/camera/android/MainActivity.kt index 6608be4..5f907ac 100644 --- a/app-android/src/main/java/dev/sdkforge/camera/android/MainActivity.kt +++ b/app-android/src/main/java/dev/sdkforge/camera/android/MainActivity.kt @@ -11,12 +11,16 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import dev.sdkforge.camera.app.App +import dev.sdkforge.camera.app.AppConstants import dev.sdkforge.camera.domain.CameraConfig import dev.sdkforge.camera.domain.Facing import dev.sdkforge.camera.domain.Format +import dev.sdkforge.camera.domain.ScanResult import dev.sdkforge.camera.ui.rememberCameraController class MainActivity : ComponentActivity() { @@ -52,16 +56,25 @@ class MainActivity : ComponentActivity() { cameraFacing = Facing.BACK, ), ) - + val scans = remember { mutableStateSetOf() } App( cameraController = cameraController, + scans = scans, modifier = Modifier .fillMaxSize(), ) LaunchedEffect(Unit) { cameraController.scannedResults.collect { scanResult -> - toast("${scanResult.format.name}; value = ${scanResult.value}") + if (scans.contains(scanResult)) { + toast("${AppConstants.SUCCESS_TITLE} - ${AppConstants.ALREADY_SCANNED_MESSAGE}") + } else { + if (scans.size == AppConstants.HISTORY_SCANS_MAX_LENGTH) { + scans.remove(scans.first()) + } + scans.add(scanResult) + toast("${AppConstants.SUCCESS_TITLE} - ${AppConstants.SCANNED_VALUE} ${scanResult.value}") + } } } diff --git a/app-shared/build.gradle.kts b/app-shared/build.gradle.kts index db18eb8..37c6fe3 100644 --- a/app-shared/build.gradle.kts +++ b/app-shared/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { api(compose.foundation) api(compose.material3) + api(compose.materialIconsExtended) } } } diff --git a/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/App.kt b/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/App.kt index 4398be9..6be61eb 100644 --- a/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/App.kt +++ b/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/App.kt @@ -5,21 +5,31 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import dev.sdkforge.camera.domain.ScanResult import dev.sdkforge.camera.ui.CameraController -import dev.sdkforge.camera.ui.CameraView @Composable fun App( cameraController: CameraController, + scans: Set, modifier: Modifier = Modifier, ) = ApplicationTheme { Surface( modifier = modifier, color = MaterialTheme.colorScheme.background, ) { - CameraView( + ScannerScreen( cameraController = cameraController, + scans = scans, modifier = Modifier.fillMaxSize(), ) } } + +object AppConstants { + const val HISTORY_SCANS_MAX_LENGTH = 5 + const val ERROR_TITLE = "Error" + const val SUCCESS_TITLE = "Success" + const val ALREADY_SCANNED_MESSAGE = "Already scanned, check scans history" + const val SCANNED_VALUE = "Scanned value:" +} diff --git a/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/ScannerScreen.kt b/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/ScannerScreen.kt new file mode 100644 index 0000000..02ce7b5 --- /dev/null +++ b/app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/ScannerScreen.kt @@ -0,0 +1,144 @@ +package dev.sdkforge.camera.app + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sdkforge.camera.domain.ScanResult +import dev.sdkforge.camera.ui.CameraController +import dev.sdkforge.camera.ui.CameraView + +@Composable +fun ScannerScreen( + cameraController: CameraController, + scans: Set, + modifier: Modifier = Modifier, +) { + var isHistoryDialogShown by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + CameraView( + cameraController = cameraController, + modifier = Modifier.fillMaxSize(), + ) + ButtonsOverlay( + controller = cameraController, + modifier = modifier.padding(top = innerPadding.calculateTopPadding()), + { isHistoryDialogShown = !isHistoryDialogShown }, + ) + ScansHistoryDialog( + showDialog = isHistoryDialogShown, + scans = scans, + ) { + isHistoryDialogShown = false + } + } +} + +@Composable +fun ButtonsOverlay( + controller: CameraController, + modifier: Modifier = Modifier, + onHistoryClicked: () -> Unit, +) { + var isFlashOn by remember { mutableStateOf(false) } + val targetIcon = if (isFlashOn) Icons.Default.FlashOff else Icons.Default.FlashOn + + Column { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Top, + modifier = modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = "Show scans history", + modifier = Modifier.clickable { + onHistoryClicked.invoke() + }, + ) + Icon( + imageVector = targetIcon, + contentDescription = "Flash toggle", + modifier = Modifier.clickable { + controller.toggleFlash() + isFlashOn = controller.isFlashIsOn() + }, + ) + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Flip between front and back active cameras", + modifier = Modifier.clickable { + controller.toggleActiveCamera() + }, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ScansHistoryDialog( + showDialog: Boolean, + scans: Set, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + if (showDialog) { + ModalBottomSheet( + onDismissRequest = { + onDismiss.invoke() + }, + sheetState = sheetState, + modifier = Modifier.fillMaxSize(), + ) { + if (scans.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Scans history is empty", + ) + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp).fillMaxWidth(), + ) { + itemsIndexed(items = scans.toList()) { index, item -> + Text( + text = "${index + 1}. ${item.value}", + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } + } + } +} diff --git a/app-shared/src/iosMain/kotlin/dev/sdkforge/camera/app/ComposeAppViewController.kt b/app-shared/src/iosMain/kotlin/dev/sdkforge/camera/app/ComposeAppViewController.kt index b1863f3..9e77798 100644 --- a/app-shared/src/iosMain/kotlin/dev/sdkforge/camera/app/ComposeAppViewController.kt +++ b/app-shared/src/iosMain/kotlin/dev/sdkforge/camera/app/ComposeAppViewController.kt @@ -2,12 +2,15 @@ package dev.sdkforge.camera.app import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.window.ComposeUIViewController import dev.sdkforge.camera.domain.CameraConfig import dev.sdkforge.camera.domain.Facing import dev.sdkforge.camera.domain.Format +import dev.sdkforge.camera.domain.ScanResult import dev.sdkforge.camera.ui.rememberCameraController import kotlin.experimental.ExperimentalObjCName import platform.AVFoundation.AVCaptureDevice @@ -37,18 +40,28 @@ fun ComposeAppViewController() = ComposeUIViewController( cameraFacing = Facing.BACK, ), ) - + val scans = remember { mutableStateSetOf() } App( cameraController = cameraController, + scans = scans, modifier = Modifier .fillMaxSize(), ) LaunchedEffect(Unit) { cameraController.scannedResults.collect { scanResult -> + val alertText = if (scans.contains(scanResult)) { + AppConstants.ERROR_TITLE to AppConstants.ALREADY_SCANNED_MESSAGE + } else { + if (scans.size == AppConstants.HISTORY_SCANS_MAX_LENGTH) { + scans.remove(scans.first()) + } + scans.add(scanResult) + AppConstants.SUCCESS_TITLE to "${AppConstants.SCANNED_VALUE} ${scanResult.value}" + } val alert = UIAlertController.alertControllerWithTitle( - title = "Scanned format: ${scanResult.format}", - message = "Scanned value: ${scanResult.value}", + title = alertText.first, + message = alertText.second, preferredStyle = UIAlertControllerStyleAlert, ).apply { addAction( diff --git a/shared-ui/src/androidMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.android.kt b/shared-ui/src/androidMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.android.kt index 8046e54..21c58e0 100644 --- a/shared-ui/src/androidMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.android.kt +++ b/shared-ui/src/androidMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.android.kt @@ -5,6 +5,7 @@ import android.graphics.Color import androidx.annotation.OptIn import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.TorchState import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat @@ -169,4 +170,22 @@ internal actual class PlatformCameraView( internal actual fun onRelease() { controller.unbind() } + + internal actual fun toggleFlash() { + val currentTorchState = controller.torchState.value + controller.enableTorch(currentTorchState != TorchState.ON) + } + + internal actual fun isFlashIsOn(): Boolean { + return controller.torchState.value == TorchState.ON + } + + internal actual fun toggleActiveCamera() { + val currentCameraSelector = controller.cameraSelector + controller.cameraSelector = when (currentCameraSelector) { + CameraSelector.DEFAULT_BACK_CAMERA -> CameraSelector.DEFAULT_FRONT_CAMERA + CameraSelector.DEFAULT_FRONT_CAMERA -> CameraSelector.DEFAULT_BACK_CAMERA + else -> CameraSelector.DEFAULT_BACK_CAMERA + } + } } diff --git a/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/CameraController.kt b/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/CameraController.kt index 696d47b..5828428 100644 --- a/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/CameraController.kt +++ b/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/CameraController.kt @@ -8,6 +8,7 @@ import dev.sdkforge.camera.domain.CameraConfig import dev.sdkforge.camera.domain.ScanResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow /** * Platform-specific implementation of the native camera controller. @@ -59,7 +60,7 @@ internal abstract class NativeCameraController( * This mutable shared flow is used internally to emit scan results * and can be shared with multiple collectors. */ - protected val initialScannedResults: MutableSharedFlow = MutableSharedFlow() + protected val initialScannedResults: MutableSharedFlow = MutableSharedFlow(replay = 1) /** * Public flow of scanned results. @@ -67,7 +68,7 @@ internal abstract class NativeCameraController( * This flow emits [ScanResult] objects whenever a barcode is successfully * scanned and decoded. */ - override val scannedResults: Flow = initialScannedResults + override val scannedResults: SharedFlow = initialScannedResults /** * The initial camera state implementation. @@ -138,6 +139,18 @@ internal abstract class NativeCameraController( override fun onRelease() { platformCameraView.onRelease() } + + override fun toggleFlash() { + platformCameraView.toggleFlash() + } + + override fun isFlashIsOn(): Boolean { + return platformCameraView.isFlashIsOn() + } + + override fun toggleActiveCamera() { + platformCameraView.toggleActiveCamera() + } } /** @@ -195,4 +208,23 @@ abstract class CameraController { * Implementations should properly clean up camera resources. */ internal abstract fun onRelease() + + /** + * Changes state of camera flash to opposite of current. + * + * Provides control of flash in torch mode only. + */ + abstract fun toggleFlash() + + /** + * Check for flash is currently on in torch mode. + */ + abstract fun isFlashIsOn(): Boolean + + /** + * Changes what camera is active at the moment. + * + * Provides control of what camera, frontal or back, is currently active. + */ + abstract fun toggleActiveCamera() } diff --git a/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.kt b/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.kt index c301775..40ef1c7 100644 --- a/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.kt +++ b/shared-ui/src/commonMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.kt @@ -46,4 +46,23 @@ internal expect class PlatformCameraView { * properly clean up camera resources and stop the preview. */ internal fun onRelease() + + /** + * Changes state of camera flash to opposite of current. + * + * Provides control of flash in torch mode only. + */ + internal fun toggleFlash() + + /** + * Check for flash is currently on in torch mode. + */ + internal fun isFlashIsOn(): Boolean + + /** + * Changes what camera is active at the moment. + * + * Provides control of what camera, frontal or back, is currently active. + */ + internal fun toggleActiveCamera() } diff --git a/shared-ui/src/iosMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.ios.kt b/shared-ui/src/iosMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.ios.kt index ca1fd4a..4e7019f 100644 --- a/shared-ui/src/iosMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.ios.kt +++ b/shared-ui/src/iosMain/kotlin/dev/sdkforge/camera/ui/PlatformCameraView.ios.kt @@ -11,9 +11,13 @@ import platform.AVFoundation.AVCaptureAutoFocusRangeRestrictionNear import platform.AVFoundation.AVCaptureConnection import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVCaptureDeviceInput +import platform.AVFoundation.AVCaptureDevicePosition import platform.AVFoundation.AVCaptureDevicePositionBack import platform.AVFoundation.AVCaptureDevicePositionFront +import platform.AVFoundation.AVCaptureDevicePositionUnspecified +import platform.AVFoundation.AVCaptureDeviceType import platform.AVFoundation.AVCaptureDeviceTypeBuiltInTripleCamera +import platform.AVFoundation.AVCaptureInput import platform.AVFoundation.AVCaptureMetadataOutput import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol import platform.AVFoundation.AVCaptureOutput @@ -30,7 +34,10 @@ import platform.AVFoundation.automaticallyEnablesLowLightBoostWhenAvailable import platform.AVFoundation.defaultDeviceWithDeviceType import platform.AVFoundation.isAutoFocusRangeRestrictionSupported import platform.AVFoundation.isLowLightBoostSupported +import platform.AVFoundation.isTorchAvailable import platform.AVFoundation.isTorchModeSupported +import platform.AVFoundation.position +import platform.AVFoundation.setFlashMode import platform.AVFoundation.torchMode import platform.CoreGraphics.CGRectZero import platform.QuartzCore.CALayer @@ -163,7 +170,6 @@ internal actual class PlatformCameraView( preferFrontCamera: Boolean, ): AVCaptureDevice? { val preferredPosition = if (preferFrontCamera) AVCaptureDevicePositionFront else AVCaptureDevicePositionBack - return AVCaptureDevice.defaultDeviceWithDeviceType( deviceType = AVCaptureDeviceTypeBuiltInTripleCamera, mediaType = AVMediaTypeVideo, @@ -265,4 +271,61 @@ internal actual class PlatformCameraView( internal actual fun onRelease() { captureSession.stopRunning() } + + /** + * Changes state of camera flash to opposite of current. + * + * Provides control of flash in torch mode only. + */ + internal actual fun toggleFlash() { + val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) + if (device?.isTorchAvailable() == true) { + val targetMode = when (device.torchMode) { + AVCaptureTorchModeOn -> AVCaptureTorchModeOff + AVCaptureTorchModeOff -> AVCaptureTorchModeOn + else -> AVCaptureTorchModeAuto + } + device.setFlashMode(targetMode) + } + } + + /** + * Check for flash is currently on in torch mode. + */ + internal actual fun isFlashIsOn(): Boolean { + val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) + var isFlashOn = false + if (device?.isTorchAvailable() == true) { + isFlashOn = device.torchMode == AVCaptureTorchModeOn + } + return isFlashOn + } + + /** + * Changes what camera is active at the moment. + * + * Provides control of what camera, frontal or back, is currently active. + */ + internal actual fun toggleActiveCamera() { + val frontCameraDeviceInput = AVCaptureDevice.defaultDeviceWithDeviceType( + AVCaptureDeviceTypeBuiltInTripleCamera, + AVMediaTypeVideo, + AVCaptureDevicePositionFront + ) as? AVCaptureDeviceInput + val backCameraDeviceInput = AVCaptureDevice.defaultDeviceWithDeviceType( + AVCaptureDeviceTypeBuiltInTripleCamera, + AVMediaTypeVideo, + AVCaptureDevicePositionBack + ) as? AVCaptureDeviceInput + + captureSession.beginConfiguration() + if (captureSession.inputs.contains(frontCameraDeviceInput)) { + frontCameraDeviceInput?.let { captureSession.removeInput(it) } + backCameraDeviceInput?.let { captureSession.addInput(it) } + } else if (captureSession.inputs.contains(backCameraDeviceInput)) { + backCameraDeviceInput?.let { captureSession.removeInput(it) } + frontCameraDeviceInput?.let { captureSession.addInput(it) } + } + captureSession.commitConfiguration() + } }