"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:
@@ -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.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.ScaleGestureDetector
|
import android.view.ScaleGestureDetector
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
@@ -13,6 +17,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.camera.core.*
|
import androidx.camera.core.*
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
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.Text
|
||||||
import com.google.mlkit.vision.text.TextRecognition
|
import com.google.mlkit.vision.text.TextRecognition
|
||||||
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -29,24 +36,28 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var viewFinder: PreviewView
|
private lateinit var viewFinder: PreviewView
|
||||||
private lateinit var overlayFrame: android.view.View
|
private lateinit var overlayFrame: android.view.View
|
||||||
private lateinit var tvResult: TextView
|
private lateinit var tvResult: TextView
|
||||||
|
private lateinit var tvPrediction: TextView
|
||||||
private lateinit var btnCapture: Button
|
private lateinit var btnCapture: Button
|
||||||
private lateinit var cameraExecutor: ExecutorService
|
private lateinit var cameraExecutor: ExecutorService
|
||||||
private var imageCapture: ImageCapture? = null
|
private var imageCapture: ImageCapture? = null
|
||||||
private var cameraControl: CameraControl? = null
|
private var cameraControl: CameraControl? = null
|
||||||
private var currentZoomRatio = 1f
|
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
|
// Inicializa el OCR con las opciones estándar
|
||||||
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
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(
|
private val requestPermissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { isGranted: Boolean ->
|
) { isGranted: Boolean ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
viewFinder.post { startCamera() }
|
viewFinder.post { startCamera() }
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
val toolbar: Toolbar = findViewById(R.id.toolbar)
|
||||||
|
setSupportActionBar(toolbar)
|
||||||
|
|
||||||
viewFinder = findViewById(R.id.viewFinder)
|
viewFinder = findViewById(R.id.viewFinder)
|
||||||
overlayFrame = findViewById(R.id.overlayFrame)
|
overlayFrame = findViewById(R.id.overlayFrame)
|
||||||
tvResult = findViewById(R.id.tvResult)
|
tvResult = findViewById(R.id.tvResult)
|
||||||
|
tvPrediction = findViewById(R.id.tvPrediction)
|
||||||
btnCapture = findViewById(R.id.btnCapture)
|
btnCapture = findViewById(R.id.btnCapture)
|
||||||
|
|
||||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
// Cargar predicción inicial
|
||||||
|
obtenerPrediccion()
|
||||||
|
|
||||||
// Configurar el detector de gestos de zoom
|
// Configurar el detector de gestos de zoom
|
||||||
val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||||
cameraControl?.let { control ->
|
cameraControl?.let { control ->
|
||||||
val scale = detector.scaleFactor
|
val scale = detector.scaleFactor
|
||||||
currentZoomRatio *= scale
|
currentZoomRatio *= scale
|
||||||
// Limitamos el zoom entre 1x y el máximo de la cámara (ej: 8x)
|
|
||||||
currentZoomRatio = currentZoomRatio.coerceIn(1f, 8f)
|
currentZoomRatio = currentZoomRatio.coerceIn(1f, 8f)
|
||||||
control.setZoomRatio(currentZoomRatio)
|
control.setZoomRatio(currentZoomRatio)
|
||||||
}
|
}
|
||||||
@@ -76,7 +93,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Aplicar el detector al visor de la cámara
|
|
||||||
viewFinder.setOnTouchListener { view, event ->
|
viewFinder.setOnTouchListener { view, event ->
|
||||||
scaleGestureDetector.onTouchEvent(event)
|
scaleGestureDetector.onTouchEvent(event)
|
||||||
view.performClick()
|
view.performClick()
|
||||||
@@ -91,62 +107,55 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Kilometrapp", "Error controlado en UI: ${e.message}")
|
Log.e("Kilometrapp", "Error en UI: ${e.message}")
|
||||||
Toast.makeText(this, "Error en la interfaz: ${e.message}", Toast.LENGTH_LONG).show()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private fun startCamera() {
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
|
||||||
|
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
val preview = Preview.Builder()
|
|
||||||
.build()
|
|
||||||
.also {
|
|
||||||
it.surfaceProvider = viewFinder.surfaceProvider
|
it.surfaceProvider = viewFinder.surfaceProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
imageCapture = ImageCapture.Builder()
|
imageCapture = ImageCapture.Builder()
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
cameraProvider.unbindAll()
|
cameraProvider.unbindAll()
|
||||||
|
val camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
|
||||||
val camera = cameraProvider.bindToLifecycle(
|
|
||||||
this, cameraSelector, preview, imageCapture
|
|
||||||
)
|
|
||||||
|
|
||||||
cameraControl = camera.cameraControl
|
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) {
|
} catch (exc: Exception) {
|
||||||
Log.e("Kilometrapp", "Error al inicializar CameraX", exc)
|
Toast.makeText(this, getString(R.string.camera_error), Toast.LENGTH_SHORT).show()
|
||||||
Toast.makeText(this, "Error al abrir la cámara", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
}, ContextCompat.getMainExecutor(this))
|
}, ContextCompat.getMainExecutor(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun takePhoto() {
|
private fun takePhoto() {
|
||||||
val imageCapture = imageCapture ?: return
|
val imageCapture = imageCapture ?: return
|
||||||
|
|
||||||
imageCapture.takePicture(
|
imageCapture.takePicture(
|
||||||
ContextCompat.getMainExecutor(this),
|
ContextCompat.getMainExecutor(this),
|
||||||
object : ImageCapture.OnImageCapturedCallback() {
|
object : ImageCapture.OnImageCapturedCallback() {
|
||||||
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||||
processStaticImage(imageProxy)
|
processStaticImage(imageProxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
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
|
val mediaImage = imageProxy.image
|
||||||
if (mediaImage != null) {
|
if (mediaImage != null) {
|
||||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||||
|
|
||||||
recognizer.process(image)
|
recognizer.process(image)
|
||||||
.addOnSuccessListener { visionText ->
|
.addOnSuccessListener { visionText ->
|
||||||
val bloquesEnCuadro = visionText.textBlocks.filter {
|
val bloquesEnCuadro = visionText.textBlocks.filter {
|
||||||
isInsideFocusFrame(it.boundingBox, imageProxy.width, imageProxy.height)
|
isInsideFocusFrame(it.boundingBox, imageProxy.width, imageProxy.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bloquesEnCuadro.isEmpty()) {
|
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 {
|
} else {
|
||||||
showSelectionDialog(bloquesEnCuadro)
|
showSelectionDialog(bloquesEnCuadro)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.addOnCompleteListener {
|
.addOnCompleteListener { imageProxy.close() }
|
||||||
imageProxy.close()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
imageProxy.close()
|
imageProxy.close()
|
||||||
}
|
}
|
||||||
@@ -180,20 +185,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun showSelectionDialog(bloques: List<Text.TextBlock>) {
|
private fun showSelectionDialog(bloques: List<Text.TextBlock>) {
|
||||||
val candidatos = mutableListOf<String>()
|
val candidatos = mutableListOf<String>()
|
||||||
|
val regex = Regex("\\d{2,8}")
|
||||||
for (bloque in bloques) {
|
for (bloque in bloques) {
|
||||||
// Buscamos cualquier secuencia de números (de 2 a 8 cifras)
|
regex.findAll(bloque.text).forEach { match ->
|
||||||
// Esto separará el "22" del "246115" aunque el OCR los pegue
|
|
||||||
val matches = Regex("\\d{2,8}").findAll(bloque.text)
|
|
||||||
matches.forEach { match ->
|
|
||||||
val num = match.value
|
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) {
|
if (num.length >= 7) {
|
||||||
val ultimosSeis = num.takeLast(6)
|
val last6 = num.takeLast(6)
|
||||||
if (!candidatos.contains(ultimosSeis)) candidatos.add(ultimosSeis)
|
if (!candidatos.contains(last6)) candidatos.add(last6)
|
||||||
|
|
||||||
val primerosDos = num.take(2)
|
|
||||||
if (!candidatos.contains(primerosDos)) candidatos.add(primerosDos)
|
|
||||||
} else {
|
} else {
|
||||||
if (!candidatos.contains(num)) candidatos.add(num)
|
if (!candidatos.contains(num)) candidatos.add(num)
|
||||||
}
|
}
|
||||||
@@ -201,73 +199,243 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidatos.isEmpty()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordenamos para que los números de 6 cifras (kilometraje habitual) salgan arriba
|
|
||||||
val listaFinal = candidatos.sortedByDescending { it.length }.toTypedArray()
|
val listaFinal = candidatos.sortedByDescending { it.length }.toTypedArray()
|
||||||
|
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("Selecciona el Kilometraje")
|
.setTitle(getString(R.string.select_km_title))
|
||||||
.setItems(listaFinal) { _, which ->
|
.setItems(listaFinal) { _, which ->
|
||||||
val seleccionado = listaFinal[which]
|
val seleccionado = listaFinal[which]
|
||||||
tvResult.text = "$seleccionado KM"
|
tvResult.text = getString(R.string.km_suffix, seleccionado)
|
||||||
showConfirmationDialog(seleccionado)
|
showConfirmationDialog(seleccionado)
|
||||||
}
|
}
|
||||||
.setNegativeButton("Cancelar", null)
|
.setNegativeButton(getString(R.string.btn_cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showConfirmationDialog(kilometraje: String) {
|
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)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("¿Qué quieres registrar?")
|
.setTitle(getString(R.string.dialog_km_message, kilometraje))
|
||||||
.setMessage("Kilometraje: $kilometraje KM")
|
.setItems(opciones) { _, which ->
|
||||||
.setPositiveButton("Revisión") { _, _ ->
|
when (which) {
|
||||||
enviarAServidor(kilometraje, "Revision kilometros")
|
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()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enviarAServidor(kilometraje: String, observacionesText: String) {
|
private fun enviarAServidor(kilometraje: String, observacionesText: String) {
|
||||||
cameraExecutor.execute {
|
cameraExecutor.execute {
|
||||||
try {
|
try {
|
||||||
// Usamos el driver de MySQL que es el más estable en Android
|
|
||||||
Class.forName("com.mysql.jdbc.Driver")
|
Class.forName("com.mysql.jdbc.Driver")
|
||||||
|
val connection = java.sql.DriverManager.getConnection(dbUrl, dbUser, dbPass)
|
||||||
// Datos de conexión
|
val sql = "INSERT INTO mantenimiento (kms, observaciones, fecha) VALUES (?, ?, NOW())"
|
||||||
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 statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
|
|
||||||
// Extraemos solo los números para guardar como entero
|
|
||||||
val soloNumeros = kilometraje.filter { it.isDigit() }.toIntOrNull() ?: 0
|
val soloNumeros = kilometraje.filter { it.isDigit() }.toIntOrNull() ?: 0
|
||||||
|
|
||||||
statement.setInt(1, soloNumeros)
|
statement.setInt(1, soloNumeros)
|
||||||
statement.setString(2, observacionesText)
|
statement.setString(2, observacionesText)
|
||||||
|
|
||||||
statement.executeUpdate()
|
statement.executeUpdate()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
runOnUiThread {
|
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) {
|
} catch (e: Throwable) {
|
||||||
Log.e("Kilometrapp", "Error MariaDB: ${e.message}")
|
|
||||||
runOnUiThread {
|
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 {
|
private fun isInsideFocusFrame(boundingBox: Rect?, imgWidth: Int, imgHeight: Int): Boolean {
|
||||||
if (boundingBox == null) return false
|
if (boundingBox == null) return false
|
||||||
|
|
||||||
// 1. Obtener dimensiones del visor y del recuadro en pantalla
|
|
||||||
val viewWidth = viewFinder.width
|
val viewWidth = viewFinder.width
|
||||||
val viewHeight = viewFinder.height
|
val viewHeight = viewFinder.height
|
||||||
|
val scaleX = viewWidth.toFloat() / imgHeight.toFloat()
|
||||||
// 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 scaleY = viewHeight.toFloat() / imgWidth.toFloat()
|
val scaleY = viewHeight.toFloat() / imgWidth.toFloat()
|
||||||
|
|
||||||
val rectCenterX = boundingBox.centerX() * scaleX
|
val rectCenterX = boundingBox.centerX() * scaleX
|
||||||
val rectCenterY = boundingBox.centerY() * scaleY
|
val rectCenterY = boundingBox.centerY() * scaleY
|
||||||
|
return rectCenterX in overlayFrame.left.toFloat()..overlayFrame.right.toFloat() &&
|
||||||
// 4. Verificar si el centro del texto detectado está dentro del rectángulo rojo
|
rectCenterY in overlayFrame.top.toFloat()..overlayFrame.bottom.toFloat()
|
||||||
return rectCenterX in frameLeft..frameRight && rectCenterY in frameTop..frameBottom
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@@ -7,12 +7,21 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".MainActivity">
|
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 -->
|
<!-- Vista en vivo de la cámara -->
|
||||||
<androidx.camera.view.PreviewView
|
<androidx.camera.view.PreviewView
|
||||||
android:id="@+id/viewFinder"
|
android:id="@+id/viewFinder"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||||
app:layout_constraintBottom_toTopOf="@id/containerResultado"
|
app:layout_constraintBottom_toTopOf="@id/containerResultado"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
@@ -32,7 +41,7 @@
|
|||||||
android:id="@+id/tvInstrucciones"
|
android:id="@+id/tvInstrucciones"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="ENCUADRA Y PULSA CAPTURAR"
|
android:text="@string/instruction_text"
|
||||||
android:textColor="#FF0000"
|
android:textColor="#FF0000"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
@@ -45,13 +54,13 @@
|
|||||||
android:id="@+id/btnCapture"
|
android:id="@+id/btnCapture"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="CAPTURAR"
|
android:text="@string/btn_capture"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/viewFinder"
|
app:layout_constraintBottom_toBottomOf="@id/viewFinder"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
<!-- Contenedor inferior para mostrar los kilómetros detectados -->
|
<!-- Contenedor inferior -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/containerResultado"
|
android:id="@+id/containerResultado"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -66,7 +75,7 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Kilometraje Detectado:"
|
android:text="@string/label_detected_km"
|
||||||
android:textColor="#999999"
|
android:textColor="#999999"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:layout_gravity="center_horizontal" />
|
android:layout_gravity="center_horizontal" />
|
||||||
@@ -75,12 +84,29 @@
|
|||||||
android:id="@+id/tvResult"
|
android:id="@+id/tvResult"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Buscando cuentakilómetros..."
|
android:text="@string/status_searching"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_gravity="center_horizontal" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,3 +1,46 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Mantenimiento</string>
|
<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>
|
</resources>
|
||||||
Reference in New Issue
Block a user