Mejoras para la aplicacion

This commit is contained in:
2026-08-03 13:25:22 +02:00
parent 37b6cb1695
commit 18328607d0
26 changed files with 1359 additions and 171 deletions
+1
View File
@@ -52,6 +52,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
+10
View File
@@ -27,6 +27,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -4,11 +4,13 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.example.saipp.ui.AppNavigation
import com.example.saipp.ui.theme.SAIppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
@@ -4,7 +4,6 @@ import com.example.saipp.data.model.Assignment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.SQLException
import java.sql.Types
class AssignmentRepository {
suspend fun getAll(): Result<List<Assignment>> = withContext(Dispatchers.IO) {
@@ -12,7 +11,7 @@ class AssignmentRepository {
try {
val sql = """
SELECT a.id_asignacion, a.id_sai, a.id_persona, a.fecha_asignado, a.fecha_devolucion, a.observaciones,
s.ns as sai_ns, p.nombre as person_name
s.ns as sai_ns, p.nombre as person_name, p.activo as person_active
FROM Asignado a
LEFT JOIN SAIs s ON a.id_sai = s.id_sai
LEFT JOIN Persona p ON a.id_persona = p.id_persona
@@ -21,6 +20,9 @@ class AssignmentRepository {
val resultSet = statement.executeQuery()
val items = mutableListOf<Assignment>()
while (resultSet.next()) {
// We'll use the 'personName' to filter if person is active in the list screen
// OR we can add a flag to the Assignment model. I'll add 'person_active' check.
val isActive = resultSet.getInt("person_active") == 1
items.add(Assignment(
id = resultSet.getInt("id_asignacion"),
idSai = resultSet.getInt("id_sai"),
@@ -29,7 +31,7 @@ class AssignmentRepository {
returnDate = resultSet.getString("fecha_devolucion"),
observations = resultSet.getString("observaciones"),
saiNs = resultSet.getString("sai_ns"),
personName = resultSet.getString("person_name")
personName = if (isActive) resultSet.getString("person_name") else "${resultSet.getString("person_name")} (Baja)"
))
}
Result.success(items)
@@ -77,7 +79,15 @@ class AssignmentRepository {
statement.setString(4, item.returnDate)
statement.setString(5, item.observations)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
// Update SAI status to 'Asignado'
val updateSaiSql = "UPDATE SAIs SET estado = 'Asignado' WHERE id_sai = ?"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, item.idSai)
updateSaiStmt.executeUpdate()
Result.success(Unit)
}
else Result.failure(Exception("no_rows_inserted"))
} catch (e: SQLException) {
Result.failure(e)
@@ -98,7 +108,17 @@ class AssignmentRepository {
statement.setString(5, item.observations)
statement.setInt(6, item.id)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
// If return date is set, make SAI 'Disponible' again?
// Let's assume manual status management is better or auto-toggle if return date exists.
if (!item.returnDate.isNullOrBlank()) {
val updateSaiSql = "UPDATE SAIs SET estado = 'Disponible' WHERE id_sai = ?"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, item.idSai)
updateSaiStmt.executeUpdate()
}
Result.success(Unit)
}
else Result.failure(Exception("update_failed"))
} catch (e: SQLException) {
Result.failure(e)
@@ -110,10 +130,25 @@ class AssignmentRepository {
suspend fun delete(id: Int): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
// First get SAI id to free it
val getSaiSql = "SELECT id_sai FROM Asignado WHERE id_asignacion = ?"
val getSaiStmt = connection.prepareStatement(getSaiSql)
getSaiStmt.setInt(1, id)
val rs = getSaiStmt.executeQuery()
val saiId = if (rs.next()) rs.getInt("id_sai") else null
val sql = "DELETE FROM Asignado WHERE id_asignacion = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, id)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
if (saiId != null) {
val updateSaiSql = "UPDATE SAIs SET estado = 'Disponible' WHERE id_sai = ?"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, saiId)
updateSaiStmt.executeUpdate()
}
Result.success(Unit)
}
else Result.failure(Exception("delete_failed"))
} catch (e: SQLException) {
Result.failure(e)
@@ -39,7 +39,16 @@ class BatteryRepository {
statement.setString(2, item.brand)
statement.setString(3, item.installDate)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
// AUTO-UPDATE SAI STATUS: If SAI was in repair, make it available now that it has a new battery
if (item.idSai != null) {
val updateSaiSql = "UPDATE SAIs SET estado = 'Disponible' WHERE id_sai = ? AND estado = 'En reparación'"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, item.idSai)
updateSaiStmt.executeUpdate()
}
Result.success(Unit)
}
else Result.failure(Exception("no_rows_inserted"))
} catch (e: SQLException) {
Result.failure(e)
@@ -58,7 +67,16 @@ class BatteryRepository {
statement.setString(3, item.installDate)
statement.setInt(4, item.id)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
// AUTO-UPDATE SAI STATUS: Same logic as insert
if (item.idSai != null) {
val updateSaiSql = "UPDATE SAIs SET estado = 'Disponible' WHERE id_sai = ? AND estado = 'En reparación'"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, item.idSai)
updateSaiStmt.executeUpdate()
}
Result.success(Unit)
}
else Result.failure(Exception("update_failed"))
} catch (e: SQLException) {
Result.failure(e)
@@ -70,10 +88,26 @@ class BatteryRepository {
suspend fun delete(id: Int): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
// First get SAI id to mark it as in repair
val getSaiSql = "SELECT id_sai FROM Bateria WHERE id_bateria = ?"
val getSaiStmt = connection.prepareStatement(getSaiSql)
getSaiStmt.setInt(1, id)
val rs = getSaiStmt.executeQuery()
val saiId = if (rs.next()) rs.getInt("id_sai").takeIf { !rs.wasNull() } else null
val sql = "DELETE FROM Bateria WHERE id_bateria = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, id)
if (statement.executeUpdate() > 0) Result.success(Unit)
if (statement.executeUpdate() > 0) {
// AUTO-UPDATE SAI STATUS: If battery record is removed, assume SAI is back in repair
if (saiId != null) {
val updateSaiSql = "UPDATE SAIs SET estado = 'En reparación' WHERE id_sai = ?"
val updateSaiStmt = connection.prepareStatement(updateSaiSql)
updateSaiStmt.setInt(1, saiId)
updateSaiStmt.executeUpdate()
}
Result.success(Unit)
}
else Result.failure(Exception("delete_failed"))
} catch (e: SQLException) {
Result.failure(e)
@@ -105,4 +139,33 @@ class BatteryRepository {
connection.close()
}
}
suspend fun getCount(): Result<Int> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT COUNT(*) FROM Bateria"
val statement = connection.prepareStatement(sql)
val rs = statement.executeQuery()
if (rs.next()) Result.success(rs.getInt(1)) else Result.success(0)
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun getExpiredCount(): Result<Int> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
// Count batteries older than 2 years (730 days)
val sql = "SELECT COUNT(*) FROM Bateria WHERE fecha_instalacion < DATE_SUB(CURDATE(), INTERVAL 2 YEAR)"
val statement = connection.prepareStatement(sql)
val rs = statement.executeQuery()
if (rs.next()) Result.success(rs.getInt(1)) else Result.success(0)
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
}
@@ -9,14 +9,37 @@ class PersonRepository {
suspend fun getAll(): Result<List<Person>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_persona, nombre FROM Persona"
val sql = "SELECT id_persona, nombre, activo FROM Persona"
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
val items = mutableListOf<Person>()
while (resultSet.next()) {
items.add(Person(
id = resultSet.getInt("id_persona"),
name = resultSet.getString("nombre")
name = resultSet.getString("nombre"),
activo = resultSet.getInt("activo") == 1
))
}
Result.success(items)
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun getActive(): Result<List<Person>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_persona, nombre, activo FROM Persona WHERE activo = 1"
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
val items = mutableListOf<Person>()
while (resultSet.next()) {
items.add(Person(
id = resultSet.getInt("id_persona"),
name = resultSet.getString("nombre"),
activo = true
))
}
Result.success(items)
@@ -30,7 +53,7 @@ class PersonRepository {
suspend fun insert(item: Person): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "INSERT INTO Persona (nombre) VALUES (?)"
val sql = "INSERT INTO Persona (nombre, activo) VALUES (?, 1)"
val statement = connection.prepareStatement(sql)
statement.setString(1, item.name)
if (statement.executeUpdate() > 0) Result.success(Unit)
@@ -45,10 +68,27 @@ class PersonRepository {
suspend fun update(item: Person): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "UPDATE Persona SET nombre = ? WHERE id_persona = ?"
val sql = "UPDATE Persona SET nombre = ?, activo = ? WHERE id_persona = ?"
val statement = connection.prepareStatement(sql)
statement.setString(1, item.name)
statement.setInt(2, item.id)
statement.setInt(2, if (item.activo) 1 else 0)
statement.setInt(3, item.id)
if (statement.executeUpdate() > 0) Result.success(Unit)
else Result.failure(Exception("update_failed"))
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun toggleStatus(id: Int, isActive: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "UPDATE Persona SET activo = ? WHERE id_persona = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, if (isActive) 1 else 0)
statement.setInt(2, id)
if (statement.executeUpdate() > 0) Result.success(Unit)
else Result.failure(Exception("update_failed"))
} catch (e: SQLException) {
@@ -76,14 +116,15 @@ class PersonRepository {
suspend fun getById(id: Int): Result<Person> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_persona, nombre FROM Persona WHERE id_persona = ?"
val sql = "SELECT id_persona, nombre, activo FROM Persona WHERE id_persona = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, id)
val resultSet = statement.executeQuery()
if (resultSet.next()) {
Result.success(Person(
id = resultSet.getInt("id_persona"),
name = resultSet.getString("nombre")
name = resultSet.getString("nombre"),
activo = resultSet.getInt("activo") == 1
))
} else {
Result.failure(Exception("not_found"))
@@ -94,4 +135,18 @@ class PersonRepository {
connection.close()
}
}
suspend fun getActiveCount(): Result<Int> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT COUNT(*) FROM Persona WHERE activo = 1"
val statement = connection.prepareStatement(sql)
val rs = statement.executeQuery()
if (rs.next()) Result.success(rs.getInt(1)) else Result.success(0)
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
}
@@ -1,6 +1,7 @@
package com.example.saipp.data
import com.example.saipp.data.model.Sai
import com.example.saipp.data.model.HistoryItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.SQLException
@@ -9,7 +10,12 @@ class SaiRepository {
suspend fun getAll(): Result<List<Sai>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_sai, ns, modelo FROM SAIs"
val sql = """
SELECT s.id_sai, s.ns, s.modelo, s.estado, p.nombre as person_name
FROM SAIs s
LEFT JOIN Asignado a ON s.id_sai = a.id_sai AND a.fecha_devolucion IS NULL
LEFT JOIN Persona p ON a.id_persona = p.id_persona
""".trimIndent()
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
val sais = mutableListOf<Sai>()
@@ -17,7 +23,32 @@ class SaiRepository {
sais.add(Sai(
id = resultSet.getInt("id_sai"),
ns = resultSet.getString("ns"),
modelo = resultSet.getString("modelo")
modelo = resultSet.getString("modelo"),
estado = resultSet.getString("estado") ?: "Disponible",
asignadoA = resultSet.getString("person_name")
))
}
Result.success(sais)
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun getAvailable(): Result<List<Sai>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_sai, ns, modelo, estado FROM SAIs WHERE estado = 'Disponible'"
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
val sais = mutableListOf<Sai>()
while (resultSet.next()) {
sais.add(Sai(
id = resultSet.getInt("id_sai"),
ns = resultSet.getString("ns"),
modelo = resultSet.getString("modelo"),
estado = resultSet.getString("estado")
))
}
Result.success(sais)
@@ -50,7 +81,7 @@ class SaiRepository {
suspend fun insert(ns: String, modelo: String?): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "INSERT INTO SAIs (ns, modelo) VALUES (?, ?)"
val sql = "INSERT INTO SAIs (ns, modelo, estado) VALUES (?, ?, 'En reparación')"
val statement = connection.prepareStatement(sql)
statement.setString(1, ns)
statement.setString(2, modelo)
@@ -66,11 +97,12 @@ class SaiRepository {
suspend fun update(sai: Sai): Result<Unit> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "UPDATE SAIs SET ns = ?, modelo = ? WHERE id_sai = ?"
val sql = "UPDATE SAIs SET ns = ?, modelo = ?, estado = ? WHERE id_sai = ?"
val statement = connection.prepareStatement(sql)
statement.setString(1, sai.ns)
statement.setString(2, sai.modelo)
statement.setInt(3, sai.id)
statement.setString(3, sai.estado)
statement.setInt(4, sai.id)
if (statement.executeUpdate() > 0) Result.success(Unit)
else Result.failure(Exception("update_failed"))
} catch (e: SQLException) {
@@ -98,7 +130,13 @@ class SaiRepository {
suspend fun getById(id: Int): Result<Sai> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = "SELECT id_sai, ns, modelo FROM SAIs WHERE id_sai = ?"
val sql = """
SELECT s.id_sai, s.ns, s.modelo, s.estado, p.nombre as person_name
FROM SAIs s
LEFT JOIN Asignado a ON s.id_sai = a.id_sai AND a.fecha_devolucion IS NULL
LEFT JOIN Persona p ON a.id_persona = p.id_persona
WHERE s.id_sai = ?
""".trimIndent()
val statement = connection.prepareStatement(sql)
statement.setInt(1, id)
val resultSet = statement.executeQuery()
@@ -106,7 +144,9 @@ class SaiRepository {
Result.success(Sai(
id = resultSet.getInt("id_sai"),
ns = resultSet.getString("ns"),
modelo = resultSet.getString("modelo")
modelo = resultSet.getString("modelo"),
estado = resultSet.getString("estado") ?: "Disponible",
asignadoA = resultSet.getString("person_name")
))
} else {
Result.failure(Exception("not_found"))
@@ -117,4 +157,106 @@ class SaiRepository {
connection.close()
}
}
suspend fun getStats(): Result<Pair<Int, Int>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val totalSql = "SELECT COUNT(*) FROM SAIs"
val availableSql = "SELECT COUNT(*) FROM SAIs WHERE estado = 'Disponible'"
val totalStmt = connection.prepareStatement(totalSql)
val availableStmt = connection.prepareStatement(availableSql)
val totalRs = totalStmt.executeQuery()
val availableRs = availableStmt.executeQuery()
var total = 0
var available = 0
if (totalRs.next()) total = totalRs.getInt(1)
if (availableRs.next()) available = availableRs.getInt(1)
Result.success(Pair(total, available))
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun getByNs(ns: String): Result<Sai> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val sql = """
SELECT s.id_sai, s.ns, s.modelo, s.estado, p.nombre as person_name
FROM SAIs s
LEFT JOIN Asignado a ON s.id_sai = a.id_sai AND a.fecha_devolucion IS NULL
LEFT JOIN Persona p ON a.id_persona = p.id_persona
WHERE s.ns = ?
""".trimIndent()
val statement = connection.prepareStatement(sql)
statement.setString(1, ns)
val resultSet = statement.executeQuery()
if (resultSet.next()) {
Result.success(Sai(
id = resultSet.getInt("id_sai"),
ns = resultSet.getString("ns"),
modelo = resultSet.getString("modelo"),
estado = resultSet.getString("estado") ?: "Disponible",
asignadoA = resultSet.getString("person_name")
))
} else {
Result.failure(Exception("not_found"))
}
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
suspend fun getHistory(id: Int): Result<List<HistoryItem>> = withContext(Dispatchers.IO) {
val connection = MariaDbConnection.getConnection() ?: return@withContext Result.failure(Exception("db_connection_error"))
try {
val items = mutableListOf<HistoryItem>()
// 1. Get assignments and returns
val assignSql = """
SELECT p.nombre, a.fecha_asignado, a.fecha_devolucion
FROM Asignado a
JOIN Persona p ON a.id_persona = p.id_persona
WHERE a.id_sai = ?
""".trimIndent()
val assignStmt = connection.prepareStatement(assignSql)
assignStmt.setInt(1, id)
val assignRs = assignStmt.executeQuery()
while (assignRs.next()) {
val name = assignRs.getString(1)
val start = assignRs.getString(2)
val end = assignRs.getString(3)
items.add(HistoryItem("Asignación", "Entregado a $name", start))
if (end != null) {
items.add(HistoryItem("Devolución", "Devuelto por $name", end))
}
}
// 2. Get battery changes
val batterySql = "SELECT marca, fecha_instalacion FROM Bateria WHERE id_sai = ?"
val batteryStmt = connection.prepareStatement(batterySql)
batteryStmt.setInt(1, id)
val batteryRs = batteryStmt.executeQuery()
while (batteryRs.next()) {
val brand = batteryRs.getString(1)
val date = batteryRs.getString(2)
items.add(HistoryItem("Mantenimiento", "Batería $brand instalada", date))
}
Result.success(items.sortedByDescending { it.date ?: "" })
} catch (e: SQLException) {
Result.failure(e)
} finally {
connection.close()
}
}
}
@@ -0,0 +1,7 @@
package com.example.saipp.data.model
data class HistoryItem(
val type: String, // 'Alta', 'Asignación', 'Devolución', 'Mantenimiento'
val description: String,
val date: String?
)
@@ -2,5 +2,6 @@ package com.example.saipp.data.model
data class Person(
val id: Int = 0,
val name: String
val name: String,
val activo: Boolean = true
)
@@ -3,5 +3,7 @@ package com.example.saipp.data.model
data class Sai(
val id: Int = 0,
val ns: String,
val modelo: String? = null
val modelo: String? = null,
val estado: String = "Disponible",
val asignadoA: String? = null
)
@@ -15,6 +15,9 @@ sealed class Screen(val route: String) {
object EditSai : Screen("edit_sai/{saiId}") {
fun createRoute(id: Int) = "edit_sai/$id"
}
object SaiDetail : Screen("sai_detail/{saiId}") {
fun createRoute(id: Int) = "sai_detail/$id"
}
object BatteryList : Screen("battery_list")
object AddBattery : Screen("add_battery")
object EditBattery : Screen("edit_battery/{batteryId}") {
@@ -30,6 +33,8 @@ sealed class Screen(val route: String) {
object EditAssignment : Screen("edit_assignment/{assignmentId}") {
fun createRoute(id: Int) = "edit_assignment/$id"
}
object AvailableSaiList : Screen("available_sai_list")
object QuickInfo : Screen("quick_info")
}
@Composable
@@ -42,7 +47,9 @@ fun AppNavigation() {
onNavigateToSais = { navController.navigate(Screen.SaiList.route) },
onNavigateToBatteries = { navController.navigate(Screen.BatteryList.route) },
onNavigateToPeople = { navController.navigate(Screen.PersonList.route) },
onNavigateToAssignments = { navController.navigate(Screen.AssignmentList.route) }
onNavigateToAssignments = { navController.navigate(Screen.AssignmentList.route) },
onNavigateToAvailableSais = { navController.navigate(Screen.AvailableSaiList.route) },
onNavigateToQuickInfo = { navController.navigate(Screen.QuickInfo.route) }
)
}
@@ -51,13 +58,12 @@ fun AppNavigation() {
SaiListScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToScan = { navController.navigate(Screen.SaiScanner.route) },
onNavigateToEdit = { id -> navController.navigate(Screen.EditSai.createRoute(id)) }
onNavigateToEdit = { id -> navController.navigate(Screen.EditSai.createRoute(id)) },
onNavigateToDetail = { id -> navController.navigate(Screen.SaiDetail.createRoute(id)) }
)
}
composable(Screen.SaiScanner.route) {
SaiScannerScreen(
onNavigateBack = { navController.popBackStack() }
)
SaiScannerScreen(onNavigateBack = { navController.popBackStack() })
}
composable(
route = Screen.EditSai.route,
@@ -66,6 +72,13 @@ fun AppNavigation() {
val id = backStackEntry.arguments?.getInt("saiId") ?: 0
EditSaiScreen(saiId = id, onNavigateBack = { navController.popBackStack() })
}
composable(
route = Screen.SaiDetail.route,
arguments = listOf(navArgument("saiId") { type = NavType.IntType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getInt("saiId") ?: 0
SaiDetailScreen(saiId = id, onNavigateBack = { navController.popBackStack() })
}
// Batteries
composable(Screen.BatteryList.route) {
@@ -123,5 +136,11 @@ fun AppNavigation() {
val id = backStackEntry.arguments?.getInt("assignmentId") ?: 0
EditAssignmentScreen(assignmentId = id, onNavigateBack = { navController.popBackStack() })
}
composable(Screen.AvailableSaiList.route) {
AvailableSaiListScreen(onNavigateBack = { navController.popBackStack() })
}
composable(Screen.QuickInfo.route) {
QuickInfoScreen(onNavigateBack = { navController.popBackStack() })
}
}
}
@@ -100,6 +100,31 @@ fun ResultDialog(
)
}
@Composable
fun DeleteConfirmDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.confirm_delete_title)) },
text = { Text(stringResource(R.string.confirm_delete_message)) },
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.button_delete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.button_cancel))
}
}
)
}
@Composable
fun ConnectionIndicator(
modifier: Modifier = Modifier,
@@ -9,16 +9,22 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.unit.dp
import com.example.saipp.R
import com.example.saipp.data.AssignmentRepository
import com.example.saipp.data.model.Assignment
import com.example.saipp.ui.ScannerDialog
import com.example.saipp.ui.SearchBar
import com.example.saipp.ui.DeleteConfirmDialog
import com.example.saipp.util.PdfGenerator
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
@@ -37,6 +43,9 @@ fun AssignmentListScreen(
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var showQuickScanner by remember { mutableStateOf(false) }
var hideInactive by remember { mutableStateOf(true) }
var itemToDelete by remember { mutableStateOf<Assignment?>(null) }
fun loadItems() {
scope.launch {
@@ -52,11 +61,15 @@ fun AssignmentListScreen(
loadItems()
}
val filteredItems = remember(items, searchQuery) {
val filteredItems = remember(items, searchQuery, hideInactive) {
items.filter {
it.personName?.contains(searchQuery, ignoreCase = true) ?: false ||
it.saiNs?.contains(searchQuery, ignoreCase = true) ?: false ||
it.observations?.contains(searchQuery, ignoreCase = true) ?: false
val matchesSearch = it.personName?.contains(searchQuery, ignoreCase = true) ?: false ||
it.saiNs?.contains(searchQuery, ignoreCase = true) ?: false ||
it.observations?.contains(searchQuery, ignoreCase = true) ?: false
val matchesActive = if (hideInactive) !it.personName!!.contains("(Baja)") else true
matchesSearch && matchesActive
}
}
@@ -85,7 +98,7 @@ fun AssignmentListScreen(
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToAdd) {
Icon(Icons.Default.Add, contentDescription = "Añadir SAI - Persona")
Icon(Icons.Default.Add, contentDescription = "Añadir Asignación")
}
}
) { padding ->
@@ -94,9 +107,38 @@ fun AssignmentListScreen(
query = searchQuery,
onQueryChange = { searchQuery = it },
onScanClick = { showQuickScanner = true },
modifier = Modifier.padding(16.dp)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = hideInactive,
onCheckedChange = { hideInactive = it }
)
Text(
text = stringResource(R.string.filter_hide_inactive),
style = MaterialTheme.typography.bodySmall
)
}
if (itemToDelete != null) {
DeleteConfirmDialog(
onDismiss = { itemToDelete = null },
onConfirm = {
val id = itemToDelete!!.id
itemToDelete = null
scope.launch {
repository.delete(id).onSuccess { loadItems() }
}
}
)
}
if (showQuickScanner) {
ScannerDialog(
onDismiss = { showQuickScanner = false },
@@ -112,38 +154,51 @@ fun AssignmentListScreen(
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredItems) { item ->
ListItem(
headlineContent = { Text(item.personName ?: "Persona desconocida") },
supportingContent = {
Column {
Text("SAI: ${item.saiNs ?: "N/A"}")
Text("Asignado: ${formatDate(item.assignedDate)}")
if (item.returnDate != null) {
Text("Devolución: ${formatDate(item.returnDate)}")
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
ListItem(
headlineContent = {
Text(
text = item.personName ?: "Persona desconocida",
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
color = if (item.personName?.contains("(Baja)") == true) Color.Gray else Color.Unspecified
)
},
supportingContent = {
Column {
Text("SAI: ${item.saiNs ?: "N/A"}")
Text("Asignado: ${formatDate(item.assignedDate)}")
if (item.returnDate != null) {
Text("Devolución: ${formatDate(item.returnDate)}")
}
if (!item.observations.isNullOrBlank()) {
Text("Obs: ${item.observations}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
}
}
if (!item.observations.isNullOrBlank()) {
Text("Obs: ${item.observations}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
}
}
},
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar")
}
IconButton(onClick = {
scope.launch {
repository.delete(item.id).onSuccess { loadItems() }
},
trailingContent = {
Row {
IconButton(onClick = { PdfGenerator.generateReceipt(context, item) }) {
Icon(imageVector = Icons.Default.Share, contentDescription = "Generar Recibo", tint = MaterialTheme.colorScheme.tertiary)
}
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = { itemToDelete = item }) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar", tint = MaterialTheme.colorScheme.error)
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar")
}
}
}
)
HorizontalDivider()
)
}
}
}
}
@@ -0,0 +1,97 @@
package com.example.saipp.ui.management
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.saipp.R
import com.example.saipp.data.SaiRepository
import com.example.saipp.data.model.Sai
import com.example.saipp.ui.SearchBar
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AvailableSaiListScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repository = remember { SaiRepository() }
var sais by remember { mutableStateOf<List<Sai>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
fun loadSais() {
scope.launch {
isLoading = true
repository.getAvailable()
.onSuccess { sais = it }
.onFailure { Toast.makeText(context, "Error: ${it.message}", Toast.LENGTH_SHORT).show() }
isLoading = false
}
}
LaunchedEffect(Unit) {
loadSais()
}
val filteredSais = remember(sais, searchQuery) {
sais.filter {
it.ns.contains(searchQuery, ignoreCase = true) ||
(it.modelo?.contains(searchQuery, ignoreCase = true) ?: false)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.menu_available_sais)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Atrás")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it },
modifier = Modifier.padding(16.dp)
)
if (isLoading) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
if (filteredSais.isEmpty()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No hay SAIs disponibles")
}
} else {
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
items(filteredSais) { sai ->
ListItem(
headlineContent = { Text("NS: ${sai.ns}") },
supportingContent = { Text("Modelo: ${sai.modelo ?: "N/A"}") }
)
HorizontalDivider()
}
}
}
}
}
}
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import com.example.saipp.data.BatteryRepository
import com.example.saipp.data.model.Battery
import com.example.saipp.ui.SearchBar
import com.example.saipp.ui.DeleteConfirmDialog
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
@@ -35,6 +36,8 @@ fun BatteryListScreen(
var items by remember { mutableStateOf<List<Battery>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var batteryToDelete by remember { mutableStateOf<Battery?>(null) }
fun loadItems() {
scope.launch {
@@ -92,38 +95,55 @@ fun BatteryListScreen(
onQueryChange = { searchQuery = it },
modifier = Modifier.padding(16.dp)
)
if (batteryToDelete != null) {
DeleteConfirmDialog(
onDismiss = { batteryToDelete = null },
onConfirm = {
val id = batteryToDelete!!.id
batteryToDelete = null
scope.launch {
repository.delete(id).onSuccess { loadItems() }
}
}
)
}
if (isLoading) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredItems) { item ->
ListItem(
headlineContent = { Text("Marca: ${item.brand ?: "Desconocida"}") },
supportingContent = {
Column {
Text("Instalación: ${formatDate(item.installDate)}")
item.idSai?.let { Text("SAI ID: $it") }
}
},
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar")
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
ListItem(
headlineContent = { Text("Marca: ${item.brand ?: "Desconocida"}", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
supportingContent = {
Column {
Text("Instalación: ${formatDate(item.installDate)}")
item.idSai?.let { Text("SAI ID: $it") }
}
IconButton(onClick = {
scope.launch {
repository.delete(item.id).onSuccess { loadItems() }
},
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = { batteryToDelete = item }) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar", tint = MaterialTheme.colorScheme.error)
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar")
}
}
}
)
HorizontalDivider()
)
}
}
}
}
@@ -27,13 +27,18 @@ fun EditSaiScreen(
var ns by remember { mutableStateOf("") }
var modelo by remember { mutableStateOf("") }
var estado by remember { mutableStateOf("Disponible") }
var isLoading by remember { mutableStateOf(true) }
var isSaving by remember { mutableStateOf(false) }
var expanded by remember { mutableStateOf(false) }
val statusOptions = listOf("Disponible", "Asignado", "En reparación", "Baja")
LaunchedEffect(saiId) {
repository.getById(saiId).onSuccess {
ns = it.ns
modelo = it.modelo ?: ""
estado = it.estado
isLoading = false
}.onFailure {
Toast.makeText(context, "Error al cargar SAI", Toast.LENGTH_SHORT).show()
@@ -77,13 +82,44 @@ fun EditSaiScreen(
label = { Text(stringResource(R.string.label_modelo)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Status Dropdown
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = estado,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.label_status)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
statusOptions.forEach { selectionOption ->
DropdownMenuItem(
text = { Text(selectionOption) },
onClick = {
estado = selectionOption
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
if (ns.isNotBlank()) {
isSaving = true
scope.launch {
repository.update(Sai(id = saiId, ns = ns, modelo = modelo))
repository.update(Sai(id = saiId, ns = ns, modelo = modelo, estado = estado))
.onSuccess {
Toast.makeText(context, "SAI actualizado", Toast.LENGTH_SHORT).show()
onNavigateBack()
@@ -1,15 +1,20 @@
package com.example.saipp.ui.management
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.saipp.ui.ConnectionIndicator
import com.example.saipp.R
import com.example.saipp.data.MariaDbConnection
import com.example.saipp.data.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -17,14 +22,31 @@ fun HomeScreen(
onNavigateToSais: () -> Unit,
onNavigateToBatteries: () -> Unit,
onNavigateToPeople: () -> Unit,
onNavigateToAssignments: () -> Unit
onNavigateToAssignments: () -> Unit,
onNavigateToAvailableSais: () -> Unit,
onNavigateToQuickInfo: () -> Unit
) {
var connectionStatus by remember { mutableStateOf<Result<Unit>?>(null) }
var isCheckingConnection by remember { mutableStateOf(true) }
val saiRepo = remember { SaiRepository() }
val batteryRepo = remember { BatteryRepository() }
val personRepo = remember { PersonRepository() }
var saiStats by remember { mutableStateOf(Pair(0, 0)) }
var batteryCount by remember { mutableStateOf(0) }
var expiredBatteries by remember { mutableStateOf(0) }
var peopleCount by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
isCheckingConnection = true
connectionStatus = MariaDbConnection.testConnection()
if (connectionStatus?.isSuccess == true) {
saiStats = saiRepo.getStats().getOrDefault(Pair(0, 0))
batteryCount = batteryRepo.getCount().getOrDefault(0)
expiredBatteries = batteryRepo.getExpiredCount().getOrDefault(0)
peopleCount = personRepo.getActiveCount().getOrDefault(0)
}
isCheckingConnection = false
}
@@ -37,61 +59,163 @@ fun HomeScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ConnectionIndicator(
isChecking = isCheckingConnection,
status = connectionStatus
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
MenuButton(
text = stringResource(R.string.menu_sais),
onClick = onNavigateToSais,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
MenuButton(
text = stringResource(R.string.menu_batteries),
onClick = onNavigateToBatteries,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
MenuButton(
text = stringResource(R.string.menu_people),
onClick = onNavigateToPeople,
modifier = Modifier.fillMaxWidth()
)
// Dashboard Stats
StatsDashboard(saiStats, batteryCount, peopleCount)
if (expiredBatteries > 0) {
Spacer(modifier = Modifier.height(16.dp))
MaintenanceAlert(expiredBatteries)
}
Spacer(modifier = Modifier.height(24.dp))
// Quick Scan Button
Button(
onClick = onNavigateToQuickInfo,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary)
) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.menu_quick_info))
}
Spacer(modifier = Modifier.height(16.dp))
MenuButton(
text = stringResource(R.string.menu_assignments),
onClick = onNavigateToAssignments,
modifier = Modifier.fillMaxWidth()
)
// Main Menu Grid
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MenuCard(
text = stringResource(R.string.menu_sais),
icon = Icons.Default.Computer,
onClick = onNavigateToSais,
modifier = Modifier.weight(1f)
)
MenuCard(
text = stringResource(R.string.menu_batteries),
icon = Icons.Default.BatteryChargingFull,
onClick = onNavigateToBatteries,
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MenuCard(
text = stringResource(R.string.menu_people),
icon = Icons.Default.People,
onClick = onNavigateToPeople,
modifier = Modifier.weight(1f)
)
MenuCard(
text = stringResource(R.string.menu_assignments),
icon = Icons.Default.AssignmentInd,
onClick = onNavigateToAssignments,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onNavigateToAvailableSais,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.menu_available_sais), style = MaterialTheme.typography.titleMedium)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
fun MenuButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier.height(64.dp),
shape = MaterialTheme.shapes.medium
fun MaintenanceAlert(count: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(text = text, style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(stringResource(R.string.maintenance_alert_title), fontWeight = FontWeight.Bold)
Text(stringResource(R.string.maintenance_alert_message, count), style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
fun StatsDashboard(saiStats: Pair<Int, Int>, batteryCount: Int, peopleCount: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(R.string.dashboard_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
StatItem(count = saiStats.first.toString(), label = stringResource(R.string.stats_sais), subLabel = "${saiStats.second} ${stringResource(R.string.stats_available)}")
StatItem(count = batteryCount.toString(), label = stringResource(R.string.stats_batteries))
StatItem(count = peopleCount.toString(), label = stringResource(R.string.stats_people))
}
}
}
}
@Composable
fun StatItem(count: String, label: String, subLabel: String? = null) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(count, fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Text(label, style = MaterialTheme.typography.labelMedium)
if (subLabel != null) {
Text(subLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
}
}
}
@Composable
fun MenuCard(text: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(
onClick = onClick,
modifier = modifier.height(100.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
Text(text, style = MaterialTheme.typography.labelLarge, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
}
@@ -9,15 +9,19 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.unit.dp
import com.example.saipp.data.PersonRepository
import com.example.saipp.data.model.Person
import com.example.saipp.ui.SearchBar
import com.example.saipp.ui.DeleteConfirmDialog
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -33,6 +37,8 @@ fun PersonListScreen(
var items by remember { mutableStateOf<List<Person>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var personToDelete by remember { mutableStateOf<Person?>(null) }
fun loadItems() {
scope.launch {
@@ -75,32 +81,72 @@ fun PersonListScreen(
onQueryChange = { searchQuery = it },
modifier = Modifier.padding(16.dp)
)
if (personToDelete != null) {
DeleteConfirmDialog(
onDismiss = { personToDelete = null },
onConfirm = {
val id = personToDelete!!.id
personToDelete = null
scope.launch {
repository.delete(id).onSuccess { loadItems() }
}
}
)
}
if (isLoading) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredItems) { item ->
ListItem(
headlineContent = { Text(item.name) },
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar")
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
ListItem(
headlineContent = {
Text(
text = item.name,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
color = if (item.activo) Color.Unspecified else Color.Gray
)
},
supportingContent = {
if (!item.activo) {
Text("Baja", color = Color.Red, style = MaterialTheme.typography.labelSmall)
}
IconButton(onClick = {
scope.launch {
repository.delete(item.id).onSuccess { loadItems() }
},
trailingContent = {
Row {
IconButton(onClick = {
scope.launch {
repository.toggleStatus(item.id, !item.activo)
.onSuccess { loadItems() }
}
}) {
Icon(
imageVector = if (item.activo) Icons.Default.PersonOff else Icons.Default.PersonAdd,
contentDescription = if (item.activo) "Dar de baja" else "Reactivar",
tint = if (item.activo) Color.Red else Color.Green
)
}
IconButton(onClick = { onNavigateToEdit(item.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = { personToDelete = item }) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar", tint = MaterialTheme.colorScheme.error)
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar")
}
}
}
)
HorizontalDivider()
)
}
}
}
}
@@ -0,0 +1,139 @@
package com.example.saipp.ui.management
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.example.saipp.R
import com.example.saipp.data.SaiRepository
import com.example.saipp.data.model.Sai
import com.example.saipp.ui.scanner.BarcodeScannerView
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QuickInfoScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val repository = remember { SaiRepository() }
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted -> hasCameraPermission = granted }
)
LaunchedEffect(Unit) {
if (!hasCameraPermission) {
launcher.launch(Manifest.permission.CAMERA)
}
}
var scannedSai by remember { mutableStateOf<Sai?>(null) }
var isSearching by remember { mutableStateOf(false) }
var lastScannedCode by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.quick_info_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Atrás")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (hasCameraPermission) {
Box(modifier = Modifier.weight(1f)) {
BarcodeScannerView(onBarcodeDetected = { code ->
if (code != lastScannedCode) {
lastScannedCode = code
isSearching = true
// In a real app we would use a proper CoroutineScope,
// but LaunchedEffect(code) is cleaner here.
}
})
}
} else {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Permiso de cámara necesario")
}
}
LaunchedEffect(lastScannedCode) {
if (lastScannedCode.isNotEmpty()) {
repository.getByNs(lastScannedCode).onSuccess {
scannedSai = it
}.onFailure {
scannedSai = null
}
isSearching = false
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(16.dp)) {
if (isSearching) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else if (scannedSai != null) {
Text("Información Encontrada", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
InfoRow("NS:", scannedSai!!.ns)
InfoRow("Modelo:", scannedSai!!.modelo ?: "Desconocido")
InfoRow("Estado:", scannedSai!!.estado)
if (scannedSai!!.asignadoA != null) {
InfoRow("Asignado a:", scannedSai!!.asignadoA!!)
}
} else if (lastScannedCode.isNotEmpty()) {
Text("No se encontró ningún SAI con el código:", color = MaterialTheme.colorScheme.error)
Text(lastScannedCode, fontWeight = FontWeight.Bold)
} else {
Text(stringResource(R.string.quick_info_scan_hint), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
}
}
}
}
@Composable
fun InfoRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(label, modifier = Modifier.width(100.dp), fontWeight = FontWeight.Medium)
Text(value, fontWeight = FontWeight.Bold)
}
}
@@ -0,0 +1,158 @@
package com.example.saipp.ui.management
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.saipp.data.SaiRepository
import com.example.saipp.data.model.HistoryItem
import com.example.saipp.data.model.Sai
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SaiDetailScreen(
saiId: Int,
onNavigateBack: () -> Unit
) {
val repository = remember { SaiRepository() }
var sai by remember { mutableStateOf<Sai?>(null) }
var history by remember { mutableStateOf<List<HistoryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(saiId) {
isLoading = true
val saiRes = repository.getById(saiId)
val historyRes = repository.getHistory(saiId)
sai = saiRes.getOrNull()
history = historyRes.getOrDefault(emptyList())
isLoading = false
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Detalle del SAI") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Atrás")
}
}
)
}
) { padding ->
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
sai?.let { SaiInfoCard(it) }
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Historial de Actividad",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
if (history.isEmpty()) {
Text("No hay registros previos para este equipo.", color = Color.Gray)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(history) { item ->
HistoryCard(item)
}
}
}
}
}
}
}
@Composable
fun SaiInfoCard(sai: Sai) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Computer, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "NS: ${sai.ns}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Modelo: ${sai.modelo ?: "N/A"}")
Text(text = "Estado actual: ${sai.estado}")
if (sai.asignadoA != null) {
Text(text = "En posesión de: ${sai.asignadoA}", color = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
fun HistoryCard(item: HistoryItem) {
val icon = when(item.type) {
"Asignación" -> Icons.Default.Login
"Devolución" -> Icons.Default.Logout
"Mantenimiento" -> Icons.Default.Build
else -> Icons.Default.Info
}
val color = when(item.type) {
"Asignación" -> Color(0xFFC62828) // Red
"Devolución" -> Color(0xFF2E7D32) // Green
"Mantenimiento" -> Color(0xFFEF6C00) // Orange
else -> Color.Gray
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = item.description, style = MaterialTheme.typography.bodyMedium)
item.date?.let {
Text(text = formatDate(it), style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
}
}
}
fun formatDate(dbDate: String): String {
return try {
val dbFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val displayFormat = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault())
val date = dbFormat.parse(dbDate)
if (date != null) displayFormat.format(date) else dbDate
} catch (e: Exception) {
dbDate
}
}
@@ -5,20 +5,19 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.unit.dp
import com.example.saipp.data.SaiRepository
import com.example.saipp.data.model.Sai
import com.example.saipp.ui.ScannerDialog
import com.example.saipp.ui.SearchBar
import com.example.saipp.ui.ScannerDialog
import com.example.saipp.ui.DeleteConfirmDialog
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -26,7 +25,8 @@ import kotlinx.coroutines.launch
fun SaiListScreen(
onNavigateBack: () -> Unit,
onNavigateToScan: () -> Unit,
onNavigateToEdit: (Int) -> Unit
onNavigateToEdit: (Int) -> Unit,
onNavigateToDetail: (Int) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -35,6 +35,8 @@ fun SaiListScreen(
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var showQuickScanner by remember { mutableStateOf(false) }
var saiToDelete by remember { mutableStateOf<Sai?>(null) }
fun loadSais() {
scope.launch {
@@ -91,33 +93,72 @@ fun SaiListScreen(
}
)
}
if (saiToDelete != null) {
DeleteConfirmDialog(
onDismiss = { saiToDelete = null },
onConfirm = {
val id = saiToDelete!!.id
saiToDelete = null
scope.launch {
repository.delete(id).onSuccess { loadSais() }
}
}
)
}
if (isLoading) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredSais) { sai ->
ListItem(
headlineContent = { Text("NS: ${sai.ns}") },
supportingContent = { Text("Modelo: ${sai.modelo ?: "N/A"}") },
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(sai.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar")
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { onNavigateToDetail(sai.id) },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
ListItem(
headlineContent = { Text("NS: ${sai.ns}", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
supportingContent = {
Column {
Text("Modelo: ${sai.modelo ?: "N/A"}")
Text(
text = sai.estado,
color = when(sai.estado) {
"Disponible" -> Color(0xFF2E7D32)
"Asignado" -> Color(0xFFC62828)
"En reparación" -> Color(0xFFEF6C00)
else -> Color.Gray
},
style = MaterialTheme.typography.labelSmall
)
if (sai.estado == "Asignado" && sai.asignadoA != null) {
Text(
text = "Asignado a: ${sai.asignadoA}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
IconButton(onClick = {
scope.launch {
repository.delete(sai.id).onSuccess { loadSais() }
},
trailingContent = {
Row {
IconButton(onClick = { onNavigateToEdit(sai.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Editar", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = { saiToDelete = sai }) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar", tint = MaterialTheme.colorScheme.error)
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar")
}
}
}
)
HorizontalDivider()
)
}
}
}
}
@@ -0,0 +1,73 @@
package com.example.saipp.util
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.net.Uri
import androidx.core.content.FileProvider
import com.example.saipp.data.model.Assignment
import java.io.File
import java.io.FileOutputStream
object PdfGenerator {
fun generateReceipt(context: Context, assignment: Assignment) {
val pdfDocument = PdfDocument()
val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create() // A4 size
val page = pdfDocument.startPage(pageInfo)
val canvas: Canvas = page.canvas
val paint = Paint()
// Title
paint.textSize = 24f
paint.isFakeBoldText = true
canvas.drawText("RECIBO DE ENTREGA DE EQUIPO", 50f, 50f, paint)
// Content
paint.textSize = 14f
paint.isFakeBoldText = false
var y = 100f
val content = listOf(
"Persona: ${assignment.personName ?: "N/A"}",
"Equipo (SAI) NS: ${assignment.saiNs ?: "N/A"}",
"Fecha Asignación: ${assignment.assignedDate ?: "N/A"}",
"Observaciones: ${assignment.observations ?: "Ninguna"}",
"",
"",
"__________________________",
"Firma del Receptor"
)
for (line in content) {
canvas.drawText(line, 50f, y, paint)
y += 30f
}
pdfDocument.finishPage(page)
// Save file
val file = File(context.cacheDir, "recibo_${assignment.id}.pdf")
try {
pdfDocument.writeTo(FileOutputStream(file))
sharePdf(context, file)
} catch (e: Exception) {
e.printStackTrace()
} finally {
pdfDocument.close()
}
}
private fun sharePdf(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Compartir Recibo"))
}
}
+27
View File
@@ -22,11 +22,38 @@
<string name="placeholder_search">Buscar...</string>
<string name="error_duplicate_sai">Este SAI ya está registrado en el sistema.</string>
<string name="scan_to_search">Escanear para buscar</string>
<string name="status_available">Disponible</string>
<string name="status_assigned">Asignado</string>
<string name="status_repair">En reparación</string>
<string name="status_retired">Baja</string>
<string name="label_status">Estado</string>
<string name="action_deactivate">Dar de Baja</string>
<string name="action_reactivate">Reactivar</string>
<string name="filter_hide_inactive">Ocultar personal de baja</string>
<string name="dashboard_title">Estado General</string>
<string name="stats_sais">SAIs</string>
<string name="stats_batteries">Baterías</string>
<string name="stats_people">Personal</string>
<string name="stats_available">disponibles</string>
<string name="maintenance_alert_title">Alerta de Mantenimiento</string>
<string name="maintenance_alert_message">Hay %1$d baterías con más de 2 años de antigüedad. Se recomienda revisarlas.</string>
<string name="confirm_delete_title">¿Eliminar registro?</string>
<string name="confirm_delete_message">Esta acción no se puede deshacer. ¿Estás seguro de que deseas eliminar este elemento?</string>
<string name="button_delete">Eliminar</string>
<string name="button_cancel">Cancelar</string>
<string name="menu_quick_info">Consulta Rápida</string>
<string name="quick_info_title">Información del SAI</string>
<string name="quick_info_scan_hint">Escanea el código de barras del SAI para ver su estado actual.</string>
<string name="menu_sais">Gestión de SAIs</string>
<string name="menu_batteries">Gestión de Baterías</string>
<string name="menu_people">Gestión de Personas</string>
<string name="menu_assignments">Gestion SAI - Persona</string>
<string name="menu_available_sais">SAIs Disponibles</string>
<string name="label_person">Persona</string>
<string name="label_assigned_date">Fecha Asignación (DD-MM-AAAA)</string>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
</paths>