package es.globergy.tknative import android.Manifest import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import coil.compose.AsyncImage import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions import es.globergy.tknative.network.AuthRequest import es.globergy.tknative.network.TkApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import java.io.FileOutputStream class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { TicketOcrScreen() } } } } @Composable private fun TicketOcrScreen() { val context = LocalContext.current val scope = rememberCoroutineScope() var imageUri by remember { mutableStateOf(null) } var capturedImageFile by remember { mutableStateOf(null) } var status by remember { mutableStateOf("Listo para capturar ticket") } var localOcrText by remember { mutableStateOf("") } var remoteDate by remember { mutableStateOf("") } var remoteMerchant by remember { mutableStateOf("") } var remoteAmount by remember { mutableStateOf("") } var remoteVat by remember { mutableStateOf("") } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> if (!granted) { status = "Permiso de camara denegado" } } val takePictureLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicture(), ) { ok -> status = if (ok) "Foto capturada" else "Captura cancelada" } val pickFromGalleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), ) { uri -> if (uri != null) { imageUri = uri capturedImageFile = null status = "Imagen seleccionada desde galeria" } } fun ensureCameraPermission(): Boolean { val granted = ContextCompat.checkSelfPermission( context, Manifest.permission.CAMERA, ) == PackageManager.PERMISSION_GRANTED if (!granted) { permissionLauncher.launch(Manifest.permission.CAMERA) } return granted } fun createCaptureUri(): Uri { val dir = File(context.cacheDir, "images").apply { mkdirs() } val file = File(dir, "ticket_${System.currentTimeMillis()}.jpg") capturedImageFile = file return FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file, ) } Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(text = "TK Native OCR", style = MaterialTheme.typography.headlineSmall) Text(text = status) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { if (ensureCameraPermission()) { val uri = createCaptureUri() imageUri = uri takePictureLauncher.launch(uri) } }) { Text("Tomar foto") } Button(onClick = { pickFromGalleryLauncher.launch("image/*") }) { Text("Galeria") } } imageUri?.let { uri -> Card(modifier = Modifier.fillMaxWidth()) { AsyncImage( model = uri, contentDescription = "Ticket", modifier = Modifier .fillMaxWidth() .height(260.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { scope.launch { status = "Ejecutando OCR local" val text = runLocalOcr(context, uri) localOcrText = text status = "OCR local completado" } }) { Text("OCR local") } Button(onClick = { scope.launch { status = "Autenticando y enviando a OCR servidor" val result = runServerOcr(context, uri, capturedImageFile) result.fold( onSuccess = { parsed -> remoteDate = parsed.expense_date.orEmpty() remoteMerchant = parsed.merchant.orEmpty() remoteAmount = parsed.amount?.toString().orEmpty() remoteVat = parsed.vat_percent?.toString().orEmpty() status = "OCR servidor completado" }, onFailure = { err -> status = "Error OCR servidor: ${err.message}" }, ) } }) { Text("OCR servidor") } } } ?: run { Text("Captura una imagen para iniciar OCR") } if (localOcrText.isNotBlank()) { OutlinedTextField( value = localOcrText, onValueChange = {}, label = { Text("Texto OCR local") }, modifier = Modifier.fillMaxWidth(), minLines = 4, ) } Text("Campos extraidos del OCR servidor") OutlinedTextField(value = remoteDate, onValueChange = {}, label = { Text("Fecha") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = remoteMerchant, onValueChange = {}, label = { Text("Comercio") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = remoteAmount, onValueChange = {}, label = { Text("Importe") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = remoteVat, onValueChange = {}, label = { Text("IVA") }, modifier = Modifier.fillMaxWidth()) Spacer(modifier = Modifier.height(16.dp)) Text("Credenciales demo usadas para OCR servidor: empleado@tk.local") } } private suspend fun runLocalOcr(context: android.content.Context, uri: Uri): String { val image = InputImage.fromFilePath(context, uri) val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) val result = recognizer.process(image).await() return result.text } private suspend fun runServerOcr( context: android.content.Context, uri: Uri, capturedImageFile: File?, ): Result { return withContext(Dispatchers.IO) { try { val authResp = TkApi.service.authToken( AuthRequest(email = "empleado@tk.local", password = "empleado123"), ) val authBody = authResp.body() if (!authResp.isSuccessful || authBody?.ok != true) { return@withContext Result.failure(IllegalStateException(authBody?.error ?: "No autenticado")) } val token = authBody.access_token ?: authBody.token if (token.isNullOrBlank()) { return@withContext Result.failure(IllegalStateException("Token vacio")) } val file = capturedImageFile ?: copyUriToTempFile(context, uri) val requestBody = file.asRequestBody("image/jpeg".toMediaType()) val part = MultipartBody.Part.createFormData("ticket", file.name, requestBody) val ocrResp = TkApi.service.ocrExtract( authorization = "Bearer $token", ticket = part, ) val ocrBody = ocrResp.body() if (!ocrResp.isSuccessful || ocrBody?.ok != true || ocrBody.data?.parsed == null) { return@withContext Result.failure(IllegalStateException(ocrBody?.error ?: "Error OCR")) } Result.success(ocrBody.data.parsed) } catch (e: Exception) { Result.failure(e) } } } private fun copyUriToTempFile(context: android.content.Context, uri: Uri): File { val dir = File(context.cacheDir, "images").apply { mkdirs() } val out = File(dir, "gallery_${System.currentTimeMillis()}.jpg") context.contentResolver.openInputStream(uri).use { input -> FileOutputStream(out).use { output -> if (input != null) { input.copyTo(output) } } } return out }