"Implementación de OCR, historial filtrado y predicción de mantenimiento con margen de viaje."

Y si quieres ser más detallado, puedes añadir debajo:
•
Captura de kms con Google ML Kit.
•
Conexión directa a MariaDB remota.
•
Cálculo de cambio de aceite con margen de 320km para viaje a Puertollano.
•
Añadido historial con filtros y registro de otros mantenimientos (neumáticos).
•
Creado archivo README.md con la documentación del proyecto.
This commit is contained in:
2026-06-26 09:35:39 +02:00
parent 848d3b47d1
commit 328c763fbc
5 changed files with 382 additions and 106 deletions
+41
View File
@@ -0,0 +1,41 @@
# Mantenimiento (Kilometrapp) - Estado Actual del Proyecto
Aplicación Android nativa desarrollada en Kotlin diseñada para la gestión logística y mecánica de vehículos mediante captura inteligente de kilometraje.
### 🚀 Características Implementadas:
1. **Captura Inteligente (OCR):**
* Integración de **Google ML Kit Text Recognition** para la lectura automática del cuentakilómetros.
* Uso de **CameraX** con implementación de recuadro de enfoque (*Focus Frame*) y soporte para **Pinch-to-Zoom (1x - 8x)**.
* Algoritmo de limpieza de cadenas mediante Regex para separar valores pegados (ej: temperatura de kilometraje).
2. **Persistencia en la Nube:**
* Conexión directa a base de datos **MariaDB** remota.
* Uso del driver **MySQL Connector 5.1.49** para garantizar máxima compatibilidad con dispositivos Android (evitando errores de `DriverAction`).
* Sistema de guardado seguro mediante *Executors* para no bloquear la interfaz de usuario.
3. **Lógica de Negocio y Predicción:**
* **Algoritmo Predictivo de Aceite:** Cálculo automático del próximo cambio (intervalo de 15,000 km).
* **Margen de Viaje Geográfico:** Resta automática de **320 km** al cálculo de mantenimiento para planificar el viaje de retorno Torremolinos - Puertollano.
* **Estimación Temporal:** Cálculo de fecha aproximada de mantenimiento basada en la media de kilómetros diarios recorridos.
4. **Gestión de Historial y UX:**
* **Toolbar con Menú:** Acceso directo a registros históricos desde la barra superior.
* **Filtros Inteligentes:** Buscador de registros por categorías (Revisiones, Aceite, Neumáticos) mediante consultas `LIKE` flexibles.
* **Registro Multimodal:** Opciones rápidas para mantenimiento común y entrada de texto libre para incidencias personalizadas.
* **Localización:** Interfaz y fechas totalmente adaptadas al formato español (`dd/MM/yyyy`).
### 🛠️ Stack Tecnológico:
* **Lenguaje:** Kotlin
* **Cámara:** CameraX (Core, Lifecycle, View)
* **IA/ML:** ML Kit Text Recognition (Latin)
* **Base de Datos:** MariaDB / JDBC (MySQL Connector)
* **UI:** Material Design / ConstraintLayout
### 📊 Estructura de Datos (MariaDB):
* **Tabla:** `mantenimiento`
* **Columnas:** `id` (INT), `kms` (INT), `observaciones` (TEXT), `fecha` (DATETIME).
---
**Estado:** Estable y funcional para el seguimiento diario del vehículo con margen de seguridad para viajes de larga distancia.
@@ -1,9 +1,13 @@
package com.example.kilometrapp // <-- Asegúrate de que este es tu paquete exacto
package com.example.kilometrapp
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Rect
import android.view.Menu
import android.view.MenuItem
import android.view.ScaleGestureDetector
import android.view.ViewGroup
import android.widget.FrameLayout
import android.os.Bundle
import android.util.Log
import android.widget.Button
@@ -13,6 +17,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
@@ -21,6 +26,8 @@ import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.Text
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -29,24 +36,28 @@ class MainActivity : AppCompatActivity() {
private lateinit var viewFinder: PreviewView
private lateinit var overlayFrame: android.view.View
private lateinit var tvResult: TextView
private lateinit var tvPrediction: TextView
private lateinit var btnCapture: Button
private lateinit var cameraExecutor: ExecutorService
private var imageCapture: ImageCapture? = null
private var cameraControl: CameraControl? = null
private var currentZoomRatio = 1f
private var isDialogShowing = false
private val dbUrl = "jdbc:mysql://mariadb.peta9.com:3306/mantenimiento_db"
private val dbUser = "pedro"
private val dbPass = "Pedro@110387"
// Inicializa el OCR con las opciones estándar
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
// Lanzador para solicitar el permiso de forma nativa en pantalla
// Lanzador para solicitar el permiso
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
viewFinder.post { startCamera() }
} else {
Toast.makeText(this, "Permiso de cámara obligatorio denegado.", Toast.LENGTH_LONG).show()
Toast.makeText(this, getString(R.string.permission_denied), Toast.LENGTH_LONG).show()
}
}
@@ -55,20 +66,26 @@ class MainActivity : AppCompatActivity() {
try {
setContentView(R.layout.activity_main)
val toolbar: Toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
viewFinder = findViewById(R.id.viewFinder)
overlayFrame = findViewById(R.id.overlayFrame)
tvResult = findViewById(R.id.tvResult)
tvPrediction = findViewById(R.id.tvPrediction)
btnCapture = findViewById(R.id.btnCapture)
cameraExecutor = Executors.newSingleThreadExecutor()
// Cargar predicción inicial
obtenerPrediccion()
// Configurar el detector de gestos de zoom
val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
cameraControl?.let { control ->
val scale = detector.scaleFactor
currentZoomRatio *= scale
// Limitamos el zoom entre 1x y el máximo de la cámara (ej: 8x)
currentZoomRatio = currentZoomRatio.coerceIn(1f, 8f)
control.setZoomRatio(currentZoomRatio)
}
@@ -76,7 +93,6 @@ class MainActivity : AppCompatActivity() {
}
})
// Aplicar el detector al visor de la cámara
viewFinder.setOnTouchListener { view, event ->
scaleGestureDetector.onTouchEvent(event)
view.performClick()
@@ -91,62 +107,55 @@ class MainActivity : AppCompatActivity() {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} catch (e: Exception) {
Log.e("Kilometrapp", "Error controlado en UI: ${e.message}")
Toast.makeText(this, "Error en la interfaz: ${e.message}", Toast.LENGTH_LONG).show()
Log.e("Kilometrapp", "Error en UI: ${e.message}")
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_history) {
mostrarHistorial()
true
} else {
super.onOptionsItemSelected(item)
}
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
try {
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder()
.build()
.also {
val preview = Preview.Builder().build().also {
it.surfaceProvider = viewFinder.surfaceProvider
}
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll()
val camera = cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture
)
val camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
cameraControl = camera.cameraControl
// Opcional: Establecer un zoom inicial (ej: 2x) para ver mejor el tablero
// cameraControl?.setLinearZoom(0.3f) // 0.0 a 1.0
Log.d("Kilometrapp", "Cámara vinculada correctamente para captura")
} catch (exc: Exception) {
Log.e("Kilometrapp", "Error al inicializar CameraX", exc)
Toast.makeText(this, "Error al abrir la cámara", Toast.LENGTH_SHORT).show()
Toast.makeText(this, getString(R.string.camera_error), Toast.LENGTH_SHORT).show()
}
}, ContextCompat.getMainExecutor(this))
}
private fun takePhoto() {
val imageCapture = imageCapture ?: return
imageCapture.takePicture(
ContextCompat.getMainExecutor(this),
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
processStaticImage(imageProxy)
}
override fun onError(exception: ImageCaptureException) {
Log.e("Kilometrapp", "Error al capturar foto: ${exception.message}")
Log.e("Kilometrapp", "Error captura: ${exception.message}")
}
}
)
@@ -157,22 +166,18 @@ class MainActivity : AppCompatActivity() {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
recognizer.process(image)
.addOnSuccessListener { visionText ->
val bloquesEnCuadro = visionText.textBlocks.filter {
isInsideFocusFrame(it.boundingBox, imageProxy.width, imageProxy.height)
}
if (bloquesEnCuadro.isEmpty()) {
Toast.makeText(this, "No se detectó texto en el recuadro", Toast.LENGTH_SHORT).show()
Toast.makeText(this, getString(R.string.no_text_detected), Toast.LENGTH_SHORT).show()
} else {
showSelectionDialog(bloquesEnCuadro)
}
}
.addOnCompleteListener {
imageProxy.close()
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
@@ -180,20 +185,13 @@ class MainActivity : AppCompatActivity() {
private fun showSelectionDialog(bloques: List<Text.TextBlock>) {
val candidatos = mutableListOf<String>()
val regex = Regex("\\d{2,8}")
for (bloque in bloques) {
// Buscamos cualquier secuencia de números (de 2 a 8 cifras)
// Esto separará el "22" del "246115" aunque el OCR los pegue
val matches = Regex("\\d{2,8}").findAll(bloque.text)
matches.forEach { match ->
regex.findAll(bloque.text).forEach { match ->
val num = match.value
// Si el número es muy largo (ej: 22246115), intentamos ver si los últimos 6 son el kilometraje
if (num.length >= 7) {
val ultimosSeis = num.takeLast(6)
if (!candidatos.contains(ultimosSeis)) candidatos.add(ultimosSeis)
val primerosDos = num.take(2)
if (!candidatos.contains(primerosDos)) candidatos.add(primerosDos)
val last6 = num.takeLast(6)
if (!candidatos.contains(last6)) candidatos.add(last6)
} else {
if (!candidatos.contains(num)) candidatos.add(num)
}
@@ -201,73 +199,243 @@ class MainActivity : AppCompatActivity() {
}
if (candidatos.isEmpty()) {
Toast.makeText(this, "No se encontraron números claros", Toast.LENGTH_SHORT).show()
Toast.makeText(this, getString(R.string.no_numbers_found), Toast.LENGTH_SHORT).show()
return
}
// Ordenamos para que los números de 6 cifras (kilometraje habitual) salgan arriba
val listaFinal = candidatos.sortedByDescending { it.length }.toTypedArray()
AlertDialog.Builder(this)
.setTitle("Selecciona el Kilometraje")
.setTitle(getString(R.string.select_km_title))
.setItems(listaFinal) { _, which ->
val seleccionado = listaFinal[which]
tvResult.text = "$seleccionado KM"
tvResult.text = getString(R.string.km_suffix, seleccionado)
showConfirmationDialog(seleccionado)
}
.setNegativeButton("Cancelar", null)
.setNegativeButton(getString(R.string.btn_cancel), null)
.show()
}
private fun showConfirmationDialog(kilometraje: String) {
val opciones = arrayOf("Revisión kilómetros", "Cambio de aceite")
val opciones = arrayOf(
getString(R.string.btn_revision),
getString(R.string.btn_oil_change),
getString(R.string.btn_tires),
getString(R.string.btn_other)
)
AlertDialog.Builder(this)
.setTitle("¿Qué quieres registrar?")
.setMessage("Kilometraje: $kilometraje KM")
.setPositiveButton("Revisión") { _, _ ->
enviarAServidor(kilometraje, "Revision kilometros")
.setTitle(getString(R.string.dialog_km_message, kilometraje))
.setItems(opciones) { _, which ->
when (which) {
0 -> enviarAServidor(kilometraje, getString(R.string.db_obs_revision))
1 -> enviarAServidor(kilometraje, getString(R.string.db_obs_oil_change))
2 -> showCustomInputDialog(kilometraje, "Neumáticos")
3 -> showCustomInputDialog(kilometraje, "")
}
.setNeutralButton("Cambio Aceite") { _, _ ->
enviarAServidor(kilometraje, "Cambio de aceite")
}
.setNegativeButton("Reintentar", null)
.setNegativeButton(getString(R.string.btn_retry), null)
.show()
}
private fun showCustomInputDialog(kilometraje: String, prefill: String) {
val input = android.widget.EditText(this)
input.hint = getString(R.string.dialog_custom_hint)
if (prefill.isNotEmpty()) {
input.setText(prefill)
input.setSelection(input.text.length)
}
val container = FrameLayout(this)
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.setMargins(60, 20, 60, 0)
input.layoutParams = params
container.addView(input)
val title = if (prefill.isEmpty()) getString(R.string.btn_other) else getString(R.string.btn_tires)
AlertDialog.Builder(this)
.setTitle(title)
.setView(container)
.setPositiveButton(getString(R.string.btn_save)) { _, _ ->
val textoPersonalizado = input.text.toString()
if (textoPersonalizado.isNotBlank()) {
enviarAServidor(kilometraje, textoPersonalizado)
} else {
Toast.makeText(this, "Debes escribir algo", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(getString(R.string.btn_cancel), null)
.show()
}
private fun enviarAServidor(kilometraje: String, observacionesText: String) {
cameraExecutor.execute {
try {
// Usamos el driver de MySQL que es el más estable en Android
Class.forName("com.mysql.jdbc.Driver")
// Datos de conexión
val url = "jdbc:mysql://mariadb.peta9.com:3306/mantenimiento_db"
val user = "pedro"
val pass = "Pedro@110387"
val connection = java.sql.DriverManager.getConnection(url, user, pass)
// Preparar el INSERT (corregido: comas y parámetros)
val sql = "INSERT INTO mantenimiento (kms, observaciones, fecha, foto) VALUES (?, ?, NOW(), null)"
val connection = java.sql.DriverManager.getConnection(dbUrl, dbUser, dbPass)
val sql = "INSERT INTO mantenimiento (kms, observaciones, fecha) VALUES (?, ?, NOW())"
val statement = connection.prepareStatement(sql)
// Extraemos solo los números para guardar como entero
val soloNumeros = kilometraje.filter { it.isDigit() }.toIntOrNull() ?: 0
statement.setInt(1, soloNumeros)
statement.setString(2, observacionesText)
statement.executeUpdate()
connection.close()
runOnUiThread {
Toast.makeText(this, "¡$observacionesText guardado!", Toast.LENGTH_SHORT).show()
Toast.makeText(this, getString(R.string.save_success, observacionesText), Toast.LENGTH_SHORT).show()
obtenerPrediccion()
}
} catch (e: Throwable) {
Log.e("Kilometrapp", "Error MariaDB: ${e.message}")
runOnUiThread {
Toast.makeText(this, "Error de conexión: ${e.message}", Toast.LENGTH_LONG).show()
Toast.makeText(this, getString(R.string.connection_error, e.message), Toast.LENGTH_LONG).show()
}
}
}
}
private fun obtenerPrediccion() {
cameraExecutor.execute {
try {
Class.forName("com.mysql.jdbc.Driver")
val connection = java.sql.DriverManager.getConnection(dbUrl, dbUser, dbPass)
val rsOil = connection.createStatement().executeQuery(
"SELECT kms FROM mantenimiento WHERE observaciones LIKE '%aceite%' ORDER BY fecha DESC LIMIT 1"
)
var ultimoCambio = 0
if (rsOil.next()) ultimoCambio = rsOil.getInt("kms")
val rsStats = connection.createStatement().executeQuery(
"SELECT MIN(fecha) as f_min, MIN(kms) as k_min, MAX(fecha) as f_max, MAX(kms) as k_max FROM mantenimiento"
)
var fechaEstimada: String? = null
var restan = 0
if (rsStats.next() && ultimoCambio > 0) {
val fMin = rsStats.getTimestamp("f_min")
val kMin = rsStats.getInt("k_min")
val fMax = rsStats.getTimestamp("f_max")
val kMax = rsStats.getInt("k_max")
if (fMin != null && fMax != null && kMax > kMin) {
val diffMs = fMax.time - fMin.time
val diffDays = diffMs / (1000 * 60 * 60 * 24)
if (diffDays > 0) {
val kmPorDia = (kMax - kMin).toDouble() / diffDays
val distanciaPuertollano = 320
restan = (ultimoCambio + 15000 - distanciaPuertollano) - kMax
if (restan > 0 && kmPorDia > 0) {
val diasFaltan = (restan / kmPorDia).toLong()
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, diasFaltan.toInt())
fechaEstimada = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(calendar.time)
}
}
}
if (restan <= 0) {
val distanciaPuertollano = 320
restan = (ultimoCambio + 15000 - distanciaPuertollano) - kMax
}
}
connection.close()
runOnUiThread {
if (ultimoCambio > 0) {
if (restan <= 0) {
tvPrediction.text = getString(R.string.urgent_oil_change, Math.abs(restan))
tvPrediction.setTextColor(android.graphics.Color.RED)
} else {
val fechaTexto = fechaEstimada ?: "---"
tvPrediction.text = getString(R.string.next_oil_change, restan, fechaTexto)
tvPrediction.setTextColor(android.graphics.Color.parseColor("#4CAF50"))
}
} else {
tvPrediction.text = getString(R.string.no_oil_change_data)
}
}
} catch (e: Throwable) {
Log.e("Kilometrapp", "Error predicción: ${e.message}")
}
}
}
private fun mostrarHistorial() {
val opcionesFiltro = arrayOf("Todos los registros", "Solo Revisiones", "Solo Cambios de Aceite", "Solo Neumáticos/Ruedas")
AlertDialog.Builder(this)
.setTitle("Filtrar historial")
.setItems(opcionesFiltro) { _, which ->
when (which) {
0 -> cargarDatosHistorial(null)
1 -> cargarDatosHistorial(getString(R.string.db_obs_revision))
2 -> cargarDatosHistorial(getString(R.string.db_obs_oil_change))
3 -> cargarDatosHistorial("rueda")
}
}
.setNegativeButton("Cancelar", null)
.show()
}
private fun cargarDatosHistorial(filtro: String?) {
cameraExecutor.execute {
try {
Class.forName("com.mysql.jdbc.Driver")
val connection = java.sql.DriverManager.getConnection(dbUrl, dbUser, dbPass)
val query = if (filtro == null) {
"SELECT kms, observaciones, fecha FROM mantenimiento ORDER BY fecha DESC LIMIT 20"
} else {
val searchKey = when {
filtro.contains("aceite", ignoreCase = true) -> "aceite"
filtro.contains("rueda", ignoreCase = true) || filtro.contains("neumatico", ignoreCase = true) -> "rueda"
else -> "revision"
}
"SELECT kms, observaciones, fecha FROM mantenimiento WHERE observaciones LIKE '%$searchKey%' ORDER BY fecha DESC LIMIT 20"
}
val rs = connection.createStatement().executeQuery(query)
val dbFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val outFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val historial = mutableListOf<String>()
while (rs.next()) {
val km = rs.getInt("kms")
val obs = rs.getString("observaciones")
val fechaStr = rs.getString("fecha")
val fechaFormateada = try {
val date = dbFormat.parse(fechaStr)
if (date != null) outFormat.format(date) else "---"
} catch (e: Exception) {
fechaStr?.split(".")?.get(0) ?: "---"
}
historial.add("$km KM - $obs\n($fechaFormateada)")
}
connection.close()
runOnUiThread {
if (historial.isEmpty()) {
Toast.makeText(this, "No hay registros para este filtro", Toast.LENGTH_SHORT).show()
} else {
AlertDialog.Builder(this)
.setTitle(getString(R.string.history_title))
.setItems(historial.toTypedArray(), null)
.setPositiveButton("Cerrar", null)
.show()
}
}
} catch (e: Throwable) {
runOnUiThread {
Toast.makeText(this, getString(R.string.connection_error, e.message), Toast.LENGTH_LONG).show()
}
}
}
@@ -275,27 +443,14 @@ class MainActivity : AppCompatActivity() {
private fun isInsideFocusFrame(boundingBox: Rect?, imgWidth: Int, imgHeight: Int): Boolean {
if (boundingBox == null) return false
// 1. Obtener dimensiones del visor y del recuadro en pantalla
val viewWidth = viewFinder.width
val viewHeight = viewFinder.height
// 2. Coordenadas del recuadro de enfoque en la pantalla
val frameLeft = overlayFrame.left.toFloat()
val frameTop = overlayFrame.top.toFloat()
val frameRight = overlayFrame.right.toFloat()
val frameBottom = overlayFrame.bottom.toFloat()
// 3. Mapear coordenadas del OCR (que vienen en tamaño de imagen) a coordenadas de pantalla
// Nota: Esto es una simplificación, pero para un recuadro central funciona bien.
val scaleX = viewWidth.toFloat() / imgHeight.toFloat() // CameraX suele invertir W/H por rotación
val scaleX = viewWidth.toFloat() / imgHeight.toFloat()
val scaleY = viewHeight.toFloat() / imgWidth.toFloat()
val rectCenterX = boundingBox.centerX() * scaleX
val rectCenterY = boundingBox.centerY() * scaleY
// 4. Verificar si el centro del texto detectado está dentro del rectángulo rojo
return rectCenterX in frameLeft..frameRight && rectCenterY in frameTop..frameBottom
return rectCenterX in overlayFrame.left.toFloat()..overlayFrame.right.toFloat() &&
rectCenterY in overlayFrame.top.toFloat()..overlayFrame.bottom.toFloat()
}
override fun onDestroy() {
+32 -6
View File
@@ -7,12 +7,21 @@
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_500"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:layout_constraintTop_toTopOf="parent" />
<!-- Vista en vivo de la cámara -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toTopOf="@id/containerResultado"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
@@ -32,7 +41,7 @@
android:id="@+id/tvInstrucciones"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ENCUADRA Y PULSA CAPTURAR"
android:text="@string/instruction_text"
android:textColor="#FF0000"
android:textSize="12sp"
android:textStyle="bold"
@@ -45,13 +54,13 @@
android:id="@+id/btnCapture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="CAPTURAR"
android:text="@string/btn_capture"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="@id/viewFinder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Contenedor inferior para mostrar los kilómetros detectados -->
<!-- Contenedor inferior -->
<LinearLayout
android:id="@+id/containerResultado"
android:layout_width="match_parent"
@@ -66,7 +75,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kilometraje Detectado:"
android:text="@string/label_detected_km"
android:textColor="#999999"
android:textSize="14sp"
android:layout_gravity="center_horizontal" />
@@ -75,12 +84,29 @@
android:id="@+id/tvResult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Buscando cuentakilómetros..."
android:text="@string/status_searching"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginTop="8dp"
android:layout_gravity="center_horizontal" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333333"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/tvPrediction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_loading_prediction"
android:textColor="#4CAF50"
android:textSize="14sp"
android:textStyle="italic"
android:layout_gravity="center_horizontal" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_history"
android:title="@string/menu_history"
android:icon="@android:drawable/ic_menu_recent_history"
app:showAsAction="always" />
</menu>
+43
View File
@@ -1,3 +1,46 @@
<resources>
<string name="app_name">Mantenimiento</string>
<!-- Pantalla Principal -->
<string name="instruction_text">ENCUADRA Y PULSA CAPTURAR</string>
<string name="btn_capture">CAPTURAR</string>
<string name="label_detected_km">Kilometraje Detectado:</string>
<string name="status_searching">Buscando cuentakilómetros...</string>
<string name="status_loading_prediction">Cargando predicción...</string>
<!-- Mensajes y Diálogos -->
<string name="permission_denied">Permiso de cámara obligatorio denegado.</string>
<string name="camera_error">Error al abrir la cámara</string>
<string name="no_text_detected">No se detectó texto en el recuadro</string>
<string name="select_km_title">Selecciona el Kilometraje</string>
<string name="no_numbers_found">No se encontraron números claros</string>
<string name="km_suffix">%1$s KM</string>
<string name="dialog_register_title">¿Qué quieres registrar?</string>
<string name="dialog_km_message">Kilometraje: %1$s KM</string>
<string name="btn_revision">Revisión</string>
<string name="btn_oil_change">Cambio Aceite</string>
<string name="btn_retry">Reintentar</string>
<string name="btn_cancel">Cancelar</string>
<string name="save_success">¡%1$s guardado!</string>
<string name="connection_error">Error de conexión: %1$s</string>
<!-- Predicciones -->
<string name="urgent_oil_change">¡URGENTE! Sal hacia Puertollano (Exceso: %1$d km)</string>
<string name="next_oil_change">Kms para ir a Puertollano: %1$d KM (Aprox. %2$s)</string>
<string name="no_oil_change_data">Sin registro de cambio de aceite</string>
<!-- Valores Base de Datos (Internos) -->
<string name="db_obs_revision">Revision kilometros</string>
<string name="db_obs_oil_change">Cambio de aceite</string>
<!-- Menú -->
<string name="menu_history">Ver Historial</string>
<string name="history_title">Últimos Registros</string>
<!-- Personalizado -->
<string name="btn_tires">Neumáticos</string>
<string name="btn_other">Otros</string>
<string name="dialog_custom_title">Detalle del mantenimiento</string>
<string name="dialog_custom_hint">Escribe aquí (ej: Pastillas, Filtros...)</string>
<string name="btn_save">Guardar</string>
</resources>