Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -52,16 +56,25 @@ class MainActivity : ComponentActivity() {
cameraFacing = Facing.BACK,
),
)

val scans = remember { mutableStateSetOf<ScanResult>() }
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}")
}
}
}

Expand Down
1 change: 1 addition & 0 deletions app-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ kotlin {

api(compose.foundation)
api(compose.material3)
api(compose.materialIconsExtended)
}
}
}
Expand Down
14 changes: 12 additions & 2 deletions app-shared/src/commonMain/kotlin/dev/sdkforge/camera/app/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScanResult>,
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:"
}
Original file line number Diff line number Diff line change
@@ -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<ScanResult>,
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<ScanResult>,
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),
)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,18 +40,28 @@ fun ComposeAppViewController() = ComposeUIViewController(
cameraFacing = Facing.BACK,
),
)

val scans = remember { mutableStateSetOf<ScanResult>() }
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -59,15 +60,15 @@ 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<ScanResult> = MutableSharedFlow()
protected val initialScannedResults: MutableSharedFlow<ScanResult> = MutableSharedFlow(replay = 1)

/**
* Public flow of scanned results.
*
* This flow emits [ScanResult] objects whenever a barcode is successfully
* scanned and decoded.
*/
override val scannedResults: Flow<ScanResult> = initialScannedResults
override val scannedResults: SharedFlow<ScanResult> = initialScannedResults

/**
* The initial camera state implementation.
Expand Down Expand Up @@ -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()
}
}

/**
Expand Down Expand Up @@ -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()
}
Loading
Loading