diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 380d1de..3efd055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] -agp = "9.1.0" -kotlin = "2.3.20" -compose = "1.10.2" -kotlinx-coroutines = "1.10.2" +agp = "9.1.1" +kotlin = "2.3.21" +compose = "1.10.3" +kotlinx-coroutines = "1.11.0" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" androidx-activityCompose = "1.13.0" androidx-appcompat = "1.7.1" androidx-core-ktx = "1.18.0" -androidx-lifecycle = "2.9.6" -androidx-camera = "1.5.3" -dokka = "2.1.0" +androidx-lifecycle = "2.10.0" +androidx-camera = "1.6.1" +dokka = "2.2.0" klint = "14.2.0" maven-publish = "0.36.0" mlkitBarcodeScanning-android = "17.3.0" @@ -19,8 +19,9 @@ moko = "0.20.1" zxing = "3.5.4" material3 = "1.10.0-alpha05" material-icons = "1.7.3" -javacv = "1.5.10" -opencv = "4.9.0-1.5.10" +imagepickerkmp = "1.0.41" +javacv = "1.5.13" +opencv = "4.13.0-1.5.13" [libraries] androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } @@ -53,6 +54,7 @@ moko-permissions-compose = { module = "dev.icerock.moko:permissions-compose", ve ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } zxing-javase = { module = "com.google.zxing:javase", version.ref = "zxing" } +imagepickerkmp = { module = "io.github.ismoy:imagepickerkmp", version.ref = "imagepickerkmp" } javacv = { module = "org.bytedeco:javacv", version.ref = "javacv" } opencv-platform = { module = "org.bytedeco:opencv-platform", version.ref = "opencv" } diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..5f4567d --- /dev/null +++ b/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== diff --git a/kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt b/kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt new file mode 100644 index 0000000..97616a5 --- /dev/null +++ b/kscan/src/androidMain/kotlin/org/ncgroup/kscan/ImageScanner.android.kt @@ -0,0 +1,79 @@ +package org.ncgroup.kscan + +import android.graphics.BitmapFactory +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage + +actual fun scanImage( + imageBytes: ByteArray, + codeTypes: List, + filter: (Barcode) -> Boolean, + result: (BarcodeResult) -> Unit, +) { + val bitmap = try { + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + } catch (e: Exception) { + result(BarcodeResult.OnFailed(Exception("Failed to decode image: ${e.message}"))) + return + } + + if (bitmap == null) { + result(BarcodeResult.OnFailed(Exception("Failed to decode image bytes"))) + return + } + + val inputImage = InputImage.fromBitmap(bitmap, 0) + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(BarcodeFormatMapper.toMlKitFormats(codeTypes)) + .build() + + val scanner = BarcodeScanning.getClient(options) + + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + val hasAllFormats = codeTypes.isEmpty() || codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS) + + val matchingBarcode = barcodes.firstOrNull { mlKitBarcode -> + val isFormatMatch = if (hasAllFormats) { + BarcodeFormatMapper.isKnownFormat(mlKitBarcode.format) + } else { + val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format) + codeTypes.contains(appFormat) + } + + if (!isFormatMatch) return@firstOrNull false + + val displayValue = mlKitBarcode.displayValue ?: return@firstOrNull false + val rawBytes = mlKitBarcode.rawBytes ?: displayValue.encodeToByteArray() + val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format) + + val barcode = Barcode( + data = displayValue, + format = appFormat.toString(), + rawBytes = rawBytes, + ) + + filter(barcode) + } + + if (matchingBarcode != null) { + val displayValue = matchingBarcode.displayValue!! + val rawBytes = matchingBarcode.rawBytes ?: displayValue.encodeToByteArray() + val appFormat = BarcodeFormatMapper.toAppFormat(matchingBarcode.format) + + val barcode = Barcode( + data = displayValue, + format = appFormat.toString(), + rawBytes = rawBytes, + ) + result(BarcodeResult.OnSuccess(barcode)) + } else { + result(BarcodeResult.OnFailed(Exception("No barcode found in image"))) + } + } + .addOnFailureListener { exception -> + result(BarcodeResult.OnFailed(exception)) + } +} diff --git a/kscan/src/commonMain/kotlin/org/ncgroup/kscan/ImageScanner.kt b/kscan/src/commonMain/kotlin/org/ncgroup/kscan/ImageScanner.kt new file mode 100644 index 0000000..859a2af --- /dev/null +++ b/kscan/src/commonMain/kotlin/org/ncgroup/kscan/ImageScanner.kt @@ -0,0 +1,34 @@ +package org.ncgroup.kscan + +/** + * Scans a barcode from an image provided as a byte array. + * + * This function allows scanning barcodes from static images (e.g., from gallery, + * screenshots, or downloaded images) rather than from a live camera feed. + * + * @param imageBytes The image data as a byte array (e.g., PNG, JPEG). + * @param codeTypes The barcode formats to scan for. Defaults to all formats. + * @param filter Optional filter to accept or reject detected barcodes. + * @param result Callback invoked with the scan result. + * + * Example usage: + * ```kotlin + * val imageBytes = selectedImage.readBytes() + * scanImage( + * imageBytes = imageBytes, + * codeTypes = listOf(BarcodeFormat.FORMAT_QR_CODE) + * ) { result -> + * when (result) { + * is BarcodeResult.OnSuccess -> println(result.barcode.data) + * is BarcodeResult.OnFailed -> println(result.exception.message) + * is BarcodeResult.OnCanceled -> { /* not applicable */ } + * } + * } + * ``` + */ +expect fun scanImage( + imageBytes: ByteArray, + codeTypes: List = listOf(BarcodeFormat.FORMAT_ALL_FORMATS), + filter: (Barcode) -> Boolean = { true }, + result: (BarcodeResult) -> Unit, +) diff --git a/kscan/src/iosMain/kotlin/org/ncgroup/kscan/ImageScanner.ios.kt b/kscan/src/iosMain/kotlin/org/ncgroup/kscan/ImageScanner.ios.kt new file mode 100644 index 0000000..c0c37ef --- /dev/null +++ b/kscan/src/iosMain/kotlin/org/ncgroup/kscan/ImageScanner.ios.kt @@ -0,0 +1,165 @@ +package org.ncgroup.kscan + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGImageRef +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.create +import platform.UIKit.UIImage +import platform.Vision.VNBarcodeObservation +import platform.Vision.VNBarcodeSymbologyAztec +import platform.Vision.VNBarcodeSymbologyCode128 +import platform.Vision.VNBarcodeSymbologyCode39 +import platform.Vision.VNBarcodeSymbologyCode93 +import platform.Vision.VNBarcodeSymbologyDataMatrix +import platform.Vision.VNBarcodeSymbologyEAN13 +import platform.Vision.VNBarcodeSymbologyEAN8 +import platform.Vision.VNBarcodeSymbologyPDF417 +import platform.Vision.VNBarcodeSymbologyQR +import platform.Vision.VNBarcodeSymbologyUPCE +import platform.Vision.VNDetectBarcodesRequest +import platform.Vision.VNImageRequestHandler +import platform.Vision.VNRequest + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +actual fun scanImage( + imageBytes: ByteArray, + codeTypes: List, + filter: (Barcode) -> Boolean, + result: (BarcodeResult) -> Unit, +) { + val nsData = imageBytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = imageBytes.size.toULong()) + } + + val uiImage = UIImage.imageWithData(nsData) + if (uiImage == null) { + result(BarcodeResult.OnFailed(Exception("Failed to decode image bytes"))) + return + } + + val cgImage: CGImageRef = uiImage.CGImage ?: run { + result(BarcodeResult.OnFailed(Exception("Failed to get CGImage from UIImage"))) + return + } + + val handler = VNImageRequestHandler(cgImage, mapOf()) + + val request = VNDetectBarcodesRequest { request: VNRequest?, error: NSError? -> + if (error != null) { + result(BarcodeResult.OnFailed(Exception(error.localizedDescription))) + return@VNDetectBarcodesRequest + } + + val observations = request?.results?.filterIsInstance() + if (observations.isNullOrEmpty()) { + result(BarcodeResult.OnFailed(Exception("No barcode found in image"))) + return@VNDetectBarcodesRequest + } + + val hasAllFormats = codeTypes.isEmpty() || codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS) + + val matchingObservation = observations.firstOrNull { observation -> + val symbology = observation.symbology ?: return@firstOrNull false + val appFormat = symbologyToAppFormat(symbology) + + val isFormatMatch = if (hasAllFormats) { + appFormat != BarcodeFormat.TYPE_UNKNOWN + } else { + codeTypes.contains(appFormat) + } + + if (!isFormatMatch) return@firstOrNull false + + val payloadString = observation.payloadStringValue ?: return@firstOrNull false + val rawBytes = payloadString.encodeToByteArray() + + val barcode = Barcode( + data = payloadString, + format = appFormat.toString(), + rawBytes = rawBytes, + ) + + filter(barcode) + } + + if (matchingObservation != null) { + val payloadString = matchingObservation.payloadStringValue!! + val appFormat = symbologyToAppFormat(matchingObservation.symbology ?: "") + val rawBytes = payloadString.encodeToByteArray() + + val barcode = Barcode( + data = payloadString, + format = appFormat.toString(), + rawBytes = rawBytes, + ) + result(BarcodeResult.OnSuccess(barcode)) + } else { + result(BarcodeResult.OnFailed(Exception("No matching barcode found in image"))) + } + } + + // Set symbologies to detect + val symbologies = toVisionSymbologies(codeTypes) + if (symbologies.isNotEmpty()) { + request.setSymbologies(symbologies) + } + + try { + handler.performRequests(listOf(request), null) + } catch (e: Exception) { + result(BarcodeResult.OnFailed(Exception("Failed to perform barcode detection: ${e.message}"))) + } +} + +private fun symbologyToAppFormat(symbology: String): BarcodeFormat { + return when (symbology) { + VNBarcodeSymbologyQR -> BarcodeFormat.FORMAT_QR_CODE + VNBarcodeSymbologyEAN13 -> BarcodeFormat.FORMAT_EAN_13 + VNBarcodeSymbologyEAN8 -> BarcodeFormat.FORMAT_EAN_8 + VNBarcodeSymbologyCode128 -> BarcodeFormat.FORMAT_CODE_128 + VNBarcodeSymbologyCode39 -> BarcodeFormat.FORMAT_CODE_39 + VNBarcodeSymbologyCode93 -> BarcodeFormat.FORMAT_CODE_93 + VNBarcodeSymbologyUPCE -> BarcodeFormat.FORMAT_UPC_E + VNBarcodeSymbologyPDF417 -> BarcodeFormat.FORMAT_PDF417 + VNBarcodeSymbologyAztec -> BarcodeFormat.FORMAT_AZTEC + VNBarcodeSymbologyDataMatrix -> BarcodeFormat.FORMAT_DATA_MATRIX + else -> BarcodeFormat.TYPE_UNKNOWN + } +} + +private fun toVisionSymbologies(appFormats: List): List { + if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) { + return listOfNotNull( + VNBarcodeSymbologyQR, + VNBarcodeSymbologyEAN13, + VNBarcodeSymbologyEAN8, + VNBarcodeSymbologyCode128, + VNBarcodeSymbologyCode39, + VNBarcodeSymbologyCode93, + VNBarcodeSymbologyUPCE, + VNBarcodeSymbologyPDF417, + VNBarcodeSymbologyAztec, + VNBarcodeSymbologyDataMatrix, + ) + } + + return appFormats.mapNotNull { format -> + when (format) { + BarcodeFormat.FORMAT_QR_CODE -> VNBarcodeSymbologyQR + BarcodeFormat.FORMAT_EAN_13 -> VNBarcodeSymbologyEAN13 + BarcodeFormat.FORMAT_EAN_8 -> VNBarcodeSymbologyEAN8 + BarcodeFormat.FORMAT_CODE_128 -> VNBarcodeSymbologyCode128 + BarcodeFormat.FORMAT_CODE_39 -> VNBarcodeSymbologyCode39 + BarcodeFormat.FORMAT_CODE_93 -> VNBarcodeSymbologyCode93 + BarcodeFormat.FORMAT_UPC_E -> VNBarcodeSymbologyUPCE + BarcodeFormat.FORMAT_PDF417 -> VNBarcodeSymbologyPDF417 + BarcodeFormat.FORMAT_AZTEC -> VNBarcodeSymbologyAztec + BarcodeFormat.FORMAT_DATA_MATRIX -> VNBarcodeSymbologyDataMatrix + else -> null + } + } +} diff --git a/kscan/src/jvmMain/kotlin/org/ncgroup/kscan/ImageScanner.jvm.kt b/kscan/src/jvmMain/kotlin/org/ncgroup/kscan/ImageScanner.jvm.kt new file mode 100644 index 0000000..d2f3b88 --- /dev/null +++ b/kscan/src/jvmMain/kotlin/org/ncgroup/kscan/ImageScanner.jvm.kt @@ -0,0 +1,105 @@ +package org.ncgroup.kscan + +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.ResultMetadataType +import com.google.zxing.common.HybridBinarizer +import java.io.ByteArrayInputStream +import java.util.EnumMap +import javax.imageio.ImageIO + +actual fun scanImage( + imageBytes: ByteArray, + codeTypes: List, + filter: (Barcode) -> Boolean, + result: (BarcodeResult) -> Unit, +) { + try { + val inputStream = ByteArrayInputStream(imageBytes) + val bufferedImage = ImageIO.read(inputStream) + + if (bufferedImage == null) { + result(BarcodeResult.OnFailed(Exception("Failed to decode image bytes"))) + return + } + + val width = bufferedImage.width + val height = bufferedImage.height + val pixels = IntArray(width * height) + bufferedImage.getRGB(0, 0, width, height, pixels, 0, width) + + val luminances = ByteArray(width * height) + for (i in pixels.indices) { + val pixel = pixels[i] + val r = (pixel shr 16) and 0xff + val g = (pixel shr 8) and 0xff + val b = pixel and 0xff + luminances[i] = ((r + (g shl 1) + b) shr 2).toByte() + } + + val source = BufferLuminanceSource(luminances, width, height) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = MultiFormatReader() + val hints: MutableMap = EnumMap(DecodeHintType::class.java) + + val hasAllFormats = codeTypes.isEmpty() || codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS) + if (!hasAllFormats) { + val formats = codeTypes.mapNotNull { it.toZxingFormat() } + if (formats.isNotEmpty()) { + hints[DecodeHintType.POSSIBLE_FORMATS] = formats + } + } + hints[DecodeHintType.CHARACTER_SET] = "ISO-8859-1" + hints[DecodeHintType.TRY_HARDER] = true + + reader.setHints(hints) + + val zxingResult = try { + reader.decode(binaryBitmap) + } catch (e: NotFoundException) { + result(BarcodeResult.OnFailed(Exception("No barcode found in image"))) + return + } + + val rawBytes = if (zxingResult.resultMetadata?.containsKey(ResultMetadataType.BYTE_SEGMENTS) == true) { + @Suppress("UNCHECKED_CAST") + val byteSegments = zxingResult.resultMetadata[ResultMetadataType.BYTE_SEGMENTS] as? List + byteSegments?.firstOrNull() ?: zxingResult.text.toByteArray(Charsets.ISO_8859_1) + } else { + zxingResult.text.toByteArray(Charsets.ISO_8859_1) + } + + val barcode = Barcode( + data = zxingResult.text, + format = zxingResult.barcodeFormat.toKScanFormat().toString(), + rawBytes = rawBytes, + ) + + if (filter(barcode)) { + result(BarcodeResult.OnSuccess(barcode)) + } else { + result(BarcodeResult.OnFailed(Exception("Barcode filtered out"))) + } + } catch (e: Exception) { + result(BarcodeResult.OnFailed(e)) + } +} + +private class BufferLuminanceSource( + private val luminances: ByteArray, + width: Int, + height: Int, +) : com.google.zxing.LuminanceSource(width, height) { + + override fun getRow(y: Int, row: ByteArray?): ByteArray { + val width = width + val res = if (row == null || row.size < width) ByteArray(width) else row + System.arraycopy(luminances, y * width, res, 0, width) + return res + } + + override fun getMatrix(): ByteArray = luminances +} diff --git a/kscan/src/wasmJsMain/kotlin/org/ncgroup/kscan/ImageScanner.wasmJs.kt b/kscan/src/wasmJsMain/kotlin/org/ncgroup/kscan/ImageScanner.wasmJs.kt new file mode 100644 index 0000000..592be2d --- /dev/null +++ b/kscan/src/wasmJsMain/kotlin/org/ncgroup/kscan/ImageScanner.wasmJs.kt @@ -0,0 +1,10 @@ +package org.ncgroup.kscan + +actual fun scanImage( + imageBytes: ByteArray, + codeTypes: List, + filter: (Barcode) -> Boolean, + result: (BarcodeResult) -> Unit, +) { + result(BarcodeResult.OnFailed(Exception("Image scanning is not yet supported on WASM"))) +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index ea0aab5..b25bb95 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.imagepickerkmp) api(project(":kscan")) } androidMain.dependencies { diff --git a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/App.kt b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/App.kt index 0a883b9..200cf57 100644 --- a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/App.kt +++ b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/App.kt @@ -1,6 +1,36 @@ package org.ncgroup.kscan import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +enum class ScannerMode { + Default, + Custom, + Image, +} + +class ScannerModeState( + initial: ScannerMode = ScannerMode.Default +) { + var mode by mutableStateOf(initial) +} + +val LocalScannerModeState = compositionLocalOf { ScannerModeState() } @Composable -fun App() = CustomUI() +fun App() { + val scannerModeState = remember { ScannerModeState() } + + CompositionLocalProvider(LocalScannerModeState provides scannerModeState) { + when (scannerModeState.mode) { + ScannerMode.Default -> DefaultUI() + ScannerMode.Custom -> CustomUI() + ScannerMode.Image -> ImageScannerUI() + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/CustomUI.kt b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/CustomUI.kt index 2e456e1..86f85ea 100644 --- a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/CustomUI.kt +++ b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/CustomUI.kt @@ -19,24 +19,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable -fun CustomUI(modifier: Modifier = Modifier) { +fun CustomUI() { var showScanner by remember { mutableStateOf(false) } var barcode by remember { mutableStateOf("") } var format by remember { mutableStateOf("") } val scannerController = remember { ScannerController() } - Scaffold { padding -> - Box( - modifier = Modifier.fillMaxSize().padding(padding), - ) { + Scaffold( + topBar = { + if (!showScanner) { + ModeSelector() + } + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .fillMaxSize() + .padding(padding), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), ) { - Text(text = barcode) - Text(text = format) + if (barcode.isNotEmpty()) { + Text(text = "Data: $barcode") + Text(text = "Format: $format") + } Button( onClick = { showScanner = true }, ) { @@ -46,10 +54,7 @@ fun CustomUI(modifier: Modifier = Modifier) { if (showScanner) { ScannerView( - codeTypes = - listOf( - BarcodeFormat.FORMAT_ALL_FORMATS, - ), + codeTypes = listOf(BarcodeFormat.FORMAT_ALL_FORMATS), scannerUiOptions = null, scannerController = scannerController, ) { result -> @@ -68,12 +73,11 @@ fun CustomUI(modifier: Modifier = Modifier) { } } } - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = - modifier - .padding(bottom = 24.dp) - .align(Alignment.BottomCenter), + modifier = Modifier + .padding(bottom = 24.dp) + .align(Alignment.BottomCenter), horizontalAlignment = Alignment.CenterHorizontally, ) { Button( diff --git a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/DefaultUI.kt b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/DefaultUI.kt index 60f9083..c6fa897 100644 --- a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/DefaultUI.kt +++ b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/DefaultUI.kt @@ -23,17 +23,25 @@ fun DefaultUI() { var barcode by remember { mutableStateOf("") } var format by remember { mutableStateOf("") } - Scaffold { padding -> - Box( - modifier = Modifier.fillMaxSize().padding(padding), - ) { + Scaffold( + topBar = { + if (!showScanner) { + ModeSelector() + } + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .fillMaxSize() + .padding(padding), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), ) { - Text(text = barcode) - Text(text = format) + if (barcode.isNotEmpty()) { + Text(text = "Data: $barcode") + Text(text = "Format: $format") + } Button( onClick = { showScanner = true }, ) { @@ -43,10 +51,7 @@ fun DefaultUI() { if (showScanner) { ScannerView( - codeTypes = - listOf( - BarcodeFormat.FORMAT_ALL_FORMATS, - ), + codeTypes = listOf(BarcodeFormat.FORMAT_ALL_FORMATS), ) { result -> when (result) { is BarcodeResult.OnSuccess -> { diff --git a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ImageScannerUI.kt b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ImageScannerUI.kt new file mode 100644 index 0000000..c6aeb7d --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ImageScannerUI.kt @@ -0,0 +1,137 @@ +package org.ncgroup.kscan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.ismoy.imagepickerkmp.domain.extensions.loadBytes +import io.github.ismoy.imagepickerkmp.features.imagepicker.config.ImagePickerKMPConfig +import io.github.ismoy.imagepickerkmp.features.imagepicker.model.ImagePickerResult +import io.github.ismoy.imagepickerkmp.features.imagepicker.ui.rememberImagePickerKMP + +@Composable +fun ImageScannerUI(modifier: Modifier = Modifier) { + var barcode by remember { mutableStateOf("") } + var format by remember { mutableStateOf("") } + var isScanning by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + val picker = rememberImagePickerKMP( + config = ImagePickerKMPConfig() + ) + val pickerResult = picker.result + + LaunchedEffect(pickerResult) { + when (pickerResult) { + is ImagePickerResult.Success -> { + val photo = pickerResult.photos.firstOrNull() + if (photo != null) { + isScanning = true + errorMessage = "" + barcode = "" + format = "" + + val imageBytes = photo.loadBytes() + scanImage( + imageBytes = imageBytes, + codeTypes = listOf(BarcodeFormat.FORMAT_ALL_FORMATS), + ) { result -> + isScanning = false + when (result) { + is BarcodeResult.OnSuccess -> { + barcode = result.barcode.data + format = result.barcode.format + } + is BarcodeResult.OnFailed -> { + errorMessage = "Error: ${result.exception.message}" + } + BarcodeResult.OnCanceled -> { + // Not applicable for image scanning + } + } + } + } + picker.reset() + } + is ImagePickerResult.Error -> { + errorMessage = "Error: ${pickerResult.exception.message}" + picker.reset() + } + is ImagePickerResult.Dismissed -> { + picker.reset() + } + else -> {} + } + } + + Scaffold( + topBar = { + ModeSelector() + } + ) { padding -> + Box( + modifier = modifier.fillMaxSize().padding(padding), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (barcode.isNotEmpty()) { + Text(text = "Data: $barcode") + Text(text = "Format: $format") + } + + if (errorMessage.isNotEmpty()) { + Text(text = errorMessage, color = Color.Red) + } + + when (pickerResult) { + is ImagePickerResult.Loading -> { + CircularProgressIndicator() + Text(text = "Loading image...") + } + else -> { + if (isScanning) { + CircularProgressIndicator() + Text(text = "Scanning...") + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { picker.launchCamera() }, + enabled = !isScanning && pickerResult !is ImagePickerResult.Loading, + ) { + Text(text = "Camera") + } + Button( + onClick = { picker.launchGallery() }, + enabled = !isScanning && pickerResult !is ImagePickerResult.Loading, + ) { + Text(text = "Gallery") + } + } + } + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ModeSelector.kt b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ModeSelector.kt new file mode 100644 index 0000000..4f491dc --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/org/ncgroup/kscan/ModeSelector.kt @@ -0,0 +1,44 @@ +package org.ncgroup.kscan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ModeSelector(modifier: Modifier = Modifier) { + val modeState = LocalScannerModeState.current + val statusBarPadding = WindowInsets.statusBars.asPaddingValues() + + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = statusBarPadding.calculateTopPadding() + 16.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + FilterChip( + selected = modeState.mode == ScannerMode.Default, + onClick = { modeState.mode = ScannerMode.Default }, + label = { Text("Default") }, + ) + FilterChip( + selected = modeState.mode == ScannerMode.Custom, + onClick = { modeState.mode = ScannerMode.Custom }, + label = { Text("Custom") }, + ) + FilterChip( + selected = modeState.mode == ScannerMode.Image, + onClick = { modeState.mode = ScannerMode.Image }, + label = { Text("Image") }, + ) + } +} diff --git a/tools/ktlint b/tools/ktlint deleted file mode 100755 index ff7e4e8..0000000 Binary files a/tools/ktlint and /dev/null differ