From 895760cb5dfa3eb5dd2773c32204cc27d91060f8 Mon Sep 17 00:00:00 2001
From: Nino
Date: Sun, 26 Jul 2026 22:33:09 -0600
Subject: [PATCH] Initial commit
---
.gitignore | 20 +
README.md | 220 ++
app/build.gradle | 50 +
app/proguard-rules.pro | 1 +
app/src/main/AndroidManifest.xml | 59 +
app/src/main/assets/guide.md | 156 ++
.../java/com/recordatorios/app/Category.kt | 30 +
.../com/recordatorios/app/CategoryManager.kt | 139 ++
.../com/recordatorios/app/MainActivity.kt | 1901 +++++++++++++++++
.../main/java/com/recordatorios/app/Note.kt | 20 +
.../java/com/recordatorios/app/NoteAdapter.kt | 379 ++++
.../com/recordatorios/app/NotesManager.kt | 116 +
.../java/com/recordatorios/app/PhotoUtils.kt | 99 +
.../com/recordatorios/app/ReminderReceiver.kt | 175 ++
.../recordatorios/app/ReminderScheduler.kt | 58 +
.../com/recordatorios/app/SecurityManager.kt | 138 ++
.../recordatorios/app/TodayWidgetFactory.kt | 109 +
.../recordatorios/app/TodayWidgetProvider.kt | 72 +
.../recordatorios/app/TodayWidgetService.kt | 17 +
.../com/recordatorios/app/VoiceNoteParser.kt | 295 +++
.../recordatorios/app/WidgetConfigActivity.kt | 54 +
app/src/main/res/drawable/circle_category.xml | 15 +
app/src/main/res/drawable/ic_bell.xml | 10 +
app/src/main/res/drawable/ic_calendar.xml | 10 +
app/src/main/res/drawable/ic_check.xml | 10 +
app/src/main/res/drawable/ic_clock.xml | 10 +
app/src/main/res/drawable/ic_filter.xml | 10 +
.../res/drawable/ic_launcher_foreground.xml | 16 +
app/src/main/res/drawable/ic_mic.xml | 14 +
app/src/main/res/drawable/ic_notification.xml | 10 +
app/src/main/res/drawable/ic_pencil.xml | 10 +
app/src/main/res/drawable/ic_settings.xml | 10 +
app/src/main/res/drawable/ic_sun.xml | 10 +
app/src/main/res/drawable/ic_trash.xml | 10 +
app/src/main/res/layout/activity_main.xml | 720 +++++++
.../res/layout/activity_widget_config.xml | 102 +
.../res/layout/dialog_category_editor.xml | 42 +
.../res/layout/dialog_category_manager.xml | 24 +
.../main/res/layout/dialog_note_editor.xml | 291 +++
app/src/main/res/layout/item_note.xml | 180 ++
.../main/res/layout/item_section_header.xml | 40 +
app/src/main/res/layout/item_tab.xml | 26 +
app/src/main/res/layout/widget_note_item.xml | 40 +
app/src/main/res/layout/widget_today_info.xml | 78 +
app/src/main/res/menu/menu_main.xml | 9 +
.../res/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +
app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 14356 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 64018 bytes
app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 6674 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 30660 bytes
app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 24542 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 106445 bytes
.../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 51731 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 213838 bytes
.../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 86556 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 346198 bytes
app/src/main/res/values/colors.xml | 11 +
app/src/main/res/values/strings.xml | 96 +
app/src/main/res/values/themes.xml | 12 +
app/src/main/res/xml/widget_today.xml | 14 +
build.gradle | 4 +
gradle.properties | 5 +
gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61608 bytes
gradle/wrapper/gradle-wrapper.properties | 6 +
gradlew | 244 +++
gradlew.bat | 92 +
keqing.png | Bin 0 -> 815435 bytes
settings.gradle | 16 +
69 files changed, 6315 insertions(+)
create mode 100644 .gitignore
create mode 100755 README.md
create mode 100755 app/build.gradle
create mode 100755 app/proguard-rules.pro
create mode 100755 app/src/main/AndroidManifest.xml
create mode 100755 app/src/main/assets/guide.md
create mode 100755 app/src/main/java/com/recordatorios/app/Category.kt
create mode 100755 app/src/main/java/com/recordatorios/app/CategoryManager.kt
create mode 100755 app/src/main/java/com/recordatorios/app/MainActivity.kt
create mode 100755 app/src/main/java/com/recordatorios/app/Note.kt
create mode 100755 app/src/main/java/com/recordatorios/app/NoteAdapter.kt
create mode 100755 app/src/main/java/com/recordatorios/app/NotesManager.kt
create mode 100755 app/src/main/java/com/recordatorios/app/PhotoUtils.kt
create mode 100755 app/src/main/java/com/recordatorios/app/ReminderReceiver.kt
create mode 100755 app/src/main/java/com/recordatorios/app/ReminderScheduler.kt
create mode 100755 app/src/main/java/com/recordatorios/app/SecurityManager.kt
create mode 100755 app/src/main/java/com/recordatorios/app/TodayWidgetFactory.kt
create mode 100755 app/src/main/java/com/recordatorios/app/TodayWidgetProvider.kt
create mode 100755 app/src/main/java/com/recordatorios/app/TodayWidgetService.kt
create mode 100755 app/src/main/java/com/recordatorios/app/VoiceNoteParser.kt
create mode 100755 app/src/main/java/com/recordatorios/app/WidgetConfigActivity.kt
create mode 100755 app/src/main/res/drawable/circle_category.xml
create mode 100755 app/src/main/res/drawable/ic_bell.xml
create mode 100755 app/src/main/res/drawable/ic_calendar.xml
create mode 100755 app/src/main/res/drawable/ic_check.xml
create mode 100755 app/src/main/res/drawable/ic_clock.xml
create mode 100644 app/src/main/res/drawable/ic_filter.xml
create mode 100755 app/src/main/res/drawable/ic_launcher_foreground.xml
create mode 100755 app/src/main/res/drawable/ic_mic.xml
create mode 100755 app/src/main/res/drawable/ic_notification.xml
create mode 100755 app/src/main/res/drawable/ic_pencil.xml
create mode 100755 app/src/main/res/drawable/ic_settings.xml
create mode 100755 app/src/main/res/drawable/ic_sun.xml
create mode 100755 app/src/main/res/drawable/ic_trash.xml
create mode 100755 app/src/main/res/layout/activity_main.xml
create mode 100755 app/src/main/res/layout/activity_widget_config.xml
create mode 100755 app/src/main/res/layout/dialog_category_editor.xml
create mode 100755 app/src/main/res/layout/dialog_category_manager.xml
create mode 100755 app/src/main/res/layout/dialog_note_editor.xml
create mode 100755 app/src/main/res/layout/item_note.xml
create mode 100755 app/src/main/res/layout/item_section_header.xml
create mode 100755 app/src/main/res/layout/item_tab.xml
create mode 100755 app/src/main/res/layout/widget_note_item.xml
create mode 100755 app/src/main/res/layout/widget_today_info.xml
create mode 100755 app/src/main/res/menu/menu_main.xml
create mode 100755 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100755 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
create mode 100755 app/src/main/res/mipmap-hdpi/ic_launcher.png
create mode 100755 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
create mode 100755 app/src/main/res/mipmap-mdpi/ic_launcher.png
create mode 100755 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
create mode 100755 app/src/main/res/mipmap-xhdpi/ic_launcher.png
create mode 100755 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
create mode 100755 app/src/main/res/mipmap-xxhdpi/ic_launcher.png
create mode 100755 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
create mode 100755 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
create mode 100755 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
create mode 100755 app/src/main/res/values/colors.xml
create mode 100755 app/src/main/res/values/strings.xml
create mode 100755 app/src/main/res/values/themes.xml
create mode 100755 app/src/main/res/xml/widget_today.xml
create mode 100755 build.gradle
create mode 100755 gradle.properties
create mode 100755 gradle/wrapper/gradle-wrapper.jar
create mode 100755 gradle/wrapper/gradle-wrapper.properties
create mode 100755 gradlew
create mode 100755 gradlew.bat
create mode 100755 keqing.png
create mode 100755 settings.gradle
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 0000000000000000000000000000000000000000..696b28e4269aecb87b8fd67bda38419b0bcd7f6e
GIT binary patch
literal 14356
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
O0000@^N
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..3ed9fb7eaff1ab2946df07922e9ebf5f14b29c02
GIT binary patch
literal 64018
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 )D}6gTRZxlFx#`OT{pNP<2uJ1
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 |