commit 895760cb5dfa3eb5dd2773c32204cc27d91060f8
Author: Nino
Date: Sun Jul 26 22:33:09 2026 -0600
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4047ed9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,20 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build
+/app/release
+opencode.txt
+*.apk
+*.aab
+*.jks
+*.keystore
+*/build/
+*.log
+*.hprof
diff --git a/README.md b/README.md
new file mode 100755
index 0000000..083caca
--- /dev/null
+++ b/README.md
@@ -0,0 +1,220 @@
+# Recordatorios
+
+App Android de notas y recordatorios con Material 3 Dynamic Colors (Monet).
+
+## Funcionalidades
+
+- **Notas** con título y contenido multilínea
+- **Recordatorios** con fecha y hora exacta (AlarmManager)
+- **Recurrencia**: diaria, semanal, mensual, anual
+- **Notificaciones** push al cumplirse la fecha, incluso con la app cerrada
+- **Tabs**: Hoy (tareas del día), Pendientes (futuros), Eliminados (papelera)
+- **Check**: completa una nota y la envía a Eliminados; si es recurrente, adelanta al próximo período
+- **Buscador** por título y contenido en tiempo real
+- **Papelera** con restauración y borrado permanente
+
+## Requisitos
+
+- Android Studio Hedgehog (2023.1.1) o superior
+- JDK 17
+- Gradle 8.0
+- compileSdk 34, minSdk 26
+
+## Cómo ejecutar
+
+```bash
+git clone
+cd Recordatorios
+./gradlew assembleDebug
+```
+
+El APK se genera en `app/build/outputs/apk/debug/app-debug.apk`.
+
+Si el SDK no está en `~/Android/Sdk`, crear `local.properties`:
+
+```
+sdk.dir=/ruta/al/Android/Sdk
+```
+
+## Estructura del proyecto
+
+```
+app/src/main/java/com/recordatorios/app/
+├── MainActivity.kt # Actividad principal, maneja tabs, editor, filtros
+├── Note.kt # Modelo de datos (data class)
+├── NotesManager.kt # Persistencia con SharedPreferences + Gson
+├── NoteAdapter.kt # RecyclerView adapter con expand/colapsar
+├── ReminderReceiver.kt # BroadcastReceiver para notificaciones + recurrencia
+└── ReminderScheduler.kt # AlarmManager (setExactAndAllowWhileIdle)
+
+app/src/main/res/
+├── layout/
+│ ├── activity_main.xml # Toolbar, tabs, buscador, RecyclerView, FAB
+│ ├── item_note.xml # Card con expand, check, fecha, acciones
+│ └── dialog_note_editor.xml # Editor: título, contenido, recordatorio, recurrencia
+├── drawable/ # Iconos vectoriales (pencil, trash, sun, clock, bell, check)
+└── values/
+ ├── themes.xml # Theme.Material3.DynamicColors.DayNight + NoActionBar
+ ├── colors.xml
+ └── strings.xml # Español
+```
+
+## Flujo de desarrollo
+
+1. **Modelo** → modificar `Note.kt` (agregar/quitar campos)
+2. **Persistencia** → `NotesManager.kt` (SharedPreferences + Gson)
+3. **UI** → layout XML + binding en `MainActivity.kt`
+4. **Adapter** → `NoteAdapter.kt` para cambios en la lista
+5. **Recordatorios** → `ReminderScheduler.kt` + `ReminderReceiver.kt`
+6. **Compilar** → `./gradlew assembleDebug`
+
+Si se agrega un campo a `Note.kt`, Gson lo serializa automáticamente. No hay migraciones de base de datos.
+
+## Estilo
+
+- Tema: `Theme.Material3.DynamicColors.DayNight` (colores dinámicos del wallpaper)
+- Cards: `MaterialCardView`, 12dp radius, 2dp elevation
+- TextFields: `OutlinedBox`
+- Tabs con iconos vectoriales
+- Sin ActionBar nativa — solo Toolbar personalizada
+
+---
+
+## Guía de Uso
+
+### Introducción
+
+Recordatorios es una aplicación Android para gestionar notas y recordatorios con un diseño moderno usando Material 3 y colores dinámicos.
+
+### Pestañas
+
+#### ☀️ Hoy
+Muestra los recordatorios programados para el día actual y los que ya vencieron.
+
+- **Tareas del día**: Recordatorios con fecha de hoy
+- **Vencidos**: Recordatorios que ya pasaron su fecha
+- Las notas se agrupan en secciones para fácil identificación
+
+#### 🕐 Pendientes
+Muestra los recordatorios futuros, aquellos programados para fechas posteriores a hoy.
+
+- Solo muestra recordatorios que aún no han llegado
+- Ordenados cronológicamente
+
+#### 📅 Calendario
+Vista de calendario mensual con indicadores visuales.
+
+- **Puntos de color**: Indican recordatorios en ese día
+- **Tap simple**: Muestra los recordatorios del día seleccionado
+- **Triple tap**: Crea un nuevo recordatorio para esa fecha
+- **Swipe horizontal**: Cambia entre meses
+- **Lista inferior**: Muestra todos los recordatorios del mes actual
+
+#### 🗑️ Basurero
+Papelera donde van las notas completadas o eliminadas.
+
+- **Restaurar**: Devuelve la nota a la lista principal
+- **Eliminar permanentemente**: Borra la nota definitivamente
+- Las notas recurrentes completadas adelantan al próximo período
+
+#### ⚙️ Configuración
+Ajustes de la aplicación.
+
+### Funcionalidades Principales
+
+#### Crear una Nota
+1. Presiona el botón **+** en la esquina inferior derecha
+2. Escribe un título y/o contenido
+3. Activa el switch de recordatorio si deseas una notificación
+4. Selecciona fecha y hora
+5. Elige la recurrencia (diaria, semanal, mensual, anual)
+6. Selecciona una categoría (opcional)
+7. Agrega una foto (opcional)
+8. Presiona **Guardar**
+
+#### Nota por Voz
+1. Presiona el botón de **micrófono** junto al botón +
+2. Di tu recordatorio
+3. La app parseará automáticamente lo dicho
+
+#### Editar una Nota
+1. Expande la nota presionando la flecha ▼
+2. Presiona **Editar**
+3. Modifica los campos necesarios
+4. Presiona **Guardar**
+
+#### Completar una Nota
+- Presiona el botón de **check** ✓ en la card
+- La nota se mueve al basurero
+- Si es recurrente, se programa automáticamente para el próximo período
+
+#### Eliminar una Nota
+1. Expande la nota
+2. Presiona **Eliminar**
+3. Confirma la eliminación
+4. La nota va al basurero
+
+#### Buscar Notas
+- Usa el campo de búsqueda en la parte superior
+- Busca por título o contenido en tiempo real
+
+#### Filtrar por Categoría
+- En la pestaña de Pendientes, selecciona las categorías deseadas
+- Los filtros se guardan automáticamente
+
+### Categorías
+
+#### Crear Categoría
+1. En el editor de notas, presiona **Administrar categorías**
+2. Presiona **Agregar categoría**
+3. Escribe un nombre
+4. Selecciona un color
+5. Presiona **Guardar**
+
+#### Editar/Eliminar Categoría
+- En el administrador de categorías, usa los botones de lápiz o basura
+
+### Configuración
+
+#### Biometría
+- Activa el desbloqueo con huella dactilar o reconocimiento facial
+- Solo se solicita una vez al abrir la app
+
+#### Tema
+- **Sistema**: Sigue el tema del dispositivo
+- **Claro**: Siempre tema claro
+- **Oscuro**: Siempre tema oscuro
+
+#### Fondo Personalizado
+- Selecciona una imagen de tu galería como fondo
+- Se aplica un efecto de blur para mejor legibilidad
+
+#### Notificaciones
+- Mostrar imagen en notificaciones (activado por defecto)
+
+#### Limpieza Automática de Basurero
+- Configura cuánto tiempo permanecen las notas en el basurero
+- Opciones: Desactivado, 1 día, 3 días, 7 días, 30 días
+
+#### Datos
+- **Exportar**: Guarda todas las notas y categorías en un archivo JSON
+- **Importar**: Restaura desde un archivo de backup
+
+### Gestos
+
+- **Swipe horizontal**: Cambia entre pestañas
+- **Swipe en calendario**: Cambia de mes
+
+### Consejos
+
+1. **Recurrencia**: Usa recordatorios recurrentes para tareas periódicas
+2. **Categorías**: Organiza tus notas por colores para identificarlas rápidamente
+3. **Bordes de color**: Las cards muestran el color de su categoría en el borde
+4. **Backup**: Exporta regularmente tus datos para no perder información
+5. **Búsqueda**: Usa la búsqueda para encontrar notas antiguas rápidamente
+
+---
+
+## Repositorio Oficial
+
+**[https://git.nakano47.com/nino/Recordatorios-app](https://git.nakano47.com/nino/Recordatorios-app)**
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100755
index 0000000..aa35126
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,50 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'com.recordatorios.app'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.recordatorios.app"
+ minSdk 26
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.11.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
+ implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'androidx.activity:activity-ktx:1.8.2'
+ implementation 'androidx.biometric:biometric:1.1.0'
+ implementation 'androidx.security:security-crypto:1.1.0-alpha06'
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100755
index 0000000..fb164d6
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100755
index 0000000..f3871e8
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/guide.md b/app/src/main/assets/guide.md
new file mode 100755
index 0000000..45c47fa
--- /dev/null
+++ b/app/src/main/assets/guide.md
@@ -0,0 +1,156 @@
+# Guía de Uso - Recordatorios
+
+## Introducción
+
+Recordatorios es una aplicación Android para gestionar notas y recordatorios con un diseño moderno usando Material 3 y colores dinámicos.
+
+---
+
+## Pestañas
+
+### ☀️ Hoy
+Muestra los recordatorios programados para el día actual y los que ya vencieron.
+
+- **Tareas del día**: Recordatorios con fecha de hoy
+- **Vencidos**: Recordatorios que ya pasaron su fecha
+- Las notas se agrupan en secciones para fácil identificación
+
+### 🕐 Pendientes
+Muestra los recordatorios futuros, aquellos programados para fechas posteriores a hoy.
+
+- Solo muestra recordatorios que aún no han llegado
+- Ordenados cronológicamente
+
+### 📅 Calendario
+Vista de calendario mensual con indicadores visuales.
+
+- **Puntos de color**: Indican recordatorios en ese día
+- **Tap simple**: Muestra los recordatorios del día seleccionado
+- **Triple tap**: Crea un nuevo recordatorio para esa fecha
+- **Swipe horizontal**: Cambia entre meses
+- **Lista inferior**: Muestra todos los recordatorios del mes actual
+
+### 🗑️ Basurero
+Papelera donde van las notas completadas o eliminadas.
+
+- **Restaurar**: Devuelve la nota a la lista principal
+- **Eliminar permanentemente**: Borra la nota definitivamente
+- Las notas recurrentes completadas adelantan al próximo período
+
+### ⚙️ Configuración
+Ajustes de la aplicación.
+
+---
+
+## Funcionalidades Principales
+
+### Crear una Nota
+1. Presiona el botón **+** en la esquina inferior derecha
+2. Escribe un título y/o contenido
+3. Activa el switch de recordatorio si deseas una notificación
+4. Selecciona fecha y hora
+5. Elige la recurrencia (diaria, semanal, mensual, anual)
+6. Selecciona una categoría (opcional)
+7. Agrega una foto (opcional)
+8. Presiona **Guardar**
+
+### Nota por Voz
+1. Presiona el botón de **micrófono** junto al botón +
+2. Di tu recordatorio
+3. La app parseará automáticamente lo dicho
+
+### Editar una Nota
+1. Expande la nota presionando la flecha ▼
+2. Presiona **Editar**
+3. Modifica los campos necesarios
+4. Presiona **Guardar**
+
+### Completar una Nota
+- Presiona el botón de **check** ✓ en la card
+- La nota se mueve al basurero
+- Si es recurrente, se programa automáticamente para el próximo período
+
+### Eliminar una Nota
+1. Expande la nota
+2. Presiona **Eliminar**
+3. Confirma la eliminación
+4. La nota va al basurero
+
+### Buscar Notas
+- Usa el campo de búsqueda en la parte superior
+- Busca por título o contenido en tiempo real
+
+### Filtrar por Categoría
+- En la pestaña de Pendientes, selecciona las categorías deseadas
+- Los filtros se guardan automáticamente
+
+---
+
+## Categorías
+
+### Crear Categoría
+1. En el editor de notas, presiona **Administrar categorías**
+2. Presiona **Agregar categoría**
+3. Escribe un nombre
+4. Selecciona un color
+5. Presiona **Guardar**
+
+### Editar/Eliminar Categoría
+- En el administrador de categorías, usa los botones de lápiz o basura
+
+---
+
+## Configuración
+
+### Biometría
+- Activa el desbloqueo con huella dactilar o reconocimiento facial
+- Solo se solicita una vez al abrir la app
+
+### Tema
+- **Sistema**: Sigue el tema del dispositivo
+- **Claro**: Siempre tema claro
+- **Oscuro**: Siempre tema oscuro
+
+### Fondo Personalizado
+- Selecciona una imagen de tu galería como fondo
+- Se aplica un efecto de blur para mejor legibilidad
+
+### Notificaciones
+- Mostrar imagen en notificaciones (activado por defecto)
+
+### Limpieza Automática de Basurero
+- Configura cuánto tiempo permanecen las notas en el basurero
+- Opciones: Desactivado, 1 día, 3 días, 7 días, 30 días
+
+### Datos
+- **Exportar**: Guarda todas las notas y categorías en un archivo JSON
+- **Importar**: Restaura desde un archivo de backup
+
+---
+
+## Gestos
+
+- **Swipe horizontal**: Cambia entre pestañas
+- **Swipe en calendario**: Cambia de mes
+
+---
+
+## Consejos
+
+1. **Recurrencia**: Usa recordatorios recurrentes para tareas periódicas
+2. **Categorías**: Organiza tus notas por colores para identificarlas rápidamente
+3. **Bordes de color**: Las cards muestran el color de su categoría en el borde
+4. **Backup**: Exporta regularmente tus datos para no perder información
+5. **Búsqueda**: Usa la búsqueda para encontrar notas antiguas rápidamente
+
+---
+
+## Repositorio Oficial
+
+El código fuente está disponible en:
+
+**[https://git.nakano47.com/nino/Recordatorios-app](https://git.nakano47.com/nino/Recordatorios-app)**
+
+---
+
+*Versión 1.0*
diff --git a/app/src/main/java/com/recordatorios/app/Category.kt b/app/src/main/java/com/recordatorios/app/Category.kt
new file mode 100755
index 0000000..0b51529
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/Category.kt
@@ -0,0 +1,30 @@
+package com.recordatorios.app
+
+data class Category(
+ val id: String = java.util.UUID.randomUUID().toString(),
+ var name: String,
+ var color: Int
+) {
+ companion object {
+ val DEFAULT_COLORS = listOf(
+ 0xFFF44336.toInt(), // red
+ 0xFFE91E63.toInt(), // pink
+ 0xFF9C27B0.toInt(), // purple
+ 0xFF673AB7.toInt(), // deep purple
+ 0xFF3F51B5.toInt(), // indigo
+ 0xFF2196F3.toInt(), // blue
+ 0xFF03A9F4.toInt(), // light blue
+ 0xFF00BCD4.toInt(), // cyan
+ 0xFF009688.toInt(), // teal
+ 0xFF4CAF50.toInt(), // green
+ 0xFF8BC34A.toInt(), // light green
+ 0xFFFFEB3B.toInt(), // yellow
+ 0xFFFF9800.toInt(), // orange
+ 0xFFFF5722.toInt(), // deep orange
+ 0xFF795548.toInt(), // brown
+ 0xFF607D8B.toInt(), // blue grey
+ 0xFF9E9E9E.toInt(), // grey
+ 0xFF000000.toInt(), // black
+ )
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/CategoryManager.kt b/app/src/main/java/com/recordatorios/app/CategoryManager.kt
new file mode 100755
index 0000000..f184e77
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/CategoryManager.kt
@@ -0,0 +1,139 @@
+package com.recordatorios.app
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+
+class CategoryManager private constructor(context: Context) {
+
+ private var prefs: SharedPreferences =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val gson = Gson()
+ private var securityManager: SecurityManager? = null
+
+ private var categories: MutableList = mutableListOf()
+
+ fun setSecurityManager(securityManager: SecurityManager) {
+ this.securityManager = securityManager
+ val json = getPrefs().getString(KEY_CATEGORIES, null)
+ if (json != null) {
+ val type = object : TypeToken>() {}.type
+ val loadedCategories = gson.fromJson>(json, type)
+ if (loadedCategories != null && loadedCategories.isNotEmpty()) {
+ categories = loadedCategories
+ return
+ }
+ }
+ if (categories.isEmpty()) {
+ createDefaults()
+ }
+ }
+
+ fun reload() {
+ val json = getPrefs().getString(KEY_CATEGORIES, null)
+ if (json != null) {
+ val type = object : TypeToken>() {}.type
+ categories = gson.fromJson(json, type) ?: mutableListOf()
+ }
+ if (categories.isEmpty()) {
+ createDefaults()
+ }
+ }
+
+ private fun getPrefs(): SharedPreferences {
+ return securityManager?.getEncryptedPrefs() ?: prefs
+ }
+
+ init {
+ loadCategories()
+ if (categories.isEmpty()) {
+ createDefaults()
+ }
+ }
+
+ private fun createDefaults() {
+ val defaults = listOf(
+ Category(name = "Personal", color = 0xFF2196F3.toInt()),
+ Category(name = "Trabajo", color = 0xFFFF9800.toInt()),
+ Category(name = "Estudio", color = 0xFF4CAF50.toInt()),
+ Category(name = "Salud", color = 0xFFF44336.toInt()),
+ Category(name = "Finanzas", color = 0xFF9C27B0.toInt()),
+ Category(name = "Otros", color = 0xFF607D8B.toInt()),
+ )
+ categories.addAll(defaults)
+ saveCategories()
+ }
+
+ private fun loadCategories() {
+ val json = getPrefs().getString(KEY_CATEGORIES, null) ?: return
+ val type = object : TypeToken>() {}.type
+ categories = gson.fromJson(json, type) ?: mutableListOf()
+ }
+
+ private fun saveCategories() {
+ getPrefs().edit().putString(KEY_CATEGORIES, gson.toJson(categories)).apply()
+ }
+
+ fun getAllCategories(): List = categories.toList()
+
+ fun getCategory(id: String): Category? = categories.find { it.id == id }
+
+ fun addCategory(category: Category) {
+ categories.add(category)
+ saveCategories()
+ }
+
+ fun updateCategory(id: String, name: String, color: Int) {
+ val cat = categories.find { it.id == id } ?: return
+ cat.name = name
+ cat.color = color
+ saveCategories()
+ }
+
+ fun deleteCategory(id: String) {
+ categories.removeAll { it.id == id }
+ saveCategories()
+ }
+
+ fun getNoneCategoryId(): String? = categories.find { it.name == "Sin categoría" }?.id
+
+ fun exportToJson(): String {
+ return gson.toJson(categories)
+ }
+
+ fun importFromJson(json: String, merge: Boolean = true) {
+ val type = object : TypeToken>() {}.type
+ val importedCategories = gson.fromJson>(json, type) ?: return
+ if (importedCategories.isEmpty()) return
+
+ if (!merge) {
+ categories.clear()
+ categories.addAll(importedCategories)
+ } else {
+ for (category in importedCategories) {
+ val existing = categories.indexOfFirst { it.id == category.id || it.name.equals(category.name, ignoreCase = true) }
+ if (existing != -1) {
+ categories[existing] = category.copy(id = categories[existing].id)
+ } else {
+ categories.add(category)
+ }
+ }
+ }
+ saveCategories()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "categories_prefs"
+ private const val KEY_CATEGORIES = "categories"
+
+ @Volatile
+ private var instance: CategoryManager? = null
+
+ fun getInstance(context: Context): CategoryManager {
+ return instance ?: synchronized(this) {
+ instance ?: CategoryManager(context.applicationContext).also { instance = it }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/MainActivity.kt b/app/src/main/java/com/recordatorios/app/MainActivity.kt
new file mode 100755
index 0000000..7a9a0b3
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/MainActivity.kt
@@ -0,0 +1,1901 @@
+package com.recordatorios.app
+
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.media.MediaRecorder
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.speech.RecognizerIntent
+import android.text.Editable
+import android.text.TextWatcher
+import android.graphics.Color
+import android.util.Base64
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.DecelerateInterpolator
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import android.widget.ImageButton
+import android.widget.ImageView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.chip.Chip
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
+import com.google.android.material.tabs.TabLayout
+import com.google.gson.Gson
+import com.recordatorios.app.databinding.ActivityMainBinding
+import com.recordatorios.app.databinding.DialogNoteEditorBinding
+import com.recordatorios.app.databinding.DialogCategoryManagerBinding
+import com.recordatorios.app.databinding.DialogCategoryEditorBinding
+import android.content.Context
+import java.io.File
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var notesManager: NotesManager
+ private lateinit var categoryManager: CategoryManager
+ private lateinit var securityManager: SecurityManager
+ private lateinit var adapter: NoteAdapter
+ private lateinit var monthNotesAdapter: NoteAdapter
+
+ private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
+ private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+
+ private var editingNote: Note? = null
+ private var selectedDate: Long? = null
+ private var selectedHour = 0
+ private var selectedMinute = 0
+ private var currentNotes = listOf()
+ private var selectedCategoryFilter: Set = emptySet()
+ private var isUpdatingFilter = false
+ private var advancedCategoryFilter: Set = emptySet()
+ private var filterDateStart: Long? = null
+ private var filterDateEnd: Long? = null
+ private var mediaRecorder: MediaRecorder? = null
+ private var audioFilePath: String? = null
+ private var currentEditorBinding: DialogNoteEditorBinding? = null
+ private var currentPhotoPath: String = ""
+ private var calendarCurrentMonth: Calendar = Calendar.getInstance()
+ private var swipeTouchStartX = 0f
+ private var swipeTouchStartY = 0f
+ private var biometricEnabled = false
+ private var isUnlocked = false
+ private var suppressSearch = false
+ private val calendarTapStates = mutableMapOf()
+
+ private val speechLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == RESULT_OK) {
+ val spokenText = result.data
+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
+ ?.getOrNull(0) ?: ""
+ currentEditorBinding?.etContent?.apply {
+ val existing = text?.toString() ?: ""
+ val sep = if (existing.isBlank()) "" else "\n\n"
+ setText(existing + sep + spokenText)
+ setSelection(length())
+ }
+ }
+ currentEditorBinding?.btnVoiceRecord?.isEnabled = true
+ currentEditorBinding?.textVoiceStatus?.text = getString(R.string.voice_note)
+ }
+
+ private val voiceAddLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == RESULT_OK) {
+ val spokenText = result.data
+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
+ ?.getOrNull(0) ?: ""
+ if (spokenText.isNotBlank()) {
+ val categories = categoryManager.getAllCategories().map { it.name }
+ val parsed = VoiceNoteParser.parse(spokenText, categories)
+ showNoteEditor(null, parsed)
+ }
+ }
+ }
+
+ private val photoPickerLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ val savedPath = PhotoUtils.copyToInternalStorage(this, uri)
+ if (savedPath != null) {
+ currentPhotoPath = savedPath
+ currentEditorBinding?.let { updatePhotoPreview(it, savedPath) }
+ }
+ }
+ }
+
+ private val backgroundPickerLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ val savedPath = PhotoUtils.copyToInternalStorage(this, uri)
+ if (savedPath != null) {
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ prefs.edit().putString("background_path", savedPath).apply()
+ applyBackgroundImage()
+ }
+ }
+ }
+
+ private val exportLauncher = registerForActivityResult(
+ ActivityResultContracts.CreateDocument("application/json")
+ ) { uri: Uri? ->
+ if (uri != null) {
+ exportData(uri)
+ }
+ }
+
+ private val importLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ importData(uri)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.hide()
+ title = ""
+
+ securityManager = SecurityManager.getInstance(this)
+ notesManager = NotesManager.getInstance(this)
+ notesManager.setSecurityManager(securityManager)
+ categoryManager = CategoryManager.getInstance(this)
+ categoryManager.setSecurityManager(securityManager)
+
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ biometricEnabled = prefs.getBoolean("biometric_enabled", false)
+
+ if (biometricEnabled) {
+ securityManager.authenticateBiometric(this) { success ->
+ if (success) {
+ securityManager.migrateData()
+ categoryManager.reload()
+ initializeApp()
+ } else {
+ finish()
+ }
+ }
+ } else {
+ initializeApp()
+ }
+ }
+
+ private fun initializeApp() {
+ migrateOldCategories()
+ cleanTrash()
+
+ requestNotificationPermission()
+
+ setupRecyclerView()
+ setupTabs()
+ setupCategoryFilter()
+ setupAdvancedFilter()
+ setupSearch()
+ setupFab()
+ setupCalendar()
+ setupSwipeGestures()
+ setupSettings()
+ applyBackgroundImage()
+ loadNotes()
+ }
+
+ private fun migrateOldCategories() {
+ val allNotes = notesManager.getAllNotes()
+ val validIds = categoryManager.getAllCategories().map { it.id }.toSet()
+ if (validIds.isEmpty()) return
+ var changed = false
+ for (note in allNotes) {
+ if (note.category.isNotEmpty() && note.category !in validIds) {
+ note.category = ""
+ changed = true
+ }
+ }
+ if (changed) notesManager.saveNotes(allNotes)
+ }
+
+ private fun setupRecyclerView() {
+ adapter = NoteAdapter(
+ onEdit = { note -> showNoteEditor(note) },
+ onDelete = { note -> confirmDelete(note) },
+ onToggleComplete = { note -> toggleComplete(note) },
+ onRestore = { note -> restoreNote(note) },
+ onPermanentDelete = { note -> confirmPermanentDelete(note) }
+ )
+ adapter.setCategories(categoryManager.getAllCategories())
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+
+ monthNotesAdapter = NoteAdapter(
+ onEdit = { note -> showNoteEditor(note) },
+ onDelete = { note -> confirmDelete(note) },
+ onToggleComplete = { note -> toggleComplete(note) },
+ onRestore = { note -> restoreNote(note) },
+ onPermanentDelete = { note -> confirmPermanentDelete(note) }
+ )
+ monthNotesAdapter.setCategories(categoryManager.getAllCategories())
+ binding.monthNotesList.layoutManager = LinearLayoutManager(this)
+ binding.monthNotesList.adapter = monthNotesAdapter
+ }
+
+ private fun setupTabs() {
+ val tabIcons = listOf(
+ R.drawable.ic_sun,
+ R.drawable.ic_clock,
+ R.drawable.ic_calendar,
+ R.drawable.ic_trash,
+ R.drawable.ic_settings
+ )
+ for (i in 0 until binding.tabLayout.tabCount) {
+ binding.tabLayout.getTabAt(i)?.setIcon(tabIcons[i])
+ }
+
+ binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab?) {
+ val position = binding.tabLayout.selectedTabPosition
+ val isTodayTab = position == 0
+ val isCalendarTab = position == 2
+ val isDeletedTab = position == 3
+ val isSettingsTab = position == 4
+
+ binding.calendarContainer.visibility = if (isCalendarTab) View.VISIBLE else View.GONE
+ binding.settingsContainer.visibility = if (isSettingsTab) View.VISIBLE else View.GONE
+ binding.recyclerView.visibility = if (isCalendarTab || isSettingsTab) View.GONE else View.VISIBLE
+ binding.emptyView.visibility = View.GONE
+ suppressSearch = true
+ binding.etSearch.text?.clear()
+ val showSearch = !isCalendarTab && !isSettingsTab && !isTodayTab
+ binding.searchLayout.visibility = if (showSearch) View.VISIBLE else View.GONE
+ binding.btnFilter.visibility = if (showSearch) View.VISIBLE else View.GONE
+ binding.chipGroupFilter.visibility = View.GONE
+ binding.btnAdd.visibility = if (isDeletedTab || isCalendarTab || isSettingsTab) View.GONE else View.VISIBLE
+ binding.btnVoiceAdd.visibility = if (isDeletedTab || isCalendarTab || isSettingsTab) View.GONE else View.VISIBLE
+
+ adapter.setShowDeleted(isDeletedTab)
+
+ when {
+ isCalendarTab -> refreshCalendar()
+ isSettingsTab -> updateSettingsSwitch()
+ else -> {
+ val animate = position == 0 || position == 1 || position == 3
+ if (animate) binding.recyclerView.itemAnimator = null
+ loadNotes(animate = animate)
+ }
+ }
+ suppressSearch = false
+ }
+ override fun onTabUnselected(tab: TabLayout.Tab?) {}
+ override fun onTabReselected(tab: TabLayout.Tab?) {
+ val position = binding.tabLayout.selectedTabPosition
+ when (position) {
+ 2 -> refreshCalendar()
+ 0, 1, 3 -> {
+ binding.recyclerView.itemAnimator = null
+ loadNotes(animate = true)
+ }
+ else -> loadNotes()
+ }
+ }
+ })
+ }
+
+ private fun setupCalendar() {
+ // month navigation handled via swipe gestures
+ }
+
+ private fun refreshCalendar() {
+ val cal = calendarCurrentMonth.clone() as Calendar
+ val month = cal.get(Calendar.MONTH)
+ val year = cal.get(Calendar.YEAR)
+
+ val isDark = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
+ val calendarTextColor = if (isDark) Color.WHITE else Color.BLACK
+
+ val monthNames = arrayOf("Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre")
+ binding.textMonthYear.text = "${monthNames[month]} $year"
+ binding.textMonthYear.setTextColor(calendarTextColor)
+
+ val dayNamesView = binding.calendarDayNames
+ if (dayNamesView.childCount == 0) {
+ val dayNames = arrayOf("Dom", "Lun", "Mar", "Mi\u00e9", "Jue", "Vie", "S\u00e1b")
+ for (name in dayNames) {
+ val tv = TextView(this).apply {
+ text = name
+ gravity = Gravity.CENTER
+ textSize = 12f
+ setTextColor(calendarTextColor)
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
+ }
+ dayNamesView.addView(tv)
+ }
+ }
+
+ val allNotes = notesManager.getAllNotes()
+ .filter { it.deletedAt == null && it.reminderDate != null }
+ val calForDate = Calendar.getInstance()
+ val colorsForDay = mutableMapOf>()
+ val categories = categoryManager.getAllCategories()
+ for (note in allNotes) {
+ calForDate.timeInMillis = note.reminderDate!!
+ if (calForDate.get(Calendar.MONTH) == month && calForDate.get(Calendar.YEAR) == year) {
+ val day = calForDate.get(Calendar.DAY_OF_MONTH)
+ val color = categories.find { it.id == note.category }?.color ?: 0xFF9E9E9E.toInt()
+ colorsForDay.getOrPut(day) { mutableSetOf() }.add(color)
+ }
+ }
+
+ cal.set(Calendar.DAY_OF_MONTH, 1)
+ val firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1
+ val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
+
+ val today = Calendar.getInstance()
+ val isCurrentMonth = today.get(Calendar.MONTH) == month && today.get(Calendar.YEAR) == year
+ val todayDay = today.get(Calendar.DAY_OF_MONTH)
+
+ val grid = binding.calendarDaysGrid
+ grid.removeAllViews()
+
+ val cellWidth = resources.displayMetrics.widthPixels / 7
+ val cellHeight = (cellWidth * 0.8).toInt()
+ val dotSize = (5 * resources.displayMetrics.density).toInt()
+ val density = resources.displayMetrics.density
+
+ var day = 1
+ for (row in 0 until 6) {
+ for (col in 0 until 7) {
+ val index = row * 7 + col
+
+ val cell = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ layoutParams = ViewGroup.LayoutParams(cellWidth, cellHeight)
+ }
+
+ if (index < firstDayOfWeek || day > daysInMonth) {
+ grid.addView(cell)
+ continue
+ }
+
+ val dayNum = day
+ val dayText = TextView(this).apply {
+ text = dayNum.toString()
+ textSize = 14f
+ gravity = Gravity.CENTER
+ setTextColor(calendarTextColor)
+ }
+
+ if (isCurrentMonth && dayNum == todayDay) {
+ val size = (36 * density).toInt()
+ dayText.layoutParams = ViewGroup.LayoutParams(size, size)
+ dayText.setBackgroundResource(R.drawable.circle_category)
+ dayText.background.setTint(0xFF2196F3.toInt())
+ dayText.setTextColor(Color.WHITE)
+ }
+
+ cell.addView(dayText)
+
+ val dayColors = colorsForDay[dayNum]
+ if (dayColors != null) {
+ val dotRow = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ }
+ for (color in dayColors) {
+ val dot = View(this).apply {
+ layoutParams = ViewGroup.LayoutParams(dotSize, dotSize)
+ setBackgroundResource(R.drawable.circle_category)
+ background.setTint(color)
+ }
+ dotRow.addView(dot)
+ }
+ cell.addView(dotRow)
+ }
+
+ cell.setOnClickListener {
+ val key = "${year}-${month}-${dayNum}"
+ val count = (calendarTapStates[key] ?: 0) + 1
+ calendarTapStates[key] = count
+ when (count) {
+ 1 -> {
+ Handler(Looper.getMainLooper()).postDelayed({
+ val finalCount = calendarTapStates[key] ?: 0
+ calendarTapStates.remove(key)
+ if (finalCount < 3) showNotesForDay(year, month, dayNum)
+ }, 500)
+ }
+ 3 -> {
+ calendarTapStates.remove(key)
+ Calendar.getInstance().apply {
+ set(year, month, dayNum, get(Calendar.HOUR_OF_DAY), get(Calendar.MINUTE), 0)
+ set(Calendar.MILLISECOND, 0)
+ }.let { cal ->
+ showNoteEditor(null, prefillDate = cal.timeInMillis)
+ }
+ }
+ }
+ }
+
+ grid.addView(cell)
+ day++
+ }
+ }
+
+ val monthNotes = allNotes.filter { note ->
+ calForDate.timeInMillis = note.reminderDate!!
+ calForDate.get(Calendar.MONTH) == month && calForDate.get(Calendar.YEAR) == year
+ }.sortedBy { it.reminderDate }
+
+ binding.labelMonthNotes.text = "${monthNames[month]} — ${monthNotes.size} recordatorio${if (monthNotes.size != 1) "s" else ""}"
+ binding.monthNotesList.itemAnimator = null
+ monthNotesAdapter.submitList(monthNotes, animate = true)
+ }
+
+ private fun showNotesForDay(year: Int, month: Int, day: Int) {
+ val targetCal = Calendar.getInstance().apply {
+ set(year, month, day, 0, 0, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ val startOfDay = targetCal.timeInMillis
+ val endOfDay = startOfDay + 86400000L
+
+ val notesForDay = notesManager.getAllNotes().filter { note ->
+ note.deletedAt == null && note.reminderDate != null &&
+ note.reminderDate!! >= startOfDay && note.reminderDate!! < endOfDay
+ }
+
+ if (notesForDay.isEmpty()) return
+
+ val items = notesForDay.map {
+ val timeStr = timeFormat.format(Date(it.reminderDate!!))
+ "${it.title.ifBlank { "Sin t\u00edtulo" }} - $timeStr"
+ }
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle("$day/${month + 1}/$year")
+ .setItems(items.toTypedArray(), null)
+ .setPositiveButton("Cerrar", null)
+ .show()
+ }
+
+ private fun setupSwipeGestures() {
+ // handled via dispatchTouchEvent
+ }
+
+ override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
+ if (ev != null && ev.y > binding.tabLayout.bottom) {
+ when (ev.action) {
+ MotionEvent.ACTION_DOWN -> {
+ swipeTouchStartX = ev.x
+ swipeTouchStartY = ev.y
+ }
+ MotionEvent.ACTION_UP -> {
+ val diffX = ev.x - swipeTouchStartX
+ val diffY = ev.y - swipeTouchStartY
+ val absDiffX = Math.abs(diffX)
+ val absDiffY = Math.abs(diffY)
+ val current = binding.tabLayout.selectedTabPosition
+
+ if (absDiffY > 250 && absDiffY > absDiffX * 2.5f && current == 2) {
+ val gridLoc = IntArray(2)
+ binding.calendarDaysGrid.getLocationInWindow(gridLoc)
+ val gridBottom = gridLoc[1] + binding.calendarDaysGrid.height
+ if (Math.round(swipeTouchStartY) < gridBottom) {
+ if (diffY > 0) {
+ calendarCurrentMonth.add(Calendar.MONTH, -1)
+ animateMonthTransition(toPrev = true, onComplete = { refreshCalendar() }, vertical = true)
+ } else {
+ calendarCurrentMonth.add(Calendar.MONTH, 1)
+ animateMonthTransition(toPrev = false, onComplete = { refreshCalendar() }, vertical = true)
+ }
+ return true
+ }
+ }
+
+ if (absDiffX > 150 && absDiffX > absDiffY * 1.5f) {
+ if (diffX > 0 && current > 0) {
+ binding.tabLayout.getTabAt(current - 1)?.select()
+ return true
+ } else if (diffX < 0 && current < binding.tabLayout.tabCount - 1) {
+ binding.tabLayout.getTabAt(current + 1)?.select()
+ return true
+ }
+ }
+ }
+ }
+ }
+ return super.dispatchTouchEvent(ev)
+ }
+
+ private fun setupSearch() {
+ binding.etSearch.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+ override fun afterTextChanged(s: Editable?) {
+ if (!suppressSearch) applySearch(s?.toString() ?: "")
+ }
+ })
+ }
+
+ private fun setupFab() {
+ binding.btnAdd.setOnClickListener { showNoteEditor(null) }
+
+ binding.btnVoiceAdd.setOnClickListener {
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
+ putExtra(RecognizerIntent.EXTRA_PROMPT, "Di lo que quieras recordar…")
+ }
+ try {
+ voiceAddLauncher.launch(intent)
+ } catch (e: Exception) {
+ Toast.makeText(this, getString(R.string.voice_unavailable), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun setupCategoryFilter() {
+ binding.chipGroupFilter.removeAllViews()
+ val categories = categoryManager.getAllCategories()
+ val prefs = getSharedPreferences("filters", Context.MODE_PRIVATE)
+ val savedFilters = prefs.getStringSet("category_filters", emptySet()) ?: emptySet()
+
+ for (cat in categories) {
+ val chip = Chip(this)
+ chip.id = View.generateViewId()
+ chip.text = cat.name
+ chip.isCheckable = true
+ chip.tag = cat.id
+ val dot = AppCompatResources.getDrawable(this, R.drawable.circle_category)
+ dot?.setTint(cat.color)
+ chip.chipIcon = dot
+ binding.chipGroupFilter.addView(chip)
+ }
+
+ if (savedFilters.isNotEmpty()) {
+ isUpdatingFilter = true
+ for (i in 0 until binding.chipGroupFilter.childCount) {
+ val chip = binding.chipGroupFilter.getChildAt(i) as Chip
+ if (chip.tag as? String in savedFilters) {
+ chip.isChecked = true
+ }
+ }
+ selectedCategoryFilter = savedFilters
+ isUpdatingFilter = false
+ loadNotes()
+ }
+
+ binding.chipGroupFilter.setOnCheckedStateChangeListener { group, _ ->
+ if (isUpdatingFilter) return@setOnCheckedStateChangeListener
+ isUpdatingFilter = true
+
+ selectedCategoryFilter = group.checkedChipIds.mapNotNull { id ->
+ val chip = group.findViewById(id)
+ chip?.tag as? String
+ }.toSet()
+
+ prefs.edit().putStringSet("category_filters", selectedCategoryFilter).apply()
+
+ isUpdatingFilter = false
+ loadNotes()
+ }
+ }
+
+ private fun setupAdvancedFilter() {
+ binding.btnFilter.setOnClickListener { showAdvancedSearchDialog() }
+ updateFilterButtonState()
+ }
+
+ private fun updateFilterButtonState() {
+ val hasFilters = advancedCategoryFilter.isNotEmpty() || filterDateStart != null || filterDateEnd != null
+ binding.btnFilter.imageAlpha = if (hasFilters) 255 else 128
+ }
+
+ private fun applyAdvancedFilters(notes: List): List {
+ var filtered = notes
+ if (advancedCategoryFilter.isNotEmpty()) {
+ filtered = filtered.filter { it.category in advancedCategoryFilter }
+ }
+ val start = filterDateStart
+ val end = filterDateEnd
+ if (start != null || end != null) {
+ filtered = filtered.filter { note ->
+ val rd = note.reminderDate ?: return@filter true
+ (start == null || rd >= start) &&
+ (end == null || rd <= end)
+ }
+ }
+ return filtered
+ }
+
+ private fun showAdvancedSearchDialog() {
+ val categories = categoryManager.getAllCategories()
+ val catIds = categories.map { it.id }.toTypedArray()
+
+ var tempDateStart = filterDateStart
+ var tempDateEnd = filterDateEnd
+
+ val startStr = tempDateStart?.let { dateFormat.format(Date(it)) } ?: ""
+ val endStr = tempDateEnd?.let { dateFormat.format(Date(it)) } ?: ""
+
+ val layout = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 24, 48, 24)
+ }
+
+ val catTitle = TextView(this).apply {
+ text = "Categorías"
+ textSize = 16f
+ setTypeface(null, android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, 12)
+ layout.addView(this)
+ }
+
+ val catChips = com.google.android.material.chip.ChipGroup(this).apply {
+ isSingleSelection = false
+ isSelectionRequired = false
+ for (i in categories.indices) {
+ val cat = categories[i]
+ val chip = com.google.android.material.chip.Chip(this@MainActivity).apply {
+ id = View.generateViewId()
+ text = cat.name
+ isCheckable = true
+ tag = catIds[i]
+ isChecked = catIds[i] in advancedCategoryFilter
+ val dot = AppCompatResources.getDrawable(this@MainActivity, R.drawable.circle_category)
+ dot?.setTint(cat.color)
+ chipIcon = dot
+ }
+ addView(chip)
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).also { it.bottomMargin = 24 }
+ layout.addView(this)
+ }
+
+ val dateTitle = TextView(this).apply {
+ text = "Rango de fecha (recordatorio)"
+ textSize = 16f
+ setTypeface(null, android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, 12)
+ layout.addView(this)
+ }
+
+ val dateRow = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = android.view.Gravity.CENTER_VERTICAL
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ layout.addView(this)
+ }
+
+ val btnStart = com.google.android.material.button.MaterialButton(this).apply {
+ text = startStr.ifEmpty { "Desde" }
+ setOnClickListener {
+ val picker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText("Fecha inicio")
+ .setSelection(tempDateStart ?: MaterialDatePicker.todayInUtcMilliseconds())
+ .build()
+ picker.addOnPositiveButtonClickListener { millis ->
+ tempDateStart = utcMillisToLocalDate(millis)
+ text = dateFormat.format(Date(tempDateStart!!))
+ }
+ picker.show(supportFragmentManager, "filter_start_date")
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
+ ).also { it.setMargins(0, 0, 8, 0) }
+ dateRow.addView(this)
+ }
+
+ val btnEnd = com.google.android.material.button.MaterialButton(this).apply {
+ text = endStr.ifEmpty { "Hasta" }
+ setOnClickListener {
+ val picker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText("Fecha fin")
+ .setSelection(tempDateEnd ?: MaterialDatePicker.todayInUtcMilliseconds())
+ .build()
+ picker.addOnPositiveButtonClickListener { millis ->
+ tempDateEnd = utcMillisToLocalDate(millis)
+ text = dateFormat.format(Date(tempDateEnd!!))
+ }
+ picker.show(supportFragmentManager, "filter_end_date")
+ }
+ layoutParams = LinearLayout.LayoutParams(
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
+ ).also { it.setMargins(8, 0, 0, 0) }
+ dateRow.addView(this)
+ }
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Búsqueda avanzada")
+ .setView(layout)
+ .setPositiveButton("Aplicar") { _, _ ->
+ advancedCategoryFilter = catChips.checkedChipIds.mapNotNull { id ->
+ val chip = catChips.findViewById(id)
+ chip?.tag as? String
+ }.toSet()
+ filterDateStart = tempDateStart
+ filterDateEnd = tempDateEnd
+ updateFilterButtonState()
+ loadNotes()
+ }
+ .setNegativeButton("Limpiar") { _, _ ->
+ advancedCategoryFilter = emptySet()
+ filterDateStart = null
+ filterDateEnd = null
+ updateFilterButtonState()
+ loadNotes()
+ }
+ .setNeutralButton("Cancelar", null)
+ .show()
+ }
+
+ private fun setupSettings() {
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ biometricEnabled = prefs.getBoolean("biometric_enabled", false)
+ binding.switchBiometric.isChecked = biometricEnabled
+
+ binding.switchBiometric.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (securityManager.isBiometricAvailable()) {
+ securityManager.authenticateBiometric(this) { success ->
+ if (success) {
+ securityManager.migrateData()
+ biometricEnabled = true
+ prefs.edit().putBoolean("biometric_enabled", true).apply()
+ } else {
+ binding.switchBiometric.isChecked = false
+ }
+ }
+ } else {
+ binding.switchBiometric.isChecked = false
+ }
+ } else {
+ biometricEnabled = false
+ securityManager.lock()
+ prefs.edit().putBoolean("biometric_enabled", false).apply()
+ }
+ }
+
+ val themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ setThemeChip(themeMode)
+ binding.chipGroupTheme.setOnCheckedStateChangeListener { _, checkedIds ->
+ if (checkedIds.isNotEmpty()) {
+ val mode = when (checkedIds[0]) {
+ binding.chipThemeLight.id -> AppCompatDelegate.MODE_NIGHT_NO
+ binding.chipThemeDark.id -> AppCompatDelegate.MODE_NIGHT_YES
+ else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+ }
+ prefs.edit().putInt("theme_mode", mode).apply()
+ AppCompatDelegate.setDefaultNightMode(mode)
+ }
+ }
+
+ binding.btnPickBackground.setOnClickListener {
+ backgroundPickerLauncher.launch("image/*")
+ }
+ binding.btnRemoveBackground.setOnClickListener {
+ val prefs2 = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ prefs2.edit().remove("background_path").apply()
+ binding.ivBackground.setImageDrawable(null)
+ binding.ivBackground.visibility = View.GONE
+ binding.backgroundScrim.visibility = View.GONE
+ }
+
+ binding.switchNotifImage.isChecked = prefs.getBoolean("notif_show_image", true)
+ binding.switchNotifImage.setOnCheckedChangeListener { _, isChecked ->
+ prefs.edit().putBoolean("notif_show_image", isChecked).apply()
+ }
+
+ val snoozeMinutes = prefs.getInt("snooze_minutes", 10)
+ setSnoozeChip(snoozeMinutes)
+ binding.chipGroupSnooze.setOnCheckedStateChangeListener { _, checkedIds ->
+ if (checkedIds.isNotEmpty()) {
+ val minutes = when (checkedIds[0]) {
+ binding.chipSnooze30Min.id -> 30
+ binding.chipSnooze1Hour.id -> 60
+ binding.chipSnooze2Hours.id -> 120
+ else -> 10
+ }
+ prefs.edit().putInt("snooze_minutes", minutes).apply()
+ }
+ }
+
+ val trashDays = prefs.getInt("trash_days", 0)
+ setTrashChip(trashDays)
+ binding.chipGroupTrash.setOnCheckedStateChangeListener { _, checkedIds ->
+ if (checkedIds.isNotEmpty()) {
+ val days = when (checkedIds[0]) {
+ binding.chipTrash1Day.id -> 1
+ binding.chipTrash3Days.id -> 3
+ binding.chipTrash7Days.id -> 7
+ binding.chipTrash30Days.id -> 30
+ else -> 0
+ }
+ prefs.edit().putInt("trash_days", days).apply()
+ }
+ }
+
+ binding.btnExport.setOnClickListener { exportLauncher.launch("recordatorios_backup_${System.currentTimeMillis()}.json") }
+ binding.btnImport.setOnClickListener { importLauncher.launch("application/json") }
+
+ binding.btnHelpGuide.setOnClickListener { showHelpGuide() }
+ }
+
+ private fun showHelpGuide() {
+ val webView = android.webkit.WebView(this)
+ webView.settings.javaScriptEnabled = false
+ webView.settings.defaultTextEncodingName = "UTF-8"
+
+ val markdown = assets.open("guide.md").bufferedReader().use { it.readText() }
+ val html = """
+
+
+
+
+
+
+
+
+ ${markdownToHtml(markdown)}
+
+
+ """.trimIndent()
+
+ webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Guía de Uso")
+ .setView(webView)
+ .setPositiveButton("Cerrar", null)
+ .show()
+ }
+
+ private fun markdownToHtml(markdown: String): String {
+ var html = markdown
+ html = html.replace(Regex("^### (.+)$", RegexOption.MULTILINE), "$1
")
+ html = html.replace(Regex("^## (.+)$", RegexOption.MULTILINE), "$1
")
+ html = html.replace(Regex("^# (.+)$", RegexOption.MULTILINE), "$1
")
+ html = html.replace(Regex("^---$", RegexOption.MULTILINE), "
")
+ html = html.replace(Regex("\\*\\*(.+?)\\*\\*"), "$1")
+ html = html.replace(Regex("\\*(.+?)\\*"), "$1")
+ html = html.replace(Regex("\\[(.+?)\\]\\((.+?)\\)"), "$1")
+ html = html.replace(Regex("`(.+?)`"), "$1")
+ html = html.replace(Regex("^- (.+)$", RegexOption.MULTILINE), "$1")
+ html = html.replace(Regex("(.*\n?)+"), "")
+ html = html.replace(Regex("^(\\d+)\\. (.+)$", RegexOption.MULTILINE), "$2")
+ html = html.replace(Regex("\n\n"), "
")
+ html = "
$html
"
+ html = html.replace("
", "").replace("", "").replace("", "")
+ html = html.replace("
", "
")
+ html = html.replace("")
+ return html
+ }
+
+ private fun setSnoozeChip(minutes: Int) {
+ val id = when (minutes) {
+ 30 -> binding.chipSnooze30Min.id
+ 60 -> binding.chipSnooze1Hour.id
+ 120 -> binding.chipSnooze2Hours.id
+ else -> binding.chipSnooze10Min.id
+ }
+ binding.chipGroupSnooze.check(id)
+ }
+
+ private fun setTrashChip(days: Int) {
+ val id = when (days) {
+ 1 -> binding.chipTrash1Day.id
+ 3 -> binding.chipTrash3Days.id
+ 7 -> binding.chipTrash7Days.id
+ 30 -> binding.chipTrash30Days.id
+ else -> binding.chipTrashOff.id
+ }
+ binding.chipGroupTrash.check(id)
+ }
+
+ private fun setThemeChip(mode: Int) {
+ val id = when (mode) {
+ AppCompatDelegate.MODE_NIGHT_NO -> binding.chipThemeLight.id
+ AppCompatDelegate.MODE_NIGHT_YES -> binding.chipThemeDark.id
+ else -> binding.chipThemeSystem.id
+ }
+ binding.chipGroupTheme.check(id)
+ }
+
+ private fun applyBackgroundImage() {
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ val path = prefs.getString("background_path", null)
+ if (path.isNullOrBlank()) {
+ binding.ivBackground.visibility = View.GONE
+ binding.backgroundScrim.visibility = View.GONE
+ return
+ }
+ val blurred = PhotoUtils.loadBlurredBitmap(this, path, maxWidth = 720, radius = 8f)
+ if (blurred != null) {
+ binding.ivBackground.setImageBitmap(blurred)
+ binding.ivBackground.visibility = View.VISIBLE
+ val isDark = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
+ binding.backgroundScrim.setBackgroundColor(if (isDark) 0x44000000.toInt() else 0x55FFFFFF.toInt())
+ binding.backgroundScrim.visibility = View.VISIBLE
+ } else {
+ binding.ivBackground.visibility = View.GONE
+ binding.backgroundScrim.visibility = View.GONE
+ }
+ }
+
+ private fun updateSettingsSwitch() {
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ biometricEnabled = prefs.getBoolean("biometric_enabled", false)
+ binding.switchBiometric.isChecked = biometricEnabled
+ val themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ setThemeChip(themeMode)
+ binding.switchNotifImage.isChecked = prefs.getBoolean("notif_show_image", true)
+ setSnoozeChip(prefs.getInt("snooze_minutes", 10))
+ setTrashChip(prefs.getInt("trash_days", 0))
+ }
+
+ private fun cleanTrash() {
+ val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
+ val trashDays = prefs.getInt("trash_days", 0)
+ if (trashDays <= 0) return
+ val cutoff = System.currentTimeMillis() - trashDays * 86400000L
+ val allNotes = notesManager.getAllNotes()
+ val expired = allNotes.filter { it.deletedAt != null && it.deletedAt!! < cutoff }.map { it.id }.toSet()
+ if (expired.isNotEmpty()) {
+ expired.forEach { notesManager.permanentlyDeleteNote(it) }
+ }
+ }
+
+ private fun exportData(uri: Uri) {
+ try {
+ val allNotes = notesManager.getAllNotes()
+ val photosMap = mutableMapOf()
+ for (note in allNotes) {
+ if (note.photoPath.isNotBlank()) {
+ val file = File(note.photoPath)
+ if (file.exists()) {
+ val bytes = file.readBytes()
+ val base64 = Base64.encodeToString(bytes, Base64.DEFAULT)
+ photosMap[file.name] = base64
+ note.photoPath = file.name
+ } else {
+ note.photoPath = ""
+ }
+ }
+ }
+ val gson = Gson()
+ val notesJson = gson.toJson(allNotes)
+ val backupData = BackupData(
+ notes = notesJson,
+ categories = categoryManager.exportToJson(),
+ photos = photosMap
+ )
+ val json = gson.toJson(backupData)
+ contentResolver.openOutputStream(uri)?.use { outputStream ->
+ OutputStreamWriter(outputStream).use { writer ->
+ writer.write(json)
+ }
+ }
+ Toast.makeText(this, "Datos exportados correctamente", Toast.LENGTH_LONG).show()
+ } catch (e: Exception) {
+ Toast.makeText(this, "Error al exportar: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun importData(uri: Uri) {
+ try {
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ InputStreamReader(inputStream).use { reader ->
+ val json = reader.readText()
+ val gson = Gson()
+ val backupData = gson.fromJson(json, BackupData::class.java)
+
+ if (backupData != null) {
+ notesManager.importFromJson(backupData.notes)
+ categoryManager.importFromJson(backupData.categories, merge = false)
+ val photosMap = backupData.photos
+ val allNotes = notesManager.getAllNotes()
+ for (note in allNotes) {
+ if (note.photoPath.isNotBlank()) {
+ val filename = File(note.photoPath).name
+ val base64 = photosMap[filename]
+ if (base64 != null) {
+ try {
+ val dir = File(filesDir, "photos")
+ dir.mkdirs()
+ val file = File(dir, filename)
+ file.writeBytes(Base64.decode(base64, Base64.DEFAULT))
+ note.photoPath = file.absolutePath
+ } catch (_: Exception) {
+ note.photoPath = ""
+ }
+ } else {
+ note.photoPath = ""
+ }
+ }
+ }
+ notesManager.saveNotes(allNotes)
+ adapter.setCategories(categoryManager.getAllCategories())
+ monthNotesAdapter.setCategories(categoryManager.getAllCategories())
+ setupCategoryFilter()
+ migrateOldCategories()
+ Toast.makeText(this, "Datos importados correctamente", Toast.LENGTH_LONG).show()
+ loadNotes()
+ } else {
+ Toast.makeText(this, "Archivo de backup inválido", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this, "Error al importar: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private data class BackupData(
+ val notes: String = "",
+ val categories: String = "",
+ val photos: Map = emptyMap()
+ )
+
+ private fun loadNotes(animate: Boolean = false) {
+ val position = binding.tabLayout.selectedTabPosition
+ if (position == 2 || position == 4) return
+
+ val allNotes = notesManager.getAllNotes()
+ val isDeletedTab = position == 3
+ val isTodayTab = position == 0
+
+ adapter.setShowDeleted(isDeletedTab)
+
+ currentNotes = when (position) {
+ 0 -> {
+ filterToday(allNotes.filter { !it.isCompleted && it.deletedAt == null })
+ }
+ 1 -> {
+ val pending = filterPendingReminders(allNotes.filter { !it.isCompleted && it.deletedAt == null })
+ applyAdvancedFilters(pending)
+ }
+ 3 -> {
+ val deleted = allNotes.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }
+ applyAdvancedFilters(deleted)
+ }
+ else -> allNotes
+ }
+
+ val usesAdvancedFilter = position == 1 || position == 3
+ if (!isTodayTab && !usesAdvancedFilter && selectedCategoryFilter.isNotEmpty()) {
+ currentNotes = currentNotes.filter { it.category in selectedCategoryFilter }
+ }
+
+ applySearch(binding.etSearch.text?.toString() ?: "", animate = animate)
+ TodayWidgetProvider.updateAllWidgets(this)
+ }
+
+ private fun applySearch(query: String, animate: Boolean = false) {
+ val position = binding.tabLayout.selectedTabPosition
+ if (position == 2 || position == 4) return
+
+ val filtered = if (query.isBlank()) {
+ currentNotes
+ } else {
+ val q = query.lowercase(Locale.ROOT)
+ currentNotes.filter { note ->
+ note.title.lowercase(Locale.ROOT).contains(q) ||
+ note.content.lowercase(Locale.ROOT).contains(q)
+ }
+ }
+
+ adapter.submitList(filtered, isTodayTab = position == 0, animate = animate)
+
+ val emptyMsg = when {
+ query.isNotBlank() && filtered.isEmpty() -> "Sin resultados para \"$query\""
+ position == 0 -> getString(R.string.no_notes_today)
+ position == 1 -> getString(R.string.no_reminders_pending)
+ position == 3 -> "No hay notas eliminadas"
+ else -> getString(R.string.no_notes)
+ }
+ binding.emptyView.text = emptyMsg
+ binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
+ }
+
+ private fun getTodayRange(): Pair {
+ val start = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ return start to (start + 86400000L)
+ }
+
+ private fun filterToday(notes: List): List {
+ val (todayStart, todayEnd) = getTodayRange()
+ val filtered = notes.filter { note ->
+ note.reminderDate != null && note.reminderDate!! < todayEnd
+ }
+ return filtered.sortedByDescending { note ->
+ if (note.reminderDate!! < todayStart) 1 else 0
+ }
+ }
+
+ private fun filterPendingReminders(notes: List): List {
+ val (_, todayEnd) = getTodayRange()
+ return notes.filter { it.reminderDate == null || it.reminderDate!! >= todayEnd }
+ }
+
+ private fun toggleComplete(note: Note) {
+ if (note.recurrence != RecurrenceType.NONE && note.reminderDate != null) {
+ val nextDate = calculateNextRecurrence(note.reminderDate!!, note.recurrence)
+ note.reminderDate = nextDate
+ note.isCompleted = false
+ notesManager.updateNote(note)
+ ReminderScheduler.cancelReminder(this, note)
+ ReminderScheduler.scheduleReminder(this, note)
+ } else {
+ note.isCompleted = true
+ note.deletedAt = System.currentTimeMillis()
+ notesManager.updateNote(note)
+ }
+ loadNotes()
+ }
+
+ private fun calculateNextRecurrence(currentTime: Long, recurrence: RecurrenceType): Long {
+ val cal = Calendar.getInstance().apply { timeInMillis = currentTime }
+ return when (recurrence) {
+ RecurrenceType.DAILY -> cal.apply { add(Calendar.DAY_OF_YEAR, 1) }.timeInMillis
+ RecurrenceType.WEEKLY -> cal.apply { add(Calendar.WEEK_OF_YEAR, 1) }.timeInMillis
+ RecurrenceType.MONTHLY -> cal.apply { add(Calendar.MONTH, 1) }.timeInMillis
+ RecurrenceType.YEARLY -> cal.apply { add(Calendar.YEAR, 1) }.timeInMillis
+ else -> currentTime
+ }
+ }
+
+ private fun confirmDelete(note: Note) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.delete_note)
+ .setMessage(R.string.delete_confirm)
+ .setPositiveButton(R.string.delete) { _, _ ->
+ ReminderScheduler.cancelReminder(this, note)
+ notesManager.softDeleteNote(note.id)
+ loadNotes()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
+ private fun confirmPermanentDelete(note: Note) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Eliminar permanentemente")
+ .setMessage("Esta nota se eliminar\u00e1 para siempre.")
+ .setPositiveButton("Eliminar") { _, _ ->
+ notesManager.permanentlyDeleteNote(note.id)
+ loadNotes()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
+ private fun restoreNote(note: Note) {
+ notesManager.restoreNote(note.id)
+ binding.tabLayout.getTabAt(0)?.select()
+ binding.recyclerView.adapter = adapter
+ Toast.makeText(this, "Nota restaurada", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun startVoiceRecording(dialogBinding: DialogNoteEditorBinding) {
+ if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(android.Manifest.permission.RECORD_AUDIO),
+ RECORD_AUDIO_REQUEST_CODE
+ )
+ return
+ }
+
+ if (mediaRecorder != null) {
+ stopRecording(dialogBinding)
+ return
+ }
+
+ try {
+ val dir = File(filesDir, "voice_notes")
+ dir.mkdirs()
+ audioFilePath = File(dir, "voice_${System.currentTimeMillis()}.3gp").absolutePath
+
+ mediaRecorder = if (Build.VERSION.SDK_INT >= 31) {
+ MediaRecorder(this)
+ } else {
+ @Suppress("DEPRECATION") MediaRecorder()
+ }.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
+ setOutputFile(audioFilePath)
+ prepare()
+ start()
+ }
+
+ dialogBinding.btnVoiceRecord.setColorFilter(getColor(android.R.color.holo_red_dark))
+ dialogBinding.textVoiceStatus.text = getString(R.string.voice_recording)
+ dialogBinding.btnVoiceRecord.isEnabled = true
+ } catch (e: Exception) {
+ Toast.makeText(this, "Error al grabar: ${e.message}", Toast.LENGTH_SHORT).show()
+ mediaRecorder = null
+ }
+ }
+
+ private fun stopRecording(dialogBinding: DialogNoteEditorBinding) {
+ try {
+ mediaRecorder?.apply {
+ stop()
+ release()
+ }
+ } catch (_: Exception) {}
+ mediaRecorder = null
+
+ dialogBinding.btnVoiceRecord.clearColorFilter()
+ dialogBinding.textVoiceStatus.text = getString(R.string.voice_note)
+
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
+ putExtra(RecognizerIntent.EXTRA_PROMPT, "Habla ahora...")
+ }
+ try {
+ speechLauncher.launch(intent)
+ } catch (e: Exception) {
+ Toast.makeText(this, getString(R.string.voice_unavailable), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == RECORD_AUDIO_REQUEST_CODE) {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ currentEditorBinding?.let { startVoiceRecording(it) }
+ } else {
+ Toast.makeText(this, "Permiso de micrófono requerido", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun showNoteEditor(existingNote: Note?, voiceNote: ParsedVoiceNote? = null, prefillDate: Long? = null) {
+ editingNote = existingNote
+ val dialogBinding = DialogNoteEditorBinding.inflate(layoutInflater)
+ currentEditorBinding = dialogBinding
+
+ resetEditorState()
+ populateCategoryChips(dialogBinding, existingNote?.category)
+ currentPhotoPath = existingNote?.photoPath ?: ""
+
+ if (existingNote == null && prefillDate != null) {
+ dialogBinding.switchReminder.isChecked = true
+ selectedDate = prefillDate
+ val cal = Calendar.getInstance().apply { timeInMillis = prefillDate }
+ selectedHour = cal.get(Calendar.HOUR_OF_DAY)
+ selectedMinute = cal.get(Calendar.MINUTE)
+ updateDateTimeFields(dialogBinding)
+ showReminderFields(dialogBinding, true)
+ }
+
+ if (existingNote != null) {
+ dialogBinding.etTitle.setText(existingNote.title)
+ dialogBinding.etContent.setText(existingNote.content)
+ if (existingNote.reminderDate != null) {
+ dialogBinding.switchReminder.isChecked = true
+ selectedDate = existingNote.reminderDate
+ val cal = Calendar.getInstance().apply { timeInMillis = existingNote.reminderDate!! }
+ selectedHour = cal.get(Calendar.HOUR_OF_DAY)
+ selectedMinute = cal.get(Calendar.MINUTE)
+ updateDateTimeFields(dialogBinding)
+ showReminderFields(dialogBinding, true)
+ setRecurrenceRadio(dialogBinding, existingNote.recurrence)
+ }
+ } else if (voiceNote != null) {
+ dialogBinding.etTitle.setText(voiceNote.title)
+ dialogBinding.etContent.setText(voiceNote.content)
+ if (voiceNote.reminderDate != null) {
+ dialogBinding.switchReminder.isChecked = true
+ selectedDate = voiceNote.reminderDate
+ val cal = Calendar.getInstance().apply { timeInMillis = voiceNote.reminderDate }
+ selectedHour = cal.get(Calendar.HOUR_OF_DAY)
+ selectedMinute = cal.get(Calendar.MINUTE)
+ updateDateTimeFields(dialogBinding)
+ showReminderFields(dialogBinding, true)
+ }
+ if (voiceNote.categoryName != null) {
+ val matchingCategory = categoryManager.getAllCategories().find {
+ it.name.equals(voiceNote.categoryName, ignoreCase = true)
+ }
+ if (matchingCategory != null) {
+ selectCategoryChip(dialogBinding, matchingCategory.id)
+ }
+ }
+ if (voiceNote.recurrence != RecurrenceType.NONE) {
+ setRecurrenceRadio(dialogBinding, voiceNote.recurrence)
+ }
+ }
+
+ if (currentPhotoPath.isNotBlank()) {
+ updatePhotoPreview(dialogBinding, currentPhotoPath)
+ }
+
+ dialogBinding.btnAddPhoto.setOnClickListener {
+ photoPickerLauncher.launch("image/*")
+ }
+
+ dialogBinding.btnRemovePhoto.setOnClickListener {
+ currentPhotoPath = ""
+ dialogBinding.imgPhotoPreview.visibility = View.GONE
+ dialogBinding.btnRemovePhoto.visibility = View.GONE
+ dialogBinding.textPhotoStatus.text = getString(R.string.photo_add)
+ }
+
+ dialogBinding.btnManageCategories.setOnClickListener { showCategoryManager(dialogBinding) }
+
+ val isSpeechAvailable = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
+ .let { it.resolveActivity(packageManager) != null }
+ if (!isSpeechAvailable) {
+ dialogBinding.voiceNoteRow.visibility = View.GONE
+ } else {
+ dialogBinding.btnVoiceRecord.setOnClickListener {
+ startVoiceRecording(dialogBinding)
+ }
+ }
+
+ dialogBinding.switchReminder.setOnCheckedChangeListener { _, isChecked ->
+ showReminderFields(dialogBinding, isChecked)
+ if (!isChecked) {
+ selectedDate = null
+ dialogBinding.etDate.setText("")
+ dialogBinding.etTime.setText("")
+ } else if (selectedDate == null) {
+ val cal = Calendar.getInstance()
+ selectedDate = cal.timeInMillis
+ selectedHour = cal.get(Calendar.HOUR_OF_DAY)
+ selectedMinute = cal.get(Calendar.MINUTE)
+ updateDateTimeFields(dialogBinding)
+ }
+ }
+
+ dialogBinding.etDate.setOnClickListener {
+ val picker = MaterialDatePicker.Builder.datePicker()
+ .setTitleText(R.string.select_date)
+ .setSelection(localDateToUtcMillis(selectedDate ?: System.currentTimeMillis()))
+ .build()
+
+ picker.addOnPositiveButtonClickListener { millis ->
+ selectedDate = utcMillisToLocalDate(millis)
+ updateDateTimeFields(dialogBinding)
+ }
+
+ picker.show(supportFragmentManager, "date_picker")
+ }
+
+ dialogBinding.etTime.setOnClickListener {
+ val now = Calendar.getInstance()
+ val hour = if (selectedDate != null) {
+ Calendar.getInstance().apply { timeInMillis = selectedDate!! }
+ .get(Calendar.HOUR_OF_DAY)
+ } else now.get(Calendar.HOUR_OF_DAY)
+ val minute = if (selectedDate != null) {
+ Calendar.getInstance().apply { timeInMillis = selectedDate!! }
+ .get(Calendar.MINUTE)
+ } else now.get(Calendar.MINUTE)
+
+ val picker = MaterialTimePicker.Builder()
+ .setTitleText(R.string.select_time)
+ .setHour(hour)
+ .setMinute(minute)
+ .setTimeFormat(TimeFormat.CLOCK_24H)
+ .build()
+
+ picker.addOnPositiveButtonClickListener {
+ selectedHour = picker.hour
+ selectedMinute = picker.minute
+ if (selectedDate != null) {
+ val cal = Calendar.getInstance().apply { timeInMillis = selectedDate!! }
+ cal.set(Calendar.HOUR_OF_DAY, selectedHour)
+ cal.set(Calendar.MINUTE, selectedMinute)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+ selectedDate = cal.timeInMillis
+ }
+ updateDateTimeFields(dialogBinding)
+ }
+
+ picker.show(supportFragmentManager, "time_picker")
+ }
+
+ val dialog = MaterialAlertDialogBuilder(this)
+ .setTitle(if (existingNote != null) R.string.edit_note else R.string.add_note)
+ .setView(dialogBinding.root)
+ .setPositiveButton(R.string.save, null)
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val title = dialogBinding.etTitle.text.toString().trim()
+ val content = dialogBinding.etContent.text.toString().trim()
+
+ if (title.isEmpty() && content.isEmpty()) {
+ Toast.makeText(this, "Escribe un t\u00edtulo o contenido", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ val reminderTime = if (dialogBinding.switchReminder.isChecked && selectedDate != null) {
+ val cal = Calendar.getInstance().apply { timeInMillis = selectedDate!! }
+ cal.set(Calendar.HOUR_OF_DAY, selectedHour)
+ cal.set(Calendar.MINUTE, selectedMinute)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+ cal.timeInMillis
+ } else null
+
+ val recurrence = if (dialogBinding.switchReminder.isChecked) {
+ getSelectedRecurrence(dialogBinding)
+ } else RecurrenceType.NONE
+
+ val categoryId = getSelectedCategoryId(dialogBinding)
+
+ if (editingNote != null) {
+ editingNote!!.title = title
+ editingNote!!.content = content
+ editingNote!!.reminderDate = reminderTime
+ editingNote!!.recurrence = recurrence
+ editingNote!!.category = categoryId
+ editingNote!!.photoPath = currentPhotoPath
+ notesManager.updateNote(editingNote!!)
+
+ ReminderScheduler.cancelReminder(this, editingNote!!)
+ if (reminderTime != null) {
+ ReminderScheduler.scheduleReminder(this, editingNote!!)
+ }
+ } else {
+ val note = Note(
+ title = title,
+ content = content,
+ reminderDate = reminderTime,
+ recurrence = recurrence,
+ category = categoryId,
+ photoPath = currentPhotoPath
+ )
+ notesManager.addNote(note)
+ if (reminderTime != null) {
+ ReminderScheduler.scheduleReminder(this, note)
+ }
+ }
+
+ dialog.dismiss()
+ loadNotes()
+ }
+ }
+
+ private fun populateCategoryChips(dialogBinding: DialogNoteEditorBinding, selectedCategoryId: String?) {
+ dialogBinding.chipGroupCategory.removeAllViews()
+ val categories = categoryManager.getAllCategories()
+
+ val noneChip = Chip(this)
+ noneChip.id = View.generateViewId()
+ noneChip.text = getString(R.string.category_none)
+ noneChip.isCheckable = true
+ noneChip.tag = ""
+ dialogBinding.chipGroupCategory.addView(noneChip)
+ if (selectedCategoryId.isNullOrEmpty()) noneChip.isChecked = true
+
+ for (cat in categories) {
+ val chip = Chip(this)
+ chip.id = View.generateViewId()
+ chip.text = cat.name
+ chip.isCheckable = true
+ chip.tag = cat.id
+ val dot = AppCompatResources.getDrawable(this, R.drawable.circle_category)
+ dot?.setTint(cat.color)
+ chip.chipIcon = dot
+ dialogBinding.chipGroupCategory.addView(chip)
+ if (cat.id == selectedCategoryId) chip.isChecked = true
+ }
+ }
+
+ private fun selectCategoryChip(dialogBinding: DialogNoteEditorBinding, categoryId: String) {
+ for (i in 0 until dialogBinding.chipGroupCategory.childCount) {
+ val chip = dialogBinding.chipGroupCategory.getChildAt(i) as? Chip
+ if (chip?.tag as? String == categoryId) {
+ chip.isChecked = true
+ break
+ }
+ }
+ }
+
+ private fun showCategoryManager(dialogBinding: DialogNoteEditorBinding) {
+ val managerBinding = DialogCategoryManagerBinding.inflate(layoutInflater)
+ refreshCategoryList(managerBinding)
+
+ managerBinding.btnAddCategory.setOnClickListener {
+ showCategoryEditor(null) {
+ refreshCategoryList(managerBinding)
+ populateCategoryChips(dialogBinding, null)
+ }
+ }
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.manage_categories)
+ .setView(managerBinding.root)
+ .setPositiveButton("Cerrar", null)
+ .show()
+ }
+
+ private fun refreshCategoryList(managerBinding: DialogCategoryManagerBinding) {
+ managerBinding.categoryList.removeAllViews()
+ val categories = categoryManager.getAllCategories()
+ for (cat in categories) {
+ val chip = Chip(this)
+ chip.text = cat.name
+ chip.isCheckable = false
+ chip.isClickable = false
+ val dot = AppCompatResources.getDrawable(this, R.drawable.circle_category)
+ dot?.setTint(cat.color)
+ chip.chipIcon = dot
+
+ val editBtn = ImageButton(this).apply {
+ setImageResource(R.drawable.ic_pencil)
+ contentDescription = "Editar"
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
+ background = null
+ setPadding(8, 8, 8, 8)
+ layoutParams = LinearLayout.LayoutParams(
+ (40 * resources.displayMetrics.density).toInt(),
+ (40 * resources.displayMetrics.density).toInt()
+ )
+ setColorFilter(android.graphics.Color.GRAY)
+ setOnClickListener {
+ showCategoryEditor(cat) {
+ refreshCategoryList(managerBinding)
+ }
+ }
+ }
+
+ val deleteBtn = ImageButton(this).apply {
+ setImageResource(R.drawable.ic_trash)
+ contentDescription = "Eliminar"
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
+ background = null
+ setPadding(8, 8, 8, 8)
+ layoutParams = LinearLayout.LayoutParams(
+ (40 * resources.displayMetrics.density).toInt(),
+ (40 * resources.displayMetrics.density).toInt()
+ )
+ setColorFilter(android.graphics.Color.GRAY)
+ setOnClickListener {
+ MaterialAlertDialogBuilder(this@MainActivity)
+ .setTitle("Eliminar categor\u00eda")
+ .setMessage(getString(R.string.category_delete_confirm))
+ .setPositiveButton("Eliminar") { _, _ ->
+ val notes = notesManager.getAllNotes()
+ for (note in notes) {
+ if (note.category == cat.id) note.category = ""
+ }
+ notesManager.saveNotes(notes)
+ categoryManager.deleteCategory(cat.id)
+ selectedCategoryFilter = selectedCategoryFilter.filter { it in categoryManager.getAllCategories().map { c -> c.id } }.toSet()
+ getSharedPreferences("filters", Context.MODE_PRIVATE).edit()
+ .putStringSet("category_filters", selectedCategoryFilter).apply()
+ refreshCategoryList(managerBinding)
+ adapter.setCategories(categoryManager.getAllCategories())
+ monthNotesAdapter.setCategories(categoryManager.getAllCategories())
+ setupCategoryFilter()
+ loadNotes()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+ }
+
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(0, 4, 0, 4)
+ addView(chip, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
+ addView(editBtn)
+ addView(deleteBtn)
+ }
+ managerBinding.categoryList.addView(row)
+ }
+ }
+
+ private fun showCategoryEditor(existing: Category?, onDone: () -> Unit) {
+ val editorBinding = DialogCategoryEditorBinding.inflate(layoutInflater)
+ var selectedColor = existing?.color ?: Category.DEFAULT_COLORS[0]
+
+ if (existing != null) {
+ editorBinding.etCategoryName.setText(existing.name)
+ }
+
+ val swatchesContainer = editorBinding.colorSwatches
+ val swatchSize = (40 * resources.displayMetrics.density).toInt()
+ val margin = (4 * resources.displayMetrics.density).toInt()
+ val cols = 6
+
+ for (i in Category.DEFAULT_COLORS.indices) {
+ if (i % cols == 0) {
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ }
+ swatchesContainer.addView(row)
+ }
+ val color = Category.DEFAULT_COLORS[i]
+ val swatch = View(this)
+ swatch.layoutParams = ViewGroup.MarginLayoutParams(swatchSize, swatchSize)
+ (swatch.layoutParams as ViewGroup.MarginLayoutParams).setMargins(margin, margin, margin, margin)
+ swatch.background = createColorSwatchDrawable(color, color == selectedColor)
+ swatch.setOnClickListener {
+ selectedColor = color
+ for (r in 0 until swatchesContainer.childCount) {
+ val row = swatchesContainer.getChildAt(r) as? LinearLayout ?: continue
+ for (c in 0 until row.childCount) {
+ row.getChildAt(c).background = createColorSwatchDrawable(
+ Category.DEFAULT_COLORS[(r * cols + c)],
+ false
+ )
+ }
+ }
+ swatch.background = createColorSwatchDrawable(color, true)
+ }
+ val lastRow = swatchesContainer.getChildAt(swatchesContainer.childCount - 1) as? LinearLayout
+ lastRow?.addView(swatch)
+ }
+
+ val dialog = MaterialAlertDialogBuilder(this)
+ .setTitle(if (existing != null) R.string.edit_category else R.string.new_category)
+ .setView(editorBinding.root)
+ .setPositiveButton(R.string.save, null)
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val name = editorBinding.etCategoryName.text.toString().trim()
+ if (name.isEmpty()) {
+ Toast.makeText(this, "Escribe un nombre", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ if (existing != null) {
+ categoryManager.updateCategory(existing.id, name, selectedColor)
+ } else {
+ categoryManager.addCategory(Category(name = name, color = selectedColor))
+ }
+ dialog.dismiss()
+ adapter.setCategories(categoryManager.getAllCategories())
+ monthNotesAdapter.setCategories(categoryManager.getAllCategories())
+ setupCategoryFilter()
+ onDone()
+ }
+ }
+
+ private fun createColorSwatchDrawable(color: Int, isSelected: Boolean): android.graphics.drawable.GradientDrawable {
+ val shape = android.graphics.drawable.GradientDrawable()
+ shape.shape = android.graphics.drawable.GradientDrawable.OVAL
+ shape.setColor(color)
+ if (isSelected) {
+ shape.setStroke(6, android.graphics.Color.BLACK)
+ }
+ return shape
+ }
+
+ private fun updateDateTimeFields(dialogBinding: DialogNoteEditorBinding) {
+ if (selectedDate != null) {
+ dialogBinding.etDate.setText(dateFormat.format(Date(selectedDate!!)))
+ dialogBinding.etTime.setText(
+ String.format(Locale.getDefault(), "%02d:%02d", selectedHour, selectedMinute)
+ )
+ }
+ }
+
+ private fun showReminderFields(dialogBinding: DialogNoteEditorBinding, show: Boolean) {
+ val visibility = if (show) View.VISIBLE else View.GONE
+ dialogBinding.reminderFields.visibility = visibility
+ dialogBinding.labelRecurrence.visibility = visibility
+ dialogBinding.radioRecurrence.visibility = visibility
+ }
+
+ private fun setRecurrenceRadio(dialogBinding: DialogNoteEditorBinding, recurrence: RecurrenceType) {
+ val id = when (recurrence) {
+ RecurrenceType.NONE -> R.id.radioNone
+ RecurrenceType.DAILY -> R.id.radioDaily
+ RecurrenceType.WEEKLY -> R.id.radioWeekly
+ RecurrenceType.MONTHLY -> R.id.radioMonthly
+ RecurrenceType.YEARLY -> R.id.radioYearly
+ }
+ dialogBinding.radioRecurrence.check(id)
+ }
+
+ private fun getSelectedRecurrence(dialogBinding: DialogNoteEditorBinding): RecurrenceType {
+ val checkedId = dialogBinding.radioRecurrence.checkedRadioButtonId
+ return when (checkedId) {
+ R.id.radioDaily -> RecurrenceType.DAILY
+ R.id.radioWeekly -> RecurrenceType.WEEKLY
+ R.id.radioMonthly -> RecurrenceType.MONTHLY
+ R.id.radioYearly -> RecurrenceType.YEARLY
+ else -> RecurrenceType.NONE
+ }
+ }
+
+ private fun getSelectedCategoryId(dialogBinding: DialogNoteEditorBinding): String {
+ val checkedChipId = dialogBinding.chipGroupCategory.checkedChipId
+ if (checkedChipId == View.NO_ID) return ""
+ val chip = dialogBinding.chipGroupCategory.findViewById(checkedChipId) ?: return ""
+ return chip.tag as? String ?: ""
+ }
+
+ private fun updatePhotoPreview(dialogBinding: DialogNoteEditorBinding, path: String) {
+ val bitmap = BitmapFactory.decodeFile(path)
+ if (bitmap != null) {
+ dialogBinding.imgPhotoPreview.setImageBitmap(bitmap)
+ dialogBinding.imgPhotoPreview.visibility = View.VISIBLE
+ dialogBinding.btnRemovePhoto.visibility = View.VISIBLE
+ dialogBinding.textPhotoStatus.text = getString(R.string.photo_add)
+ } else {
+ dialogBinding.imgPhotoPreview.visibility = View.GONE
+ dialogBinding.btnRemovePhoto.visibility = View.GONE
+ dialogBinding.textPhotoStatus.text = getString(R.string.photo_error)
+ }
+ }
+
+ private fun resetEditorState() {
+ selectedDate = null
+ selectedHour = 0
+ selectedMinute = 0
+ currentPhotoPath = ""
+ }
+
+ private fun utcMillisToLocalDate(utcMillis: Long): Long {
+ val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { timeInMillis = utcMillis }
+ val local = Calendar.getInstance().apply {
+ set(utc.get(Calendar.YEAR), utc.get(Calendar.MONTH), utc.get(Calendar.DAY_OF_MONTH), 0, 0, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ return local.timeInMillis
+ }
+
+ private fun localDateToUtcMillis(localMillis: Long): Long {
+ val local = Calendar.getInstance().apply { timeInMillis = localMillis }
+ val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
+ set(local.get(Calendar.YEAR), local.get(Calendar.MONTH), local.get(Calendar.DAY_OF_MONTH), 0, 0, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ return utc.timeInMillis
+ }
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(
+ this, android.Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
+ NOTIFICATION_PERMISSION_REQUEST_CODE
+ )
+ }
+ }
+ }
+
+ private fun animateMonthTransition(toPrev: Boolean, onComplete: () -> Unit, vertical: Boolean = false) {
+ val size = if (vertical) resources.displayMetrics.heightPixels else resources.displayMetrics.widthPixels
+ val out = if (toPrev) size.toFloat() else -size.toFloat()
+ val `in` = -out
+
+ val anim = binding.calendarContent.animate()
+ .alpha(0f)
+ .setDuration(200)
+ .setInterpolator(DecelerateInterpolator())
+ .withEndAction {
+ if (vertical) binding.calendarContent.translationY = `in`
+ else binding.calendarContent.translationX = `in`
+ binding.calendarContent.alpha = 0f
+ onComplete()
+ binding.calendarContent.animate()
+ .translationX(0f)
+ .translationY(0f)
+ .alpha(1f)
+ .setDuration(300)
+ .setInterpolator(DecelerateInterpolator())
+ .start()
+ }
+ if (vertical) anim.translationY(out) else anim.translationX(out)
+ anim.start()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (!biometricEnabled || securityManager.isUnlocked()) {
+ loadNotes()
+ applyBackgroundImage()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ isUnlocked = false
+ }
+
+ companion object {
+ private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 100
+ private const val RECORD_AUDIO_REQUEST_CODE = 101
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/Note.kt b/app/src/main/java/com/recordatorios/app/Note.kt
new file mode 100755
index 0000000..1acd6b0
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/Note.kt
@@ -0,0 +1,20 @@
+package com.recordatorios.app
+
+import java.io.Serializable
+
+enum class RecurrenceType {
+ NONE, DAILY, WEEKLY, MONTHLY, YEARLY
+}
+
+data class Note(
+ val id: Long = System.currentTimeMillis(),
+ var title: String = "",
+ var content: String = "",
+ var createdAt: Long = System.currentTimeMillis(),
+ var reminderDate: Long? = null,
+ var recurrence: RecurrenceType = RecurrenceType.NONE,
+ var isCompleted: Boolean = false,
+ var deletedAt: Long? = null,
+ var category: String = "",
+ var photoPath: String = ""
+) : Serializable
diff --git a/app/src/main/java/com/recordatorios/app/NoteAdapter.kt b/app/src/main/java/com/recordatorios/app/NoteAdapter.kt
new file mode 100755
index 0000000..366d9c7
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/NoteAdapter.kt
@@ -0,0 +1,379 @@
+package com.recordatorios.app
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.core.content.ContextCompat
+import android.graphics.Color
+import android.view.View
+import android.view.ViewGroup
+import android.view.LayoutInflater
+import android.view.animation.DecelerateInterpolator
+import android.widget.ImageView
+import androidx.recyclerview.widget.RecyclerView
+import com.recordatorios.app.databinding.ItemNoteBinding
+import com.recordatorios.app.databinding.ItemSectionHeaderBinding
+import java.lang.ref.WeakReference
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+class NoteAdapter(
+ private val onEdit: (Note) -> Unit,
+ private val onDelete: (Note) -> Unit,
+ private val onToggleComplete: (Note) -> Unit,
+ private val onRestore: (Note) -> Unit,
+ private val onPermanentDelete: (Note) -> Unit
+) : RecyclerView.Adapter() {
+
+ private var notes = mutableListOf()
+ private var showDeleted = false
+ private var categoryFilter: String? = null
+ private var categories: List = emptyList()
+ private val expandedIds = mutableSetOf()
+ private var showTodaySection = false
+ private var staggerPending = false
+ private var rvRef: WeakReference? = null
+
+ companion object {
+ private const val TYPE_NOTE = 0
+ private const val TYPE_HEADER = 1
+ }
+
+ private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
+ private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+ private val headerDateFormat = SimpleDateFormat("EEEE dd/MM/yyyy", Locale.getDefault())
+
+ init {
+ setHasStableIds(true)
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+ rvRef = WeakReference(recyclerView)
+ }
+
+ override fun getItemId(position: Int): Long {
+ if (!showTodaySection) return notes[position].id
+ if (getItemViewType(position) == TYPE_HEADER) {
+ return -1L - position
+ }
+ val note = getNoteAtPosition(position)
+ return note.id
+ }
+
+ fun submitList(list: List, isTodayTab: Boolean = false, animate: Boolean = false) {
+ showTodaySection = isTodayTab
+ staggerPending = animate
+ notes = list.toMutableList()
+ notifyDataSetChanged()
+ if (animate) {
+ rvRef?.get()?.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
+ override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int,
+ oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
+ v.removeOnLayoutChangeListener(this)
+ performStaggerAnimation()
+ }
+ })
+ }
+ }
+
+ fun setShowDeleted(show: Boolean) {
+ if (showDeleted != show) {
+ showDeleted = show
+ expandedIds.clear()
+ }
+ }
+
+ fun setCategories(cats: List) {
+ categories = cats
+ }
+
+ override fun getItemCount(): Int {
+ if (!showTodaySection) return notes.size
+ val todayNotes = notes.filter { isToday(it) }
+ val overdueNotes = notes.filter { !isToday(it) }
+ return when {
+ todayNotes.isNotEmpty() && overdueNotes.isNotEmpty() -> todayNotes.size + 1 + overdueNotes.size + 1
+ todayNotes.isNotEmpty() -> todayNotes.size + 1
+ overdueNotes.isNotEmpty() -> overdueNotes.size + 1
+ else -> 0
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ if (!showTodaySection) return TYPE_NOTE
+ val todayNotes = notes.filter { isToday(it) }
+ val overdueNotes = notes.filter { !isToday(it) }
+
+ var currentIndex = 0
+ if (todayNotes.isNotEmpty()) {
+ if (position == currentIndex) return TYPE_HEADER
+ currentIndex++
+ if (position < currentIndex + todayNotes.size) return TYPE_NOTE
+ currentIndex += todayNotes.size
+ }
+ if (overdueNotes.isNotEmpty()) {
+ if (position == currentIndex) return TYPE_HEADER
+ currentIndex++
+ if (position < currentIndex + overdueNotes.size) return TYPE_NOTE
+ }
+ return TYPE_NOTE
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return if (viewType == TYPE_HEADER) {
+ val binding = ItemSectionHeaderBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ SectionHeaderViewHolder(binding)
+ } else {
+ val binding = ItemNoteBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ NoteViewHolder(binding)
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ if (holder is SectionHeaderViewHolder) {
+ holder.bind(position)
+ } else if (holder is NoteViewHolder) {
+ holder.bind(getNoteAtPosition(position))
+ }
+ }
+
+ override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ if (staggerPending) {
+ val pos = holder.layoutPosition.coerceAtLeast(0)
+ holder.itemView.alpha = 0f
+ holder.itemView.translationY = 200f
+ holder.itemView.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .translationZ(10f)
+ .setDuration(500)
+ .setStartDelay((pos * 100L).coerceAtMost(800L))
+ .setInterpolator(DecelerateInterpolator())
+ .withEndAction {
+ holder.itemView.translationZ = 0f
+ }
+ .start()
+ }
+ }
+
+ override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewDetachedFromWindow(holder)
+ holder.itemView.animate().cancel()
+ holder.itemView.alpha = 1f
+ holder.itemView.translationY = 0f
+ holder.itemView.translationZ = 0f
+ }
+
+ private fun performStaggerAnimation() {
+ staggerPending = false
+ val rv = rvRef?.get() ?: return
+ for (i in 0 until rv.childCount) {
+ val child = rv.getChildAt(i)
+ if (child.alpha < 0.01f) continue
+ val holder = rv.getChildViewHolder(child)
+ val pos = holder.layoutPosition.coerceAtLeast(0)
+ child.alpha = 0f
+ child.translationY = 200f
+ child.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .translationZ(10f)
+ .setDuration(500)
+ .setStartDelay((pos * 100L).coerceAtMost(800L))
+ .setInterpolator(DecelerateInterpolator())
+ .withEndAction { child.translationZ = 0f }
+ .start()
+ }
+ }
+
+ private fun isToday(note: Note): Boolean {
+ val todayStart = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ val todayEnd = todayStart + 86400000L
+ return note.reminderDate != null && note.reminderDate!! >= todayStart && note.reminderDate!! < todayEnd
+ }
+
+ private fun getNoteAtPosition(position: Int): Note {
+ if (!showTodaySection) return notes[position]
+ val todayNotes = notes.filter { isToday(it) }
+ val overdueNotes = notes.filter { !isToday(it) }
+
+ var currentIndex = 0
+ if (todayNotes.isNotEmpty()) {
+ currentIndex++ // skip header
+ if (position < currentIndex + todayNotes.size) {
+ return todayNotes[position - currentIndex]
+ }
+ currentIndex += todayNotes.size
+ }
+ if (overdueNotes.isNotEmpty()) {
+ currentIndex++ // skip header
+ if (position < currentIndex + overdueNotes.size) {
+ return overdueNotes[position - currentIndex]
+ }
+ }
+ return notes.firstOrNull() ?: notes[0]
+ }
+
+ inner class SectionHeaderViewHolder(private val binding: ItemSectionHeaderBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(position: Int) {
+ val todayNotes = notes.filter { isToday(it) }
+ if (todayNotes.isNotEmpty() && position == 0) {
+ binding.textSectionTitle.text = "Hoy"
+ binding.textSectionDate.text = headerDateFormat.format(Date(System.currentTimeMillis()))
+ binding.textSectionDate.visibility = View.VISIBLE
+ } else {
+ binding.textSectionTitle.text = "Vencidos"
+ binding.textSectionDate.visibility = View.GONE
+ }
+ }
+ }
+
+ inner class NoteViewHolder(private val binding: ItemNoteBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(note: Note) {
+ binding.textTitle.text = note.title.ifBlank { "Sin t\u00edtulo" }
+ val ta = binding.root.context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColorPrimary))
+ binding.textTitle.setTextColor(ta.getColor(0, Color.WHITE))
+ ta.recycle()
+
+ val cat = categories.find { it.id == note.category }
+ if (cat != null) {
+ binding.categoryDot.visibility = View.VISIBLE
+ binding.categoryDot.setBackgroundResource(R.drawable.circle_category)
+ binding.categoryDot.background.setTint(cat.color)
+ binding.root.strokeColor = cat.color
+ binding.root.strokeWidth = 4
+ } else {
+ binding.categoryDot.visibility = View.GONE
+ binding.root.strokeColor = Color.TRANSPARENT
+ binding.root.strokeWidth = 0
+ }
+
+ if (note.reminderDate != null) {
+ binding.textDate.text = dateFormat.format(Date(note.reminderDate!!))
+ binding.textDate.visibility = View.VISIBLE
+ } else {
+ binding.textDate.visibility = View.GONE
+ }
+
+ val isExpanded = expandedIds.contains(note.id)
+ binding.btnExpand.rotation = if (isExpanded) 180f else 0f
+ val toggleExpand = {
+ if (expandedIds.contains(note.id)) expandedIds.remove(note.id)
+ else expandedIds.add(note.id)
+ notifyItemChanged(layoutPosition)
+ }
+ binding.btnExpand.setOnClickListener { toggleExpand() }
+ binding.textTitle.setOnClickListener { toggleExpand() }
+
+ if (showDeleted) {
+ binding.btnMarkComplete.visibility = View.GONE
+ binding.chipRecurrence.visibility = View.GONE
+ binding.textTitle.alpha = 0.5f
+ binding.root.alpha = 1.0f
+
+ binding.textContent.visibility = View.VISIBLE
+ binding.textReminder.visibility = View.GONE
+
+ binding.detailsSection.visibility = if (isExpanded) View.VISIBLE else View.GONE
+ binding.textContent.text = note.content.ifBlank { "Sin contenido" }
+
+ binding.btnEdit.visibility = View.GONE
+ binding.btnDelete.visibility = View.GONE
+ binding.btnRestore.visibility = View.VISIBLE
+ binding.btnPermanentDelete.visibility = View.VISIBLE
+ binding.btnMarkComplete.setOnClickListener { onToggleComplete(note) }
+ binding.btnEdit.setOnClickListener { onEdit(note) }
+ binding.btnDelete.setOnClickListener { onDelete(note) }
+
+ binding.btnRestore.setOnClickListener { onRestore(note) }
+ binding.btnPermanentDelete.setOnClickListener { onPermanentDelete(note) }
+
+ hidePhoto()
+ } else {
+ binding.btnMarkComplete.visibility = View.VISIBLE
+ binding.btnEdit.visibility = View.VISIBLE
+ binding.btnDelete.visibility = View.VISIBLE
+ binding.btnRestore.visibility = View.GONE
+ binding.btnPermanentDelete.visibility = View.GONE
+ binding.textTitle.alpha = 1.0f
+
+ binding.detailsSection.visibility = if (isExpanded) View.VISIBLE else View.GONE
+
+ binding.btnMarkComplete.alpha =
+ if (note.isCompleted) 1.0f else 0.5f
+ binding.root.alpha = if (note.isCompleted) 0.4f else 1.0f
+
+ binding.textContent.text = note.content.ifBlank { "Sin contenido" }
+
+ if (note.reminderDate != null) {
+ val timeStr = timeFormat.format(Date(note.reminderDate!!))
+ binding.textReminder.text = "$timeStr"
+ binding.textReminder.visibility = View.VISIBLE
+ binding.textReminder.setCompoundDrawablesWithIntrinsicBounds(
+ com.recordatorios.app.R.drawable.ic_bell, 0, 0, 0
+ )
+ binding.textReminder.compoundDrawablePadding = 6
+
+ binding.chipRecurrence.visibility = View.VISIBLE
+ binding.chipRecurrence.text = when (note.recurrence) {
+ RecurrenceType.DAILY -> "Diario"
+ RecurrenceType.WEEKLY -> "Semanal"
+ RecurrenceType.MONTHLY -> "Mensual"
+ RecurrenceType.YEARLY -> "Anual"
+ RecurrenceType.NONE -> null
+ }
+ if (note.recurrence == RecurrenceType.NONE) {
+ binding.chipRecurrence.visibility = View.GONE
+ }
+ } else {
+ binding.textReminder.visibility = View.GONE
+ binding.chipRecurrence.visibility = View.GONE
+ }
+
+ binding.btnMarkComplete.setOnClickListener { onToggleComplete(note) }
+ binding.btnEdit.setOnClickListener { onEdit(note) }
+ binding.btnDelete.setOnClickListener { onDelete(note) }
+
+ loadPhoto(note.photoPath)
+ }
+ }
+
+ private fun loadPhoto(photoPath: String) {
+ if (photoPath.isBlank()) {
+ hidePhoto()
+ return
+ }
+ val ctx = binding.root.context
+ val blurred = PhotoUtils.loadBlurredBitmap(ctx, photoPath)
+ if (blurred != null) {
+ binding.ivPhotoBlur.setImageBitmap(blurred)
+ binding.ivPhotoBlur.visibility = View.VISIBLE
+ binding.photoScrim.visibility = View.VISIBLE
+ val isDark = (ctx.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
+ binding.photoScrim.setBackgroundColor(if (isDark) 0x44000000.toInt() else 0x55FFFFFF.toInt())
+ } else {
+ hidePhoto()
+ }
+ }
+
+ private fun hidePhoto() {
+ binding.ivPhotoBlur.visibility = View.GONE
+ binding.photoScrim.visibility = View.GONE
+ binding.ivPhotoBlur.setImageBitmap(null)
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/NotesManager.kt b/app/src/main/java/com/recordatorios/app/NotesManager.kt
new file mode 100755
index 0000000..3869924
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/NotesManager.kt
@@ -0,0 +1,116 @@
+package com.recordatorios.app
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+
+class NotesManager private constructor(context: Context) {
+
+ private var prefs: SharedPreferences =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val gson = Gson()
+ private var securityManager: SecurityManager? = null
+
+ fun setSecurityManager(securityManager: SecurityManager) {
+ this.securityManager = securityManager
+ }
+
+ private fun getPrefs(): SharedPreferences {
+ return securityManager?.getEncryptedPrefs() ?: prefs
+ }
+
+ fun getAllNotes(): MutableList {
+ val json = getPrefs().getString(KEY_NOTES, null) ?: return mutableListOf()
+ val type = object : TypeToken>() {}.type
+ return gson.fromJson(json, type) ?: mutableListOf()
+ }
+
+ fun saveNotes(notes: List) {
+ getPrefs().edit().putString(KEY_NOTES, gson.toJson(notes)).apply()
+ }
+
+ fun addNote(note: Note) {
+ val notes = getAllNotes()
+ notes.add(0, note)
+ saveNotes(notes)
+ }
+
+ fun updateNote(note: Note) {
+ val notes = getAllNotes()
+ val index = notes.indexOfFirst { it.id == note.id }
+ if (index != -1) {
+ notes[index] = note
+ saveNotes(notes)
+ }
+ }
+
+ fun softDeleteNote(noteId: Long) {
+ val notes = getAllNotes()
+ val note = notes.find { it.id == noteId } ?: return
+ note.deletedAt = System.currentTimeMillis()
+ saveNotes(notes)
+ }
+
+ fun restoreNote(noteId: Long) {
+ val notes = getAllNotes()
+ val note = notes.find { it.id == noteId } ?: return
+ note.deletedAt = null
+ note.isCompleted = false
+ saveNotes(notes)
+ }
+
+ fun permanentlyDeleteNote(noteId: Long) {
+ val notes = getAllNotes()
+ notes.removeAll { it.id == noteId }
+ saveNotes(notes)
+ }
+
+ fun getNote(noteId: Long): Note? {
+ return getAllNotes().find { it.id == noteId }
+ }
+
+ fun toggleComplete(noteId: Long) {
+ val notes = getAllNotes()
+ val note = notes.find { it.id == noteId } ?: return
+ note.isCompleted = true
+ note.deletedAt = System.currentTimeMillis()
+ saveNotes(notes)
+ }
+
+ fun exportToJson(): String {
+ return gson.toJson(getAllNotes())
+ }
+
+ fun importFromJson(json: String) {
+ val type = object : TypeToken>() {}.type
+ val importedNotes = gson.fromJson>(json, type) ?: return
+ val existingNotes = getAllNotes()
+ val existingIds = existingNotes.map { it.id }.toSet()
+
+ for (note in importedNotes) {
+ if (note.id !in existingIds) {
+ existingNotes.add(note)
+ } else {
+ val index = existingNotes.indexOfFirst { it.id == note.id }
+ if (index != -1) {
+ existingNotes[index] = note
+ }
+ }
+ }
+ saveNotes(existingNotes)
+ }
+ companion object {
+ private const val PREFS_NAME = "recordatorios_prefs"
+ private const val KEY_NOTES = "notes"
+
+ @Volatile
+ private var INSTANCE: NotesManager? = null
+
+ fun getInstance(context: Context): NotesManager {
+ return INSTANCE ?: synchronized(this) {
+ INSTANCE ?: NotesManager(context.applicationContext).also { INSTANCE = it }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/PhotoUtils.kt b/app/src/main/java/com/recordatorios/app/PhotoUtils.kt
new file mode 100755
index 0000000..49a618f
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/PhotoUtils.kt
@@ -0,0 +1,99 @@
+@file:Suppress("DEPRECATION")
+
+package com.recordatorios.app
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.renderscript.Allocation
+import android.renderscript.Element
+import android.renderscript.RenderScript
+import android.renderscript.ScriptIntrinsicBlur
+import android.util.LruCache
+import java.io.File
+import java.io.FileOutputStream
+
+object PhotoUtils {
+
+ private const val MAX_WIDTH = 300
+ private const val BLUR_RADIUS = 8f
+
+ private val bitmapCache = LruCache(40)
+
+ fun copyToInternalStorage(context: Context, uri: Uri): String? {
+ return try {
+ val dir = File(context.filesDir, "photos")
+ dir.mkdirs()
+ val file = File(dir, "photo_${System.currentTimeMillis()}.jpg")
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(file).use { output ->
+ input.copyTo(output)
+ }
+ }
+ file.absolutePath
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun loadPhotoBitmap(path: String, maxWidth: Int = MAX_WIDTH): Bitmap? {
+ if (path.isBlank()) return null
+ val cacheKey = "${path}_${maxWidth}_${BLUR_RADIUS}"
+ bitmapCache.get(cacheKey)?.let { return it }
+
+ return try {
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeFile(path, options)
+
+ val scaleFactor = maxOf(options.outWidth / maxWidth, 1)
+
+ val decodeOptions = BitmapFactory.Options().apply {
+ inSampleSize = scaleFactor
+ }
+ val bitmap = BitmapFactory.decodeFile(path, decodeOptions) ?: return null
+ bitmapCache.put(cacheKey, bitmap)
+ bitmap
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun loadBlurredBitmap(context: Context, path: String, maxWidth: Int = MAX_WIDTH, radius: Float = BLUR_RADIUS): Bitmap? {
+ if (path.isBlank()) return null
+ val cacheKey = "${path}_${maxWidth}_${radius}_blurred"
+ bitmapCache.get(cacheKey)?.let { return it }
+
+ val bitmap = loadPhotoBitmap(path, maxWidth) ?: return null
+ val blurred = applyBlur(context, bitmap, radius)
+ if (blurred != null) {
+ bitmapCache.put(cacheKey, blurred)
+ }
+ return blurred
+ }
+
+ private fun applyBlur(context: Context, bitmap: Bitmap, radius: Float): Bitmap? {
+ return try {
+ val rs = RenderScript.create(context)
+ val input = Allocation.createFromBitmap(rs, bitmap)
+ val output = Allocation.createTyped(rs, input.type)
+ val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
+ script.setRadius(radius.coerceIn(0f, 25f))
+ script.setInput(input)
+ script.forEach(output)
+ output.copyTo(bitmap)
+ rs.destroy()
+ bitmap
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun deletePhoto(path: String) {
+ if (path.isNotBlank()) {
+ try { File(path).delete() } catch (_: Exception) {}
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/ReminderReceiver.kt b/app/src/main/java/com/recordatorios/app/ReminderReceiver.kt
new file mode 100755
index 0000000..9baf583
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/ReminderReceiver.kt
@@ -0,0 +1,175 @@
+package com.recordatorios.app
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import java.util.Calendar
+
+class ReminderReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action
+ val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
+
+ if (action == ACTION_SNOOZE || action == ACTION_COMPLETE) {
+ handleNotificationAction(context, action, noteId)
+ return
+ }
+
+ val noteTitle = intent.getStringExtra(EXTRA_NOTE_TITLE) ?: "Recordatorio"
+ val noteContent = intent.getStringExtra(EXTRA_NOTE_CONTENT) ?: ""
+ val photoPath = intent.getStringExtra(EXTRA_PHOTO_PATH) ?: ""
+
+ if (noteId == -1L) return
+
+ createNotificationChannel(context)
+
+ val openIntent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ context, noteId.toInt(), openIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val snoozeIntent = Intent(context, ReminderReceiver::class.java).apply {
+ setAction(ACTION_SNOOZE)
+ putExtra(EXTRA_NOTE_ID, noteId)
+ putExtra(EXTRA_NOTE_TITLE, noteTitle)
+ }
+ val snoozePendingIntent = PendingIntent.getBroadcast(
+ context, (noteId + 1000).toInt(), snoozeIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val completeIntent = Intent(context, ReminderReceiver::class.java).apply {
+ setAction(ACTION_COMPLETE)
+ putExtra(EXTRA_NOTE_ID, noteId)
+ putExtra(EXTRA_NOTE_TITLE, noteTitle)
+ }
+ val completePendingIntent = PendingIntent.getBroadcast(
+ context, (noteId + 2000).toInt(), completeIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle(noteTitle)
+ .setContentText(noteContent.ifBlank { "Tienes un recordatorio pendiente" })
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .addAction(R.drawable.ic_clock, "Posponer 10 min", snoozePendingIntent)
+ .addAction(R.drawable.ic_check, "Completar", completePendingIntent)
+
+ if (photoPath.isNotBlank()) {
+ val bitmap = BitmapFactory.decodeFile(photoPath)
+ if (bitmap != null) {
+ @Suppress("DEPRECATION")
+ val bigPictureStyle = NotificationCompat.BigPictureStyle()
+ .bigPicture(bitmap)
+ bigPictureStyle.bigLargeIcon(null as Bitmap?)
+ notificationBuilder.setStyle(bigPictureStyle)
+ }
+ }
+
+ try {
+ NotificationManagerCompat.from(context).notify(noteId.toInt(), notificationBuilder.build())
+ } catch (e: SecurityException) {
+ // Permission not granted
+ }
+
+ // Schedule next occurrence if recurring
+ val recurrenceStr = intent.getStringExtra(EXTRA_RECURRENCE) ?: RecurrenceType.NONE.name
+ val recurrence = try { RecurrenceType.valueOf(recurrenceStr) } catch (e: Exception) { RecurrenceType.NONE }
+
+ if (recurrence != RecurrenceType.NONE) {
+ val originalTime = intent.getLongExtra(EXTRA_REMINDER_TIME, 0L)
+ if (originalTime > 0) {
+ val nextTime = calculateNextRecurrence(originalTime, recurrence)
+ val notesManager = NotesManager.getInstance(context)
+ val note = notesManager.getNote(noteId)
+ if (note != null) {
+ val updatedNote = note.copy(reminderDate = nextTime)
+ notesManager.updateNote(updatedNote)
+ ReminderScheduler.scheduleReminder(context, updatedNote)
+ }
+ }
+ }
+ }
+
+ private fun handleNotificationAction(context: Context, action: String, noteId: Long) {
+ if (noteId == -1L) return
+
+ val notesManager = NotesManager.getInstance(context)
+ val note = notesManager.getNote(noteId) ?: return
+
+ if (action == ACTION_SNOOZE) {
+ val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
+ val snoozeMinutes = prefs.getInt("snooze_minutes", 10)
+ val newTime = System.currentTimeMillis() + snoozeMinutes * 60 * 1000
+ val updatedNote = note.copy(reminderDate = newTime)
+ notesManager.updateNote(updatedNote)
+ ReminderScheduler.scheduleReminder(context, updatedNote)
+
+ NotificationManagerCompat.from(context).cancel(noteId.toInt())
+ } else if (action == ACTION_COMPLETE) {
+ if (note.recurrence != RecurrenceType.NONE) {
+ val nextTime = calculateNextRecurrence(note.reminderDate ?: System.currentTimeMillis(), note.recurrence)
+ val updatedNote = note.copy(reminderDate = nextTime)
+ notesManager.updateNote(updatedNote)
+ ReminderScheduler.scheduleReminder(context, updatedNote)
+ } else {
+ notesManager.toggleComplete(noteId)
+ }
+ NotificationManagerCompat.from(context).cancel(noteId.toInt())
+ }
+ }
+
+ private fun calculateNextRecurrence(currentTime: Long, recurrence: RecurrenceType): Long {
+ val cal = Calendar.getInstance().apply { timeInMillis = currentTime }
+ return when (recurrence) {
+ RecurrenceType.DAILY -> cal.apply { add(Calendar.DAY_OF_YEAR, 1) }.timeInMillis
+ RecurrenceType.WEEKLY -> cal.apply { add(Calendar.WEEK_OF_YEAR, 1) }.timeInMillis
+ RecurrenceType.MONTHLY -> cal.apply { add(Calendar.MONTH, 1) }.timeInMillis
+ RecurrenceType.YEARLY -> cal.apply { add(Calendar.YEAR, 1) }.timeInMillis
+ else -> currentTime
+ }
+ }
+
+ private fun createNotificationChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.reminder_channel_name),
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = context.getString(R.string.reminder_channel_desc)
+ }
+ val notificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ const val CHANNEL_ID = "recordatorios_channel"
+ const val EXTRA_NOTE_ID = "note_id"
+ const val EXTRA_NOTE_TITLE = "note_title"
+ const val EXTRA_NOTE_CONTENT = "note_content"
+ const val EXTRA_RECURRENCE = "recurrence"
+ const val EXTRA_REMINDER_TIME = "reminder_time"
+ const val EXTRA_PHOTO_PATH = "photo_path"
+ const val ACTION_SNOOZE = "com.recordatorios.app.ACTION_SNOOZE"
+ const val ACTION_COMPLETE = "com.recordatorios.app.ACTION_COMPLETE"
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/ReminderScheduler.kt b/app/src/main/java/com/recordatorios/app/ReminderScheduler.kt
new file mode 100755
index 0000000..580683d
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/ReminderScheduler.kt
@@ -0,0 +1,58 @@
+package com.recordatorios.app
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.content.edit
+
+object ReminderScheduler {
+
+ fun scheduleReminder(context: Context, note: Note) {
+ val reminderTime = note.reminderDate ?: return
+
+ val currentTime = System.currentTimeMillis()
+ if (reminderTime < currentTime) return
+
+ val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
+ val showImageInNotif = prefs.getBoolean("notif_show_image", true)
+
+ val intent = Intent(context, ReminderReceiver::class.java).apply {
+ putExtra(ReminderReceiver.EXTRA_NOTE_ID, note.id)
+ putExtra(ReminderReceiver.EXTRA_NOTE_TITLE, note.title.ifBlank { "Recordatorio" })
+ putExtra(ReminderReceiver.EXTRA_NOTE_CONTENT, note.content)
+ putExtra(ReminderReceiver.EXTRA_RECURRENCE, note.recurrence.name)
+ putExtra(ReminderReceiver.EXTRA_REMINDER_TIME, reminderTime)
+ if (showImageInNotif) putExtra(ReminderReceiver.EXTRA_PHOTO_PATH, note.photoPath)
+ }
+
+ val pendingIntent = PendingIntent.getBroadcast(
+ context, note.id.toInt(), intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP, reminderTime, pendingIntent
+ )
+ } else {
+ alarmManager.setExact(
+ AlarmManager.RTC_WAKEUP, reminderTime, pendingIntent
+ )
+ }
+ }
+
+ fun cancelReminder(context: Context, note: Note) {
+ val intent = Intent(context, ReminderReceiver::class.java)
+ val pendingIntent = PendingIntent.getBroadcast(
+ context, note.id.toInt(), intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.cancel(pendingIntent)
+ pendingIntent.cancel()
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/SecurityManager.kt b/app/src/main/java/com/recordatorios/app/SecurityManager.kt
new file mode 100755
index 0000000..8b1aff3
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/SecurityManager.kt
@@ -0,0 +1,138 @@
+package com.recordatorios.app
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import java.security.KeyStore
+import javax.crypto.Cipher
+
+class SecurityManager private constructor(private val context: Context) {
+
+ private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+
+ private var encryptedPrefs: SharedPreferences? = null
+ private var isUnlocked = false
+ private var authCallback: ((Boolean) -> Unit)? = null
+
+ init {
+ initializeKeystoreKey()
+ }
+
+ private fun initializeKeystoreKey() {
+ try {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .setUserAuthenticationRequired(true)
+ .build()
+
+ encryptedPrefs = EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ fun isBiometricAvailable(): Boolean {
+ val biometricManager = BiometricManager.from(context)
+ return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
+ BiometricManager.BIOMETRIC_SUCCESS -> true
+ else -> false
+ }
+ }
+
+ fun authenticateBiometric(activity: androidx.appcompat.app.AppCompatActivity, onResult: (Boolean) -> Unit) {
+ if (isUnlocked) {
+ onResult(true)
+ return
+ }
+
+ authCallback = onResult
+
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Desbloquear Recordatorios")
+ .setSubtitle("Confirma tu huella o rostro para acceder a tus datos")
+ .setNegativeButtonText("Cancelar")
+ .build()
+
+ val executor = ContextCompat.getMainExecutor(context)
+
+ val prompt = BiometricPrompt(
+ activity,
+ executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ isUnlocked = true
+ authCallback?.invoke(true)
+ authCallback = null
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
+ errorCode == BiometricPrompt.ERROR_USER_CANCELED) {
+ authCallback?.invoke(false)
+ } else {
+ authCallback?.invoke(false)
+ }
+ authCallback = null
+ }
+
+ override fun onAuthenticationFailed() {
+ // Reintentar automáticamente
+ }
+ }
+ )
+
+ prompt.authenticate(promptInfo)
+ }
+
+ fun getEncryptedPrefs(): SharedPreferences? {
+ return encryptedPrefs
+ }
+
+ fun isUnlocked(): Boolean {
+ return isUnlocked
+ }
+
+ fun lock() {
+ isUnlocked = false
+ }
+
+ fun migrateData() {
+ val oldNotesPrefs = context.getSharedPreferences("recordatorios_prefs", Context.MODE_PRIVATE)
+ val oldCategoriesPrefs = context.getSharedPreferences("categories_prefs", Context.MODE_PRIVATE)
+
+ val encrypted = encryptedPrefs ?: return
+
+ val notesData = oldNotesPrefs.getString("notes", null)
+ if (notesData != null && !encrypted.contains("notes")) {
+ encrypted.edit().putString("notes", notesData).apply()
+ }
+
+ val categoriesData = oldCategoriesPrefs.getString("categories", null)
+ if (categoriesData != null && !encrypted.contains("categories")) {
+ encrypted.edit().putString("categories", categoriesData).apply()
+ }
+ }
+
+ companion object {
+ private const val PREFS_NAME = "encrypted_recordatorios_prefs"
+
+ @Volatile
+ private var INSTANCE: SecurityManager? = null
+
+ fun getInstance(context: Context): SecurityManager {
+ return INSTANCE ?: synchronized(this) {
+ INSTANCE ?: SecurityManager(context.applicationContext).also { INSTANCE = it }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/TodayWidgetFactory.kt b/app/src/main/java/com/recordatorios/app/TodayWidgetFactory.kt
new file mode 100755
index 0000000..5f1d8f3
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/TodayWidgetFactory.kt
@@ -0,0 +1,109 @@
+package com.recordatorios.app
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+class TodayWidgetFactory(
+ private val context: Context,
+ private val appWidgetId: Int,
+ private val showTime: Boolean,
+ private val showCategory: Boolean,
+ private val maxItems: Int
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private var notes: List = emptyList()
+ private var categories: List = emptyList()
+ private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+
+ override fun onCreate() {
+ loadData()
+ }
+
+ override fun onDataSetChanged() {
+ loadData()
+ }
+
+ override fun onDestroy() {}
+
+ override fun getCount(): Int = notes.size
+
+ override fun getViewAt(position: Int): RemoteViews {
+ val note = notes[position]
+ val item = RemoteViews(context.packageName, R.layout.widget_note_item)
+
+ item.setTextViewText(R.id.widgetItemTitle, note.title.ifBlank { "Sin t\u00edtulo" })
+
+ if (showTime && note.reminderDate != null) {
+ item.setTextViewText(R.id.widgetItemTime, timeFormat.format(Date(note.reminderDate!!)))
+ item.setViewVisibility(R.id.widgetItemTime, android.view.View.VISIBLE)
+ } else {
+ item.setViewVisibility(R.id.widgetItemTime, android.view.View.GONE)
+ }
+
+ if (showCategory) {
+ val cat = categories.find { it.id == note.category }
+ if (cat != null) {
+ item.setViewVisibility(R.id.widgetItemDot, android.view.View.VISIBLE)
+ item.setInt(R.id.widgetItemDot, "setColorFilter", cat.color)
+ } else {
+ item.setViewVisibility(R.id.widgetItemDot, android.view.View.GONE)
+ }
+ } else {
+ item.setViewVisibility(R.id.widgetItemDot, android.view.View.GONE)
+ }
+
+ val fillIntent = Intent().apply {
+ putExtra("note_id", note.id)
+ }
+ item.setOnClickFillInIntent(R.id.widgetItemTitle, fillIntent)
+
+ return item
+ }
+
+ override fun getLoadingView(): RemoteViews? = null
+
+ override fun getViewTypeCount(): Int = 1
+
+ override fun getItemId(position: Int): Long = notes[position].id
+
+ override fun hasStableIds(): Boolean = true
+
+ private fun loadData() {
+ categories = CategoryManager.getInstance(context).getAllCategories()
+
+ val allNotes = NotesManager.getInstance(context)
+ .getAllNotes()
+ .filter { !it.isCompleted && it.deletedAt == null }
+
+ val todayStart = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+ val todayEnd = todayStart + 86400000L
+
+ val todayNotes = allNotes.filter { note ->
+ note.reminderDate != null && note.reminderDate!! >= todayStart && note.reminderDate!! < todayEnd
+ }
+
+ val overdueNotes = allNotes.filter { note ->
+ note.reminderDate != null && note.reminderDate!! < todayStart
+ }
+
+ notes = (todayNotes + overdueNotes)
+ .sortedByDescending { it.reminderDate ?: 0L }
+
+ if (maxItems > 0 && notes.size > maxItems) {
+ notes = notes.take(maxItems)
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/TodayWidgetProvider.kt b/app/src/main/java/com/recordatorios/app/TodayWidgetProvider.kt
new file mode 100755
index 0000000..f4c1ac1
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/TodayWidgetProvider.kt
@@ -0,0 +1,72 @@
+package com.recordatorios.app
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+
+class TodayWidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ for (appWidgetId in appWidgetIds) {
+ updateWidget(context, appWidgetManager, appWidgetId)
+ }
+ }
+
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ val prefs = context.getSharedPreferences(PREFS_WIDGET, Context.MODE_PRIVATE)
+ for (id in appWidgetIds) {
+ prefs.edit().remove("widget_${id}_show_time")
+ .remove("widget_${id}_show_category")
+ .remove("widget_${id}_max_items")
+ .apply()
+ }
+ }
+
+ companion object {
+ const val PREFS_WIDGET = "widget_preferences"
+
+ fun updateAllWidgets(context: Context) {
+ val manager = AppWidgetManager.getInstance(context)
+ val ids = manager.getAppWidgetIds(
+ ComponentName(context, TodayWidgetProvider::class.java)
+ )
+ for (id in ids) {
+ updateWidget(context, manager, id)
+ }
+ }
+
+ private fun updateWidget(context: Context, manager: AppWidgetManager, appWidgetId: Int) {
+ val prefs = context.getSharedPreferences(PREFS_WIDGET, Context.MODE_PRIVATE)
+ val showTime = prefs.getBoolean("widget_${appWidgetId}_show_time", true)
+ val showCategory = prefs.getBoolean("widget_${appWidgetId}_show_category", true)
+ val maxItems = prefs.getInt("widget_${appWidgetId}_max_items", 10)
+
+ val views = RemoteViews(context.packageName, R.layout.widget_today_info)
+ views.setTextViewText(R.id.widgetItemCount, "")
+
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pi = PendingIntent.getActivity(
+ context, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widgetNoteList, pi)
+
+ val serviceIntent = Intent(context, TodayWidgetService::class.java).apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ putExtra("show_time", showTime)
+ putExtra("show_category", showCategory)
+ putExtra("max_items", maxItems)
+ }
+ views.setRemoteAdapter(R.id.widgetNoteList, serviceIntent)
+ views.setEmptyView(R.id.widgetNoteList, R.id.widgetEmptyView)
+
+ manager.updateAppWidget(appWidgetId, views)
+ }
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/TodayWidgetService.kt b/app/src/main/java/com/recordatorios/app/TodayWidgetService.kt
new file mode 100755
index 0000000..89f9005
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/TodayWidgetService.kt
@@ -0,0 +1,17 @@
+package com.recordatorios.app
+
+import android.content.Intent
+import android.widget.RemoteViewsService
+
+class TodayWidgetService : RemoteViewsService() {
+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
+ val appWidgetId = intent.getIntExtra(
+ android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID,
+ android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
+ )
+ val showTime = intent.getBooleanExtra("show_time", true)
+ val showCategory = intent.getBooleanExtra("show_category", true)
+ val maxItems = intent.getIntExtra("max_items", 10)
+ return TodayWidgetFactory(applicationContext, appWidgetId, showTime, showCategory, maxItems)
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/VoiceNoteParser.kt b/app/src/main/java/com/recordatorios/app/VoiceNoteParser.kt
new file mode 100755
index 0000000..0a2059f
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/VoiceNoteParser.kt
@@ -0,0 +1,295 @@
+package com.recordatorios.app
+
+import java.util.Calendar
+
+data class ParsedVoiceNote(
+ val title: String,
+ val content: String,
+ val reminderDate: Long?,
+ val categoryName: String? = null,
+ val recurrence: RecurrenceType = RecurrenceType.NONE
+)
+
+object VoiceNoteParser {
+
+ private val numberWords = mapOf(
+ "uno" to 1, "una" to 1,
+ "dos" to 2,
+ "tres" to 3,
+ "cuatro" to 4,
+ "cinco" to 5,
+ "seis" to 6,
+ "siete" to 7,
+ "ocho" to 8,
+ "nueve" to 9,
+ "diez" to 10,
+ "once" to 11,
+ "doce" to 12,
+ "trece" to 13,
+ "catorce" to 14,
+ "quince" to 15,
+ "dieciseis" to 16, "dieciséis" to 16,
+ "diecisiete" to 17,
+ "dieciocho" to 18,
+ "diecinueve" to 19,
+ "veinte" to 20,
+ "veintiuno" to 21, "veintiuna" to 21,
+ "veintidos" to 22, "veintidós" to 22,
+ "veintitres" to 23, "veintitrés" to 23,
+ "veinticuatro" to 24
+ )
+
+ private val monthNames = mapOf(
+ "enero" to 0, "febrero" to 1, "marzo" to 2, "abril" to 3,
+ "mayo" to 4, "junio" to 5, "julio" to 6, "agosto" to 7,
+ "septiembre" to 8, "octubre" to 9, "noviembre" to 10, "diciembre" to 11,
+ "ener" to 0, "febr" to 1, "marz" to 2, "abr" to 3,
+ "may" to 4, "jun" to 5, "jul" to 6, "ago" to 7,
+ "sept" to 8, "oct" to 9, "nov" to 10, "dic" to 11
+ )
+
+ private val dayNames = mapOf(
+ "domingo" to Calendar.SUNDAY, "dom" to Calendar.SUNDAY,
+ "lunes" to Calendar.MONDAY, "lun" to Calendar.MONDAY,
+ "martes" to Calendar.TUESDAY, "mar" to Calendar.TUESDAY,
+ "miercoles" to Calendar.WEDNESDAY, "mie" to Calendar.WEDNESDAY,
+ "jueves" to Calendar.THURSDAY, "jue" to Calendar.THURSDAY,
+ "viernes" to Calendar.FRIDAY, "vie" to Calendar.FRIDAY,
+ "sabado" to Calendar.SATURDAY, "sab" to Calendar.SATURDAY,
+ "sábado" to Calendar.SATURDAY
+ )
+
+ fun parse(text: String, availableCategories: List = emptyList()): ParsedVoiceNote {
+ val lower = text.lowercase().trim()
+ val now = Calendar.getInstance()
+ var reminderDate: Long? = null
+ var datePhrase = ""
+ var timePhrase = ""
+ var cleanedText = lower
+ var categoryName: String? = null
+ var recurrence: RecurrenceType = RecurrenceType.NONE
+
+ val categoryResult = extractCategory(lower, availableCategories)
+ if (categoryResult != null) {
+ categoryName = categoryResult.name
+ cleanedText = cleanedText.replace(categoryResult.phrase, "").trim()
+ }
+
+ val recurrenceResult = extractRecurrence(lower)
+ if (recurrenceResult != null) {
+ recurrence = recurrenceResult.type
+ cleanedText = cleanedText.replace(recurrenceResult.phrase, "").trim()
+ }
+
+ val timeResult = extractTime(lower)
+ if (timeResult != null) {
+ timePhrase = timeResult.phrase
+ cleanedText = cleanedText.replace(timePhrase, "").trim()
+ }
+
+ val dateResult = extractDate(cleanedText, timeResult?.millis)
+ if (dateResult != null) {
+ datePhrase = dateResult.phrase
+ cleanedText = cleanedText.replace(datePhrase, "").trim()
+ reminderDate = dateResult.millis
+ } else if (timeResult != null) {
+ val cal = Calendar.getInstance().apply { timeInMillis = timeResult.millis }
+ if (cal.after(now)) {
+ reminderDate = cal.timeInMillis
+ } else {
+ cal.add(Calendar.DAY_OF_YEAR, 1)
+ reminderDate = cal.timeInMillis
+ }
+ }
+
+ val title = buildTitle(cleanedText, text)
+ val content = text.trim()
+
+ return ParsedVoiceNote(
+ title = title,
+ content = content,
+ reminderDate = reminderDate,
+ categoryName = categoryName,
+ recurrence = recurrence
+ )
+ }
+
+ private fun buildTitle(cleanedText: String, originalText: String): String {
+ val words = cleanedText.split(Regex("\\s+")).filter { it.isNotBlank() }
+ val titleWords = words.take(8)
+ if (titleWords.isEmpty()) {
+ val origWords = originalText.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
+ return origWords.take(8).joinToString(" ").replaceFirstChar { it.uppercase() }
+ .take(50)
+ }
+ return titleWords.joinToString(" ").replaceFirstChar { it.uppercase() }.take(50)
+ }
+
+ private data class TimeInfo(val phrase: String, val millis: Long)
+
+ private fun extractTime(text: String): TimeInfo? {
+ val patterns = listOf(
+ Regex("a las? (\\d{1,2})(?::(\\d{2}))?\\s*(de la manana|de la mañana|de la tarde|de la noche)?", RegexOption.IGNORE_CASE),
+ Regex("a las? (\\d{1,2})\\s*(de la manana|de la mañana|de la tarde|de la noche)?", RegexOption.IGNORE_CASE),
+ Regex("(\\d{1,2})(?::(\\d{2}))?\\s*(de la manana|de la mañana|de la tarde|de la noche)?", RegexOption.IGNORE_CASE),
+ Regex("a las? (${numberWords.keys.joinToString("|")})\\s*(de la manana|de la mañana|de la tarde|de la noche)?", RegexOption.IGNORE_CASE),
+ Regex("a las? (${numberWords.keys.joinToString("|")}) y (\\d{1,2})\\s*(de la manana|de la mañana|de la tarde|de la noche)?", RegexOption.IGNORE_CASE)
+ )
+
+ for (pattern in patterns) {
+ val match = pattern.find(text) ?: continue
+
+ var hour = match.groupValues[1].toIntOrNull()
+ if (hour == null) {
+ hour = numberWords[match.groupValues[1].lowercase()]
+ }
+ if (hour == null) continue
+
+ val minute = match.groupValues.getOrElse(2) { "" }.toIntOrNull() ?: 0
+ val period = match.groupValues.drop(2).find { it.isNotEmpty() }?.lowercase() ?: ""
+
+ var finalHour = hour
+ if (period.contains("tarde") && hour < 12) finalHour = hour + 12
+ if (period.contains("noche") && hour < 12) finalHour = hour + 12
+ if (period.contains("mañana") && hour == 12) finalHour = 0
+ if (period.contains("manana") && hour == 12) finalHour = 0
+
+ val cal = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, finalHour)
+ set(Calendar.MINUTE, minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+
+ return TimeInfo(match.value, cal.timeInMillis)
+ }
+ return null
+ }
+
+ private data class DateInfo(val phrase: String, val millis: Long)
+
+ private fun extractDate(text: String, timeMillis: Long?): DateInfo? {
+ val now = Calendar.getInstance()
+ val baseTime = timeMillis ?: now.timeInMillis
+ val baseCal = Calendar.getInstance().apply { timeInMillis = baseTime }
+ val baseHour = baseCal.get(Calendar.HOUR_OF_DAY)
+ val baseMinute = baseCal.get(Calendar.MINUTE)
+
+ val relativePatterns = listOf(
+ "pasado manana" to 2,
+ "pasado mañana" to 2,
+ "manana" to 1,
+ "mañana" to 1,
+ "hoy" to 0
+ )
+
+ for ((phrase, days) in relativePatterns) {
+ if (text.contains(phrase, ignoreCase = true)) {
+ val cal = Calendar.getInstance().apply { timeInMillis = baseTime }
+ cal.add(Calendar.DAY_OF_YEAR, days)
+ cal.set(Calendar.HOUR_OF_DAY, baseHour)
+ cal.set(Calendar.MINUTE, baseMinute)
+ return DateInfo(phrase, cal.timeInMillis)
+ }
+ }
+
+ val dayMonthPattern = Regex("""(el\s+)?(\d{1,2})\s*(de)?\s*(${monthNames.keys.joinToString("|")})""", RegexOption.IGNORE_CASE)
+ val dayMonthMatch = dayMonthPattern.find(text)
+ if (dayMonthMatch != null) {
+ val day = dayMonthMatch.groupValues[2].toIntOrNull() ?: return null
+ val monthName = dayMonthMatch.groupValues[3].lowercase()
+ val month = monthNames[monthName] ?: return null
+ val cal = Calendar.getInstance().apply {
+ timeInMillis = baseTime
+ set(Calendar.DAY_OF_MONTH, day)
+ set(Calendar.MONTH, month)
+ set(Calendar.HOUR_OF_DAY, baseHour)
+ set(Calendar.MINUTE, baseMinute)
+ }
+ if (cal.before(Calendar.getInstance())) {
+ cal.add(Calendar.YEAR, 1)
+ }
+ return DateInfo(dayMonthMatch.value, cal.timeInMillis)
+ }
+
+ val dayOfWeekPattern = Regex("""(el\s+)?(${dayNames.keys.joinToString("|")})\s*(que viene|próximo|próxima|proximo|proxima)?""", RegexOption.IGNORE_CASE)
+ val dayOfWeekMatch = dayOfWeekPattern.find(text)
+ if (dayOfWeekMatch != null) {
+ val dayName = dayOfWeekMatch.groupValues[2].lowercase()
+ val targetDay = dayNames[dayName] ?: return null
+ val cal = Calendar.getInstance().apply {
+ timeInMillis = baseTime
+ val currentDay = get(Calendar.DAY_OF_WEEK)
+ var diff = targetDay - currentDay
+ if (diff <= 0) diff += 7
+ add(Calendar.DAY_OF_YEAR, diff)
+ set(Calendar.HOUR_OF_DAY, baseHour)
+ set(Calendar.MINUTE, baseMinute)
+ }
+ return DateInfo(dayOfWeekMatch.value, cal.timeInMillis)
+ }
+
+ return null
+ }
+
+ private data class RecurrenceInfo(val phrase: String, val type: RecurrenceType)
+
+ private fun extractRecurrence(text: String): RecurrenceInfo? {
+ // Patrones para repetición
+ val patterns = listOf(
+ // Diaria
+ Regex("""repetir\s+diariamente""", RegexOption.IGNORE_CASE) to RecurrenceType.DAILY,
+ Regex("""todos?\s+los?\s*d[ií]as""", RegexOption.IGNORE_CASE) to RecurrenceType.DAILY,
+ Regex("""cada\s+d[ií]a""", RegexOption.IGNORE_CASE) to RecurrenceType.DAILY,
+ Regex("""diariamente""", RegexOption.IGNORE_CASE) to RecurrenceType.DAILY,
+
+ // Semanal
+ Regex("""repetir\s+semanalmente""", RegexOption.IGNORE_CASE) to RecurrenceType.WEEKLY,
+ Regex("""todas?\s+las?\s*semanas""", RegexOption.IGNORE_CASE) to RecurrenceType.WEEKLY,
+ Regex("""cada\s+semana""", RegexOption.IGNORE_CASE) to RecurrenceType.WEEKLY,
+ Regex("""semanalmente""", RegexOption.IGNORE_CASE) to RecurrenceType.WEEKLY,
+
+ // Mensual
+ Regex("""repetir\s+mensualmente""", RegexOption.IGNORE_CASE) to RecurrenceType.MONTHLY,
+ Regex("""todos?\s+los?\s*meses""", RegexOption.IGNORE_CASE) to RecurrenceType.MONTHLY,
+ Regex("""cada\s+mes""", RegexOption.IGNORE_CASE) to RecurrenceType.MONTHLY,
+ Regex("""mensualmente""", RegexOption.IGNORE_CASE) to RecurrenceType.MONTHLY,
+
+ // Anual
+ Regex("""repetir\s+anualmente""", RegexOption.IGNORE_CASE) to RecurrenceType.YEARLY,
+ Regex("""todos?\s+los?\s*a[nñ]os""", RegexOption.IGNORE_CASE) to RecurrenceType.YEARLY,
+ Regex("""cada\s+a[nñ]o""", RegexOption.IGNORE_CASE) to RecurrenceType.YEARLY,
+ Regex("""anualmente""", RegexOption.IGNORE_CASE) to RecurrenceType.YEARLY
+ )
+
+ for ((pattern, type) in patterns) {
+ val match = pattern.find(text)
+ if (match != null) {
+ return RecurrenceInfo(match.value, type)
+ }
+ }
+
+ return null
+ }
+
+ private data class CategoryInfo(val phrase: String, val name: String)
+
+ private fun extractCategory(text: String, availableCategories: List): CategoryInfo? {
+ // Patron 1: "categoria personal", "categoría trabajo" (espacio simple)
+ // Patron 2: "categoria: personal", "categoría: trabajo" (con dos puntos)
+ // Patron 3: "categoria - personal", "categoría - trabajo" (con guion)
+ val categoryPattern = Regex("""categor(?:ia|ía)\s*[:\-]?\s*([a-zA-ZáéíóúñÁÉÍÓÚÑ]+(?:\s+[a-zA-ZáéíóúñÁÉÍÓÚÑ]+)*)""", RegexOption.IGNORE_CASE)
+ val match = categoryPattern.find(text)
+ if (match != null) {
+ val capturedName = match.groupValues[1].trim().lowercase()
+ // Buscar coincidencia exacta con las categorias disponibles
+ val matchingCategory = availableCategories.find {
+ it.lowercase() == capturedName
+ }
+ if (matchingCategory != null) {
+ return CategoryInfo(match.value, matchingCategory)
+ }
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/com/recordatorios/app/WidgetConfigActivity.kt b/app/src/main/java/com/recordatorios/app/WidgetConfigActivity.kt
new file mode 100755
index 0000000..48cc0d7
--- /dev/null
+++ b/app/src/main/java/com/recordatorios/app/WidgetConfigActivity.kt
@@ -0,0 +1,54 @@
+package com.recordatorios.app
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.recordatorios.app.databinding.ActivityWidgetConfigBinding
+
+class WidgetConfigActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityWidgetConfigBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityWidgetConfigBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val appWidgetId = intent?.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+
+ binding.btnConfigSave.setOnClickListener {
+ val prefs = getSharedPreferences(TodayWidgetProvider.PREFS_WIDGET, Context.MODE_PRIVATE)
+ prefs.edit()
+ .putBoolean("widget_${appWidgetId}_show_time", binding.configShowTime.isChecked)
+ .putBoolean("widget_${appWidgetId}_show_category", binding.configShowCategory.isChecked)
+ .putInt(
+ "widget_${appWidgetId}_max_items",
+ when (binding.configChipGroupMax.checkedChipId) {
+ R.id.configMax5 -> 5
+ R.id.configMax15 -> 15
+ R.id.configMaxAll -> 0
+ else -> 10
+ }
+ )
+ .apply()
+
+ val result = Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+ setResult(RESULT_OK, result)
+
+ TodayWidgetProvider.updateAllWidgets(this)
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/circle_category.xml b/app/src/main/res/drawable/circle_category.xml
new file mode 100755
index 0000000..bcee64a
--- /dev/null
+++ b/app/src/main/res/drawable/circle_category.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bell.xml b/app/src/main/res/drawable/ic_bell.xml
new file mode 100755
index 0000000..7ebd22e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bell.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml
new file mode 100755
index 0000000..d116de7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calendar.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml
new file mode 100755
index 0000000..e2d5ad5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_clock.xml b/app/src/main/res/drawable/ic_clock.xml
new file mode 100755
index 0000000..7df8755
--- /dev/null
+++ b/app/src/main/res/drawable/ic_clock.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml
new file mode 100644
index 0000000..643f4fe
--- /dev/null
+++ b/app/src/main/res/drawable/ic_filter.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100755
index 0000000..4e73d9a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml
new file mode 100755
index 0000000..7589e9e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100755
index 0000000..69d5d67
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_pencil.xml b/app/src/main/res/drawable/ic_pencil.xml
new file mode 100755
index 0000000..1062250
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pencil.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
new file mode 100755
index 0000000..a284f82
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sun.xml b/app/src/main/res/drawable/ic_sun.xml
new file mode 100755
index 0000000..02bc3ed
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sun.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_trash.xml b/app/src/main/res/drawable/ic_trash.xml
new file mode 100755
index 0000000..5715d08
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trash.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100755
index 0000000..605d2ec
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,720 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_widget_config.xml b/app/src/main/res/layout/activity_widget_config.xml
new file mode 100755
index 0000000..0fc22e8
--- /dev/null
+++ b/app/src/main/res/layout/activity_widget_config.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_category_editor.xml b/app/src/main/res/layout/dialog_category_editor.xml
new file mode 100755
index 0000000..4f3225f
--- /dev/null
+++ b/app/src/main/res/layout/dialog_category_editor.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_category_manager.xml b/app/src/main/res/layout/dialog_category_manager.xml
new file mode 100755
index 0000000..1ec6938
--- /dev/null
+++ b/app/src/main/res/layout/dialog_category_manager.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_note_editor.xml b/app/src/main/res/layout/dialog_note_editor.xml
new file mode 100755
index 0000000..32e4ae4
--- /dev/null
+++ b/app/src/main/res/layout/dialog_note_editor.xml
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_note.xml b/app/src/main/res/layout/item_note.xml
new file mode 100755
index 0000000..c19ecef
--- /dev/null
+++ b/app/src/main/res/layout/item_note.xml
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_section_header.xml b/app/src/main/res/layout/item_section_header.xml
new file mode 100755
index 0000000..8ab6785
--- /dev/null
+++ b/app/src/main/res/layout/item_section_header.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_tab.xml b/app/src/main/res/layout/item_tab.xml
new file mode 100755
index 0000000..640ef0b
--- /dev/null
+++ b/app/src/main/res/layout/item_tab.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_note_item.xml b/app/src/main/res/layout/widget_note_item.xml
new file mode 100755
index 0000000..f043e36
--- /dev/null
+++ b/app/src/main/res/layout/widget_note_item.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_today_info.xml b/app/src/main/res/layout/widget_today_info.xml
new file mode 100755
index 0000000..1e8e0e6
--- /dev/null
+++ b/app/src/main/res/layout/widget_today_info.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
new file mode 100755
index 0000000..1c73a05
--- /dev/null
+++ b/app/src/main/res/menu/menu_main.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100755
index 0000000..2f6a77a
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100755
index 0000000..2f6a77a
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100755
index 0000000..696b28e
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100755
index 0000000..3ed9fb7
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100755
index 0000000..d8249c7
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100755
index 0000000..961373a
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100755
index 0000000..13c4eff
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100755
index 0000000..80f92cd
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100755
index 0000000..8025670
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100755
index 0000000..8f546f2
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100755
index 0000000..36fa1a2
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100755
index 0000000..68524ac
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100755
index 0000000..d553c2c
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,11 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #FF6200EE
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100755
index 0000000..3829554
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,96 @@
+
+
+ Recordatorios
+ Agregar nota
+ Editar nota
+ Eliminar nota
+ ¿Estás seguro de eliminar esta nota?
+ Cancelar
+ Guardar
+ Eliminar
+ Título
+ Contenido
+ No hay notas aún.\nPresiona + para crear una.
+ Establecer recordatorio
+ Fecha del recordatorio
+ Hora del recordatorio
+ Repetición
+ No repetir
+ Diario
+ Semanal
+ Mensual
+ Anual
+ Recordatorio
+ Recordatorios
+ Notificaciones de recordatorios
+ Seleccionar fecha
+ Seleccionar hora
+ Quitar recordatorio
+ Marcar como completada
+ Marcar como pendiente
+ Hoy
+ Pendientes
+ Calendario
+ Eliminados
+ No hay tareas para hoy
+ No hay recordatorios pendientes
+ Restaurar
+ Eliminar permanentemente
+ Categoría
+ Todas
+ Sin categoría
+ Administrar categorías
+ Nueva categoría
+ Editar categoría
+ Nombre de la categoría
+ ¿Eliminar esta categoría? Las notas con esta categoría quedarán sin categoría.
+ Seleccionar color
+ Nota de voz
+ Grabando…
+ Reconocimiento de voz no disponible
+ Agregar foto
+ Quitar foto
+ Error al cargar la foto
+ Configuración
+ Configuración
+ Bloqueo biométrico
+ Usar huella dactilar para desbloquear la app
+ No hay sensor biométrico disponible
+ No hay huellas registradas en el dispositivo
+ Desbloquear Recordatorios
+ Usa tu huella dactilar
+ Cancelar
+ Tema
+ Sistema
+ Claro
+ Oscuro
+ Imagen de fondo
+ Seleccionar imagen
+ Quitar fondo
+ Imagen en notificación
+ Mostrar la imagen de la nota en la notificación
+ Tiempo de posponer
+ Duración para posponer recordatorios desde notificación
+ 10 minutos
+ 30 minutos
+ 1 hora
+ 2 horas
+ Papelera
+ Vaciar automáticamente después de
+ Desactivado
+ 1 día
+ 3 días
+ 7 días
+ 30 días
+ Urgente
+
+
+ Lista de tareas de hoy
+ Hoy
+ Sin tareas para hoy
+ Personalizar widget
+ Mostrar hora
+ Mostrar color de categoría
+ Máximo de notas
+ Todas
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100755
index 0000000..849c9af
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/xml/widget_today.xml b/app/src/main/res/xml/widget_today.xml
new file mode 100755
index 0000000..cbe9cca
--- /dev/null
+++ b/app/src/main/res/xml/widget_today.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/build.gradle b/build.gradle
new file mode 100755
index 0000000..93aa190
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,4 @@
+plugins {
+ id 'com.android.application' version '8.1.0' apply false
+ id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100755
index 0000000..1869e4d
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+android.suppressUnsupportedCompileSdk=34
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000..ccebba7
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100755
index 0000000..42defcc
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..79a61d4
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,244 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100755
index 0000000..6689b85
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/keqing.png b/keqing.png
new file mode 100755
index 0000000..4fb5b8f
Binary files /dev/null and b/keqing.png differ
diff --git a/settings.gradle b/settings.gradle
new file mode 100755
index 0000000..4694f2c
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Recordatorios"
+include ':app'