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?)+"), "
      $0
    ") + 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("

      ", "
        ").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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +