splitting large files into submodules
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.clean.scanner.ui.screens
|
||||
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
|
||||
internal fun Int.toHumanType(): String = when (this) {
|
||||
Barcode.TYPE_CONTACT_INFO -> "Contact"
|
||||
Barcode.TYPE_URL -> "URL"
|
||||
Barcode.TYPE_WIFI -> "WiFi"
|
||||
Barcode.TYPE_PHONE -> "Phone"
|
||||
Barcode.TYPE_SMS -> "SMS"
|
||||
Barcode.TYPE_EMAIL -> "Email"
|
||||
Barcode.TYPE_CALENDAR_EVENT -> "Calendar"
|
||||
Barcode.TYPE_ISBN -> "ISBN"
|
||||
Barcode.TYPE_PRODUCT -> "Product"
|
||||
Barcode.TYPE_TEXT -> "Text"
|
||||
Barcode.TYPE_GEO -> "Geo"
|
||||
else -> "Unknown"
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package com.clean.scanner.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clean.scanner.R
|
||||
import com.clean.scanner.data.scanner.DetectionBox
|
||||
import com.clean.scanner.data.scanner.DetectionPoint
|
||||
import com.clean.scanner.domain.ScanResult
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} else {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
BitmapFactory.decodeStream(stream)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun detectBarcodes(
|
||||
scanner: BarcodeScanner,
|
||||
image: InputImage
|
||||
): List<Barcode> = suspendCancellableCoroutine { cont ->
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (cont.isActive) cont.resume(barcodes)
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
if (cont.isActive) cont.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GalleryScanPreviewDialog(
|
||||
imageUri: Uri?,
|
||||
candidates: List<GalleryScanCandidate>,
|
||||
onPick: (GalleryScanCandidate) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } }
|
||||
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
|
||||
var zoom by remember(imageUri) { mutableFloatStateOf(1f) }
|
||||
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
|
||||
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var scanTick by remember { mutableIntStateOf(0) }
|
||||
val markerPaint = remember {
|
||||
Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 34f
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
}
|
||||
}
|
||||
val scanner = remember {
|
||||
BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
|
||||
.enableAllPotentialBarcodes()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { scanner.close() }
|
||||
}
|
||||
|
||||
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
|
||||
val bmp = bitmap ?: return@LaunchedEffect
|
||||
val vw = viewportSize.width.toFloat()
|
||||
val vh = viewportSize.height.toFloat()
|
||||
if (vw <= 0f || vh <= 0f) return@LaunchedEffect
|
||||
|
||||
delay(120)
|
||||
|
||||
val imgW = bmp.width.toFloat()
|
||||
val imgH = bmp.height.toFloat()
|
||||
if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect
|
||||
|
||||
val baseScale = max(vw / imgW, vh / imgH)
|
||||
val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f)
|
||||
val cx = vw * 0.5f
|
||||
val cy = vh * 0.5f
|
||||
|
||||
fun screenToImageX(screenX: Float): Float {
|
||||
return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f)
|
||||
}
|
||||
fun screenToImageY(screenY: Float): Float {
|
||||
return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f)
|
||||
}
|
||||
|
||||
val left = screenToImageX(0f).coerceIn(0f, imgW - 1f)
|
||||
val right = screenToImageX(vw).coerceIn(0f, imgW - 1f)
|
||||
val top = screenToImageY(0f).coerceIn(0f, imgH - 1f)
|
||||
val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f)
|
||||
|
||||
val cropLeft = minOf(left, right).toInt()
|
||||
val cropTop = minOf(top, bottom).toInt()
|
||||
val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8)
|
||||
val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8)
|
||||
val boundedW = cropW.coerceAtMost(bmp.width - cropLeft)
|
||||
val boundedH = cropH.coerceAtMost(bmp.height - cropTop)
|
||||
if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect
|
||||
|
||||
val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH)
|
||||
val barcodes = try {
|
||||
detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0))
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val live = barcodes.mapNotNull { barcode ->
|
||||
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val normalizedBox = barcode.boundingBox?.let { bounds ->
|
||||
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
|
||||
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f)
|
||||
val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
val corners = barcode.cornerPoints?.map { p ->
|
||||
DetectionPoint(
|
||||
x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f),
|
||||
y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
)
|
||||
} ?: emptyList()
|
||||
DetectionBox(leftN, topN, rightN, bottomN, corners)
|
||||
}
|
||||
GalleryScanCandidate(
|
||||
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
|
||||
box = normalizedBox
|
||||
)
|
||||
}.distinctBy { "${it.result.type}|${it.result.content}" }
|
||||
|
||||
liveCandidates = live
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (bitmap != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.onSizeChanged {
|
||||
viewportSize = it
|
||||
scanTick++
|
||||
}
|
||||
.pointerInput(bitmap) {
|
||||
detectTransformGestures { _, panChange, zoomChange, _ ->
|
||||
zoom = (zoom * zoomChange).coerceIn(1f, 6f)
|
||||
pan += panChange
|
||||
scanTick++
|
||||
}
|
||||
}
|
||||
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.scan_from_image),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = zoom
|
||||
scaleY = zoom
|
||||
translationX = pan.x
|
||||
translationY = pan.y
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
) {
|
||||
val imageW = bitmap.width.toFloat()
|
||||
val imageH = bitmap.height.toFloat()
|
||||
if (imageW <= 0f || imageH <= 0f) return@Canvas
|
||||
|
||||
val baseScale = max(size.width / imageW, size.height / imageH)
|
||||
val effectiveScale = baseScale * zoom
|
||||
val cx = size.width * 0.5f
|
||||
val cy = size.height * 0.5f
|
||||
|
||||
fun imageToScreen(ix: Float, iy: Float): Offset {
|
||||
val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x
|
||||
val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y
|
||||
return Offset(sx, sy)
|
||||
}
|
||||
|
||||
liveCandidates.forEachIndexed { index, candidate ->
|
||||
val box = candidate.box ?: return@forEachIndexed
|
||||
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
|
||||
val points = box.corners.map { p ->
|
||||
imageToScreen(p.x * imageW, p.y * imageH)
|
||||
}
|
||||
if (points.size >= 4) {
|
||||
val path = Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
|
||||
close()
|
||||
}
|
||||
drawPath(path = path, color = color, style = Stroke(width = 4f))
|
||||
} else {
|
||||
val lt = imageToScreen(box.left * imageW, box.top * imageH)
|
||||
val rb = imageToScreen(box.right * imageW, box.bottom * imageH)
|
||||
val left = minOf(lt.x, rb.x)
|
||||
val top = minOf(lt.y, rb.y)
|
||||
val right = maxOf(lt.x, rb.x)
|
||||
val bottom = maxOf(lt.y, rb.y)
|
||||
if (right > left && bottom > top) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(right - left, bottom - top),
|
||||
cornerRadius = CornerRadius(10f, 10f),
|
||||
style = Stroke(width = 4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val center = if (points.isNotEmpty()) {
|
||||
val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size
|
||||
val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size
|
||||
Offset(sx, sy)
|
||||
} else {
|
||||
imageToScreen(
|
||||
(box.left + box.right) * 0.5f * imageW,
|
||||
(box.top + box.bottom) * 0.5f * imageH
|
||||
)
|
||||
}
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.65f),
|
||||
radius = 16f,
|
||||
center = center
|
||||
)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
"${index + 1}",
|
||||
center.x,
|
||||
center.y + 11f,
|
||||
markerPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (liveCandidates.isEmpty()) {
|
||||
Text(text = stringResource(R.string.no_code_found_in_image))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.image_scan_pick_subtitle))
|
||||
liveCandidates.forEachIndexed { index, candidate ->
|
||||
TextButton(
|
||||
onClick = { onPick(candidate) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "${index + 1}. ${candidate.result.type}",
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = candidate.result.content,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.clean.scanner.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clean.scanner.R
|
||||
import com.clean.scanner.ui.BatchScanRecord
|
||||
import com.clean.scanner.util.ClipboardUtil
|
||||
import com.clean.scanner.util.Intents
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
internal fun OverlayIconToggle(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
label: String,
|
||||
checkedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
uncheckedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
showLabel: Boolean = true
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconToggleButton(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (checked) checkedImageVector else uncheckedImageVector,
|
||||
contentDescription = label,
|
||||
tint = if (checked) Color(0xFF4AE3A3) else Color.White
|
||||
)
|
||||
}
|
||||
if (showLabel) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BatchResultsPanel(
|
||||
results: List<BatchScanRecord>,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.batch_captures_count, results.size),
|
||||
color = Color.White
|
||||
)
|
||||
results.take(3).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "${item.result.type}: ${item.result.content}",
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = timeFormat.format(Date(item.timestamp)),
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
Row {
|
||||
IconButton(onClick = { ClipboardUtil.copy(context, item.result.content) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.copy),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { Intents.shareText(context, item.result.content) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.share),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onClear, enabled = results.isNotEmpty()) {
|
||||
Text(stringResource(R.string.clear_batch))
|
||||
}
|
||||
TextButton(
|
||||
onClick = { Intents.shareText(context, buildBatchExport(results)) },
|
||||
enabled = results.isNotEmpty()
|
||||
) {
|
||||
Text(stringResource(R.string.share_batch))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBatchExport(results: List<BatchScanRecord>): String {
|
||||
if (results.isEmpty()) return ""
|
||||
val formatter = DateFormat.getDateTimeInstance()
|
||||
return results.joinToString(separator = "\n\n") { item ->
|
||||
"${formatter.format(Date(item.timestamp))}\n${item.result.type}\n${item.result.content}"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PermissionContent(
|
||||
showSettingsHint: Boolean,
|
||||
onRequestPermission: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.camera_permission_title))
|
||||
Text(text = stringResource(R.string.camera_permission_rationale))
|
||||
Button(onClick = onRequestPermission) {
|
||||
Text(text = stringResource(R.string.request_camera))
|
||||
}
|
||||
if (showSettingsHint) {
|
||||
TextButton(onClick = onOpenSettings) {
|
||||
Text(stringResource(R.string.open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.clean.scanner.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clean.scanner.domain.ScanResult
|
||||
import com.clean.scanner.util.ParsedContact
|
||||
import com.clean.scanner.util.ScanContentParsers
|
||||
import com.clean.scanner.util.UrlRiskScorer
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private data class ResultField(
|
||||
val label: String,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ResultVisualCard(
|
||||
result: ScanResult,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) }
|
||||
if (contact != null || result.type == "Contact") {
|
||||
ContactVisualCard(
|
||||
contact = contact,
|
||||
rawContent = result.content,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val fields = remember(result) { buildResultFields(result) }
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = result.type,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (fields.isEmpty()) {
|
||||
Text(
|
||||
text = result.content,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
fields.forEach { field ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = field.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF4F6277)
|
||||
)
|
||||
Text(
|
||||
text = field.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ContactCardTemplate {
|
||||
Minimal,
|
||||
Corporate,
|
||||
Playful
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactVisualCard(
|
||||
contact: ParsedContact?,
|
||||
rawContent: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val template = remember(contact, rawContent) { selectContactTemplate(contact, rawContent) }
|
||||
val background = when (template) {
|
||||
ContactCardTemplate.Corporate -> Brush.linearGradient(
|
||||
listOf(Color(0xFF081C3B), Color(0xFF0F2E58), Color(0xFF134B73))
|
||||
)
|
||||
ContactCardTemplate.Playful -> Brush.linearGradient(
|
||||
listOf(Color(0xFFFFF9EC), Color(0xFFF8F1FF), Color(0xFFEFF6FF))
|
||||
)
|
||||
ContactCardTemplate.Minimal -> Brush.linearGradient(
|
||||
listOf(Color(0xFFF9FAFC), Color(0xFFF1F5F9))
|
||||
)
|
||||
}
|
||||
val accentColor = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color(0xFF7AF7CF)
|
||||
ContactCardTemplate.Playful -> Color(0xFF304FFE)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF1E293B)
|
||||
}
|
||||
val textPrimary = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color.White
|
||||
ContactCardTemplate.Playful -> Color(0xFF16181D)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF0F172A)
|
||||
}
|
||||
val textMuted = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color(0xFFC7D6E8)
|
||||
ContactCardTemplate.Playful -> Color(0xFF55657B)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF475569)
|
||||
}
|
||||
val name = contact?.fullName ?: "Contact"
|
||||
val subtitle = listOfNotNull(contact?.title, contact?.organization).distinct().joinToString(" • ")
|
||||
val initials = remember(name) { initialsFromName(name) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(background)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = accentColor.copy(
|
||||
alpha = when (template) {
|
||||
ContactCardTemplate.Corporate -> 0.18f
|
||||
ContactCardTemplate.Playful -> 0.15f
|
||||
ContactCardTemplate.Minimal -> 0.12f
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = accentColor,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
color = textPrimary,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = textMuted,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContactLine("Phone", contact?.phones?.firstOrNull(), textPrimary, textMuted)
|
||||
ContactLine("Email", contact?.emails?.firstOrNull(), textPrimary, textMuted)
|
||||
ContactLine("Address", contact?.address, textPrimary, textMuted)
|
||||
ContactLine("Note", contact?.note, textPrimary, textMuted)
|
||||
if (contact == null) {
|
||||
ContactLine("Raw", rawContent, textPrimary, textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialsFromName(name: String): String {
|
||||
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return "?"
|
||||
return parts.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
|
||||
.joinToString("")
|
||||
.ifBlank { "?" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactLine(
|
||||
label: String,
|
||||
value: String?,
|
||||
textPrimary: Color,
|
||||
textMuted: Color
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = textMuted,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = textPrimary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): ContactCardTemplate {
|
||||
val isCardPayload = rawContent.contains("BEGIN:VCARD", ignoreCase = true) ||
|
||||
rawContent.startsWith("MECARD:", ignoreCase = true)
|
||||
val looksCorporate = !contact?.organization.isNullOrBlank() ||
|
||||
!contact?.title.isNullOrBlank()
|
||||
val denseStructuredData = listOf(
|
||||
contact?.phones?.firstOrNull(),
|
||||
contact?.emails?.firstOrNull(),
|
||||
contact?.address,
|
||||
contact?.note
|
||||
).count { !it.isNullOrBlank() } >= 3
|
||||
|
||||
return when {
|
||||
looksCorporate || isCardPayload -> ContactCardTemplate.Corporate
|
||||
denseStructuredData -> ContactCardTemplate.Minimal
|
||||
else -> ContactCardTemplate.Playful
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildResultFields(result: ScanResult): List<ResultField> {
|
||||
return when (result.type) {
|
||||
"Contact" -> {
|
||||
val contact = ScanContentParsers.parseContact(result.content)
|
||||
listOfNotNull(
|
||||
contact?.fullName?.let { ResultField("Name", it) },
|
||||
contact?.organization?.let { ResultField("Company", it) },
|
||||
contact?.title?.let { ResultField("Title", it) },
|
||||
contact?.phones?.firstOrNull()?.let { ResultField("Phone", it) },
|
||||
contact?.emails?.firstOrNull()?.let { ResultField("Email", it) },
|
||||
contact?.address?.let { ResultField("Address", it) }
|
||||
)
|
||||
}
|
||||
"Calendar" -> {
|
||||
val event = ScanContentParsers.parseCalendarEvent(result.content)
|
||||
val dateTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
||||
listOfNotNull(
|
||||
event?.title?.let { ResultField("Title", it) },
|
||||
event?.location?.let { ResultField("Location", it) },
|
||||
event?.startMillis?.let { ResultField("Start", dateTime.format(Date(it))) },
|
||||
event?.endMillis?.let { ResultField("End", dateTime.format(Date(it))) },
|
||||
event?.description?.let { ResultField("Details", it) }
|
||||
)
|
||||
}
|
||||
"WiFi" -> {
|
||||
val map = parseWifiFields(result.content)
|
||||
listOfNotNull(
|
||||
map["S"]?.takeIf { it.isNotBlank() }?.let { ResultField("SSID", it) },
|
||||
map["T"]?.takeIf { it.isNotBlank() }?.let { ResultField("Security", it) },
|
||||
map["P"]?.takeIf { it.isNotBlank() }?.let { ResultField("Password", it) }
|
||||
)
|
||||
}
|
||||
"URL" -> {
|
||||
val score = UrlRiskScorer.score(result.content)
|
||||
listOf(
|
||||
ResultField("Link", result.content),
|
||||
ResultField("Risk score", score.score.toString())
|
||||
)
|
||||
}
|
||||
"SMS" -> {
|
||||
val (number, body) = ScanContentParsers.parseSms(result.content)
|
||||
listOfNotNull(
|
||||
number.takeIf { it.isNotBlank() }?.let { ResultField("To", it) },
|
||||
body?.takeIf { it.isNotBlank() }?.let { ResultField("Message", it) }
|
||||
)
|
||||
}
|
||||
"Email" -> {
|
||||
val email = ScanContentParsers.extractEmail(result.content)
|
||||
listOf(ResultField("Email", email))
|
||||
}
|
||||
"Phone" -> {
|
||||
val phone = ScanContentParsers.extractPhoneNumber(result.content)
|
||||
listOf(ResultField("Phone", phone))
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseWifiFields(raw: String): Map<String, String> {
|
||||
val cleaned = raw.trim()
|
||||
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap()
|
||||
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
|
||||
val values = mutableMapOf<String, String>()
|
||||
payload.split(';').forEach { token ->
|
||||
val key = token.substringBefore(':', "").trim()
|
||||
if (key.isBlank()) return@forEach
|
||||
val value = token.substringAfter(':', "").trim()
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -3,22 +3,15 @@ package com.clean.scanner.ui.screens
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.Paint
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -29,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ViewList
|
||||
@@ -41,13 +33,9 @@ import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.ViewModule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -58,7 +46,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -68,16 +55,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -85,7 +66,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
@@ -98,23 +78,15 @@ import com.clean.scanner.ui.BatchScanRecord
|
||||
import com.clean.scanner.ui.components.CameraPreview
|
||||
import com.clean.scanner.util.ClipboardUtil
|
||||
import com.clean.scanner.util.Intents
|
||||
import com.clean.scanner.util.ParsedContact
|
||||
import com.clean.scanner.util.ScanContentParsers
|
||||
import com.clean.scanner.util.UrlRiskScorer
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
private data class GalleryScanCandidate(
|
||||
internal data class GalleryScanCandidate(
|
||||
val result: ScanResult,
|
||||
val box: DetectionBox?
|
||||
)
|
||||
@@ -636,748 +608,3 @@ fun ScannerScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ResultField(
|
||||
val label: String,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ResultVisualCard(
|
||||
result: ScanResult,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) }
|
||||
if (contact != null || result.type == "Contact") {
|
||||
ContactVisualCard(
|
||||
contact = contact,
|
||||
rawContent = result.content,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val fields = remember(result) { buildResultFields(result) }
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = result.type,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (fields.isEmpty()) {
|
||||
Text(
|
||||
text = result.content,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
fields.forEach { field ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = field.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF4F6277)
|
||||
)
|
||||
Text(
|
||||
text = field.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ContactCardTemplate {
|
||||
Minimal,
|
||||
Corporate,
|
||||
Playful
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactVisualCard(
|
||||
contact: ParsedContact?,
|
||||
rawContent: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val template = remember(contact, rawContent) { selectContactTemplate(contact, rawContent) }
|
||||
val background = when (template) {
|
||||
ContactCardTemplate.Corporate -> Brush.linearGradient(
|
||||
listOf(Color(0xFF081C3B), Color(0xFF0F2E58), Color(0xFF134B73))
|
||||
)
|
||||
ContactCardTemplate.Playful -> Brush.linearGradient(
|
||||
listOf(Color(0xFFFFF9EC), Color(0xFFF8F1FF), Color(0xFFEFF6FF))
|
||||
)
|
||||
ContactCardTemplate.Minimal -> Brush.linearGradient(
|
||||
listOf(Color(0xFFF9FAFC), Color(0xFFF1F5F9))
|
||||
)
|
||||
}
|
||||
val accentColor = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color(0xFF7AF7CF)
|
||||
ContactCardTemplate.Playful -> Color(0xFF304FFE)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF1E293B)
|
||||
}
|
||||
val textPrimary = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color.White
|
||||
ContactCardTemplate.Playful -> Color(0xFF16181D)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF0F172A)
|
||||
}
|
||||
val textMuted = when (template) {
|
||||
ContactCardTemplate.Corporate -> Color(0xFFC7D6E8)
|
||||
ContactCardTemplate.Playful -> Color(0xFF55657B)
|
||||
ContactCardTemplate.Minimal -> Color(0xFF475569)
|
||||
}
|
||||
val name = contact?.fullName ?: "Contact"
|
||||
val subtitle = listOfNotNull(contact?.title, contact?.organization).distinct().joinToString(" • ")
|
||||
val initials = remember(name) { initialsFromName(name) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(background)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = accentColor.copy(
|
||||
alpha = when (template) {
|
||||
ContactCardTemplate.Corporate -> 0.18f
|
||||
ContactCardTemplate.Playful -> 0.15f
|
||||
ContactCardTemplate.Minimal -> 0.12f
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = accentColor,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
color = textPrimary,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = textMuted,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContactLine("Phone", contact?.phones?.firstOrNull(), textPrimary, textMuted)
|
||||
ContactLine("Email", contact?.emails?.firstOrNull(), textPrimary, textMuted)
|
||||
ContactLine("Address", contact?.address, textPrimary, textMuted)
|
||||
ContactLine("Note", contact?.note, textPrimary, textMuted)
|
||||
if (contact == null) {
|
||||
ContactLine("Raw", rawContent, textPrimary, textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialsFromName(name: String): String {
|
||||
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return "?"
|
||||
return parts.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
|
||||
.joinToString("")
|
||||
.ifBlank { "?" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactLine(
|
||||
label: String,
|
||||
value: String?,
|
||||
textPrimary: Color,
|
||||
textMuted: Color
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = textMuted,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = textPrimary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): ContactCardTemplate {
|
||||
val isCardPayload = rawContent.contains("BEGIN:VCARD", ignoreCase = true) ||
|
||||
rawContent.startsWith("MECARD:", ignoreCase = true)
|
||||
val looksCorporate = !contact?.organization.isNullOrBlank() ||
|
||||
!contact?.title.isNullOrBlank()
|
||||
val denseStructuredData = listOf(
|
||||
contact?.phones?.firstOrNull(),
|
||||
contact?.emails?.firstOrNull(),
|
||||
contact?.address,
|
||||
contact?.note
|
||||
).count { !it.isNullOrBlank() } >= 3
|
||||
|
||||
return when {
|
||||
looksCorporate || isCardPayload -> ContactCardTemplate.Corporate
|
||||
denseStructuredData -> ContactCardTemplate.Minimal
|
||||
else -> ContactCardTemplate.Playful
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildResultFields(result: ScanResult): List<ResultField> {
|
||||
return when (result.type) {
|
||||
"Contact" -> {
|
||||
val contact = ScanContentParsers.parseContact(result.content)
|
||||
listOfNotNull(
|
||||
contact?.fullName?.let { ResultField("Name", it) },
|
||||
contact?.organization?.let { ResultField("Company", it) },
|
||||
contact?.title?.let { ResultField("Title", it) },
|
||||
contact?.phones?.firstOrNull()?.let { ResultField("Phone", it) },
|
||||
contact?.emails?.firstOrNull()?.let { ResultField("Email", it) },
|
||||
contact?.address?.let { ResultField("Address", it) }
|
||||
)
|
||||
}
|
||||
"Calendar" -> {
|
||||
val event = ScanContentParsers.parseCalendarEvent(result.content)
|
||||
val dateTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
||||
listOfNotNull(
|
||||
event?.title?.let { ResultField("Title", it) },
|
||||
event?.location?.let { ResultField("Location", it) },
|
||||
event?.startMillis?.let { ResultField("Start", dateTime.format(Date(it))) },
|
||||
event?.endMillis?.let { ResultField("End", dateTime.format(Date(it))) },
|
||||
event?.description?.let { ResultField("Details", it) }
|
||||
)
|
||||
}
|
||||
"WiFi" -> {
|
||||
val map = parseWifiFields(result.content)
|
||||
listOfNotNull(
|
||||
map["S"]?.takeIf { it.isNotBlank() }?.let { ResultField("SSID", it) },
|
||||
map["T"]?.takeIf { it.isNotBlank() }?.let { ResultField("Security", it) },
|
||||
map["P"]?.takeIf { it.isNotBlank() }?.let { ResultField("Password", it) }
|
||||
)
|
||||
}
|
||||
"URL" -> {
|
||||
val score = UrlRiskScorer.score(result.content)
|
||||
listOf(
|
||||
ResultField("Link", result.content),
|
||||
ResultField("Risk score", score.score.toString())
|
||||
)
|
||||
}
|
||||
"SMS" -> {
|
||||
val (number, body) = ScanContentParsers.parseSms(result.content)
|
||||
listOfNotNull(
|
||||
number.takeIf { it.isNotBlank() }?.let { ResultField("To", it) },
|
||||
body?.takeIf { it.isNotBlank() }?.let { ResultField("Message", it) }
|
||||
)
|
||||
}
|
||||
"Email" -> {
|
||||
val email = ScanContentParsers.extractEmail(result.content)
|
||||
listOf(ResultField("Email", email))
|
||||
}
|
||||
"Phone" -> {
|
||||
val phone = ScanContentParsers.extractPhoneNumber(result.content)
|
||||
listOf(ResultField("Phone", phone))
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseWifiFields(raw: String): Map<String, String> {
|
||||
val cleaned = raw.trim()
|
||||
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap()
|
||||
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
|
||||
val values = mutableMapOf<String, String>()
|
||||
payload.split(';').forEach { token ->
|
||||
val key = token.substringBefore(':', "").trim()
|
||||
if (key.isBlank()) return@forEach
|
||||
val value = token.substringAfter(':', "").trim()
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayIconToggle(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
label: String,
|
||||
checkedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
uncheckedImageVector: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
showLabel: Boolean = true
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconToggleButton(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (checked) checkedImageVector else uncheckedImageVector,
|
||||
contentDescription = label,
|
||||
tint = if (checked) Color(0xFF4AE3A3) else Color.White
|
||||
)
|
||||
}
|
||||
if (showLabel) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatchResultsPanel(
|
||||
results: List<BatchScanRecord>,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val timeFormat = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.batch_captures_count, results.size),
|
||||
color = Color.White
|
||||
)
|
||||
results.take(3).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "${item.result.type}: ${item.result.content}",
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = timeFormat.format(Date(item.timestamp)),
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
Row {
|
||||
IconButton(onClick = { ClipboardUtil.copy(context, item.result.content) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.copy),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { Intents.shareText(context, item.result.content) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.share),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onClear, enabled = results.isNotEmpty()) {
|
||||
Text(stringResource(R.string.clear_batch))
|
||||
}
|
||||
TextButton(
|
||||
onClick = { Intents.shareText(context, buildBatchExport(results)) },
|
||||
enabled = results.isNotEmpty()
|
||||
) {
|
||||
Text(stringResource(R.string.share_batch))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBatchExport(results: List<BatchScanRecord>): String {
|
||||
if (results.isEmpty()) return ""
|
||||
val formatter = DateFormat.getDateTimeInstance()
|
||||
return results.joinToString(separator = "\n\n") { item ->
|
||||
"${formatter.format(Date(item.timestamp))}\n${item.result.type}\n${item.result.content}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} else {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
BitmapFactory.decodeStream(stream)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun detectBarcodes(
|
||||
scanner: BarcodeScanner,
|
||||
image: InputImage
|
||||
): List<Barcode> = suspendCancellableCoroutine { cont ->
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (cont.isActive) cont.resume(barcodes)
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
if (cont.isActive) cont.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryScanPreviewDialog(
|
||||
imageUri: Uri?,
|
||||
candidates: List<GalleryScanCandidate>,
|
||||
onPick: (GalleryScanCandidate) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bitmap = remember(imageUri) { imageUri?.let { loadBitmapFromUri(context, it) } }
|
||||
var liveCandidates by remember(imageUri, candidates) { mutableStateOf(candidates) }
|
||||
var zoom by remember(imageUri) { mutableFloatStateOf(1f) }
|
||||
var pan by remember(imageUri) { mutableStateOf(Offset.Zero) }
|
||||
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var scanTick by remember { mutableIntStateOf(0) }
|
||||
val markerPaint = remember {
|
||||
Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = 34f
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
}
|
||||
}
|
||||
val scanner = remember {
|
||||
BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
|
||||
.enableAllPotentialBarcodes()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { scanner.close() }
|
||||
}
|
||||
|
||||
LaunchedEffect(bitmap, viewportSize, zoom, pan, scanTick) {
|
||||
val bmp = bitmap ?: return@LaunchedEffect
|
||||
val vw = viewportSize.width.toFloat()
|
||||
val vh = viewportSize.height.toFloat()
|
||||
if (vw <= 0f || vh <= 0f) return@LaunchedEffect
|
||||
|
||||
delay(120)
|
||||
|
||||
val imgW = bmp.width.toFloat()
|
||||
val imgH = bmp.height.toFloat()
|
||||
if (imgW <= 1f || imgH <= 1f) return@LaunchedEffect
|
||||
|
||||
val baseScale = max(vw / imgW, vh / imgH)
|
||||
val effectiveScale = (baseScale * zoom).coerceAtLeast(0.01f)
|
||||
val cx = vw * 0.5f
|
||||
val cy = vh * 0.5f
|
||||
|
||||
fun screenToImageX(screenX: Float): Float {
|
||||
return ((screenX - cx - pan.x) / effectiveScale) + (imgW * 0.5f)
|
||||
}
|
||||
fun screenToImageY(screenY: Float): Float {
|
||||
return ((screenY - cy - pan.y) / effectiveScale) + (imgH * 0.5f)
|
||||
}
|
||||
|
||||
val left = screenToImageX(0f).coerceIn(0f, imgW - 1f)
|
||||
val right = screenToImageX(vw).coerceIn(0f, imgW - 1f)
|
||||
val top = screenToImageY(0f).coerceIn(0f, imgH - 1f)
|
||||
val bottom = screenToImageY(vh).coerceIn(0f, imgH - 1f)
|
||||
|
||||
val cropLeft = minOf(left, right).toInt()
|
||||
val cropTop = minOf(top, bottom).toInt()
|
||||
val cropW = (kotlin.math.abs(right - left)).toInt().coerceAtLeast(8)
|
||||
val cropH = (kotlin.math.abs(bottom - top)).toInt().coerceAtLeast(8)
|
||||
val boundedW = cropW.coerceAtMost(bmp.width - cropLeft)
|
||||
val boundedH = cropH.coerceAtMost(bmp.height - cropTop)
|
||||
if (boundedW <= 4 || boundedH <= 4) return@LaunchedEffect
|
||||
|
||||
val cropped = Bitmap.createBitmap(bmp, cropLeft, cropTop, boundedW, boundedH)
|
||||
val barcodes = try {
|
||||
detectBarcodes(scanner, InputImage.fromBitmap(cropped, 0))
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val live = barcodes.mapNotNull { barcode ->
|
||||
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val normalizedBox = barcode.boundingBox?.let { bounds ->
|
||||
val leftN = ((bounds.left + cropLeft) / imgW).coerceIn(0f, 1f)
|
||||
val topN = ((bounds.top + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
val rightN = ((bounds.right + cropLeft) / imgW).coerceIn(0f, 1f)
|
||||
val bottomN = ((bounds.bottom + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
val corners = barcode.cornerPoints?.map { p ->
|
||||
DetectionPoint(
|
||||
x = ((p.x + cropLeft) / imgW).coerceIn(0f, 1f),
|
||||
y = ((p.y + cropTop) / imgH).coerceIn(0f, 1f)
|
||||
)
|
||||
} ?: emptyList()
|
||||
DetectionBox(leftN, topN, rightN, bottomN, corners)
|
||||
}
|
||||
GalleryScanCandidate(
|
||||
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
|
||||
box = normalizedBox
|
||||
)
|
||||
}.distinctBy { "${it.result.type}|${it.result.content}" }
|
||||
|
||||
liveCandidates = live
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.image_scan_pick_title, liveCandidates.size)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (bitmap != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.onSizeChanged {
|
||||
viewportSize = it
|
||||
scanTick++
|
||||
}
|
||||
.pointerInput(bitmap) {
|
||||
detectTransformGestures { _, panChange, zoomChange, _ ->
|
||||
zoom = (zoom * zoomChange).coerceIn(1f, 6f)
|
||||
pan += panChange
|
||||
scanTick++
|
||||
}
|
||||
}
|
||||
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.scan_from_image),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = zoom
|
||||
scaleY = zoom
|
||||
translationX = pan.x
|
||||
translationY = pan.y
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
) {
|
||||
val imageW = bitmap.width.toFloat()
|
||||
val imageH = bitmap.height.toFloat()
|
||||
if (imageW <= 0f || imageH <= 0f) return@Canvas
|
||||
|
||||
val baseScale = max(size.width / imageW, size.height / imageH)
|
||||
val effectiveScale = baseScale * zoom
|
||||
val cx = size.width * 0.5f
|
||||
val cy = size.height * 0.5f
|
||||
|
||||
fun imageToScreen(ix: Float, iy: Float): Offset {
|
||||
val sx = cx + ((ix - imageW * 0.5f) * effectiveScale) + pan.x
|
||||
val sy = cy + ((iy - imageH * 0.5f) * effectiveScale) + pan.y
|
||||
return Offset(sx, sy)
|
||||
}
|
||||
|
||||
liveCandidates.forEachIndexed { index, candidate ->
|
||||
val box = candidate.box ?: return@forEachIndexed
|
||||
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
|
||||
val points = box.corners.map { p ->
|
||||
imageToScreen(p.x * imageW, p.y * imageH)
|
||||
}
|
||||
if (points.size >= 4) {
|
||||
val path = Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
|
||||
close()
|
||||
}
|
||||
drawPath(path = path, color = color, style = Stroke(width = 4f))
|
||||
} else {
|
||||
val lt = imageToScreen(box.left * imageW, box.top * imageH)
|
||||
val rb = imageToScreen(box.right * imageW, box.bottom * imageH)
|
||||
val left = minOf(lt.x, rb.x)
|
||||
val top = minOf(lt.y, rb.y)
|
||||
val right = maxOf(lt.x, rb.x)
|
||||
val bottom = maxOf(lt.y, rb.y)
|
||||
if (right > left && bottom > top) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(right - left, bottom - top),
|
||||
cornerRadius = CornerRadius(10f, 10f),
|
||||
style = Stroke(width = 4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val center = if (points.isNotEmpty()) {
|
||||
val sx = points.sumOf { it.x.toDouble() }.toFloat() / points.size
|
||||
val sy = points.sumOf { it.y.toDouble() }.toFloat() / points.size
|
||||
Offset(sx, sy)
|
||||
} else {
|
||||
imageToScreen(
|
||||
(box.left + box.right) * 0.5f * imageW,
|
||||
(box.top + box.bottom) * 0.5f * imageH
|
||||
)
|
||||
}
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.65f),
|
||||
radius = 16f,
|
||||
center = center
|
||||
)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
"${index + 1}",
|
||||
center.x,
|
||||
center.y + 11f,
|
||||
markerPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (liveCandidates.isEmpty()) {
|
||||
Text(text = stringResource(R.string.no_code_found_in_image))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.image_scan_pick_subtitle))
|
||||
liveCandidates.forEachIndexed { index, candidate ->
|
||||
TextButton(
|
||||
onClick = { onPick(candidate) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "${index + 1}. ${candidate.result.type}",
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = candidate.result.content,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionContent(
|
||||
showSettingsHint: Boolean,
|
||||
onRequestPermission: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.camera_permission_title))
|
||||
Text(text = stringResource(R.string.camera_permission_rationale))
|
||||
Button(onClick = onRequestPermission) {
|
||||
Text(text = stringResource(R.string.request_camera))
|
||||
}
|
||||
if (showSettingsHint) {
|
||||
TextButton(onClick = onOpenSettings) {
|
||||
Text(stringResource(R.string.open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toHumanType(): String = when (this) {
|
||||
Barcode.TYPE_CONTACT_INFO -> "Contact"
|
||||
Barcode.TYPE_EMAIL -> "Email"
|
||||
Barcode.TYPE_ISBN -> "ISBN"
|
||||
Barcode.TYPE_PHONE -> "Phone"
|
||||
Barcode.TYPE_PRODUCT -> "Product"
|
||||
Barcode.TYPE_SMS -> "SMS"
|
||||
Barcode.TYPE_TEXT -> "Text"
|
||||
Barcode.TYPE_URL -> "URL"
|
||||
Barcode.TYPE_WIFI -> "WiFi"
|
||||
Barcode.TYPE_GEO -> "Geo"
|
||||
Barcode.TYPE_CALENDAR_EVENT -> "Calendar"
|
||||
Barcode.TYPE_DRIVER_LICENSE -> "Driver license"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ data class ParsedCalendarEvent(
|
||||
val allDay: Boolean = false
|
||||
)
|
||||
|
||||
data class ParsedWifiNetwork(
|
||||
val ssid: String? = null,
|
||||
val security: String? = null,
|
||||
val password: String? = null,
|
||||
val hidden: Boolean? = null
|
||||
)
|
||||
|
||||
object ScanContentParsers {
|
||||
fun extractPhoneNumber(raw: String): String {
|
||||
return raw.substringAfter("tel:", raw)
|
||||
@@ -134,6 +141,41 @@ object ScanContentParsers {
|
||||
)
|
||||
}
|
||||
|
||||
fun parseWifi(raw: String): ParsedWifiNetwork? {
|
||||
val cleaned = raw.trim()
|
||||
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return null
|
||||
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
|
||||
if (payload.isBlank()) return null
|
||||
|
||||
var ssid: String? = null
|
||||
var security: String? = null
|
||||
var password: String? = null
|
||||
var hidden: Boolean? = null
|
||||
|
||||
splitByUnescaped(payload, ';').forEach { token ->
|
||||
if (token.isBlank()) return@forEach
|
||||
val idx = indexOfUnescaped(token, ':')
|
||||
if (idx <= 0) return@forEach
|
||||
|
||||
val key = token.substring(0, idx).trim().uppercase(Locale.US)
|
||||
val value = unescapeWifiValue(token.substring(idx + 1).trim())
|
||||
when (key) {
|
||||
"S" -> ssid = value.ifBlank { null }
|
||||
"T" -> security = value.ifBlank { null }
|
||||
"P" -> password = value.ifBlank { null }
|
||||
"H", "HIDDEN" -> hidden = value.equals("true", ignoreCase = true) || value == "1"
|
||||
}
|
||||
}
|
||||
|
||||
if (ssid == null && security == null && password == null && hidden == null) return null
|
||||
return ParsedWifiNetwork(
|
||||
ssid = ssid,
|
||||
security = security,
|
||||
password = password,
|
||||
hidden = hidden
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseVCard(raw: String): ParsedContact? {
|
||||
val fromLibrary = parseVCardWithLibrary(raw)
|
||||
val fromFallback = parseVCardFallback(raw)
|
||||
@@ -439,6 +481,48 @@ object ScanContentParsers {
|
||||
return TimeZone.getTimeZone(tzId)
|
||||
}
|
||||
|
||||
private fun splitByUnescaped(input: String, separator: Char): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var escaped = false
|
||||
input.forEach { ch ->
|
||||
when {
|
||||
escaped -> {
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
}
|
||||
ch == '\\' -> escaped = true
|
||||
ch == separator -> {
|
||||
out += current.toString()
|
||||
current.setLength(0)
|
||||
}
|
||||
else -> current.append(ch)
|
||||
}
|
||||
}
|
||||
out += current.toString()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun indexOfUnescaped(input: String, needle: Char): Int {
|
||||
var escaped = false
|
||||
input.forEachIndexed { index, ch ->
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
ch == '\\' -> escaped = true
|
||||
ch == needle -> return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun unescapeWifiValue(value: String): String {
|
||||
return value
|
||||
.replace("\\\\", "\\")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\:", ":")
|
||||
.replace("\\,", ",")
|
||||
}
|
||||
|
||||
private fun mergeParsedContacts(
|
||||
primary: ParsedContact?,
|
||||
secondary: ParsedContact?
|
||||
|
||||
@@ -163,4 +163,28 @@ class ScanContentParsersTest {
|
||||
assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true)
|
||||
assertEquals("203 New York Ave, New York, NY 11377, USA", parsed?.address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseWifi_handlesStandardPayload() {
|
||||
val raw = "WIFI:T:WPA;S:OfficeNet;P:superSecret;H:false;;"
|
||||
|
||||
val parsed = ScanContentParsers.parseWifi(raw)
|
||||
assertNotNull(parsed)
|
||||
assertEquals("OfficeNet", parsed?.ssid)
|
||||
assertEquals("WPA", parsed?.security)
|
||||
assertEquals("superSecret", parsed?.password)
|
||||
assertEquals(false, parsed?.hidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseWifi_handlesEscapedCharactersAndHiddenFlag() {
|
||||
val raw = "WIFI:T:WPA2;S:Cafe\\;Guest\\,2nd\\:Floor;P:p\\\\ass\\;word;H:true;;"
|
||||
|
||||
val parsed = ScanContentParsers.parseWifi(raw)
|
||||
assertNotNull(parsed)
|
||||
assertEquals("Cafe;Guest,2nd:Floor", parsed?.ssid)
|
||||
assertEquals("WPA2", parsed?.security)
|
||||
assertEquals("p\\ass;word", parsed?.password)
|
||||
assertEquals(true, parsed?.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user