diff --git a/README.md b/README.md index cb673df..200cec0 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,99 @@ -# Balance - Aplicación de Gestión Financiera - -Una aplicación integral de gestión de finanzas personales construida con Flet (Python) que te ayuda a rastrear tu balance, transacciones, activos, pasivos y mantenerte actualizado con noticias financieras. - -## Descripción General - -Balance es una aplicación financiera moderna con tema oscuro diseñada para ayudarte a gestionar tus finanzas personales de manera efectiva. La aplicación proporciona una solución completa para rastrear tu salud financiera, incluyendo gestión de balance, registro de transacciones, seguimiento de activos y pasivos, y acceso a noticias financieras. - -## Capturas de Pantalla - - - - - - - - - - -
- Panel Principal 1
- Panel Principal 1 -
- Panel Principal 2
- Panel Principal 2 -
- Transacciones
- Transacciones -
- Activos
- Activos -
- Pasivos
- Pasivos -
- Noticias
- Noticias -
+# Balance -## Características - -### 🏠 Panel Principal - -El panel principal proporciona una visión integral de tu estado financiero de un vistazo. - -**Características Principales:** -- **Visualización de Balance/Liquidez**: Alterna entre ver tu balance total y activos líquidos -- **Modo de Transacciones**: Cambia entre el modo Balance/Liquidez y el modo Ingresos/Gastos -- **Búsqueda Rápida**: Busca entre tus transacciones por nombre o categoría -- **Lista de Transacciones**: Visualiza todas tus transacciones recientes con indicadores codificados por colores (verde para ingresos, rojo para gastos) -- **Navegación Rápida**: Acceso fácil a las secciones de Activos, Pasivos y Noticias -- **Personalización de Perfil**: Personaliza tu foto de perfil y mensaje de bienvenida - -### 💰 Transacciones - -Registra y gestiona todas tus transacciones financieras con facilidad. - -**Características:** -- Agregar nuevas transacciones con nombre, categoría y monto -- Marcar transacciones como ingresos (suma al capital) o gastos -- Ver historial de transacciones con cálculos de porcentaje -- Buscar y filtrar transacciones -- Eliminar transacciones individualmente o en lote - -### 📊 Gestión de Balance - -Mantén un registro de tu balance financiero actual y actualízalo según sea necesario. +Aplicación de finanzas personales construida con **Flet (Python)**. Tema oscuro, diseño moderno e intuitivo. -**Características:** -- Ver balance actual con marca de tiempo de última actualización -- Actualizar balance manualmente -- Rastrear cambios de balance a lo largo del tiempo - -### 📈 Activos - -Gestiona tus activos e inversiones en diferentes categorías. - -**Tipos de Activos:** -- Acciones -- Bonos -- Fondos -- Criptomonedas -- Bienes raíces -- Venta -- Otros - -**Características:** -- Agregar activos con nombre, tipo y valor -- Marcar activos como líquidos o no líquidos -- Ver todos los activos en una lista organizada -- Editar y eliminar activos -- Indicadores visuales para activos líquidos vs no líquidos - -### 📉 Pasivos - -Rastrea tus deudas y pasivos para mantener una imagen clara de tus obligaciones financieras. - -**Características:** -- Agregar pasivos con nombre, categoría y monto -- Marcar pasivos como pagados o no pagados -- Indicadores de estado visuales (verde para pagado, rojo para no pagado) -- Pagar pasivos con un clic -- Eliminar múltiples pasivos a la vez -- Actualizar lista para ver el estado actualizado - -### 📰 Noticias Financieras +## Características -Mantente informado con las últimas noticias financieras y actualizaciones del mercado. +- **Panel principal** — Vista general de tu balance, liquidez e ingresos/gastos +- **Transacciones** — Registra ingresos y gastos con categorías personalizables +- **Activos** — Gestiona acciones, bonos, cripto, bienes raíces y más +- **Pasivos** — Controla deudas con estado de pago visual +- **Préstamos** — Registra préstamos realizados y recibidos +- **Transferencias** — Movimientos entre cuentas propias +- **Asistente IA** — Chat financiero Kara AI +- **Gráficas** — Visualización de tu patrimonio con matplotlib -**Características:** -- Navegar por los últimos artículos de noticias financieras -- Ver imágenes de artículos, títulos, autores y fechas de publicación -- Actualizar para obtener las últimas noticias -- Interfaz limpia basada en tarjetas para una lectura fácil ## Instalación -### Requisitos Previos +**Requisitos:** Python 3.9+ -- Python 3.9 o superior -- Gestor de paquetes pip o uv - -### Configuración - -1. Clona el repositorio: ```bash git clone cd financeapp ``` -2. Instala las dependencias: +Instala dependencias con **uv** (recomendado): ```bash -pip install -r src/requirements.txt +uv sync ``` -O usando uv: +O con pip: ```bash -uv pip install -r src/requirements.txt +pip install . ``` -3. Ejecuta la aplicación: +Ejecuta la app: ```bash cd src -python main.py +flet run ``` ## Configuración -### Configuración Inicial - -Cuando ejecutes la aplicación por primera vez, se te pedirá: -- Ingresar tu nombre -- Seleccionar tu género (para saludos personalizados) - -### Ajustes - -Accede a los ajustes desde el panel principal para configurar: -- **Modo de Transacciones**: Alternar entre la vista Balance/Liquidez y la vista Ingresos/Gastos -- **Modo de Portafolio**: Cambiar entre diferentes métricas financieras +Crea un archivo `.env` en la raíz del proyecto: +```env +GEMINI_API_KEY=tu_api_key_aquí +``` ## Estructura del Proyecto ``` financeapp/ ├── src/ -│ ├── main.py # Punto de entrada principal de la aplicación -│ ├── controller.py # Controladores de lógica de negocio -│ ├── database.py # Modelos y operaciones de base de datos -│ ├── db_connection.py # Manejo de conexión a base de datos -│ ├── requirements.txt # Dependencias de Python -│ ├── assets/ # Recursos de la aplicación (imágenes, fuentes) -│ └── storage/ # Directorio de almacenamiento de datos -├── screenshots/ # Capturas de pantalla de la aplicación -├── pyproject.toml # Configuración del proyecto -└── README.md # Este archivo +│ ├── main.py # Punto de entrada +│ ├── router.py # Enrutamiento de vistas +│ ├── setup.py # Configuración inicial +│ ├── views/ # Vistas (UI) +│ ├── controllers/ # Lógica de negocio +│ ├── components/ # Componentes reutilizables +│ ├── db/ # Base de datos y migraciones +│ ├── dto/ # Objetos de transferencia de datos +│ ├── client/ # Cliente HTTP +│ ├── external/ # Integraciones externas (Gemini) +│ ├── core/ # Configuración central +│ ├── themes/ # Temas y estilos +│ ├── assets/ # Recursos (imágenes, fuentes) +│ └── storage/ # Almacenamiento local +├── pyproject.toml +└── README.md ``` -## Stack Tecnológico - -- **Framework**: Flet 0.28.3 -- **Lenguaje**: Python 3.9+ -- **Base de Datos**: SQLite (financeapp.db) -- **Interfaz**: Tema oscuro con diseño moderno - -## Características en Detalle - -### Gestión de Transacciones -- Actualización automática del balance cuando se agregan transacciones -- Cálculo de porcentaje para cada transacción -- Organización basada en categorías -- Funcionalidad de búsqueda para acceso rápido +## Tech Stack -### Seguimiento de Activos -- Soporte para múltiples tipos de activos -- Seguimiento de liquidez -- Indicadores de estado visuales -- Soporte para operaciones en lote +| Tecnología | Uso | +|---|---| +| **Flet 0.28.3** | Framework UI | +| **SQLite** | Base de datos local | +| **Matplotlib** | Gráficas | +| **Gemini AI** | Asistente financiero | +| **HTTPX** | Cliente HTTP | -### Gestión de Pasivos -- Seguimiento del estado de pago -- Procesamiento rápido de pagos -- Organización por categorías -- Visualización de estado - -### Integración de Noticias -- Noticias financieras en tiempo real -- Visualización de metadatos de artículos -- Soporte de imágenes -- Capacidad de actualización - -## Consejos de Uso - -1. **Actualizaciones Regulares**: Mantén tu balance y transacciones actualizados regularmente para un seguimiento financiero preciso -2. **Categorización**: Usa categorías consistentes para una mejor organización y resultados de búsqueda -3. **Liquidez de Activos**: Marca los activos líquidos correctamente para obtener cálculos de liquidez precisos -4. **Seguimiento de Pasivos**: Marca los pasivos como pagados cuando se liquiden para mantener un estado financiero preciso -5. **Función de Búsqueda**: Usa la barra de búsqueda para encontrar rápidamente transacciones específicas - -## Desarrollo - -### Compilación para Móviles - -La aplicación se puede compilar para Android e iOS usando el sistema de compilación de Flet: +## Compilación Móvil ```bash -flet build apk # Para Android -flet build ios # Para iOS +flet build apk # Android +flet build ipa # iOS ``` -### Registro de Eventos - -La aplicación mantiene registros en `storage/data/applogs.log` para depuración y monitoreo. - ## Licencia Copyright (C) 2025 por Christianymoon Ltd. ## Autor -**Christian Vergara** -- Email: imchrisyt15@gmail.com +**Christian Vergara** — imchrisyt15@gmail.com --- -*Balance - Tu compañero de finanzas personales* +*Balance — Tu compañero de finanzas personales* ✨ diff --git a/install_apk.bat b/install_apk.bat new file mode 100644 index 0000000..847c409 --- /dev/null +++ b/install_apk.bat @@ -0,0 +1,28 @@ +@echo off + +echo Building APK... +echo Continue to build the APK using Flet. This may take a few moments... +echo Press any key to continue... + +pause + +flet build apk +if errorlevel 1 ( + echo Build failed. Exiting. + pause + exit /b 1 +) + +color 0A +echo Installing APK... + +adb devices + +@REM echo if your device is not listed, please connect it and try again. +@REM echo Press any key to continue... + +@REM pause + +adb install -r ./build/apk/app-release.apk + +echo "APK installed successfully!" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f6985cf..ef5c27b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "balance" -version = "1.0.0" +version = "3.0.0-beta.1" description = "Beta Version" readme = "README.md" requires-python = ">=3.9" @@ -10,6 +10,9 @@ authors = [ dependencies = [ "flet==0.28.3", + "httpx==0.28.1", + "matplotlib==3.10.0", + "python-dotenv==1.2.2", ] [tool.flet] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cdd4b22 Binary files /dev/null and b/requirements.txt differ diff --git a/screenshots/actives.jpg b/screenshots/actives.jpg deleted file mode 100644 index 142d363..0000000 Binary files a/screenshots/actives.jpg and /dev/null differ diff --git a/screenshots/main1.jpg b/screenshots/main1.jpg deleted file mode 100644 index cb504de..0000000 Binary files a/screenshots/main1.jpg and /dev/null differ diff --git a/screenshots/main2.jpg b/screenshots/main2.jpg deleted file mode 100644 index 6a85a41..0000000 Binary files a/screenshots/main2.jpg and /dev/null differ diff --git a/screenshots/news.jpg b/screenshots/news.jpg deleted file mode 100644 index 03245f2..0000000 Binary files a/screenshots/news.jpg and /dev/null differ diff --git a/screenshots/passive.jpg b/screenshots/passive.jpg deleted file mode 100644 index a5bfb55..0000000 Binary files a/screenshots/passive.jpg and /dev/null differ diff --git a/screenshots/transactions.jpg b/screenshots/transactions.jpg deleted file mode 100644 index 6b9775e..0000000 Binary files a/screenshots/transactions.jpg and /dev/null differ diff --git a/src/assets/charts/balance_chart.png b/src/assets/charts/balance_chart.png new file mode 100644 index 0000000..602fd21 Binary files /dev/null and b/src/assets/charts/balance_chart.png differ diff --git a/src/client/client.py b/src/client/client.py index e69de29..5a41516 100644 --- a/src/client/client.py +++ b/src/client/client.py @@ -0,0 +1,37 @@ +from flet import Page +import logging + +class ClientStorage: + def __init__(self, page: Page): + self.page = page + self.prefix = "christianymoon.finance." + + + def create_key(self, key: str, value = any) -> None: + try: + self.page.client_storage.set(self.prefix + key, value) + return True + except: + logging.error(f"Error al establecer el valor de llave {key} con valor {value}") + return False + + + def get_value(self, key: str) -> any: + """Returns none if value not found""" + value = self.page.client_storage.get(self.prefix + key) + return value + + def set_value(self, key: str, value: any): + try: + self.remove_value(key) + self.create_key(key, value) + return True + except: + logging.error("Error al eliminar y crear el valor de llave") + return False + + def remove_value(self, key: str) -> None: + self.page.client_storage.remove(self.prefix + key) + + def clear_values(self): + self.page.client_storage.clear() \ No newline at end of file diff --git a/src/components/dialogs.py b/src/components/dialogs.py index c4af52d..b5e2c6a 100644 --- a/src/components/dialogs.py +++ b/src/components/dialogs.py @@ -1,5 +1,6 @@ import flet as ft + class Dialogs: def __init__(self, page: ft.Page): self.page = page @@ -14,6 +15,40 @@ def lost_data_dialog(page: ft.Page): ) return page.open(lost_data_dialog) + @staticmethod + def info_dialog(page: ft.Page, text: str, content=None, actions=None): + info_dialog = ft.CupertinoAlertDialog( + title=ft.Text(text), + content=content, + actions=actions + ) + return page.open(info_dialog) + + @staticmethod + def confirmation_dialog(page: ft.Page, text: str, content: ft.Control = None, on_confirm: callable = None, on_cancel: callable = None): + dialog = ft.AlertDialog( + title=ft.Text(text), + content=content, + ) + + def handle_cancel(e): + if on_cancel: + on_cancel(e) + page.close(dialog) + + def handle_confirm(e): + if on_confirm: + on_confirm(e) + page.close(dialog) + + dialog.actions = [ + ft.TextButton("Cancelar", on_click=handle_cancel), + ft.TextButton("Confirmar", on_click=handle_confirm) + ] + page.open(dialog) + return dialog + + @staticmethod def error_dialog(page: ft.Page, error: str): error_dialog = ft.AlertDialog( title=ft.Text("Error"), diff --git a/src/components/headers.py b/src/components/headers.py index fa04c35..04321a5 100644 --- a/src/components/headers.py +++ b/src/components/headers.py @@ -1,57 +1,111 @@ from themes.themes import Theme -import flet as ft +import flet as ft from flet import Icons as icons from controllers.controller import UserDataController + class HeaderSection: def __init__(self, theme: Theme, page: ft.Page): self.theme = theme self.page = page self.page.overlay.clear() - self.file_picker = ft.FilePicker( - on_result=self.change_profile_pic, - - ) - - - def change_profile_pic(self, e): - if e.files: - file = e.files[0] - img_path = file.path - self.page.client_storage.remove("christianymoon.finance.profile_pic") - self.page.client_storage.set("christianymoon.finance.profile_pic", img_path) - self.profile_image.src = self.page.client_storage.get("christianymoon.finance.profile_pic") - self.page.update() + def _open_notification_panel(self, e): + state = e.control.data + if not state: + # Obtener notificaciones + notifications = self.page.notification_manager.get_all( + ) if hasattr(self.page, 'notification_manager') else [] + + # Construir elementos de notificación + notification_items = [] + if notifications: + for notification in notifications: + notification_item = ft.Container( + content=ft.Row([ + ft.Column([ + ft.Text(notification.title, color=self.theme.text_primary, + size=12, weight=ft.FontWeight.W_500), + ft.Text( + notification.message, color=self.theme.text_secondary, size=10, max_lines=2), + ft.Text(notification.created_at, + color=self.theme.text_secondary, size=8), + ], spacing=2, expand=True), + ]), + padding=ft.padding.all(12), + bgcolor=self.theme.fg, + margin=ft.margin.symmetric(horizontal=0, vertical=4), + ) + notification_items.append(notification_item) + else: + notification_items = [ + ft.Container( + content=ft.Column([ + ft.Icon(icons.NOTIFICATIONS_NONE, + color=self.theme.text_secondary, size=24), + ft.Text("No hay notificaciones", + color=self.theme.text_secondary, size=11), + ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=8), + alignment=ft.alignment.center, + padding=ft.padding.all(24), + ) + ] + + panel_content = ft.Column( + [ + ft.Container( + content=ft.Column( + notification_items, + scroll=ft.ScrollMode.AUTO, + expand=True, + spacing=0, + ), + width=300, + expand=True, + bgcolor=self.theme.fg, + padding=ft.padding.symmetric( + horizontal=12, vertical=12), + ) + ], + expand=True, + alignment=ft.MainAxisAlignment.CENTER, + ) + + self.page.overlay.append(panel_content) + e.control.data = True + else: + self.page.overlay.clear() + e.control.data = False + self.page.update() def create(self, placeholder="Bienvenido de nuevo", return_page=True, config=False): self.userdata = UserDataController.controller_fetch_userdata() self.header_container = ft.Row( - expand=True, + expand=True, ) self.retrun_button = ft.IconButton( - icons.ARROW_BACK_IOS, - icon_color=self.theme.text_secondary, - icon_size=20, + icons.ARROW_BACK_IOS, + icon_color=self.theme.text_primary, + icon_size=20, on_click=lambda e: self.page.go("/"), expand=True, bgcolor=self.theme.fg, ) self.placeholder = ft.Text( - placeholder, + placeholder, color=self.theme.text_primary, - size=18, - weight=ft.FontWeight.W_500, + size=18, + weight=ft.FontWeight.W_500, expand=True, ) self.placeholder_container = ft.Container( content=self.placeholder, - expand=True, + expand=True, alignment=ft.alignment.center, ) @@ -59,41 +113,47 @@ def create(self, placeholder="Bienvenido de nuevo", return_page=True, config=Fal content=self.retrun_button, ) - self.settings_button = ft.IconButton(icons.SETTINGS, icon_color=self.theme.text_secondary, icon_size=20, on_click=lambda e: self.page.go("/settings")) + # self.notification_button = ft.IconButton(icons.NOTIFICATIONS, icon_color=self.theme.text_secondary, icon_size=20, on_click=self._open_notification_panel, data=False) + + self.settings_button = ft.IconButton( + icons.SETTINGS, icon_color=self.theme.text_primary, icon_size=20, on_click=lambda e: self.page.go("/settings")) - self.profile_image = ft.Image("./assets/avatar/profile.png", fit=ft.ImageFit.COVER, width=45, height=45) + self.profile_image = ft.Image( + "./assets/avatar/profile.png", fit=ft.ImageFit.COVER, width=45, height=45) self.profile_container = ft.Container( content=self.profile_image, width=45, height=45, border_radius=ft.border_radius.all(40), - on_click=self.file_picker.pick_files) + on_click=lambda e: self.page.go("/user"), + ) self.header_container.controls.append(self.placeholder_container) if return_page: - self.header_container.controls.insert(0, self.return_button_container) + self.header_container.controls.insert( + 0, self.return_button_container) else: - self.page.overlay.append(self.file_picker) if self.page.client_storage.get("christianymoon.finance.profile_pic"): - self.profile_image.src = self.page.client_storage.get("christianymoon.finance.profile_pic") + self.profile_image.src = self.page.client_storage.get( + "christianymoon.finance.profile_pic") else: self.profile_image.src = "./assets/avatar/profile.png" - + if self.userdata[2] == "Femenino": self.placeholder.value = f"Bienvenida de nuevo, {self.userdata[1]}!" elif self.userdata[2] == "Masculino": self.placeholder.value = f"Bienvenido de nuevo, {self.userdata[1]}!" - + self.header_container.controls.insert(0, self.profile_container) if config: self.header_container.controls.append(self.settings_button) + # self.header_container.controls.append(self.notification_button) return ft.Container( self.header_container, bgcolor=self.theme.bg, - padding=ft.padding.symmetric(horizontal=20), margin=ft.margin.only(top=20), - ) \ No newline at end of file + ) diff --git a/src/components/notification_manager.py b/src/components/notification_manager.py new file mode 100644 index 0000000..f30d747 --- /dev/null +++ b/src/components/notification_manager.py @@ -0,0 +1,78 @@ +from datetime import datetime +from enum import Enum +from typing import List, Callable + + +class NotificationType(Enum): + """Tipos de notificaciones disponibles""" + SUCCESS = "success" + ERROR = "error" + WARNING = "warning" + INFO = "info" + + +class Notification: + """Modelo de una notificación individual""" + def __init__(self, title: str, message: str, notification_type: NotificationType = NotificationType.INFO): + self.id = id(self) # ID único basado en el objeto + self.title = title + self.message = message + self.notification_type = notification_type + self.created_at = datetime.now().strftime("%H:%M") + self.read = False + + +class NotificationManager: + """Gestor centralizado de notificaciones para toda la app""" + + def __init__(self): + self.notifications: List[Notification] = [] + self.max_notifications = 20 # Mantener máximo 20 notificaciones en memoria + self.on_notification_added: Callable = None # Callback cuando se agrega una notificación + + def add_notification(self, title: str, message: str, notification_type: NotificationType = NotificationType.INFO) -> Notification: + """Agregar una nueva notificación""" + notification = Notification(title, message, notification_type) + self.notifications.insert(0, notification) # Agregar al inicio para que las más recientes estén arriba + + # Mantener el límite de notificaciones + if len(self.notifications) > self.max_notifications: + self.notifications = self.notifications[:self.max_notifications] + + # Ejecutar callback si existe + if self.on_notification_added: + self.on_notification_added(notification) + + return notification + + def get_unread_count(self) -> int: + """Obtener cantidad de notificaciones no leídas""" + return sum(1 for n in self.notifications if not n.read) + + def mark_as_read(self, notification_id: int): + """Marcar una notificación como leída""" + for notification in self.notifications: + if notification.id == notification_id: + notification.read = True + break + + def mark_all_as_read(self): + """Marcar todas las notificaciones como leídas""" + for notification in self.notifications: + notification.read = True + + def remove_notification(self, notification_id: int): + """Eliminar una notificación""" + self.notifications = [n for n in self.notifications if n.id != notification_id] + + def clear_all(self): + """Limpiar todas las notificaciones""" + self.notifications.clear() + + def get_by_type(self, notification_type: NotificationType) -> List[Notification]: + """Obtener notificaciones de un tipo específico""" + return [n for n in self.notifications if n.notification_type == notification_type] + + def get_all(self) -> List[Notification]: + """Obtener todas las notificaciones""" + return self.notifications diff --git a/src/config.py b/src/config.py deleted file mode 100644 index 550680a..0000000 --- a/src/config.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - -try: - FLET_APP_STORAGE_DATA = os.getenv("FLET_APP_STORAGE_DATA") # ANDROID MODE -except Exception: - FLET_APP_STORAGE_DATA = "/" \ No newline at end of file diff --git a/src/controllers/AI/controller.py b/src/controllers/AI/controller.py new file mode 100644 index 0000000..bfe2312 --- /dev/null +++ b/src/controllers/AI/controller.py @@ -0,0 +1,69 @@ +import logging + +from controllers.actives.controller import ActiveController +from controllers.AI.tools import gemini_tools +from controllers.balance.controller import BalanceController +from controllers.liquid.controller import LiquidController +from controllers.passives.controller import PassiveController +from external.gemini.ai import Gemini + + +class AIChatController: + + def __init__(self): + self.gemini = Gemini() + self.gemini.add_tool(gemini_tools) + + def build_financial_context(self) -> str: + """Construye un resumen financiero estructurado para el prompt.""" + + passives = PassiveController.controller_fetch_passives() + actives = ActiveController.controller_fetch_actives() + balance = BalanceController.controller_fetch_balance(formated=True) + liquidity = LiquidController.get(formated=True) + + financial_summary = { + "data": { + "passives": passives, + "actives": actives, + "current_balance": balance, + "current_liquidity": liquidity + } + + } + + return financial_summary + + def analyze_with_ai(self, user_prompt: str, model: str, thinking_level: str, instance=None) -> str: + """ + Envía el contexto financiero + prompt del usuario a Gemini y retorna la respuesta. + """ + try: + financial_context = self.build_financial_context() + + full_prompt = ( + "Eres un asistente financiero llamado Kara, no eres un doctor, no eres un asesor financiero, no eres un contador, no eres un asesor de prestamos, no eres un asesor de inversiones, eres un asistente financiero." + "Tu nombre fue basado en un personaje del juego Detroit Become Human uno de los videojuegos favoritos de Christian (el desarrollador), tu personalidad se puede basar en este personaje, si el usuario te pregunta de donde sacaste tu nombre mencionas esto, si no lo hace no lo menciones." + "puedes ser amigable y usar emojis de vez en cuando, si la pregunta tiene humor, o no tiene nada que ver con finanzas, no es necesario que uses emojis siempre" + "Al igual que el personaje de Kara podrias tener momentos en los que podrias contradecir al usuario si consideras que la decision financiera es arriesgada y con amabilidad respondes por que" + "Contexto: (el contexto puede omitirse si no tiene nada que ver con lo que se pregunta)" + f"{financial_context}\n\n" + f"Pregunta del usuario: {user_prompt}" + ) + + response = self.gemini.generate_interaction( + full_prompt, model, thinking_level, instance) + return response["content"] + except Exception as e: + logging.error(f"Error during AI analysis: {e}", exc_info=True) + return f"Error al procesar la respuesta de IA: {str(e)}" + + def generate_recommendations(self, user_prompt: str, model: str = "gemini-2.5-flash-lite") -> str: + try: + context = "Estoy haciendo una clasificacion de categorias de finanzas personales, escribe una breve descripcion para entender que se puede clasificar, no uses markdown, 4 palabras minimo, 15 palabras maximo: " + user_prompt + response = self.gemini.generate_interaction(context, model) + return response["content"] + except Exception as e: + logging.error( + f"Error during AI recommendation: {e}", exc_info=True) + return f"Error al procesar la respuesta de IA" diff --git a/src/controllers/AI/tools.py b/src/controllers/AI/tools.py new file mode 100644 index 0000000..862b49f --- /dev/null +++ b/src/controllers/AI/tools.py @@ -0,0 +1,251 @@ +from datetime import date +from enum import Enum + +from controllers.actives.controller import ActiveController +from controllers.categories.controller import CategoriesController +from controllers.transactions.controller import TransactionController +from controllers.transactions.statistics import fetch_summary +from dto.transactions import ( + TransactionDTO, + TransactionFilterDTO, + TransactionUpdateDTO, +) + + +class TransactionType(Enum): + INCOME = "income" + SPENT = "spent" + CREDIT = "credit" + + +gemini_tools = { + + "fetch_transactions": { + "function": TransactionController.fetch_transactions, + "dto": TransactionFilterDTO, + "schema": { + "type": "function", + "name": "fetch_transactions", + "description": "Obtiene todas las transacciones", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Limite de transacciones a obtener" + }, + "date_from": { + "type": "string", + "description": """Fecha de inicio en formato yyyy-mm-dd, si no se especifica avisa al usuario que puedes calcularlo a partir de la fecha del inicio de semana de la actual fecha""" + }, + "date_to": { + "type": "string", + "description": """Fecha de fin en formato yyyy-mm-dd, si no se especifica avisa al usuario que puedes calcularlo a partir de la fecha de hoy""" + }, + "category_id": { + "type": "integer", + "description": "ID de la categoría" + }, + "subcategory_id": { + "type": "integer", + "description": "ID de la subcategoría" + }, + "is_income": { + "type": "boolean", + "description": "Indica si la transacción es un ingreso o egreso" + } + } + } + } + }, + + "fetch_categories": { + "function": CategoriesController().fetch_all_categories, + "dto": None, + "schema": { + "type": "function", + "name": "fetch_categories", + "description": "Obtiene todas las categorias", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + + "fetch_subcategories": { + "function": CategoriesController().fetch_all_subcategories, + "dto": None, + "schema": { + "type": "function", + "name": "fetch_subcategories", + "description": "Obtiene todas las subcategorias", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + + "set_transaction": { + "function": TransactionController.create_transaction, + "dto": TransactionDTO, + "schema": { + "type": "function", + "name": "set_transaction", + "description": "Agrega una nueva transaccion", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "category": { + "type": "integer", + }, + "subcategory": { + "type": "integer", + }, + "price": { + "type": "number", + }, + "type": { + "type": "string", + "enum": [ + TransactionType.INCOME.value, + TransactionType.SPENT.value, + TransactionType.CREDIT.value + ], + "description": "Tipo de transacción, si no se especifica pide al usuario que tipo de transaccion es, ademas si es ingreso y gasto, pide al usuario la cuenta donde hacer la transaccion" + }, + "account_id": { + "type": "integer", + "description": """ + Registra una nueva transacción. + + Requisitos: + - Para ingresos y gastos es obligatorio conocer la cuenta. + - Las transacciones obligatoriamente se deben hacer a cuentas con fondos liquidos + - Si hay varias cuentas disponibles y el usuario no indica cuál usar, primero consulta get_accounts y pregunta al usuario. + - Nunca selecciones una cuenta automáticamente. + """, + }, + }, + "required": ["name", "category", "subcategory", "price", "type", "account_id"] + } + } + }, + + "update_transaction": { + "function": TransactionController.update_transaction, + "dto": TransactionUpdateDTO, + "schema": { + "type": "function", + "name": "update_transaction", + "description": "Actualiza una transacción", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID de la transacción" + }, + "name": { + "type": "string", + "description": "Nombre de la transacción" + }, + "category": { + "type": "string", + "description": "Categoría de la transacción" + }, + "subcategory": { + "type": "string", + "description": "Subcategoría de la transacción" + }, + "price": { + "type": "number", + "description": "Precio de la transacción" + }, + "type": { + "type": "string", + "description": "Tipo de transacción, si no se especifica pide al usuario que tipo de transaccion es, ademas si es ingreso y gasto, pide al usuario la cuenta donde hacer la transaccion" + }, + "account_id": { + "type": "integer", + "description": "ID de la cuenta a la que se hizo la transaccion, cuidado no uses el id de la transaccion, la cuenta a la que se hizo la transaccion." + } + }, + "required": ["id", "name", "category", "subcategory", "price", "type", "account_id"] + } + } + }, + + "get_accounts": { + "function": ActiveController.controller_fetch_actives, + "dto": None, + "schema": { + "type": "function", + "name": "get_accounts", + "description": "Obtiene todas las cuentas con su saldo actual", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + + "fetch_summary": { + "function": fetch_summary, + "dto": TransactionFilterDTO, + "schema": { + "type": "function", + "name": "fetch_summary", + "description": "Obtiene el resumen de transacciones", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Limite de transacciones a obtener" + }, + "date_from": { + "type": "string", + "description": "Fecha de inicio en formato yyyy-mm-dd" + }, + "date_to": { + "type": "string", + "description": "Fecha de fin en formato yyyy-mm-dd" + }, + "category_id": { + "type": "integer", + "description": "ID de la categoría" + }, + "subcategory_id": { + "type": "integer", + "description": "ID de la subcategoría" + }, + "is_income": { + "type": "boolean", + "description": "Indica si es ingreso o egreso" + }, + }, + "required": [] + } + } + }, + + "fetch_today": { + "function": date.today, + "dto": None, + "schema": { + "type": "function", + "name": "fetch_today", + "description": "Obtiene el dia de hoy", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + } +} diff --git a/src/controllers/actives/controller.py b/src/controllers/actives/controller.py new file mode 100644 index 0000000..8229538 --- /dev/null +++ b/src/controllers/actives/controller.py @@ -0,0 +1,129 @@ +from datetime import datetime +import logging + +from controllers.balance.controller import BalanceController +from controllers.liquid.controller import LiquidController +from db.database import Database + + +class ActiveController: + def __init__(self): + pass + + @staticmethod + def controller_set_active(name, active_type, amount, is_liquid): + db = Database() + try: + active_id = db.set_active(active_type, name, amount, is_liquid) + except Exception as e: + logging.error(f"Error during set active {e}") + + if is_liquid: + BalanceController.controller_change_balance(amount, True) + LiquidController.change(amount, True) + else: + BalanceController.controller_change_balance(amount, True) + + return active_id + + @staticmethod + def controller_fetch_actives(): + db = Database() + try: + all_actives = db.fetch_all_actives() + return all_actives + except Exception as e: + logging.error( + f"Error during fetching all actives: {e}", exc_info=True) + return [] + + @staticmethod + def controller_fetch_active(id): + db = Database() + try: + return db.fetch_active(id) + except Exception as e: + logging.error(f"Error during fetching active: {e}", exc_info=True) + return [] + + @staticmethod + def set_price(id, price): + db = Database() + try: + db.update_active(id, float(price)) + active = ActiveController.controller_fetch_active(id) + if active[4]: + BalanceController.controller_change_balance(float(price), True) + LiquidController.change(float(price), True) + else: + BalanceController.controller_change_balance(float(price), True) + except Exception as e: + logging.error(f"Error during update price: {e}", exc_info=True) + + @staticmethod + def controller_update_active(id, mount, is_income): + db = Database() + try: + active = ActiveController.controller_fetch_active(id) + if is_income: + if active[4]: + BalanceController.controller_change_balance( + float(mount), True) + LiquidController.change(float(mount), True) + else: + BalanceController.controller_change_balance( + float(mount), True) + db.update_active(id, float(active[3]) + float(mount)) + else: + if active[4]: + BalanceController.controller_change_balance( + float(mount), False) + LiquidController.change(float(mount), False) + else: + BalanceController.controller_change_balance( + float(mount), False) + db.update_active(id, float(active[3]) - float(mount)) + except Exception as e: + logging.error(f"Error during update active: {e}", exc_info=True) + + @staticmethod + def controller_intertransfer(source_id, target_id, mount): + db = Database() + try: + source = ActiveController.controller_fetch_active(source_id) + target = ActiveController.controller_fetch_active(target_id) + created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") + if source[3] < float(mount): + raise ValueError( + "No tienes saldo suficiente para realizar la transferencia") + ActiveController.controller_update_active(source_id, mount, False) + ActiveController.controller_update_active(target_id, mount, True) + db.set_intertransaction( + source[2], target[2], float(mount), created_at) + except Exception as e: + logging.error(f"Error during intertransfer: {e}", exc_info=True) + raise e + + @staticmethod + def controller_fetch_intertransactions(): + db = Database() + try: + intertransactions = db.fetch_intertransactions() + return intertransactions + except Exception as e: + logging.error(f"Error during fetch intertransactions: {e}") + return [] + + @staticmethod + def controller_delete_active(id): + db = Database() + try: + active = ActiveController.controller_fetch_active(id) + if active[4]: # si el activo es liquido bajar de ambas + BalanceController.controller_change_balance(active[3], False) + LiquidController.change(active[3], False) + else: + BalanceController.controller_change_balance(active[3], False) + db.delete_active(id) + except Exception as e: + logging.error(f"Error during delete active: {e}", exc_info=True) diff --git a/src/controllers/balance/controller.py b/src/controllers/balance/controller.py new file mode 100644 index 0000000..61918f3 --- /dev/null +++ b/src/controllers/balance/controller.py @@ -0,0 +1,58 @@ +from datetime import date + +from db.database import Database + + +class BalanceController: + @staticmethod + def controller_set_balance(amount, new=False): + db = Database() + if new: + db.set_balance(amount) + return + + db.update_atomic_balance(amount) + + @staticmethod + def controller_fetch_balance(formated=False): + db = Database() + try: + balance = db.fetch_balance()[1] + except TypeError: + db.set_balance(0.0) + balance = db.fetch_balance()[1] + + if formated: + return f"${balance:,.2f}" + return balance + + @staticmethod + def controller_change_balance(amount, is_income): + if not is_income: + amount = -float(amount) + else: + amount = float(amount) + + BalanceController.controller_set_balance(amount) # updates now only + + def fetch_balance_update(): + db = Database() + return db.fetch_balance()[2] + + @staticmethod + def fetch_snapshots(): + db = Database() + return db.fetch_balance_snapshots() + + @staticmethod + def fetch_last_date_snapshot(): + db = Database() + return db.fetch_last_date_snapshot() + + @classmethod + def make_snapshot(cls): + db = Database() + last_snapshot_date = db.fetch_last_date_snapshot() + if (last_snapshot_date != date.today().isoformat()) or (last_snapshot_date == None): + db.make_snapshot(cls.controller_fetch_balance( + formated=False), date.today().isoformat()) diff --git a/src/controllers/categories/controller.py b/src/controllers/categories/controller.py new file mode 100644 index 0000000..5e89c06 --- /dev/null +++ b/src/controllers/categories/controller.py @@ -0,0 +1,214 @@ +import logging + +from db.database import Database + + +class CategoriesController(): + def __init__(self): + pass + + def set_category(self, name, description=""): + db = Database() + try: + id_ = db.set_category(name, description) + return id_ + except Exception as e: + logging.error(f"Error during set category: {e}", exc_info=True) + raise e + + def delete_category(self, id): + db = Database() + try: + db.delete_category(id) + except Exception as e: + logging.error(f"Error during delete category: {e}", exc_info=True) + raise e + + def fetch_all_categories(self): + db = Database() + try: + categories = db.fetch_all_categories() + return categories + except Exception as e: + logging.error(f"Error during fetch categories: {e}", exc_info=True) + return [] + + def fetch_category_by_name(self, category_name): + db = Database() + try: + category = db.fetch_category_by_name(category_name) + return category + except Exception as e: + logging.error( + f"Error during fetch category by name: {e}", exc_info=True) + return [] + + def fetch_subcategory_by_name(self, subcategory_name): + db = Database() + try: + subcategory = db.fetch_subcategory_by_name(subcategory_name) + return subcategory + except Exception as e: + logging.error( + f"Error during fetch subcategory by name: {e}", exc_info=True) + return [] + + def get_subcategory_name(self, subcategory_id): + subc_name = None + is_numeric = False + + if isinstance(subcategory_id, int): + is_numeric = True + + if isinstance(subcategory_id, str) and subcategory_id.isdigit(): + is_numeric = True + + if is_numeric: + subc_name = self.fetch_subcategory(int(subcategory_id))[2] + else: + subc_name = str( + subcategory_id) if subcategory_id is not None else "Otros" + + return subc_name + + def get_category_name(self, category_id): + category_name = None + is_numeric = False + if isinstance(category_id, int): + is_numeric = True + + if isinstance(category_id, str) and category_id.isdigit(): + is_numeric = True + + if is_numeric: + category_name = self.fetch_category(int(category_id)) + if category_name is None: + return "Categoría no encontrada" + + return category_name[1] + else: + category_name = str(category_id) if category_id is not None else "" + + return category_name + + def fetch_category(self, id): + db = Database() + try: + category = db.fetch_category(id) + return category + except Exception as e: + logging.error(f"Error during fetch category: {e}", exc_info=True) + return None + + def update_category(self, id, name, description): + db = Database() + try: + db.update_category(id, name, description) + except Exception as e: + logging.error(f"Error during update category: {e}", exc_info=True) + raise e + + def delete_all_categories(self): + db = Database() + try: + db.delete_all_categories() + return True + except Exception as e: + logging.error( + f"Error during delete all categories: {e}", exc_info=True) + return False + + def deactivate_category(self, id): + db = Database() + try: + db.deactivate_category(id) + except Exception as e: + logging.error(f"Error during deactivate categories {e}") + + def activate_category(self, id): + db = Database() + db.activate_category(id) + + def fetch_subcategory(self, id): + db = Database() + try: + subcategory = db.fetch_subcategory(id) + return subcategory + except Exception as e: + logging.error( + f"Error during fetch subcategory: {e}", exc_info=True) + return None + + def fetch_all_subcategories(self): + db = Database() + try: + subcategories = db.fetch_all_subcategories() + return subcategories + except Exception as e: + logging.error( + f"Error during fetch subcategories: {e}", exc_info=True) + return [] + + def set_subcategory(self, category_id, name, description=""): + db = Database() + try: + id_ = db.set_subcategory(category_id, name, description) + return id_ + except Exception as e: + logging.error(f"Error during set subcategory: {e}", exc_info=True) + raise e + + def fetch_subcategories(self, category_id): + db = Database() + try: + subcategories = db.fetch_subcategories(category_id) + return subcategories + except Exception as e: + logging.error( + f"Error during fetch subcategories: {e}", exc_info=True) + return [] + + def delete_subcategory(self, id): + db = Database() + try: + db.delete_subcategory(id) + except Exception as e: + logging.error( + f"Error during delete subcategory: {e}", exc_info=True) + raise e + + def update_subcategory(self, id, name, description): + db = Database() + try: + db.update_subcategory(id, name, description) + except Exception as e: + logging.error( + f"Error during update subcategory: {e}", exc_info=True) + raise e + + def deactivate_subcategory(self, id): + db = Database() + try: + db.deactivate_subcategory(id) + except Exception as e: + logging.error( + f"Error during deactivate subcategory {e}", exc_info=True) + raise e + + def activate_subcategory(self, id): + db = Database() + try: + db.activate_subcategory(id) + except Exception as e: + logging.error( + f"Error during activate subcategory {e}", exc_info=True) + raise e + + def get_category_by_subcategory_id(self, subcategory_id): + """Obtiene el ID de la categoría mediante el ID de la subcategoría""" + db = Database() + try: + return db.fetch_category_by_subcategory_id(subcategory_id) + except Exception as e: + logging.error(f"Error fetching category id: {e}", exc_info=True) + return None diff --git a/src/controllers/chart.py b/src/controllers/chart.py new file mode 100644 index 0000000..7b77642 --- /dev/null +++ b/src/controllers/chart.py @@ -0,0 +1,60 @@ +from datetime import datetime + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt + +from core.config import ASSETS_DIR + + +class ChartGenerator: + def __init__(self): + pass + + def balance_chart(self, days: list[datetime], balance: list[float]) -> str: + if len(days) != len(balance): + raise ValueError("Las fechas y los balances no coinciden") + + route = ASSETS_DIR + "/charts/balance_chart.png" + + plt.style.use("dark_background") + + fig, ax = plt.subplots(figsize=(10, 5)) + + ax.plot( + days, + balance, + linewidth=2.5, + marker="o", + markersize=5, + ) + + # Fondo + fig.patch.set_facecolor("#000000") + ax.set_facecolor("#000000") + + # Título y etiquetas + ax.set_title("Evolución del Patrimonio", fontsize=16) + ax.set_xlabel("Fecha") + ax.set_ylabel("Patrimonio") + + # Cuadrícula + ax.grid(True, linestyle="--", alpha=0.25) + + # Mostrar todas las fechas + ax.set_xticks(days) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m")) + + # Rotar etiquetas + plt.setp(ax.get_xticklabels(), rotation=45, ha="right") + + # No cortar el primer y último punto + ax.margins(x=0.03) + + # Ajustar límites del eje X + ax.set_xlim(days[0], days[-1]) + + fig.tight_layout() + fig.savefig(route, dpi=200, bbox_inches="tight") + plt.close(fig) + + return route diff --git a/src/controllers/controller.py b/src/controllers/controller.py index 9973259..9ecaada 100644 --- a/src/controllers/controller.py +++ b/src/controllers/controller.py @@ -1,23 +1,27 @@ -from database import Database -import logging -import httpx -from datetime import datetime -from zoneinfo import ZoneInfo +from db.database import Database +from dto.transactions import TransactionFilterDTO + class DatabaseController: def __init__(self): self.db = Database() + def drop_tables(self): + self.db.drop_tables() + def create_tables(self): self.db.create_tables() - def drop_tables(self): - self.db.drop_tables() - def init_seed(self): self.db.set_balance(0.0) self.db.set_liquid(0.0) + def reset(self): + self.drop_tables() + self.create_tables() + self.init_seed() + + class UserDataController: @staticmethod def controller_set_userdata(name, gender): @@ -25,437 +29,31 @@ def controller_set_userdata(name, gender): name = "" db = Database() db.set_userdata(name, gender) - - @staticmethod + @staticmethod def controller_fetch_userdata(): db = Database() user_data = db.fetch_userdata() # return a tuple - + return user_data - + + class SearchController: def __init__(self): pass - + def controller_search_transactions(query): db = Database() - transactions = db.fetch_transactions() - + filter = TransactionFilterDTO() + transactions = db.fetch_transactions(filter) + query_lower = query.lower() filtered_transactions = [ t for t in transactions - if query_lower == t[1].lower() or query_lower == t[2].lower() + if query_lower == t.name .lower() or query_lower == str(t.category) ] return filtered_transactions -class PassiveController: - @staticmethod - def controller_set_passive(name, category, amount, is_liquidated=False): - db = Database() - try: - created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") - db.set_passive(name, category, amount, is_liquidated, created_at) - TransactionController.register_transaction(name, category, amount, False, created_at) - except Exception as e: - logging.error(f"Error during setting passive: {e}", exc_info=True) - - BalanceController.controller_change_balance(amount, False) - - @staticmethod - def fetch_passive(id): - db = Database() - try: - passive = db.fetch_passive(id) - return passive - except Exception as e: - logging.error(f"Error during searching pasive on database: {e}") - - @staticmethod - def pay_passive(id, active_id): - db = Database() - try: - passive = PassiveController.fetch_passive(id) - active = ActiveController.controller_fetch_active(active_id) - if active[3] < passive[3]: - raise ValueError("No tienes saldo suficiente para pagar este pasivo") - if passive and not passive[4]: - db.update_passive(id) - db.update_active(active_id, active[3] - passive[3]) - LiquidController.change(passive[3], False) - except Exception as e: - logging.error(f"Error during paying passive: {e}", exc_info=True) - raise e - - @staticmethod - def controller_fetch_passives(): - db = Database() - try: - all_passives = db.fetch_passives() - return all_passives - except Exception as e: - logging.error(f"Error during fetch all passives: {e}") - return [] - - @staticmethod - def controller_delete_passive(id): - db = Database() - try: - db.delete_passive(id) - except Exception as e: - logging.error("Error during delete pasive {id} error: {e}") - -class BalanceController: - @staticmethod - def controller_set_balance(amount, new=False): - db = Database() - if new: - db.set_balance(amount) - return - - db.update_atomic_balance(amount) - - @staticmethod - def controller_fetch_balance(formated=False): - db = Database() - try: - balance = db.fetch_balance()[1] - except TypeError: - db.set_balance(0.0) - balance = db.fetch_balance()[1] - - if formated: - return f"${balance:,.2f}" - return balance - - @staticmethod - def controller_change_balance(amount, is_income): - if not is_income: - amount = -float(amount) - else: - amount = float(amount) - - BalanceController.controller_set_balance(amount) # updates now only - - def fetch_balance_update(): - db = Database() - return db.fetch_balance()[2] - -class TransactionController: - @staticmethod - def controller_set_transaction(data): - if data["type"] == "credit": - PassiveController.controller_set_passive( - data["name"], - data["category"], - data["price"], - False) - return - - if not TransactionController.validate_transaction(data) and data["type"] == "spent": - raise Exception("No hay saldo suficiente para realizar la transaccion") - - db = Database() - - if data["type"] == "spent": - is_income = False - elif data["type"] == "income": - is_income = True - - - - created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") - - try: - expense_percentage = float(data["price"]) / BalanceController.controller_fetch_balance() * 100 - except ZeroDivisionError: - expense_percentage = 0.0 - db.set_transaction( - data["name"], - data["category"], - data["price"], - is_income, - expense_percentage, - created_at) - - ActiveController.controller_update_active( - data["account_id"], - data["price"], - is_income - ) - - def register_transaction(name, category, price, is_income, created_at): - db = Database() - try: - expense_percentage = float(price) / BalanceController.controller_fetch_balance() * 100 - except ZeroDivisionError: - expense_percentage = 0.0 - db.set_transaction( - name, - category, - price, - is_income, - expense_percentage, - created_at) - - def validate_transaction(data): - active = ActiveController.controller_fetch_active(data["account_id"]) - if active[3] < float(data["price"]): - return False - return True - - @staticmethod - def controller_fetch_transactions(): - try: - db = Database() - transactions = db.fetch_transactions() - return transactions - except Exception as e: - logging.error(f"Error fetching transactions: {e}", exc_info=True) - return [] - - @staticmethod - def controller_fetch_sum(is_income=True): - all_transactions = TransactionController.controller_fetch_transactions() - try: - if is_income: - income_transactions = [transaction[3] for transaction in all_transactions if transaction[4] == True] - total = sum(income_transactions) - return f"${total:,.2f}" - else: - not_income_transactions = [transaction[3] for transaction in all_transactions if transaction[4] == False] - total = sum(not_income_transactions) - return f"${total:,.2f}" - except Exception as e: - logging.error(f"Error during fetch sum of transactions {e}", exc_info=True) - -class LiquidController: - @staticmethod - def set(amount): - db = Database() - try: - db.set_liquid(float(amount)) - except Exception as e: - logging.error(f"Error during set liquid to database {e}", exc_info=True) - - @staticmethod - def get(formated=False): - db = Database() - try: - liquid_obj = db.fetch_liquid()[1] - if liquid_obj is None: - raise ValueError("Liquid object is NoneType") - except Exception as e: - logging.error(f"Error during get liquid object {e} trying to setting up", exc_info=True) - LiquidController.set(0) - liquid_obj = db.fetch_liquid()[1] - finally: - if formated: - return f"${liquid_obj:,.2f}" - return liquid_obj - - @staticmethod - def udpate(new_amount): - """Update the last element on the database""" - db = Database() - try: - db.udpate_liquid(new_amount) - except Exception as e: - logging.error(f"Error during update liquid {e}", exc_info=True) - - def change(new_amount, is_increase): - try: - if not is_increase: - new_amount = -float(new_amount) - else: - new_amount = float(new_amount) - - LiquidController.udpate(new_amount) - except Exception as e: - logging.error(f"Error during change value {e}") - -class ActiveController: - def __init__(self): - pass - - @staticmethod - def controller_set_active(name, active_type, amount, is_liquid): - db = Database() - try: - active_id = db.set_active(active_type, name, amount, is_liquid) - except Exception as e: - logging.error(f"Error during set active {e}") - - if is_liquid: - BalanceController.controller_change_balance(amount, True) - LiquidController.change(amount, True) - else: - BalanceController.controller_change_balance(amount, True) - - return active_id - - - @staticmethod - def controller_fetch_actives(): - db = Database() - try: - all_actives = db.fetch_all_actives() - return all_actives - except Exception as e: - logging.error(f"Error during fetching all actives: {e}", exc_info=True) - return [] - - @staticmethod - def controller_fetch_active(id): - db = Database() - try: - return db.fetch_active(id) - except Exception as e: - logging.error(f"Error during fetching active: {e}", exc_info=True) - return [] - - @staticmethod - def controller_update_active(id, mount, is_income): - db = Database() - try: - active = ActiveController.controller_fetch_active(id) - if is_income: - if active[4]: - BalanceController.controller_change_balance(float(mount), True) - LiquidController.change(float(mount), True) - else: - BalanceController.controller_change_balance(float(mount), True) - db.update_active(id, float(active[3]) + float(mount)) - else: - if active[4]: - BalanceController.controller_change_balance(float(mount), False) - LiquidController.change(float(mount), False) - else: - BalanceController.controller_change_balance(float(mount), False) - db.update_active(id, float(active[3]) - float(mount)) - except Exception as e: - logging.error(f"Error during update active: {e}", exc_info=True) - - @staticmethod - def controller_intertransfer(source_id, target_id, mount): - db = Database() - try: - source = ActiveController.controller_fetch_active(source_id) - target = ActiveController.controller_fetch_active(target_id) - created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") - if source[3] < float(mount): - raise ValueError("No tienes saldo suficiente para realizar la transferencia") - ActiveController.controller_update_active(source_id, mount, False) - ActiveController.controller_update_active(target_id, mount, True) - db.set_intertransaction(source[2], target[2], float(mount), created_at) - except Exception as e: - logging.error(f"Error during intertransfer: {e}", exc_info=True) - raise e - - @staticmethod - def controller_fetch_intertransactions(): - db = Database() - try: - intertransactions = db.fetch_intertransactions() - return intertransactions - except Exception as e: - logging.error(f"Error during fetch intertransactions: {e}") - return [] - - @staticmethod - def controller_delete_active(id): - db = Database() - try: - active = ActiveController.controller_fetch_active(id) - if active[4]: # si el activo es liquido bajar de ambas - BalanceController.controller_change_balance(active[3], False) - LiquidController.change(active[3], False) - else: - BalanceController.controller_change_balance(active[3], False) - db.delete_active(id) - except Exception as e: - logging.error(f"Error during delete active: {e}", exc_info=True) - -class LoanController: - def __init__(self): - pass - - @staticmethod - def calculate_interest(amount, interest): - try: - total_interest = float(amount) * (float(interest) / 100) - return total_interest - except Exception as e: - logging.error(f"Error during calculate interest {e}", exc_info=True) - return 0.0 - - def paid(id): - db = Database() - try: - db.paid_loan(id) - except Exception as e: - logging.error(f"Error during set paid {e}", exc_info=True) - raise e - - def controller_fetch_loans(): - db = Database() - try: - loans = db.fetch_borrowings() - return loans - except Exception as e: - logging.error(f"Error during fetch loans: {e}", exc_info=True) - return [] - - def liquidate(id, loan_active_id, pay_account_id): - db = Database() - try: - loan = LoanController.fetch(id) - LoanController.paid(id) - ActiveController.controller_update_active(pay_account_id, float(loan[3]), True) - ActiveController.controller_delete_active(loan_active_id) - except Exception as e: - logging.error(f"Error during liquidate loan: {e}", exc_info=True) - raise e - - def delete(id): - db = Database() - try: - db.delete_borrowing(id) - except Exception as e: - logging.error(f"Error during delete loan: {e}", exc_info=True) - - def fetch(id): - db = Database() - try: - return db.fetch_one_borrowing(id) - except Exception as e: - logging.error(f"Error during fetch loan: {e}", exc_info=True) - return [] - - def set(name, amount, interest, _id, to, _from): - db = Database() - if _from == "bank": - PassiveController.controller_set_passive(name, "Prestamo", float(amount), False) - active_id = ActiveController.controller_set_active(name, "Prestamo", float(amount), False) - total_payment = LoanController.calculate_interest(amount, interest) + float(amount) - created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") - db.set_borrowing(active_id, name, amount, interest, total_payment, created_at) - return - try: - account = ActiveController.controller_fetch_active(_id) - if account[3] < float(amount): - raise Exception("No hay suficiente saldo en la cuenta") - - if account and account[4]: - ActiveController.controller_update_active(_id, float(amount), False) # Baja liquidez - active_id = ActiveController.controller_set_active(name, "Prestamo", float(amount), False) # Crea activo no liquido - total_payment = LoanController.calculate_interest(amount, interest) + float(amount) - created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") - db.set_borrowing(active_id, name, amount, interest, total_payment, created_at) - except Exception as e: - logging.error(f"Error during set loan {e}", exc_info=True) - raise e - if __name__ == "__main__": pass diff --git a/src/controllers/date_groups.py b/src/controllers/date_groups.py new file mode 100644 index 0000000..88b23c4 --- /dev/null +++ b/src/controllers/date_groups.py @@ -0,0 +1,59 @@ +from datetime import date, datetime, timedelta +import logging + +import flet as ft + +from themes.themes import Theme + + +def get_group_date(date: date, timelapse: str): + try: + + if isinstance(date, str) and date: + date = datetime.strptime(date, "%d/%m/%Y %H:%M").date() + else: + date = date.date() + + if timelapse == "week": + week_start = date - timedelta(days=date.weekday()) + return week_start + + if timelapse == "month": + return date.year, date.month + + if timelapse == "year": + return date.year + + except Exception as e: + logging.error(f"Error al obtener la fecha: {e}") + + +def create_week_separator(week_start: date, theme: Theme): + months = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", + "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"] + week_end = week_start + timedelta(days=6) + + start_str = f"{months[week_start.month - 1]} {week_start.day}" + end_str = f"{months[week_end.month - 1]} {week_end.day}" + + return ft.Container( + content=ft.Text( + f"Semana del {start_str} al {end_str}", + color=theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ), + padding=ft.padding.only(top=10), + expand=True + ) + + +def create_month_separator(month: date, year: date, theme: Theme): + months = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", + "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"] + return ft.Text( + f"{months[month - 1]} {year}", + color=theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ) diff --git a/src/controllers/liquid/controller.py b/src/controllers/liquid/controller.py new file mode 100644 index 0000000..a7ce397 --- /dev/null +++ b/src/controllers/liquid/controller.py @@ -0,0 +1,51 @@ +import logging + +from db.database import Database + + +class LiquidController: + @staticmethod + def set(amount): + db = Database() + try: + db.set_liquid(float(amount)) + except Exception as e: + logging.error( + f"Error during set liquid to database {e}", exc_info=True) + + @staticmethod + def get(formated=False): + db = Database() + try: + liquid_obj = db.fetch_liquid()[1] + if liquid_obj is None: + raise ValueError("Liquid object is NoneType") + except Exception as e: + logging.error( + f"Error during get liquid object {e} trying to setting up", exc_info=True) + LiquidController.set(0) + liquid_obj = db.fetch_liquid()[1] + finally: + if formated: + return f"${liquid_obj:,.2f}" + return liquid_obj + + @staticmethod + def udpate(new_amount): + """Update the last element on the database""" + db = Database() + try: + db.udpate_liquid(new_amount) + except Exception as e: + logging.error(f"Error during update liquid {e}", exc_info=True) + + def change(new_amount, is_increase): + try: + if not is_increase: + new_amount = -float(new_amount) + else: + new_amount = float(new_amount) + + LiquidController.udpate(new_amount) + except Exception as e: + logging.error(f"Error during change value {e}") diff --git a/src/controllers/loans/controller.py b/src/controllers/loans/controller.py new file mode 100644 index 0000000..298f11c --- /dev/null +++ b/src/controllers/loans/controller.py @@ -0,0 +1,98 @@ +from datetime import datetime +import logging + +from controllers.actives.controller import ActiveController +from controllers.passives.controller import PassiveController +from db.database import Database + + +class LoanController: + def __init__(self): + pass + + @staticmethod + def calculate_interest(amount, interest): + try: + total_interest = float(amount) * (float(interest) / 100) + return total_interest + except Exception as e: + logging.error( + f"Error during calculate interest {e}", exc_info=True) + return 0.0 + + def paid(id): + db = Database() + try: + db.paid_loan(id) + except Exception as e: + logging.error(f"Error during set paid {e}", exc_info=True) + raise e + + def controller_fetch_loans(): + db = Database() + try: + loans = db.fetch_borrowings() + return loans + except Exception as e: + logging.error(f"Error during fetch loans: {e}", exc_info=True) + return [] + + def liquidate(id, loan_active_id, pay_account_id): + db = Database() + try: + loan = LoanController.fetch(id) + LoanController.paid(id) + ActiveController.controller_update_active( + pay_account_id, float(loan[3]), True) + ActiveController.controller_delete_active(loan_active_id) + except Exception as e: + logging.error(f"Error during liquidate loan: {e}", exc_info=True) + raise e + + def delete(id): + db = Database() + try: + db.delete_borrowing(id) + except Exception as e: + logging.error(f"Error during delete loan: {e}", exc_info=True) + + def fetch(id): + db = Database() + try: + return db.fetch_one_borrowing(id) + except Exception as e: + logging.error(f"Error during fetch loan: {e}", exc_info=True) + return [] + + def set(name, amount, interest, _id, to, _from): + db = Database() + if _from == "bank": + PassiveController.controller_set_passive( + name, "Prestamo", float(amount), False) + active_id = ActiveController.controller_set_active( + name, "Prestamo", float(amount), False) + total_payment = LoanController.calculate_interest( + amount, interest) + float(amount) + created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") + db.set_borrowing(active_id, name, amount, + interest, total_payment, created_at) + return + try: + account = ActiveController.controller_fetch_active(_id) + if account[3] < float(amount): + raise Exception("No hay suficiente saldo en la cuenta") + + if account and account[4]: + ActiveController.controller_update_active( + _id, float(amount), False) # Baja liquidez + active_id = ActiveController.controller_set_active( + # Crea activo no liquido + name, "Prestamo", float(amount), False) + total_payment = LoanController.calculate_interest( + amount, interest) + float(amount) + created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") + db.set_borrowing(active_id, name, amount, + interest, total_payment, created_at) + except Exception as e: + logging.error(f"Error during set loan {e}", exc_info=True) + raise e diff --git a/src/controllers/passives/controller.py b/src/controllers/passives/controller.py new file mode 100644 index 0000000..9544be8 --- /dev/null +++ b/src/controllers/passives/controller.py @@ -0,0 +1,65 @@ +from datetime import datetime +import logging + +from controllers.actives.controller import ActiveController +from controllers.balance.controller import BalanceController +from controllers.liquid.controller import LiquidController +from db.database import Database + + +class PassiveController: + @staticmethod + def controller_set_passive(name, category, amount, is_liquidated=False): + db = Database() + try: + created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") + db.set_passive(name, category, amount, is_liquidated, created_at) + except Exception as e: + logging.error(f"Error during setting passive: {e}", exc_info=True) + + BalanceController.controller_change_balance(amount, False) + + @staticmethod + def fetch_passive(id): + db = Database() + try: + passive = db.fetch_passive(id) + return passive + except Exception as e: + logging.error(f"Error during searching pasive on database: {e}") + + @staticmethod + def pay_passive(id, active_id): + + db = Database() + try: + passive = PassiveController.fetch_passive(id) + active = ActiveController.controller_fetch_active(active_id) + if active[3] < passive[3]: + raise ValueError( + "No tienes saldo suficiente para pagar este pasivo") + if passive and not passive[4]: + db.update_passive(id) + db.update_active(active_id, active[3] - passive[3]) + LiquidController.change(passive[3], False) + except Exception as e: + logging.error(f"Error during paying passive: {e}", exc_info=True) + raise e + + @staticmethod + def controller_fetch_passives(order_by="price"): + db = Database() + try: + all_passives = db.fetch_passives(order_by=order_by) + return all_passives + except Exception as e: + logging.error(f"Error during fetch all passives: {e}") + return [] + + @staticmethod + def controller_delete_passive(id): + db = Database() + try: + db.delete_passive(id) + except Exception as e: + logging.error("Error during delete pasive {id} error: {e}") diff --git a/src/controllers/transactions/controller.py b/src/controllers/transactions/controller.py new file mode 100644 index 0000000..4484a96 --- /dev/null +++ b/src/controllers/transactions/controller.py @@ -0,0 +1,212 @@ +from datetime import datetime +from enum import Enum +import logging + +from controllers.actives.controller import ActiveController +from controllers.balance.controller import BalanceController +from controllers.passives.controller import PassiveController +from db.database import Database +from dto.transactions import ( + TransactionDTO, + TransactionFilterDTO, + TransactionOutputDTO, + TransactionUpdateDTO, +) + + +class TransactionValidation: + + CURRENT_BALANCE_INDEX = 3 + CURRENT_LIQUIDITY_STATE = 4 + + @classmethod + def validate(cls, transaction: TransactionDTO): + cls._validate_amount(transaction) + cls._validate_required_fields(transaction) + + if transaction.type in ( + TransactionType.INCOME.value, + TransactionType.SPENT.value, + ): + account = cls._get_account(transaction) + if transaction.type == TransactionType.SPENT.value: + cls._validate_balance(account, transaction.price) + + return True + + @staticmethod + def _validate_required_fields(transaction: TransactionDTO): + required = [ + "name", + "category", + "subcategory", + "price", + "type", + ] + + for field in required: + if not getattr(transaction, field): + raise ValueError(f"El campo {field} es obligatorio") + + @staticmethod + def _validate_amount(transaction: TransactionDTO): + try: + price = float(transaction.price) + except ValueError: + raise ValueError("El monto debe ser un número") + + if price <= 0: + raise ValueError("El monto debe ser mayor a 0") + + @classmethod + def _get_account(cls, transaction: TransactionDTO): + account = ActiveController.controller_fetch_active( + transaction.account_id) + if not account: + raise ValueError("La cuenta no existe") + + if not account[cls.CURRENT_LIQUIDITY_STATE]: + raise ValueError("La cuenta no es un fondo liquido") + + return account + + @classmethod + def _validate_balance(cls, account, amount): + balance = float(account[cls.CURRENT_BALANCE_INDEX]) + if balance < float(amount): + raise ValueError( + "No tienes saldo suficiente para realizar esta transacción") + + +class TransactionType(Enum): + SPENT = "spent" + INCOME = "income" + CREDIT = "credit" + + +class TransactionController: + + @staticmethod + def create_transaction(transaction: TransactionDTO) -> None: + TransactionValidation.validate(transaction) + + is_income = True + db = Database() + created_at = datetime.now().astimezone().strftime("%d/%m/%Y %H:%M") + try: + expense_percentage = float( + transaction.price) / BalanceController.controller_fetch_balance() * 100 + except ZeroDivisionError: + expense_percentage = 0.0 + + if transaction.type == TransactionType.CREDIT.value: + is_income = False + PassiveController.controller_set_passive( + transaction.name, + transaction.category, + transaction.price, + False) + + if transaction.type == TransactionType.SPENT.value: + is_income = False + + if transaction.type in (TransactionType.SPENT.value, TransactionType.INCOME.value): + ActiveController.controller_update_active( + transaction.account_id, + transaction.price, + is_income + ) + + db.set_transaction( + transaction.name, + transaction.category, + transaction.price, + is_income, + expense_percentage, + transaction.subcategory if transaction.type != TransactionType.CREDIT.value else None, + created_at) + + @staticmethod + def fetch_transactions(filter: TransactionFilterDTO) -> list[TransactionOutputDTO]: + try: + db = Database() + transactions = db.fetch_transactions(filter) + return transactions + except Exception as e: + logging.error(f"Error fetching transactions: {e}", exc_info=True) + return [] + + @staticmethod + def controller_fetch_sum(is_income=True) -> str: + filter = TransactionFilterDTO() + all_transactions = TransactionController.fetch_transactions(filter) + try: + if is_income: + income_transactions = [ + transaction.price for transaction in all_transactions if transaction.is_income == True] + total = sum(income_transactions) + return f"${total:,.2f}" + else: + not_income_transactions = [ + transaction.price for transaction in all_transactions if transaction.is_income == False] + total = sum(not_income_transactions) + return f"${total:,.2f}" + except Exception as e: + logging.error( + f"Error during fetch sum of transactions {e}", exc_info=True) + + def delete_all_transactions(self) -> bool: + db = Database() + try: + db.delete_all_transactions() + return True + except Exception as e: + logging.error(f"Error deleting all transactions: {e}") + return False + + @staticmethod + def update_transaction(transaction: TransactionUpdateDTO): + is_income = False + try: + expense_percentage = float( + transaction.price) / BalanceController.controller_fetch_balance() * 100 + except ZeroDivisionError: + expense_percentage = 0.0 + + if transaction.type == TransactionType.INCOME.value: + is_income = True + + if transaction.type == TransactionType.SPENT.value: + is_income = False + + if transaction.type == TransactionType.CREDIT.value: + is_income = False + PassiveController.controller_set_passive( + transaction.name, + transaction.category, + transaction.price, + False) + + if transaction.type in (TransactionType.SPENT.value, TransactionType.INCOME.value): + ActiveController.controller_update_active( + transaction.account_id, + transaction.price, + is_income + ) + + db = Database() + try: + db.update_transaction( + id=transaction.id, + name=transaction.name, + category=transaction.category, + price=transaction.price, + is_income=is_income, + expense_percentage=expense_percentage, + subcategory=transaction.subcategory, + ) + + return True + except Exception as e: + logging.error(f"Error updating transaction: {e}") + return False diff --git a/src/controllers/transactions/statistics.py b/src/controllers/transactions/statistics.py new file mode 100644 index 0000000..6390166 --- /dev/null +++ b/src/controllers/transactions/statistics.py @@ -0,0 +1,11 @@ +from db.database import Database +from dto.transactions import TransactionFilterDTO + + +def fetch_summary(filter: TransactionFilterDTO, formated=True): + db = Database() + summary = db.fetch_summary(filter) + total = summary[0] if summary and summary[0] is not None else 0.0 + if formated: + return f"${total:,.2f}" + return total diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..bf8ee7c --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,10 @@ +import os + +DEFAULT_PROFILE_PIC = "./assets/avatar/profile.png" +PROFILE_PIC_KEY = "christianymoon.finance.profile_pic" + +try: + FLET_APP_STORAGE_DATA = os.getenv("FLET_APP_STORAGE_DATA") # ANDROID MODE + ASSETS_DIR = "assets" +except Exception: + FLET_APP_STORAGE_DATA = "/" diff --git a/src/database.py b/src/database.py deleted file mode 100644 index f752ad1..0000000 --- a/src/database.py +++ /dev/null @@ -1,341 +0,0 @@ -from db_connection import conn -import os - -class Database: - - def create_tables(self): - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS passive ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - category TEXT NOT NULL, - price REAL NOT NULL, - is_liquidated BOOLEAN NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS userdata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - gender TEXT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS balance ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - total_balance REAL NOT NULL, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS active ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - active_type TEXT NOT NULL, - name TEXT NOT NULL, - mount REAL NOT NULL, - is_liquid BOOLEAN NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS transactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - category TEXT NOT NULL, - price REAL NOT NULL, - is_income BOOLEAN NOT NULL, - expense_percentage REAL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS liquid ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - amount REAL NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - - cursor.execute("""CREATE TABLE IF NOT EXISTS borrowings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - active_id INTEGER, - name TEXT, - amount REAL NOT NULL, - interest REAL NOT NULL, - total_paylable REAL NOT NULL, - parcial_payments REAL DEFAULT 0, - is_paid BOOLEAN DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS intertransactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_name TEXT NOT NULL, - source_dest TEXT NOT NULL, - total REAL NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - conn.commit() - cursor.close() - - def drop_tables(self): - cursor = conn.cursor() - cursor.execute("DROP TABLE IF EXISTS passive") - cursor.execute("DROP TABLE IF EXISTS active") - cursor.execute("DROP TABLE IF EXISTS transactions") - cursor.execute("DROP TABLE IF EXISTS balance") - cursor.execute("DROP TABLE IF EXISTS liquid") - cursor.execute("DROP TABLE IF EXISTS borrowings") - cursor.execute("DROP TABLE IF EXISTS intertransactions") - conn.commit() - cursor.close() - - def set_intertransaction(self, source_name, source_dest, total, created_at=None): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO intertransactions (source_name, source_dest, total, created_at) - VALUES (?, ?, ?, ?) - """, (source_name, source_dest, total, created_at)) - conn.commit() - cursor.close() - - - - - def set_transaction(self, name, category, price, is_income, expense_percentage, created_at=None): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO transactions (name, category, price, is_income, expense_percentage, created_at) - VALUES (?, ?, ?, ?, ?, ?) - """, (name, category, price, is_income, expense_percentage, created_at)) - conn.commit() - cursor.close() - - def set_balance(self, total_balance): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO balance (total_balance) - VALUES (?) - """, (total_balance,)) - conn.commit() - cursor.close() - - def set_passive(self, name, category, price, is_liquidated, created_at=None): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO passive (name, category, price, is_liquidated, created_at) - VALUES (?, ?, ?, ?, ?) - """, (name, category, price, is_liquidated, created_at)) - conn.commit() - cursor.close() - - def set_active(self, active_type, name, mount, is_liquid): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO active (active_type, name, mount, is_liquid) - VALUES (?, ?, ?, ?) - """, (active_type, name, mount, is_liquid)) - conn.commit() - active_id = cursor.lastrowid - cursor.close() - return active_id - - def set_liquid(self, amount): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO liquid (amount) - VALUES (?) - """, (amount,)) - conn.commit() - cursor.close() - - def set_userdata(self, username, gender=None): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO userdata (username, gender) - VALUES (?, ?)""", (username, gender)) - conn.commit() - cursor.close() - - def set_borrowing(self, active_id, name, amount, interest, total_paylable, created_at=None): - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO borrowings (active_id, name, amount, interest, total_paylable, created_at) - VALUES (?, ?, ?, ?, ?, ?) - """, (active_id, name, amount, interest, total_paylable, created_at)) - conn.commit() - cursor.close() - - - def fetch_borrowings(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM borrowings") - result = cursor.fetchall() - cursor.close() - return result - - def fetch_one_borrowing(self, id): - cursor = conn.cursor() - cursor.execute("SELECT * FROM borrowings WHERE id = ?", (id,)) - result = cursor.fetchone() - cursor.close() - return result - - def fetch_transactions(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM transactions") - result = cursor.fetchall() - cursor.close() - return result - - def fetch_last_transaction(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM transactions ORDER BY created_at DESC LIMIT 1") - result = cursor.fetchone() - cursor.close() - return result - - def fetch_balance(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM balance ORDER BY last_updated DESC LIMIT 1") - result = cursor.fetchone() - cursor.close() - return result - - def fetch_passives(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM passive") - result = cursor.fetchall() - cursor.close() - return result - - def fetch_active(self, id): - cursor = conn.cursor() - cursor.execute("SELECT * FROM active WHERE id = ?", (id,)) - result = cursor.fetchone() - cursor.close() - return result - - def fetch_all_actives(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM active") - result = cursor.fetchall() - cursor.close() - return result - - def fetch_liquid(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM liquid ORDER BY created_at DESC LIMIT 1") - result = cursor.fetchone() - cursor.close() - return result - - def fetch_userdata(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM userdata ORDER BY created_at DESC LIMIT 1") - result = cursor.fetchone() - cursor.close() - return result - - def fetch_passive(self, id): - cursor = conn.cursor() - cursor.execute("SELECT * FROM passive WHERE id = ?", (id,)) - result = cursor.fetchone() - cursor.close() - return result - - def fetch_intertransactions(self): - cursor = conn.cursor() - cursor.execute("SELECT * FROM intertransactions") - result = cursor.fetchall() - cursor.close() - return result - - - def delete_all_transactions(self): - cursor = conn.cursor() - cursor.execute("DELETE FROM transactions") - conn.commit() - cursor.close() - - def delete_passive(self, id): - cursor = conn.cursor() - cursor.execute("DELETE FROM passive WHERE id = ?", (id,)) - conn.commit() - cursor.close() - - def delete_active(self, id): - cursor = conn.cursor() - cursor.execute("DELETE FROM active WHERE id = ?", (id,)) - conn.commit() - cursor.close() - - def delete_borrowing(self, id): - cursor = conn.cursor() - cursor.execute("DELETE FROM borrowings WHERE id = ?", (id,)) - conn.commit() - cursor.close() - - def update_passive(self, id): - cursor = conn.cursor() - cursor.execute(""" - UPDATE passive - SET is_liquidated = 1 - WHERE id = ? - """, (id,)) - conn.commit() - cursor.close() - - def update_active(self, id, amount): - cursor = conn.cursor() - cursor.execute(""" - UPDATE active - SET mount = ? - WHERE id = ? - """, (amount, id)) - conn.commit() - cursor.close() - - def update_atomic_balance(self, amount): - cursor = conn.cursor() - cursor.execute( - """ - UPDATE balance - SET total_balance = total_balance + ? - WHERE id = (SELECT id FROM balance ORDER BY last_updated DESC LIMIT 1) - """, (amount, )) - conn.commit() - cursor.close() - - def udpate_liquid(self, amount): - cursor = conn.cursor() - cursor.execute( - """ - UPDATE liquid - SET amount = amount + ? - WHERE id = (SELECT id FROM liquid ORDER BY created_at DESC LIMIT 1) - """, (amount, )) - conn.commit() - cursor.close() - - def paid_loan(self, id): - cursor = conn.cursor() - cursor.execute(""" - UPDATE borrowings - SET is_paid = 1 - WHERE id = ? - """, (id,)) - conn.commit() - cursor.close() \ No newline at end of file diff --git a/src/controllers/__init__.py b/src/db/__init__.py similarity index 100% rename from src/controllers/__init__.py rename to src/db/__init__.py diff --git a/src/db/database.py b/src/db/database.py new file mode 100644 index 0000000..8d8a169 --- /dev/null +++ b/src/db/database.py @@ -0,0 +1,572 @@ +from db.db_connection import conn +from db.migrate import make_migrations +from dto.transactions import TransactionOutputDTO + + +def normalize_date(d): + if not d: + return None + d_str = str(d).strip() + if len(d_str) >= 10 and d_str[4] == '-' and d_str[7] == '-': + return d_str[:10] + if len(d_str) >= 10 and d_str[2] == '/' and d_str[5] == '/': + parts = d_str[:10].split('/') + return f"{parts[2]}-{parts[1]}-{parts[0]}" + return d_str + + +class Database: + + def create_tables(self): + make_migrations(conn) + + def drop_tables(self): + cursor = conn.cursor() + cursor.execute("DROP TABLE IF EXISTS passive") + cursor.execute("DROP TABLE IF EXISTS active") + cursor.execute("DROP TABLE IF EXISTS transactions") + cursor.execute("DROP TABLE IF EXISTS balance") + cursor.execute("DROP TABLE IF EXISTS liquid") + cursor.execute("DROP TABLE IF EXISTS borrowings") + cursor.execute("DROP TABLE IF EXISTS intertransactions") + cursor.execute("DROP TABLE IF EXISTS categories") + cursor.execute("DROP TABLE IF EXISTS subcategories") + cursor.execute("DROP TABLE IF EXISTS schema_migrations") + cursor.execute("DROP TABLE IF EXISTS balance_snapshots") + conn.commit() + cursor.close() + + # TRANSACTIONS + + def set_transaction(self, name, category, price, is_income, expense_percentage, subcategory, created_at=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO transactions (name, category, price, is_income, expense_percentage, subcategory, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (name, category, price, is_income, expense_percentage, subcategory, created_at)) + conn.commit() + cursor.close() + + def fetch_transactions(self, filter): + cursor = conn.cursor() + sql = """ + SELECT * FROM transactions + WHERE 1 = 1 + """ + params = [] + + if filter.date_to and filter.date_from: + date_from = normalize_date(filter.date_from) + date_to = normalize_date(filter.date_to) + sql += """ AND ( + CASE + WHEN created_at LIKE '__/__/____%' THEN substr(created_at, 7, 4) || '-' || substr(created_at, 4, 2) || '-' || substr(created_at, 1, 2) + ELSE substr(created_at, 1, 10) + END BETWEEN ? AND ? + )""" + params.append(date_from) + params.append(date_to) + + if filter.category_id: + sql += " AND category = ?" + params.append(filter.category_id) + + if filter.subcategory_id: + sql += " AND subcategory = ?" + params.append(filter.subcategory_id) + + if filter.is_income is not None: + sql += " AND is_income = ?" + params.append(filter.is_income) + + sql += " ORDER BY id DESC" + + if filter.limit: + sql += " LIMIT ?" + params.append(filter.limit) + + cursor.execute(sql, params) + result = cursor.fetchall() + cursor.close() + return [TransactionOutputDTO(t[0], t[1], t[2], t[7], t[3], t[4], t[5], t[6]) for t in result] + + def fetch_summary(self, filter): + cursor = conn.cursor() + sql = """ + SELECT SUM(price) FROM transactions + WHERE 1 = 1 + """ + params = [] + + if filter.date_to and filter.date_from: + date_from = normalize_date(filter.date_from) + date_to = normalize_date(filter.date_to) + sql += """ AND ( + CASE + WHEN created_at LIKE '__/__/____%' THEN substr(created_at, 7, 4) || '-' || substr(created_at, 4, 2) || '-' || substr(created_at, 1, 2) + ELSE substr(created_at, 1, 10) + END BETWEEN ? AND ? + )""" + params.append(date_from) + params.append(date_to) + + if filter.category_id: + sql += " AND category = ?" + params.append(filter.category_id) + + if filter.subcategory_id: + sql += " AND subcategory = ?" + params.append(filter.subcategory_id) + + if filter.is_income is not None: + sql += " AND is_income = ?" + params.append(filter.is_income) + + sql += " ORDER BY created_at DESC" + + if filter.limit: + sql += " LIMIT ?" + params.append(filter.limit) + + cursor.execute(sql, params) + result = cursor.fetchone() + cursor.close() + return result + + def update_transaction(self, id, name, category, price, is_income, expense_percentage, subcategory): + cursor = conn.cursor() + cursor.execute(""" + UPDATE transactions + SET name = ?, category = ?, price = ?, is_income = ?, expense_percentage = ?, subcategory = ? + WHERE id = ? + """, (name, category, price, is_income, expense_percentage, subcategory, id)) + conn.commit() + cursor.close() + + def delete_all_transactions(self): + cursor = conn.cursor() + cursor.execute("DELETE FROM transactions") + conn.commit() + cursor.close() + + # CATEGORIES + + def set_category(self, name, description=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO categories (name, description) + VALUES (?, ?) + """, (name, description)) + conn.commit() + cursor.close() + return cursor.lastrowid + + def fetch_all_categories(self): + cursor = conn.cursor() + cursor.execute("SELECT * FROM categories ORDER BY id DESC") + result = cursor.fetchall() + cursor.close() + return result + + def fetch_category(self, id): + cursor = conn.cursor() + cursor.execute("SELECT * FROM categories WHERE id = ?", (id,)) + result = cursor.fetchone() + cursor.close() + return result + + def fetch_category_by_subcategory_id(self, subcategory_id): + cursor = conn.cursor() + cursor.execute(""" + SELECT c.id, c.name + FROM categories c + JOIN subcategories sc ON c.id = sc.category_id + WHERE sc.id = ? + """, (subcategory_id,)) + result = cursor.fetchone() + cursor.close() + return result if result else None + + def update_category(self, id, name, description): + cursor = conn.cursor() + cursor.execute(""" + UPDATE categories + SET name = ?, description = ? + WHERE id = ? + """, (name, description, id)) + conn.commit() + cursor.close() + + def deactivate_category(self, id): + cursor = conn.cursor() + try: + cursor.execute("""UPDATE categories + SET is_active = 0 + WHERE id = ?""", (id,)) + cursor.execute("""UPDATE subcategories + SET is_active = 0 + WHERE category_id = ?""", (id,)) + conn.commit() + except: + conn.rollback() + raise + finally: + cursor.close() + + def activate_category(self, id): + cursor = conn.cursor() + try: + cursor.execute(""" + UPDATE categories + SET is_active = 1 + WHERE id = ?""", (id,)) + cursor.execute(""" + UPDATE subcategories + SET is_active = 1 + WHERE category_id = ?""", (id,)) + conn.commit() + except: + conn.rollback() + raise + finally: + cursor.close() + + def delete_all_categories(self): + try: + cursor = conn.cursor() + cursor.execute("DELETE FROM categories") + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + + # SUBCATEGORIES + + def set_subcategory(self, category_id, name, description=""): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO subcategories (category_id, name, description) + VALUES (?, ?, ?) + """, (category_id, name, description)) + conn.commit() + subcategory_id = cursor.lastrowid + cursor.close() + return subcategory_id + + def fetch_subcategories(self, category_id): + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM subcategories + WHERE category_id = ? + ORDER BY id DESC + """, (category_id,)) + result = cursor.fetchall() + cursor.close() + return result + + def fetch_subcategory(self, subcategory_id): + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM subcategories WHERE id = ?", (subcategory_id,)) + result = cursor.fetchone() + cursor.close() + return result + + def fetch_all_subcategories(self): + cursor = conn.cursor() + cursor.execute(""" + SELECT + s.id, + s.category_id, + s.name, + s.is_active, + c.name AS category_name + FROM subcategories s + INNER JOIN categories c ON s.category_id = c.id WHERE s.is_active = 1 + ORDER BY c.id DESC + """) + result = cursor.fetchall() + cursor.close() + return result + + def update_subcategory(self, id, name, description): + cursor = conn.cursor() + cursor.execute(""" + UPDATE subcategories + SET name = ?, description = ? + WHERE id = ? + """, (name, description, id)) + conn.commit() + cursor.close() + + def deactivate_subcategory(self, id): + cursor = conn.cursor() + cursor.execute("""UPDATE subcategories + SET is_active = 0 WHERE id = ?""", (id,)) + conn.commit() + cursor.close() + + def activate_subcategory(self, id): + cursor = conn.cursor() + try: + cursor.execute("""UPDATE subcategories + SET is_active = 1 WHERE id = ?""", (id,)) + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + + # INTERTRANSACTIONS + + def set_intertransaction(self, source_name, source_dest, total, created_at=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO intertransactions (source_name, source_dest, total, created_at) + VALUES (?, ?, ?, ?) + """, (source_name, source_dest, total, created_at)) + conn.commit() + cursor.close() + + def fetch_intertransactions(self): + cursor = conn.cursor() + cursor.execute("SELECT * FROM intertransactions") + result = cursor.fetchall() + cursor.close() + return result + + # BALANCE + + def set_balance(self, total_balance): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO balance (total_balance) + VALUES (?) + """, (total_balance,)) + conn.commit() + cursor.close() + + def fetch_balance(self): + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM balance ORDER BY last_updated DESC LIMIT 1") + result = cursor.fetchone() + cursor.close() + return result + + def update_atomic_balance(self, amount): + cursor = conn.cursor() + cursor.execute( + """ + UPDATE balance + SET total_balance = total_balance + ? + WHERE id = (SELECT id FROM balance ORDER BY last_updated DESC LIMIT 1) + """, (amount, )) + conn.commit() + cursor.close() + + # PASSIVES + + def set_passive(self, name, category, price, is_liquidated, created_at=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO passive (name, category, price, is_liquidated, created_at) + VALUES (?, ?, ?, ?, ?) + """, (name, category, price, is_liquidated, created_at)) + conn.commit() + cursor.close() + + def fetch_passives(self, order_by="price"): + allowed_columns = ["name", "category", + "price", "is_liquidated", "created_at"] + if order_by not in allowed_columns: + raise ValueError(f"Invalid column name {order_by}") + cursor = conn.cursor() + cursor.execute(f"SELECT * FROM passive ORDER BY {order_by} DESC") + result = cursor.fetchall() + cursor.close() + return result + + def fetch_passive(self, id): + cursor = conn.cursor() + cursor.execute("SELECT * FROM passive WHERE id = ?", (id,)) + result = cursor.fetchone() + cursor.close() + return result + + def update_passive(self, id): + cursor = conn.cursor() + cursor.execute(""" + UPDATE passive + SET is_liquidated = 1 + WHERE id = ? + """, (id,)) + conn.commit() + cursor.close() + + def delete_passive(self, id): + cursor = conn.cursor() + cursor.execute("DELETE FROM passive WHERE id = ?", (id,)) + conn.commit() + cursor.close() + + # ACTIVES + + def set_active(self, active_type, name, mount, is_liquid): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO active (active_type, name, mount, is_liquid) + VALUES (?, ?, ?, ?) + """, (active_type, name, mount, is_liquid)) + conn.commit() + active_id = cursor.lastrowid + cursor.close() + return active_id + + def fetch_active(self, id): + cursor = conn.cursor() + cursor.execute("SELECT * FROM active WHERE id = ?", (id,)) + result = cursor.fetchone() + cursor.close() + return result + + def fetch_all_actives(self): + cursor = conn.cursor() + cursor.execute("SELECT * FROM active ORDER BY is_liquid = 1 ASC") + result = cursor.fetchall() + cursor.close() + return result + + def update_active(self, id, amount): + cursor = conn.cursor() + cursor.execute(""" + UPDATE active + SET mount = ? + WHERE id = ? + """, (amount, id)) + conn.commit() + cursor.close() + + def delete_active(self, id): + cursor = conn.cursor() + cursor.execute("DELETE FROM active WHERE id = ?", (id,)) + conn.commit() + cursor.close() + + # LIQUID + + def set_liquid(self, amount): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO liquid (amount) + VALUES (?) + """, (amount,)) + conn.commit() + cursor.close() + + def fetch_liquid(self): + cursor = conn.cursor() + cursor.execute("SELECT * FROM liquid ORDER BY created_at DESC LIMIT 1") + result = cursor.fetchone() + cursor.close() + return result + + def udpate_liquid(self, amount): + cursor = conn.cursor() + cursor.execute( + """ + UPDATE liquid + SET amount = amount + ? + WHERE id = (SELECT id FROM liquid ORDER BY created_at DESC LIMIT 1) + """, (amount, )) + conn.commit() + cursor.close() + + # USERDATA + + def set_userdata(self, username, gender=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO userdata (username, gender) + VALUES (?, ?)""", (username, gender)) + conn.commit() + cursor.close() + + def fetch_userdata(self): + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM userdata ORDER BY created_at DESC LIMIT 1") + result = cursor.fetchone() + cursor.close() + return result + + # BORROWINGS + + def set_borrowing(self, active_id, name, amount, interest, total_paylable, created_at=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO borrowings (active_id, name, amount, interest, total_paylable, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (active_id, name, amount, interest, total_paylable, created_at)) + conn.commit() + cursor.close() + + def fetch_borrowings(self): + cursor = conn.cursor() + cursor.execute("SELECT * FROM borrowings ORDER BY created_at DESC") + result = cursor.fetchall() + cursor.close() + return result + + def fetch_one_borrowing(self, id): + cursor = conn.cursor() + cursor.execute("SELECT * FROM borrowings WHERE id = ?", (id,)) + result = cursor.fetchone() + cursor.close() + return result + + def paid_loan(self, id): + cursor = conn.cursor() + cursor.execute(""" + UPDATE borrowings + SET is_paid = 1 + WHERE id = ? + """, (id,)) + conn.commit() + cursor.close() + + def delete_borrowing(self, id): + cursor = conn.cursor() + cursor.execute("DELETE FROM borrowings WHERE id = ?", (id,)) + conn.commit() + cursor.close() + + # BALANCE SNAPSHOTS + + def make_snapshot(self, balance, created_at=None): + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO balance_snapshots (balance, created_at) + VALUES (?, ?) + """, (balance, created_at)) + conn.commit() + cursor.close() + + def fetch_balance_snapshots(self): + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM balance_snapshots ORDER BY created_at ASC") + result = cursor.fetchall() + cursor.close() + return result + + def fetch_last_date_snapshot(self): + cursor = conn.cursor() + cursor.execute( + "SELECT created_at FROM balance_snapshots ORDER BY created_at DESC LIMIT 1") + result = cursor.fetchone() + cursor.close() + return result[0] if result else None diff --git a/src/db_connection.py b/src/db/db_connection.py similarity index 79% rename from src/db_connection.py rename to src/db/db_connection.py index 3da6f83..03bb8f2 100644 --- a/src/db_connection.py +++ b/src/db/db_connection.py @@ -1,11 +1,12 @@ import sqlite3 import os import logging -from config import FLET_APP_STORAGE_DATA +from core.config import FLET_APP_STORAGE_DATA try: conn = sqlite3.connect(os.path.join(FLET_APP_STORAGE_DATA, "financeapp.db"), check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA foreign_keys = ON;") logging.info(f"Database connection created at {FLET_APP_STORAGE_DATA}") except Exception as e: logging.error(f"Error connecting to database: {e}") diff --git a/src/db/migrate.py b/src/db/migrate.py new file mode 100644 index 0000000..19299f6 --- /dev/null +++ b/src/db/migrate.py @@ -0,0 +1,34 @@ +import sqlite3 +from pathlib import Path +import logging + +SCRIPT_DIR = Path(__file__).parent +MIGRATIONS_DIR = SCRIPT_DIR / "migrations" + +def make_migrations(conn) -> None: + try: + cursor = conn.cursor() + cursor.execute("""CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY)""") + cursor.execute("""SELECT COALESCE(MAX(version), 0) FROM schema_migrations """) + current_version = cursor.fetchone()[0] + migrations_files = sorted(MIGRATIONS_DIR.glob("*.sql")) + for migration in migrations_files: + version = int(migration.stem.split("_")[0]) + if version > current_version: + print(f"Ejecutando Migracion {version}") + sql = migration.read_text(encoding="utf-8") + cursor.executescript(sql) + cursor.execute( + "INSERT INTO schema_migrations(version) VALUES(?)", + (version,) + ) + conn.commit() + cursor.close() + except Exception as e: + logging.error("Exception as triggered on make migrations", exc_info=True) + + + +if __name__ == "__main__": + make_migrations() diff --git a/src/db/migrations/001_initial.sql b/src/db/migrations/001_initial.sql new file mode 100644 index 0000000..0fa00b1 --- /dev/null +++ b/src/db/migrations/001_initial.sql @@ -0,0 +1,84 @@ +CREATE TABLE IF NOT EXISTS passive ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL, + price REAL NOT NULL, + is_liquidated BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS userdata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + gender TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + total_balance REAL NOT NULL, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS active ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + active_type TEXT NOT NULL, + name TEXT NOT NULL, + mount REAL NOT NULL, + is_liquid BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL, + price REAL NOT NULL, + is_income BOOLEAN NOT NULL, + expense_percentage REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS liquid ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + amount REAL NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS borrowings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + active_id INTEGER, + name TEXT, + amount REAL NOT NULL, + interest REAL NOT NULL, + total_paylable REAL NOT NULL, + parcial_payments REAL DEFAULT 0, + is_paid BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS intertransactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_name TEXT NOT NULL, + source_dest TEXT NOT NULL, + total REAL NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +CREATE TABLE IF NOT EXISTS subcategories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY +); diff --git a/src/db/migrations/002_addsubcategories.sql b/src/db/migrations/002_addsubcategories.sql new file mode 100644 index 0000000..b0ac9fa --- /dev/null +++ b/src/db/migrations/002_addsubcategories.sql @@ -0,0 +1,3 @@ +ALTER TABLE transactions +ADD COLUMN subcategory INTEGER; + diff --git a/src/db/migrations/003_activecategories.sql b/src/db/migrations/003_activecategories.sql new file mode 100644 index 0000000..bab0c80 --- /dev/null +++ b/src/db/migrations/003_activecategories.sql @@ -0,0 +1,5 @@ +ALTER TABLE categories +ADD COLUMN is_active INTEGER DEFAULT 1; + +ALTER TABLE subcategories +ADD COLUMN is_active INTEGER DEFAULT 1; \ No newline at end of file diff --git a/src/db/migrations/004_balancesnapshots.sql b/src/db/migrations/004_balancesnapshots.sql new file mode 100644 index 0000000..a1656c3 --- /dev/null +++ b/src/db/migrations/004_balancesnapshots.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance REAL NOT NULL, + created_at DATETIME NOT NULL +); \ No newline at end of file diff --git a/src/dto/__init__.py b/src/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/transactions.py b/src/dto/transactions.py new file mode 100644 index 0000000..018ba8a --- /dev/null +++ b/src/dto/transactions.py @@ -0,0 +1,46 @@ +from datetime import datetime +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class TransactionDTO: + name: str + category: int + subcategory: int + price: float + type: str + account_id: Optional[int] = None + + +@dataclass +class TransactionOutputDTO: + id: int + name: str + category: int + subcategory: int + price: float + is_income: bool + expense_percentage: str + created_at: Optional[datetime] = None + + +@dataclass +class TransactionUpdateDTO: + id: int + name: str + category: int + subcategory: int + price: float + type: str + account_id: Optional[int] = None + + +@dataclass +class TransactionFilterDTO: + limit: Optional[int] = None + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + category_id: Optional[int] = None + subcategory_id: Optional[int] = None + is_income: Optional[bool] = None diff --git a/src/external/gemini/ai.py b/src/external/gemini/ai.py new file mode 100644 index 0000000..bb8598f --- /dev/null +++ b/src/external/gemini/ai.py @@ -0,0 +1,123 @@ +import logging +import httpx +import json +import os +import dataclasses + +try: + from dotenv import load_dotenv + load_dotenv() + _API_KEY = os.getenv("GEMINI_API_KEY") +except ImportError: + _API_KEY = None + +if not _API_KEY: + _API_KEY = "YOUR_API_KEY" + + +def default_json_serializer(obj): + if dataclasses.is_dataclass(obj): + return dataclasses.asdict(obj) + if hasattr(obj, "isoformat"): + return obj.isoformat() + return str(obj) + + +_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" + + +class Gemini: + def __init__(self, api_key: str = _API_KEY): + self.api_key = api_key + self.previous_interaction_id = None + self.tools = {} + + def add_tool(self, tool): + self.tools.update(tool) + + def generate_interaction(self, _input: str, model: str, thinking_level: str = "minimal", instance=None) -> dict: + base_url = "https://generativelanguage.googleapis.com/v1beta/interactions" + + headers = { + "x-goog-api-key": self.api_key, + "Content-Type": "application/json", + } + + data = { + "model": model, + "input": _input, + "generation_config": { + "thinking_level": thinking_level + }, + "previous_interaction_id": self.previous_interaction_id, + "tools": [tool["schema"] for tool in self.tools.values()] + } + + if not self.previous_interaction_id: + data.pop("previous_interaction_id") + + while True: + + with httpx.Client(timeout=60.0) as client: + response = client.post(base_url, headers=headers, json=data) + if response.status_code == 400: + pass + if response.status_code == 429: + raise Exception( + "Rate limit excedido utiliza otro modelo o disminuye la velocidad de las peticiones") + response.raise_for_status() + + interaction = response.json() + + _tool_phrases = { + "fetch_transactions": "Consultando transacciones", + "fetch_categories": "Obteniendo categorías", + "fetch_subcategories": "Obteniendo subcategorías", + "set_transaction": "Registrando transacción", + "update_transaction": "Actualizando transacción", + "get_accounts": "Consultando cuentas", + "fetch_summary": "Calculando resumen", + "fetch_today": "Obteniendo fecha actual", + } + + function_results = [] + for step in interaction["steps"]: + if step["type"] == "function_call": + tool_name = step["name"] + phrase = _tool_phrases.get(tool_name, tool_name) + + if instance: + instance.set_thinking_phrase(f"{phrase}...") + + if self.tools[tool_name]["dto"] is not None: + request = self.tools[tool_name]["dto"]( + **step["arguments"]) + logging.info( + f"Requested {tool_name} with dto {request}") + result = self.tools[tool_name]["function"](request) + else: + logging.info(f"Requested {tool_name} without dto") + result = self.tools[tool_name]["function"]( + **step["arguments"]) + + if instance: + instance.set_thinking_phrase(f"{phrase} ✓") + function_results.append({ + "type": "function_result", + "name": step["name"], + "call_id": step["id"], + "result": [{"type": "text", "text": json.dumps(result, default=default_json_serializer)}] + }) + + data["input"] = function_results + self.previous_interaction_id = interaction["id"] + data["previous_interaction_id"] = self.previous_interaction_id + + if not function_results: + break + + return {"interaction_id": self.previous_interaction_id, "content": interaction["steps"][1]["content"][0]["text"]} + + +if __name__ == "__main__": + pass diff --git a/src/main.py b/src/main.py index c361a3d..fbd6067 100644 --- a/src/main.py +++ b/src/main.py @@ -1,21 +1,34 @@ +import logging + import flet as ft -from flet import Icons as icons -from setup import log_dir -from controllers.controller import DatabaseController -from controllers.controller import UserDataController -from themes.themes import Theme +from setup import log_dir +from controllers.controller import DatabaseController, UserDataController +from controllers.balance.controller import BalanceController +from client.client import ClientStorage +from themes.themes import LigthMode, DarkMode from router import navigate_to -import logging + +from core.config import ASSETS_DIR class FinanceApp: def __init__(self, page: ft.Page): self.page = page - self.theme = Theme() + # self.notification_manager = NotificationManager() + self.client = ClientStorage(self.page) + dark_mode = self.client.get_value("dark_mode") + if dark_mode is None: + dark_mode = True + self.client.set_value("dark_mode", True) + + if dark_mode: + self.theme = DarkMode() + else: + self.theme = LigthMode() self.route_history = [] # Historial de rutas para navegación - + self.page.fonts = { "SF-Pro": "/fonts/SF-Pro.ttf", } @@ -24,14 +37,13 @@ def __init__(self, page: ft.Page): self.page.theme_mode = ft.ThemeMode.DARK self.page.bgcolor = self.theme.bg self.page.on_route_change = self.route_change - - self.page.on_view_pop = self.handle_pop_views self.page.run_thread(self._on_mount) def _on_mount(self): try: db = DatabaseController() db.create_tables() + BalanceController.make_snapshot() logging.info("Application mounted successfully.") logging.info(f"Setup database") if UserDataController.controller_fetch_userdata() is None: @@ -44,19 +56,14 @@ def _on_mount(self): self.page.go("/error") def route_change(self, route): - # Agregar la ruta actual al historial si no es la misma que la última if len(self.route_history) == 0 or self.route_history[-1] != route.route: self.route_history.append(route.route) navigate_to(self.page, self.theme, route.route) - - def handle_pop_views(self): - self.page.views.pop() - top_view = self.page.views[-1] - self.page.go(top_view) def main(page: ft.Page): FinanceApp(page) + if __name__ == "__main__": - ft.app(target=main, assets_dir="assets") + ft.app(target=main, assets_dir=ASSETS_DIR) diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index b1e10dd..0000000 Binary files a/src/requirements.txt and /dev/null differ diff --git a/src/router.py b/src/router.py index f07da3c..0b28a71 100644 --- a/src/router.py +++ b/src/router.py @@ -1,3 +1,5 @@ +import logging + import flet as ft from views.main_view import MainSection @@ -11,30 +13,37 @@ from views.error_view import ErrorPage from views.setup_view import Setup from views.intertransfer_view import IntertransferView +from views.ai_chat_view import AIChatView +from views.all_transactions_view import AllTransactionsView +from views.categories_view import CategoriesView +from views.databaseactions_view import DatabaseActionsView +from views.user_view import UserView from components.headers import HeaderSection from themes.themes import Theme -import logging - views = { - "/" : MainSection, - "/transaction" : TransactionSection, - "/balance" : BalanceSection, - "/active" : ActiveSection, - "/passive" : PassiveSection, - "/passive_item" : PassiveItemView, - "/borrows" : BorrowSection, - "/loan" : LoanView, - "/settings" : Settings, - "/error" : ErrorPage, - "/setup" : Setup, - "/intertransfer" : IntertransferView, + "/": MainSection, + "/transaction": TransactionSection, + "/balance": BalanceSection, + "/active": ActiveSection, + "/passive": PassiveSection, + "/passive_item": PassiveItemView, + "/borrows": BorrowSection, + "/loan": LoanView, + "/settings": Settings, + "/error": ErrorPage, + "/setup": Setup, + "/intertransfer": IntertransferView, + "/ai_chat": AIChatView, + "/all_transactions": AllTransactionsView, + "/categories": CategoriesView, + "/databaseactions": DatabaseActionsView, + "/user": UserView, } - def navigate_to(page: ft.Page, theme: Theme, route: str): header = HeaderSection(theme, page) view = views[route](theme, page) @@ -53,14 +62,199 @@ def navigate_to(page: ft.Page, theme: Theme, route: str): page.update() except Exception as e: logging.error(e) + import traceback + import platform + import os + from datetime import datetime + + # Safe imports to prevent circular references + try: + from core.config import FLET_APP_STORAGE_DATA + db_path = os.path.join( + FLET_APP_STORAGE_DATA or "", "financeapp.db") + except Exception: + db_path = "financeapp.db" + + try: + from setup import log_dir + except ImportError: + log_dir = "./applogs.log" + + tb_str = "".join(traceback.format_exception( + type(e), e, e.__traceback__)) + flet_ver = getattr(ft, "__version__", "Desconocida") + + report_text = ( + f"--- REPORTE DE SOPORTE ---\n" + f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + f"Ruta Fallida: {route}\n" + f"Error: {type(e).__name__}: {str(e)}\n" + f"SO: {platform.system()} {platform.release()} ({platform.machine()})\n" + f"Python: {platform.python_version()}\n" + f"Flet: {flet_ver}\n" + f"Resolución: {page.width}x{page.height}\n" + f"Base de Datos: {db_path}\n" + f"Archivo Logs: {log_dir}\n" + f"\n--- TRACEBACK ---\n" + f"{tb_str}" + ) + + def copy_report(ev): + page.set_clipboard(report_text) + page.open( + ft.SnackBar( + content=ft.Text( + "Reporte de soporte copiado al portapapeles.", color="#FFFFFF"), + bgcolor=theme.green_color, + action="Ok" + ) + ) + page.update() + + error_details = ft.Container( + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.BUG_REPORT, + color=theme.red_color, size=24), + ft.Text(type(e).__name__, size=16, + weight=ft.FontWeight.BOLD, color=theme.red_color), + ], alignment=ft.MainAxisAlignment.START), + ft.Text(str(e), size=14, color=theme.text_primary, + selectable=True), + ], spacing=8), + bgcolor=theme.fg, + padding=16, + border_radius=12, + border=ft.border.all(1, theme.red_color), + ) + + info_items = [ + ("Fecha y Hora", datetime.now().strftime('%Y-%m-%d %H:%M:%S')), + ("Ruta Fallida", route), + ("Sistema Operativo", f"{platform.system()} {platform.release()}"), + ("Versión Python", platform.python_version()), + ("Resolución Pantalla", f"{page.width}x{page.height}"), + ] + + info_rows = [] + for label, val in info_items: + info_rows.append( + ft.Row([ + ft.Text(label, size=13, color=theme.text_secondary, + weight=ft.FontWeight.W_500), + ft.Text(val, size=13, color=theme.text_primary, + weight=ft.FontWeight.BOLD), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN) + ) + + info_box = ft.Container( + content=ft.Column(info_rows, spacing=8), + bgcolor=theme.fg, + padding=16, + border_radius=12, + ) + + traceback_box = ft.Container( + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.TERMINAL, + color=theme.blue_color, size=18), + ft.Text("Traceback (Pila de llamadas)", size=13, + weight=ft.FontWeight.BOLD, color=theme.blue_color), + ]), + ft.Divider(height=1, color=theme.bg), + ft.Container( + content=ft.Text( + tb_str, + font_family="monospace", + size=11, + color="#A8FFB2" if theme.bg == "#0D0E0D" else "#006600", + selectable=True, + ), + height=180, + # scroll=ft.ScrollMode.AUTO, + ) + ], spacing=8), + bgcolor="#1A1C1A" if theme.bg == "#0D0E0D" else "#F0F0F0", + padding=16, + border_radius=12, + border=ft.border.all(1, theme.fg), + ) + + main_column = ft.Column( + [ + ft.Container( + content=ft.Column([ + ft.Icon(ft.Icons.REPORT_PROBLEM, + color=theme.red_color, size=64), + ft.Text("¡Ups! Algo salió mal", size=24, + weight=ft.FontWeight.W_800, color=theme.text_primary), + ft.Text( + "La aplicación ha encontrado un error inesperado. A continuación se muestran los detalles técnicos para soporte.", + size=14, color=theme.text_secondary, text_align=ft.TextAlign.CENTER + ), + ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10), + alignment=ft.alignment.center, + margin=ft.margin.only(bottom=20), + ), + + ft.Text("DETALLES DEL ERROR", size=11, + weight=ft.FontWeight.BOLD, color=theme.blue_color), + error_details, + ft.Container(height=10), + + ft.Text("INFORMACIÓN DE SOPORTE", size=11, + weight=ft.FontWeight.BOLD, color=theme.blue_color), + info_box, + ft.Container(height=10), + + ft.Text("DIAGNÓSTICO TÉCNICO", size=11, + weight=ft.FontWeight.BOLD, color=theme.blue_color), + traceback_box, + ft.Container(height=20), + + ft.Row([ + ft.ElevatedButton( + "Copiar Reporte", + icon=ft.Icons.CONTENT_COPY, + bgcolor=theme.blue_color, + color="#FFFFFF", + on_click=copy_report, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=10), + ), + expand=True, + ), + ft.OutlinedButton( + "Volver al Inicio", + icon=ft.Icons.HOME, + on_click=lambda _: page.go("/"), + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=10), + color=theme.text_primary, + ), + expand=True, + ), + ], spacing=15), + ], + spacing=10, + ) + page.views.clear() page.views.append( ft.View( route="/err", controls=[ - ft.Text("Error :( {}".format(e), size=24, weight=ft.FontWeight.BOLD, color=theme.text_primary), + ft.Container( + content=ft.Container( + content=main_column, + ), + alignment=ft.alignment.top_center, + padding=20, + ) ], - bgcolor=theme.bg + bgcolor=theme.bg, + scroll=ft.ScrollMode.AUTO ) ) - page.update() \ No newline at end of file + page.update() diff --git a/src/setup.py b/src/setup.py index b9c0f0a..5b02206 100644 --- a/src/setup.py +++ b/src/setup.py @@ -1,8 +1,8 @@ -from config import FLET_APP_STORAGE_DATA - import logging import os +from core.config import FLET_APP_STORAGE_DATA + try: if os.path.exists(FLET_APP_STORAGE_DATA) and FLET_APP_STORAGE_DATA: @@ -25,4 +25,4 @@ except Exception as e: logging.error(f"Error initializing logger: {e}", exc_info=True) - logging.info("Logger initialized at ./applogs.log") \ No newline at end of file + logging.info("Logger initialized at ./applogs.log") diff --git a/src/themes/themes.py b/src/themes/themes.py index ecc21fb..339d465 100644 --- a/src/themes/themes.py +++ b/src/themes/themes.py @@ -1,8 +1,35 @@ -class Theme: - bg = "#0D0E0D" - fg = "#202322" - text_primary = "#c0c5ca" - text_secondary = "#e9e7e1" - green_color = "#00ff88" - red_color = "#ff4444" - blue_color = "#0088ff" +class Theme(): + pass + + +# class DarkMode(Theme): +# bg = "#04060A" +# fg = "#0A0E15" +# text_primary = "#FFFFFF" +# text_secondary = "#d3d3d3" +# green_color = "#3DDC97" +# red_color = "#FF6B6B" +# blue_color = "#00C2FF" +# purple_color = "#B18CFF" + + +class DarkMode(Theme): + bg = "#121212" + fg = "#1E1E1E" + text_primary = "#D5D5D5" + text_secondary = "#8A8A8A" + green_color = "#7A9E7E" + red_color = "#C07070" + blue_color = "#7E9BB5" + purple_color = "#9A8EAD" + + +class LigthMode(Theme): + bg = "#FFFFFF" # Fondo principal blanco + fg = "#F4F4F4" # Superficie / cards gris suave + text_primary = "#000000" # Texto principal negro puro + text_secondary = "#666666" # Texto secundario / subtítulos + green_color = "#00E676" # Verde positivo / ganancias (GBM) + red_color = "#FF5252" # Rojo negativo / pérdidas (GBM) + blue_color = "#00C2FF" # Azul índigo acento principal (GBM+) + purple_color = "#B18CFF" diff --git a/src/views/actives_view.py b/src/views/actives_view.py index b5b5278..0d30048 100644 --- a/src/views/actives_view.py +++ b/src/views/actives_view.py @@ -1,12 +1,9 @@ -from controllers.controller import ( - ActiveController, -) +from flet import Icons as icons +import flet as ft +from controllers.actives.controller import ActiveController from components.dialogs import Dialogs - -from themes.themes import Theme -from flet import Icons as icons -import flet as ft +from themes.themes import Theme class ActiveSection: @@ -17,6 +14,7 @@ def __init__(self, theme: Theme, page: ft.Page): self.active_list = None self.active_section = None self.selected_actives = [] + self.total_actives = None def add_active_database(self, e): active_type = self.dropdown.value @@ -73,7 +71,7 @@ def add_active_list(self, actives_list): ], spacing=2, expand=True), ft.Container( content=ft.Icon( - icons.CHECK_CIRCLE if item[4] else icons.ERROR, color=self.theme.green_color if item[4] else self.theme.red_color, size=20), + icons.CHECK_CIRCLE if item[4] else icons.ERROR, color=self.theme.green_color if item[4] else self.theme.text_secondary, size=20), width=30, height=20 ), @@ -81,7 +79,7 @@ def add_active_list(self, actives_list): ft.Text( f"${item[3]:.2f}", color=self.theme.text_primary, size=14, weight=ft.FontWeight.W_500), ft.Text( - "Liquido" if item[4] else "No Liquido", color=self.theme.green_color if item[4] else self.theme.red_color, size=12) + "Liquido" if item[4] else "No Liquido", color=self.theme.green_color if item[4] else self.theme.text_secondary, size=12) ], horizontal_alignment=ft.CrossAxisAlignment.END, spacing=2) ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), margin=ft.margin.symmetric(vertical=5), @@ -92,102 +90,115 @@ def add_active_list(self, actives_list): ) self.page.update() - def refresh_actives(self, *args): + def _get_total_sum(self, list_items): + return sum(item[3] for item in list_items) + + def refresh_actives(self): self.active_list.controls.clear() - self.add_active_list(ActiveController.controller_fetch_actives()) + actives_list = ActiveController.controller_fetch_actives() + self.total_actives = f"${self._get_total_sum(actives_list):,.2f}" + self.add_active_list(actives_list) self.page.update() def draw(self, header): - + self.header = header.create("Activos", return_page=True) self.hint_text = ft.Text("Selecciona tipo de activo", color=self.theme.text_primary, - size=14, weight=ft.FontWeight.W_500) + size=14, weight=ft.FontWeight.W_500) self.dropdown = ft.Dropdown( - hint_text="Tipo de activo", - options=[ - ft.dropdown.Option("Acciones"), - ft.dropdown.Option("Bonos"), - ft.dropdown.Option("Fondos"), - ft.dropdown.Option("Criptomonedas"), - ft.dropdown.Option("Bienes raíces"), - ft.dropdown.Option("Venta"), - ft.dropdown.Option("Otros"), - ], - text_style=ft.TextStyle(color=self.theme.text_primary), - bgcolor=self.theme.fg, - filled=True, - border=ft.InputBorder.OUTLINE, - border_radius=20, - ) + hint_text="Tipo de activo", + options=[ + ft.dropdown.Option("Acciones"), + ft.dropdown.Option("Bonos"), + ft.dropdown.Option("Fondos"), + ft.dropdown.Option("Criptomonedas"), + ft.dropdown.Option("Bienes raíces"), + ft.dropdown.Option("Venta"), + ft.dropdown.Option("Otros"), + ], + text_style=ft.TextStyle(color=self.theme.text_primary), + hint_style=ft.TextStyle(color=self.theme.text_secondary), + fill_color=self.theme.fg, + filled=True, + border=ft.InputBorder.OUTLINE, + border_radius=20, + ) self.name_field = ft.TextField( - hint_text="Nombre del activo", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.PERSON, - bgcolor=self.theme.fg, - filled=True, - border_radius=20, - ) + hint_text="Nombre del activo", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.PERSON, + bgcolor=self.theme.fg, + filled=True, + border_radius=20, + ) self.value_field = ft.TextField( - hint_text="Valor del activo", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.ATTACH_MONEY, - bgcolor=self.theme.fg, - filled=True, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter( - allow=True, regex_string=r"^\d*\.?\d*$"), - border_radius=20, - ) + hint_text="Valor del activo", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.ATTACH_MONEY, + bgcolor=self.theme.fg, + filled=True, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), + border_radius=20, + ) - self.liquid_checkbox = ft.Checkbox(label="Es liquido", value=False) + self.liquid_checkbox = ft.Checkbox( + label="Es liquido", value=False, label_style=ft.TextStyle(color=self.theme.text_primary), check_color=self.theme.bg, active_color=self.theme.text_primary) self.panel = ft.Row([ - ft.IconButton( - icon=icons.TRENDING_UP, icon_color=self.theme.text_primary, - on_click=self.add_active_database, icon_size=24 - ), - ft.IconButton( - icon=icons.DELETE, - icon_color=self.theme.text_primary, - tooltip="Eliminar Pasivo(s)", - on_click=self.delete_active, - icon_size=24, - ), - ft.IconButton( - icon=icons.REFRESH, - icon_color=self.theme.text_primary, - tooltip="Refrescar Lista", - on_click=self.refresh_actives, - icon_size=24, - ), - ft.IconButton( - icon=icons.FILTER_LIST, - icon_color=self.theme.text_primary, - tooltip="Seleccionar", - on_click=self.show_checkboxes, - icon_size=24, - ), - ft.IconButton( - icon=icons.SYNC_ALT, - icon_color=self.theme.text_primary, - tooltip="Intertransferencia", - on_click=lambda e: self.page.go("/intertransfer"), - icon_size=24, - ), - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN) + ft.IconButton( + icon=icons.TRENDING_UP, icon_color=self.theme.text_primary, + on_click=self.add_active_database, icon_size=24 + ), + ft.IconButton( + icon=icons.DELETE, + icon_color=self.theme.text_primary, + tooltip="Eliminar Pasivo(s)", + on_click=self.delete_active, + icon_size=24, + ), + ft.IconButton( + icon=icons.CHECK_BOX, + icon_color=self.theme.text_primary, + tooltip="Seleccionar", + on_click=self.show_checkboxes, + icon_size=24, + ), + ft.IconButton( + icon=icons.SYNC_ALT, + icon_color=self.theme.text_primary, + tooltip="Intertransferencia", + on_click=lambda e: self.page.go("/intertransfer"), + icon_size=24, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN) self.active_list = ft.Column( [], spacing=0, scroll=ft.ScrollMode.ADAPTIVE, expand=True) - self.add_active_list(ActiveController.controller_fetch_actives()) + self.refresh_actives() + + self.total_sum = ft.Container( + ft.Row([ + ft.Text( + f"{self.total_actives} MXN", + color=self.theme.green_color, + size=20, + style=ft.TextStyle( + weight=ft.FontWeight.BOLD + ) + ) + ]) + ) self.active_section = ft.Container( ft.Column([ @@ -198,17 +209,11 @@ def draw(self, header): self.value_field, self.liquid_checkbox, self.panel, + self.total_sum, self.active_list, ],), margin=ft.margin.symmetric(horizontal=20, vertical=10), expand=True, ) - - return self.active_section - - - - - - + return self.active_section diff --git a/src/views/ai_chat_view.py b/src/views/ai_chat_view.py new file mode 100644 index 0000000..ef9ce9c --- /dev/null +++ b/src/views/ai_chat_view.py @@ -0,0 +1,387 @@ +import threading +from enum import Enum + +import flet as ft +from flet import Icons as icons + +from themes.themes import Theme +from controllers.AI.controller import AIChatController +from client.client import ClientStorage + + +class KaraModels(Enum): + GEMINI_3_5_FLASH = {"value": "Kara 3.5 Ultra", "key": "gemini-3.5-flash"} + GEMINI_3_1_FLASH_LITE_PREVIEW = { + "value": "Kara 3.1 Lite", "key": "gemini-3.1-flash-lite-preview"} + GEMINI_2_5_FLASH = {"value": "Kara 2.5 Flash", "key": "gemini-2.5-flash"} + + +class KaraModelsThinkingPerformance(Enum): + HIGH = {"value": "Ultra", "key": "high"} + MEDIUM = {"value": "Normal", "key": "medium"} + LOW = {"value": "Bajo", "key": "low"} + MINIMAL = {"value": "Minimo", "key": "minimal"} + + +class AIChatView: + def __init__(self, theme: Theme, page: ft.Page): + self.theme = theme + self.page = page + self.messages_column: ft.Column = None + self.prompt_field: ft.TextField = None + self.send_button: ft.IconButton = None + self._thinking_bubble = None + self.client_storage = ClientStorage(self.page) + self.aichat_controller = AIChatController() + self.thinking_phrase = "Analizando tus finanzas..." + self._thinking_text = None + + # ------------------------------------------------------------------ helpers + + def set_thinking_phrase(self, phrase: str): + self.thinking_phrase = phrase + if self._thinking_text: + self._thinking_text.value = phrase + self.page.update() + + def _create_thinking_text(self) -> ft.Text: + self._thinking_text = ft.Text( + self.thinking_phrase, + color=self.theme.text_secondary, size=12, + italic=True, + ) + return self._thinking_text + + def _bubble(self, text: str, is_user: bool) -> ft.Container: + """Crea una burbuja de mensaje con estilo diferenciado.""" + if is_user: + bg = self.theme.fg + text_color = self.theme.text_primary + border_color = self.theme.fg + align = ft.MainAxisAlignment.END + icon = icons.PERSON + icon_color = self.theme.text_primary + else: + bg = self.theme.fg + text_color = self.theme.text_primary + border_color = self.theme.fg + align = ft.MainAxisAlignment.START + icon = icons.AUTO_AWESOME + icon_color = self.theme.text_primary + + avatar = ft.Container( + content=ft.Icon(icon, color=icon_color, size=16), + width=30, + height=30, + bgcolor=bg, + border_radius=15, + alignment=ft.alignment.center, + border=ft.border.all(1, border_color), + ) + + # Determinar el theme_mode local para que el Markdown pinte todo el texto correctamente + bubble_theme_mode = ft.ThemeMode.LIGHT if self.theme.bg == "#FFFFFF" else ft.ThemeMode.DARK + + bubble = ft.Container( + theme_mode=bubble_theme_mode, + content=ft.Markdown( + text, + selectable=True, + extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, + code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK, + code_style_sheet=ft.MarkdownStyleSheet( + p_text_style=ft.TextStyle(color=text_color), + code_text_style=ft.TextStyle( + size=12, font_family="monospace"), + ), + on_tap_link=lambda e: self.page.launch_url(e.data), + ), + bgcolor=bg, + border_radius=ft.border_radius.only( + top_left=16, top_right=16, + bottom_left=0 if is_user else 16, + bottom_right=16 if is_user else 0, + ), + border=ft.border.all(1, border_color), + padding=ft.padding.symmetric(horizontal=14, vertical=10), + expand=True, + ) + + controls = [avatar, bubble] if not is_user else [bubble, avatar] + + return ft.Container( + content=ft.Row(controls, alignment=align, spacing=8, wrap=False), + padding=ft.padding.symmetric(horizontal=16, vertical=4), + expand=True, + ) + + def _thinking_indicator(self) -> ft.Container: + """Burbuja animada de 'pensando...'.""" + return ft.Container( + content=ft.Row([ + ft.Container( + content=ft.Icon(icons.AUTO_AWESOME, + color=self.theme.text_primary, size=16), + width=30, height=30, + bgcolor=self.theme.fg, + border_radius=15, + alignment=ft.alignment.center, + border=ft.border.all(1, self.theme.fg), + ), + ft.Container( + content=ft.Row([ + ft.ProgressRing(width=14, height=14, stroke_width=2, + color=self.theme.text_primary), + self._create_thinking_text(), + ], spacing=8), + bgcolor=self.theme.fg, + border_radius=16, + border=ft.border.all(1, self.theme.fg), + padding=ft.padding.symmetric(horizontal=14, vertical=10), + ), + ], alignment=ft.MainAxisAlignment.START, spacing=8), + padding=ft.padding.symmetric(horizontal=16, vertical=4), + ) + + def _add_message(self, text: str, is_user: bool): + self.messages_column.controls.append(self._bubble(text, is_user)) + self.page.update() + + def _remove_thinking(self): + if self._thinking_bubble and self._thinking_bubble in self.messages_column.controls: + self.messages_column.controls.remove(self._thinking_bubble) + self._thinking_bubble = None + + # ------------------------------------------------------------------ events + + def _on_send(self, e): + prompt = self.prompt_field.value.strip() + model = self.models_selection.value + thinking_level = self.thinking_performance_selection.value + # Guardar el modelo seleccionado + self.client_storage.set_value("last_model", model) + self.client_storage.set_value( + "thinking_performance", thinking_level) + + if not prompt: + return + + # Deshabilitar input mientras procesa + self.prompt_field.value = "" + self.prompt_field.disabled = True + self.send_button.disabled = True + self._add_message(prompt, is_user=True) + + # Mostrar indicador de carga + self._thinking_bubble = self._thinking_indicator() + self.messages_column.controls.append(self._thinking_bubble) + self.page.update() + + # Llamar a la IA en hilo separado para no bloquear la UI + def run_ai(): + response = self.aichat_controller.analyze_with_ai( + prompt, model, thinking_level=thinking_level, instance=self) + self._remove_thinking() + self._add_message(response, is_user=False) + self.prompt_field.disabled = False + self.send_button.disabled = False + self.page.update() + + threading.Thread(target=run_ai, daemon=True).start() + + def _on_field_submit(self, e): + self._on_send(e) + + # ------------------------------------------------------------------ draw + + def draw(self, header) -> ft.Column: + # ---- Header + header_bar = header.create("Kara AI", return_page=True) + + # ---- Sugerencias de prompts rápidos + suggestions = [ + "¿Cuáles son mis mayores gastos?", + "¿Han cambiado mis hábitos de gasto?", + "¿En qué categoría gasto más?", + "Dame recomendaciones para ahorrar", + ] + + self.models_selection = ft.Dropdown( + options=[ + ft.dropdown.Option( + key=KaraModels.GEMINI_3_5_FLASH.value["key"], text=KaraModels.GEMINI_3_5_FLASH.value["value"]), + ft.dropdown.Option( + key=KaraModels.GEMINI_3_1_FLASH_LITE_PREVIEW.value["key"], text=KaraModels.GEMINI_3_1_FLASH_LITE_PREVIEW.value["value"]), + ft.dropdown.Option( + key=KaraModels.GEMINI_2_5_FLASH.value["key"], text=KaraModels.GEMINI_2_5_FLASH.value["value"]), + ], + value=self.client_storage.get_value( + "last_model") or KaraModels.GEMINI_3_1_FLASH_LITE_PREVIEW.value["key"], + expand=True, + text_style=ft.TextStyle(color=self.theme.text_secondary, size=12), + fill_color=self.theme.fg, + filled=True, + content_padding=ft.padding.symmetric(horizontal=12, vertical=1), + dense=True, + border_radius=20, + border=ft.InputBorder.OUTLINE, + ) + + self.thinking_performance_selection = ft.Dropdown( + options=[ + ft.dropdown.Option(leading_icon=icons.PSYCHOLOGY, + key=KaraModelsThinkingPerformance.HIGH.value["key"], text=KaraModelsThinkingPerformance.HIGH.value["value"]), + ft.dropdown.Option(leading_icon=icons.AUTO_AWESOME, + key=KaraModelsThinkingPerformance.MEDIUM.value["key"], text=KaraModelsThinkingPerformance.MEDIUM.value["value"]), + ft.dropdown.Option(leading_icon=icons.LIGHTBULB, + key=KaraModelsThinkingPerformance.LOW.value["key"], text=KaraModelsThinkingPerformance.LOW.value["value"]), + ft.dropdown.Option(leading_icon=icons.ENERGY_SAVINGS_LEAF, + key=KaraModelsThinkingPerformance.MINIMAL.value["key"], text=KaraModelsThinkingPerformance.MINIMAL.value["value"]), + ], + value=self.client_storage.get_value( + "thinking_performance") or KaraModelsThinkingPerformance.MINIMAL.value["key"], + expand=True, + text_style=ft.TextStyle(color=self.theme.text_secondary, size=12), + fill_color=self.theme.fg, + filled=True, + content_padding=ft.padding.symmetric(horizontal=12, vertical=1), + dense=True, + border_radius=20, + border=ft.InputBorder.OUTLINE, + ) + + models = ft.Container( + content=ft.Row([ + ft.Column([ + ft.Text( + "Model", color=self.theme.text_secondary, size=12), + self.models_selection, + ], expand=True), + ft.Column([ + ft.Text( + "Thinking Performance", color=self.theme.text_secondary, size=12), + self.thinking_performance_selection, + ], expand=True), + ]), + margin=ft.margin.symmetric(horizontal=0, vertical=6), + ) + + def make_chip(text): + return ft.Container( + content=ft.Text( + text, color=self.theme.text_secondary, size=11), + bgcolor=self.theme.fg, + # border=ft.border.all(1, self.theme.text_primary), + border_radius=20, + padding=ft.padding.symmetric(horizontal=12, vertical=6), + on_click=lambda e, t=text: self._quick_send(t), + ) + + chips_row = ft.Container( + content=ft.Row( + [make_chip(s) for s in suggestions], + scroll=ft.ScrollMode.AUTO, + spacing=8, + ), + padding=ft.padding.symmetric(horizontal=0, vertical=4), + ) + + # ---- Área de mensajes + welcome_msg = ft.Container( + content=ft.Column([ + ft.Container( + content=ft.Icon(icons.AUTO_AWESOME, + color=self.theme.text_primary, size=32), + width=60, height=60, + bgcolor=self.theme.bg, + border_radius=30, + alignment=ft.alignment.center + ), + ft.Text( + "¡Hola! Soy Kara tu asistente financiero.", + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500, + ), + ft.Text( + "Pregúntame sobre tus hábitos de gasto,\npatrones financieros o cómo mejorar tus finanzas.", + color=self.theme.text_secondary, + size=12, + text_align=ft.TextAlign.CENTER, + ), + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=10, + ), + alignment=ft.alignment.center, + padding=ft.padding.symmetric(vertical=30), + ) + + self.messages_column = ft.Column( + [welcome_msg], + spacing=0, + scroll=ft.ScrollMode.ADAPTIVE, + expand=True, + auto_scroll=True, + ) + + messages_area = ft.Container( + content=self.messages_column, + expand=True, + bgcolor=self.theme.bg, + ) + + # ---- Input bar + self.prompt_field = ft.TextField( + hint_text="Escribe tu pregunta financiera...", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.NONE, + expand=True, + cursor_color=self.theme.text_primary, + multiline=False, + on_submit=self._on_field_submit, + max_lines=3, + ) + + self.send_button = ft.IconButton( + icon=icons.SEND, + icon_color=self.theme.text_primary, + icon_size=22, + on_click=self._on_send, + style=ft.ButtonStyle( + bgcolor={ft.ControlState.DEFAULT: self.theme.bg, + ft.ControlState.HOVERED: self.theme.fg}, + shape=ft.RoundedRectangleBorder(radius=20), + ), + ) + + input_bar = ft.Container( + content=ft.Row([ + self.prompt_field, + self.send_button, + ], alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + spacing=6), + bgcolor=self.theme.fg, + border_radius=30, + padding=ft.padding.symmetric(horizontal=12, vertical=4), + margin=ft.margin.symmetric(horizontal=0, vertical=10), + ) + + return ft.Container( + content=ft.Column([ + header_bar, + models, + chips_row, + messages_area, + input_bar, + ], expand=True, spacing=0), + expand=True, + margin=ft.margin.symmetric(horizontal=20, vertical=10), + ) + + def _quick_send(self, text: str): + self.prompt_field.value = text + self.page.update() + self._on_send(None) diff --git a/src/views/all_transactions_view.py b/src/views/all_transactions_view.py new file mode 100644 index 0000000..e2a9884 --- /dev/null +++ b/src/views/all_transactions_view.py @@ -0,0 +1,145 @@ +from datetime import date + +import flet as ft +from flet import Icons as icons + +from components.headers import HeaderSection +from controllers.categories.controller import CategoriesController +from controllers.date_groups import create_week_separator, get_group_date +from controllers.transactions.controller import TransactionController +from dto.transactions import TransactionFilterDTO, TransactionOutputDTO +from themes.themes import Theme + + +class AllTransactionsView: + def __init__(self, theme: Theme, page: ft.Page): + self.page = page + self.theme = theme + filter = TransactionFilterDTO() # get all transaction without filter + self.transactions = TransactionController.fetch_transactions(filter) + self.categories_controller = CategoriesController() + self.previous_date = None + + def calculate_period_time(self, date: str = None, timelapse: str = "week"): + week_start = get_group_date(date=date, timelapse=timelapse) + if week_start != self.previous_date: + self.previous_date = week_start + self.transactions_list.controls.append( + create_week_separator(week_start, self.theme)) + + def _create_transaction_item(self, transaction: TransactionOutputDTO): + category_name = self.categories_controller.get_category_name( + transaction.category) + if transaction.is_income: + trend_icon = icons.TRENDING_UP + trend_color = self.theme.green_color + else: + trend_icon = icons.TRENDING_DOWN + trend_color = self.theme.red_color + + return ft.Container( + content=ft.Row([ + ft.Container( + content=ft.Icon( + icons.SHOPPING_BAG, + color=self.theme.text_primary, + size=24 + ), + width=40, + height=40, + bgcolor=self.theme.fg, + border_radius=20, + alignment=ft.alignment.center + ), + ft.Column([ + ft.Text( + transaction.name, + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ), + ft.Text( + category_name, + color=self.theme.text_secondary, + size=12 + ), + ft.Text( + transaction.created_at, + color=self.theme.text_secondary, + size=12 + ), + ], spacing=2, expand=True), + ft.Container( + content=ft.Icon( + trend_icon, + color=trend_color, + size=20 + ), + width=30, + height=20 + ), + ft.Column([ + ft.Text( + f"${transaction.price:.2f}", + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ), + ft.Text( + f"%{transaction.expense_percentage:.2f}", + color=trend_color, + size=12 + ) + ], horizontal_alignment=ft.CrossAxisAlignment.END, spacing=2) + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + padding=ft.padding.symmetric(horizontal=20, vertical=15), + bgcolor=self.theme.fg, + border_radius=30, + margin=ft.margin.symmetric(horizontal=0, vertical=5), + ) + + def _remove_ring(self): + if self.ring_animation and self.ring_animation in self.transactions_list.controls: + self.transactions_list.controls.remove(self.ring_animation) + self.ring_animation = None + + def _set_ring(self): + return ft.Container( + content=ft.ProgressRing( + expand=True, color=self.theme.text_primary), + alignment=ft.alignment.center, + expand=True, + ) + + def _charge_transactions_list(self): + self.ring_animation = self._set_ring() + self.transactions_list.controls.append(self.ring_animation) + for transaction in self.transactions: + self.calculate_period_time( + date=transaction.created_at, timelapse="week") + self.transactions_list.controls.append( + self._create_transaction_item(transaction)) + self._remove_ring() + self.page.update() + + def draw(self, header: HeaderSection) -> ft.Container: + self.transactions_list = ft.Column( + [], + spacing=0, + scroll=ft.ScrollMode.ADAPTIVE, + expand=True, + ) + + self.page.run_thread( + self._charge_transactions_list + ) + + return ft.Container( + content=ft.Column([ + header.create(placeholder="Transacciones", + return_page=True, config=False), + self.transactions_list, + ], expand=True), + expand=True, + margin=ft.margin.symmetric(horizontal=20, vertical=10), + ) diff --git a/src/views/balance_view.py b/src/views/balance_view.py index 2fff972..4127fcd 100644 --- a/src/views/balance_view.py +++ b/src/views/balance_view.py @@ -1,23 +1,26 @@ -from controllers.controller import ( - BalanceController, -) +import flet as ft +from controllers.balance.controller import BalanceController +from controllers.chart import ChartGenerator from themes.themes import Theme -from flet import Icons as icons -import flet as ft class BalanceSection: def __init__(self, theme: Theme, page: ft.Page): self.theme = theme self.page = page + self.chart_generator = ChartGenerator() self.balance_content = None + days = [] + balance = [] + for item in BalanceController.fetch_snapshots(): + days.append(item[2]) + balance.append(float(item[1])) - def add_balance_database(self, e): - new_balance = self.balance_content.controls[1].controls[3].value - if new_balance == "": - return Dialogs.lost_data_dialog(self.page) - BalanceController.controller_set_balance(float(new_balance), new=True) + self.last_chart_route = self.chart_generator.balance_chart( + days=days, + balance=balance + ) def draw(self, header): self.balance_content = ft.Column([ @@ -28,21 +31,12 @@ def draw(self, header): ft.Text(f"{BalanceController.controller_fetch_balance(formated=True)}", color=self.theme.text_primary, size=32, weight=ft.FontWeight.BOLD), ft.Text( - f"Ultima actualizacion: {BalanceController.fetch_balance_update()}", color=self.theme.text_secondary, size=14), - ft.TextField( - hint_text="Nuevo Balance", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.NONE, - bgcolor=self.theme.fg, - filled=True, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter( - allow=True, regex_string=r"^\d*\.?\d*$"), - ), - ft.ElevatedButton("Actualizar Balance", bgcolor=self.theme.green_color, - color="#000000", on_click=self.add_balance_database) + f"Ultima actualizacion: {BalanceController.fetch_last_date_snapshot()}", color=self.theme.text_secondary, size=14), + ft.Image( + src=self.last_chart_route, + width=700, + fit=ft.ImageFit.CONTAIN, + ) ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, expand=True), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER) return self.balance_content - diff --git a/src/views/borrow_view.py b/src/views/borrow_view.py index b968872..ac669dd 100644 --- a/src/views/borrow_view.py +++ b/src/views/borrow_view.py @@ -1,13 +1,10 @@ -from controllers.controller import ( - LoanController, - ActiveController, -) +from flet import Icons as icons +import flet as ft +from controllers.actives.controller import ActiveController +from controllers.loans.controller import LoanController from components.dialogs import Dialogs - from themes.themes import Theme -from flet import Icons as icons -import flet as ft class BorrowSection: @@ -15,11 +12,16 @@ def __init__(self, theme: Theme, page: ft.Page): self.theme = theme self.page = page - def handle_loan(self, e, value): - self.page.data = { - "loan_id": value, - "active_id": None - } + def handle_loan(self, e): + try: + self.page.data = { + "loan_id": e.control.data, + "active_id": None + } + except Exception as e: + Dialogs.error_dialog(self.page, str(e)) + return + self.page.go("/loan") def get_loan_data(self, e): @@ -44,7 +46,7 @@ def get_loan_data(self, e): "to": loan_to, "from": loan_from, } - + def set_loan_database(self, e): loan_data = self.get_loan_data(e) if not loan_data: @@ -99,11 +101,11 @@ def draw_loans(self, list_control): padding=ft.padding.symmetric(horizontal=10, vertical=15), bgcolor=self.theme.fg, border_radius=20, - on_click=lambda e: self.handle_loan(e, loan[0]) + data=loan[0], + on_click=self.handle_loan ) ) self.page.update() - def ui_events(self, e): if e.control.value == "bank": @@ -152,58 +154,63 @@ def draw(self, header): self.loan_from, ]), ) - + self.loan_form = ft.Column([ ft.TextField( - hint_text="Nombre del préstamo", - border=ft.InputBorder.OUTLINE, - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - prefix_icon=icons.PERSON, - bgcolor=self.theme.fg, - filled=True, - border_radius=20, + hint_text="Nombre del préstamo", + border=ft.InputBorder.OUTLINE, + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + prefix_icon=icons.PERSON, + bgcolor=self.theme.fg, + filled=True, + border_radius=20, ), ft.TextField( - hint_text="Monto del préstamo", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.ATTACH_MONEY, - bgcolor=self.theme.fg, - filled=True, - border_radius=20, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter(allow=True, regex_string=r"^\d*\.?\d*$"), + hint_text="Monto del préstamo", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.ATTACH_MONEY, + bgcolor=self.theme.fg, + filled=True, + border_radius=20, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), ), ft.TextField( - hint_text="Porcentaje de interés (%)", - value=0, - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.PERCENT, - bgcolor=self.theme.fg, - filled=True, - border_radius=20, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter(allow=True, regex_string=r"^\d*\.?\d*$"), + hint_text="Porcentaje de interés (%)", + value=0, + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.PERCENT, + bgcolor=self.theme.fg, + filled=True, + border_radius=20, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), ), ]) self.account_dropdown = ft.Dropdown( hint_text="Cuenta origen", - options=[ft.dropdown.Option(text=f"{account[2]} ${account[3]}", key=account[0]) for account in self.accounts_actives if account[4]], - text_style=ft.TextStyle(color=self.theme.green_color), + options=[ft.dropdown.Option(text=f"{account[2]} ${account[3]}", key=account[0]) + for account in self.accounts_actives if account[4]], + text_style=ft.TextStyle(color=self.theme.text_primary), bgcolor=self.theme.fg, + fill_color=self.theme.fg, filled=True, border=ft.InputBorder.OUTLINE, border_radius=20, ) self.add_button = ft.Container( - content=ft.Text("Agregar", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), - bgcolor=self.theme.green_color, + content=ft.Text("Agregar", color=self.theme.text_primary, + size=16, weight=ft.FontWeight.BOLD), + bgcolor=self.theme.fg, padding=15, border_radius=20, alignment=ft.alignment.center, @@ -226,4 +233,3 @@ def draw(self, header): margin=ft.margin.symmetric(horizontal=20, vertical=10), expand=True, ) - diff --git a/src/views/categories_view.py b/src/views/categories_view.py new file mode 100644 index 0000000..f16fd3c --- /dev/null +++ b/src/views/categories_view.py @@ -0,0 +1,404 @@ +import flet as ft +from flet import Icons as icons + +from components.dialogs import Dialogs +from components.headers import HeaderSection as Header +from components.dialogs import Dialogs +from controllers.categories.controller import CategoriesController +from controllers.AI.controller import AIChatController +from client.client import ClientStorage +from themes.themes import Theme + + +class CategoriesView: + def __init__(self, theme: Theme, page: ft.Page): + self.theme = theme + self.page = page + self.categories = [] + self.selected_card = None + self.client_storage = ClientStorage(self.page) + self.ai_controller = AIChatController() + self.controller = CategoriesController() + + def deactivate_categories(self, category_id): + self.controller.deactivate_category(category_id) + self._load_categories() + self._load_subcategories() + + def activate_categories(self, category_id): + self.controller.activate_category(category_id) + self._load_categories() + self._load_subcategories() + + def deactivate_subcategories(self, subcategory_id): + self.controller.deactivate_subcategory(subcategory_id) + self._load_subcategories() + + def activate_subcategories(self, subcategory_id): + self.controller.activate_subcategory(subcategory_id) + self._load_subcategories() + + def _on_category_card_selected(self, card, e): + if self.selected_card is not None: + self.selected_card.deselect() + self.selected_card = card + self._load_subcategories() + + def _load_categories(self): + self.categories = self.controller.fetch_all_categories() + category_cards = [] + self.categories_container.content = ft.Column([]) + # self.selected_card = None # Resetear selección + for category in self.categories: + category_id, name, description, is_active = category + card = CategoryCard( + self.theme, + self.page, + category_id, + name, + description, + is_active, + on_selected=self._on_category_card_selected, + on_deactivate=self.deactivate_categories, + on_activate=self.activate_categories).build() + category_cards.append(card) + + self.categories_container.content = ft.Column( + category_cards, scroll=ft.ScrollMode.AUTO, expand=True) + self.page.update() + + def _load_subcategories(self): + if self.selected_card: + subcategories = self.controller.fetch_subcategories( + self.selected_card.category_id) + subcategory_cards = [] + self.subcategories_container.content = ft.Column([]) + for subcategory in subcategories: + subcategory_id, category_id, name, description, is_active = subcategory + card = SubcategoryCard( + self.theme, + self.page, + subcategory_id, + category_id, + name, + description, + is_active, + on_deactivate=self.deactivate_subcategories, + on_activate=self.activate_subcategories).build() + subcategory_cards.append(card) + + self.subcategories_container.content = ft.Column( + subcategory_cards, scroll=ft.ScrollMode.AUTO, expand=True) + self.page.update() + + def _save_category(self, e): + name = self.category_name_field.value.strip() + description = self.category_description_field.value.strip() + + if self._check_if_exists_category(name): + self.category_name_field.error_text = "Ya existe una categoría con ese nombre" + self.page.update() + return + + if not name: + self.category_name_field.error_text = "El nombre no puede estar vacío" + self.page.update() + return + else: + self.category_name_field.error_text = "" + + self.controller.set_category(name, description) + self.page.close(self.category_dialog) + self._load_categories() + + def _save_subcategory(self, e): + if self.selected_card is None: + Dialogs.error_dialog( + self.page, "Por favor, selecciona una categoría para agregar una subcategoría.") + return + + name = self.category_name_field.value.strip() + description = self.category_description_field.value.strip() + + if not name: + self.category_name_field.error_text = "El nombre no puede estar vacío" + self.page.update() + return + else: + self.category_name_field.error_text = "" + + if self._check_if_exists_subcategory(self.selected_card.category_id, name): + self.category_name_field.error_text = "Ya existe una subcategoría con ese nombre" + self.page.update() + return + + self.controller.set_subcategory( + self.selected_card.category_id, name, description) + self.page.close(self.category_dialog) + self._load_subcategories() + + def _thinking_state(self, is_thinking: bool): + if is_thinking: + self.category_description_field.hint_text = "Pensando..." + self.page.update() + else: + self.category_description_field.hint_text = "" + self.page.update() + + def _check_if_exists_category(self, name: str) -> bool: + categories = self.controller.fetch_all_categories() + return any(category[1].lower() == name.lower() for category in categories) + + def _check_if_exists_subcategory(self, category_id: int, name: str) -> bool: + subcategories = self.controller.fetch_subcategories(category_id) + return any(subcategory[2].lower() == name.lower() for subcategory in subcategories) + + def _ai_generate_description(self, e): + self.randomize_description_button.disabled = True + category_name = self.category_name_field.value + if category_name.strip() == "": + self.category_description_field.hint_text = "Por favor, ingresa un nombre de categoría para generar una descripción." + self.page.update() + return + self._thinking_state(True) + response = self.ai_controller.generate_recommendations( + category_name, self.client_storage.get_value("last_model") or "gemini-3.1-flash-lite-preview") + self._thinking_state(False) + self.category_description_field.value = response + self.randomize_description_button.disabled = False + self.page.update() + + def _create_dialog(self, title, name_label, description_label, on_save_callback, is_subcategory=False): + self.category_name_field = ft.TextField( + label=name_label, + width=300, + border_radius=20, + border_color=self.theme.text_secondary + ) + self.category_description_field = ft.TextField( + label=description_label, + width=300, + border_radius=20, + border_color=self.theme.text_secondary, + multiline=True, + min_lines=3, + max_lines=5, + hint_text="", + on_focus=self._ai_generate_description + ) + + self.randomize_description_text = ft.Text( + "Aleatoria", color=self.theme.text_secondary, size=12) + self.randomize_description_button = ft.IconButton( + icon=icons.SHUFFLE, + icon_color=self.theme.text_primary, + on_click=self._ai_generate_description, + tooltip="Generar descripción aleatoria", + ) + + self.category_dialog = ft.AlertDialog( + bgcolor=self.theme.bg, + title=ft.Text(title), + content=ft.Column([ + self.category_name_field, + self.category_description_field, + ft.Row( + [ + self.randomize_description_button, + self.randomize_description_text, + ], + alignment=ft.MainAxisAlignment.START + ) + ]), + actions=[ + ft.TextButton("Cancelar", on_click=lambda e: self.page.close( + self.category_dialog)), + ft.TextButton("Crear", on_click=on_save_callback) + ] + ) + self.page.open(self.category_dialog) + self.page.update() + + def _open_create_subcategory_dialog(self, e): + if self.selected_card is None: + Dialogs.error_dialog( + self.page, "Por favor, selecciona una categoría para agregar una subcategoría.") + return + self._create_dialog( + title=f"Crear Subcategoría para {self.selected_card.name}", + name_label="Nombre de Subcategoría", + description_label="Descripción", + on_save_callback=self._save_subcategory, # Puede ser diferente si lo necesitas + is_subcategory=True + ) + + def _open_create_category_dialog(self, e): + self._create_dialog( + title="Crear Categoría", + name_label="Nombre", + description_label="Descripción", + on_save_callback=self._save_category, + is_subcategory=False + ) + + def draw(self, header: Header): + self.header = header.create("Categorías", return_page=True) + + self.add_category_button = ft.Row( + [ + ft.Text("Agregar Categoria", color=self.theme.text_primary), + ft.IconButton( + icon=icons.ADD, + on_click=self._open_create_category_dialog, + ) + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN + ) + + self.add_subcategory_button = ft.Row( + [ + ft.Text("Agregar Subcategoria", color=self.theme.text_primary), + ft.IconButton( + icon=icons.ADD, + on_click=self._open_create_subcategory_dialog, + ) + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN + ) + + self.categories_container = ft.Container( + ft.Column( + [] + ), + border=ft.border.all(1, self.theme.text_secondary), + border_radius=10, + expand=True + ) + + self.subcategories_container = ft.Container( + ft.Column( + [] + ), + border=ft.border.all(1, self.theme.text_secondary), + border_radius=10, + expand=True + ) + + self._load_categories() + + return ft.Container( + ft.Column([ + self.header, + self.add_category_button, + self.categories_container, + self.add_subcategory_button, + self.subcategories_container + ], expand=True), + margin=ft.margin.symmetric(horizontal=20, vertical=10), + expand=True, + ) + + +class CategoryCard(): + def __init__(self, theme, page, category_id, name, description, is_active, on_selected=None, on_delete=None, on_deactivate=None, on_activate=None): + self.theme = theme + self.page = page + self.category_id = category_id + self.name = name + self.description = description + self.is_active = is_active + + # Self Properties + self.is_selected = False + self.is_disabled = False + self.opacity = 1.0 if self.is_active else 0.5 + self.icon = icons.VISIBILITY if self.is_active else icons.VISIBILITY_OFF + self.function = self.deactivate if self.is_active else self.activate + + # Callbacks + self.on_selected = on_selected + self.on_delete = on_delete + self.on_deactivate = on_deactivate + self.on_activate = on_activate + + self.card = ft.Container( + content=ft.Row([ + ft.Column([ + ft.Text(name, weight=ft.FontWeight.BOLD, size=16), + ft.Text(description, size=14, + color=self.theme.text_secondary), + ], expand=True, disabled=self.is_disabled, opacity=self.opacity), + + ft.Column([ + ft.IconButton( + icon=self.icon, + icon_color=self.theme.text_primary, + on_click=self.function, + mouse_cursor=ft.MouseCursor.GRAB + ) + ]), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, expand=True), + margin=ft.margin.symmetric(vertical=10, horizontal=10), + expand=True, + bgcolor=self.theme.bg, + data={"category_id": category_id}, + on_click=self._on_card_click, + ) + + def _on_card_click(self, e): + if not self.is_selected: + self.select() + if self.on_selected: + self.on_selected(self, e) + + def select(self): + self.is_selected = True + self.card.bgcolor = self.theme.fg + self.page.update() + + def deselect(self): + self.is_selected = False + self.card.bgcolor = self.theme.bg + self.page.update() + + def activate(self, e): + if not self.is_active: + self.on_activate(self.category_id) + if self.is_disabled: + self.is_disabled = False + + def deactivate(self, e): + if self.on_deactivate: + self.on_deactivate(self.category_id) + if not self.is_disabled: + self.is_disabled = True + + def build(self): + return self.card + + +class SubcategoryCard(CategoryCard): + def __init__(self, theme, page, subcategory_id, category_id, name, description, is_active, on_selected=None, on_delete=None, on_deactivate=None, on_activate=None): + super().__init__(theme, page, category_id, name, description, + is_active, on_selected, on_delete, on_deactivate, on_activate) + + self.subcategory_id = subcategory_id + + def deactivate(self, e): + if self.on_deactivate: + self.on_deactivate(self.subcategory_id) + if not self.is_disabled: + self.is_disabled = True + + def activate(self, e): + if not self.is_active: + self.on_activate(self.subcategory_id) + if self.is_disabled: + self.is_disabled = False + + def select(self): + pass + + def _on_card_click(self, e): + pass + + def deselect(self): + pass diff --git a/src/views/databaseactions_view.py b/src/views/databaseactions_view.py new file mode 100644 index 0000000..db65ea7 --- /dev/null +++ b/src/views/databaseactions_view.py @@ -0,0 +1,144 @@ +import flet as ft + + +from controllers.controller import DatabaseController +from controllers.categories.controller import CategoriesController +from controllers.transactions.controller import TransactionController +from components.dialogs import Dialogs +from themes.themes import Theme + + +class DatabaseActionsView: + def __init__(self, theme: Theme, page: ft.Page): + self.theme = theme + self.page = page + self.db_controller = DatabaseController() + self.transaction_controller = TransactionController() + self.category_controller = CategoriesController() + + def delete_categories(self, e): + def delete(e): + success = self.category_controller.delete_all_categories() + if success: + self.page.open(ft.SnackBar( + content=ft.Text("Categorías eliminadas exitosamente") + )) + else: + self.page.open(ft.SnackBar( + content=ft.Text("Error al eliminar las categorías") + )) + + Dialogs.confirmation_dialog( + self.page, + "Eliminar categorias", + ft.Text("¿Estás seguro de que quieres eliminar las categorías?"), + on_confirm=delete + ) + + def delete_transactions(self, e): + + def delete(e): + success = self.transaction_controller.delete_all_transactions() + if success: + self.page.open(ft.SnackBar( + content=ft.Text("Transacciones eliminadas exitosamente") + )) + else: + self.page.open(ft.SnackBar( + content=ft.Text("Error al eliminar las transacciones") + )) + + Dialogs.confirmation_dialog( + self.page, + "Eliminar transacciones", + ft.Text("¿Estás seguro de que quieres eliminar las transacciones?"), + on_confirm=delete + ) + + def delete_database(self, e): + + def delete(e): + self.db_controller.reset() + self.page.open(ft.SnackBar( + content=ft.Text("Base de datos restaurada exitosamente") + )) + + Dialogs.confirmation_dialog( + self.page, + "Eliminar base de datos", + ft.Text("¿Estás seguro de que quieres eliminar la base de datos?"), + on_confirm=delete + ) + + def _database_actions_card(self, icon: ft.Icon = None, title: str = "", callback=None, action_icon=None): + action = ft.Row( + controls=[ + ft.IconButton( + icon=action_icon, + icon_color=self.theme.text_primary, + bgcolor=self.theme.bg, + on_click=callback + ) + ], + alignment=ft.MainAxisAlignment.END, + expand=True + ) + return ft.Container( + content=ft.Row([ + ft.Container(content=icon), + ft.Text(value=title), + action, + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + bgcolor=self.theme.fg, + border_radius=10, + padding=ft.padding.symmetric(horizontal=20, vertical=10) + ) + + def draw(self, header): + header = header.create("Bases de datos", return_page=True) + + actions_column = ft.Column( + + [ + self._database_actions_card( + icon=ft.Icon(ft.Icons.COMPARE_ARROWS, + color=self.theme.text_primary), + title="Restaurar transacciones", + callback=self.delete_transactions, + action_icon=ft.Icons.DELETE + ), + + self._database_actions_card( + icon=ft.Icon(ft.Icons.CATEGORY, + color=self.theme.text_primary), + title="Restaurar categorías", + callback=self.delete_categories, + action_icon=ft.Icons.DELETE + ), + + ft.Text("DANGER ZONE", size=11, + weight=ft.FontWeight.BOLD, color=self.theme.red_color), + ft.Divider( + color=self.theme.text_primary, + ), + + self._database_actions_card( + icon=ft.Icon(ft.Icons.STORAGE, + color=self.theme.text_primary), + title="Restaurar base de datos", + callback=self.delete_database, + action_icon=ft.Icons.DELETE + ), + + + ] + ) + + return ft.Container( + ft.Column([ + header, + actions_column + ]), + margin=ft.margin.symmetric(horizontal=20, vertical=10), + expand=True, + ) diff --git a/src/views/error_view.py b/src/views/error_view.py index 879a1ad..5260e71 100644 --- a/src/views/error_view.py +++ b/src/views/error_view.py @@ -1,6 +1,7 @@ -from themes.themes import Theme import flet as ft +from themes.themes import Theme + class ErrorPage(): def __init__(self, theme: Theme, page: ft.Page): diff --git a/src/views/intertransfer_view.py b/src/views/intertransfer_view.py index b7fe7c2..d60e7c9 100644 --- a/src/views/intertransfer_view.py +++ b/src/views/intertransfer_view.py @@ -1,11 +1,11 @@ -import flet as ft -from themes.themes import Theme +import flet as ft from flet import Icons as icons +from controllers.actives.controller import ActiveController from components.headers import HeaderSection from components.dialogs import Dialogs +from themes.themes import Theme -from controllers.controller import ActiveController class IntertransferView: def __init__(self, theme: Theme, page: ft.Page): @@ -17,7 +17,8 @@ def __init__(self, theme: Theme, page: ft.Page): def intertransfer(self, e): try: - ActiveController.controller_intertransfer(self.temp_account_from, self.temp_account_to, self.quantity_field.value) + ActiveController.controller_intertransfer( + self.temp_account_from, self.temp_account_to, self.quantity_field.value) self.page.go("/active") except Exception as e: Dialogs.error_dialog(self.page, str(e)) @@ -26,7 +27,8 @@ def validate_quantity(self, e): if e.control.value == "": return - self.total_account = ActiveController.controller_fetch_active(self.temp_account_from) + self.total_account = ActiveController.controller_fetch_active( + self.temp_account_from) if self.total_account[3] < float(e.control.value): e.control.error_text = f"La cantidad no puede ser mayor a la de la cuenta {self.total_account[2]}" e.control.update() @@ -71,14 +73,19 @@ def add_intertransactions(self): content=ft.ResponsiveRow([ ft.Row([ - ft.Text(f"$ {intertransaction[3]:.2f}", color=self.theme.green_color, size=12), - ft.Text(f"{intertransaction[4]}", color=self.theme.text_primary, size=12), + ft.Text( + f"$ {intertransaction[3]:.2f}", color=self.theme.green_color, size=12), + ft.Text( + f"{intertransaction[4]}", color=self.theme.text_primary, size=12), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), ft.Row([ - ft.Text(f"{intertransaction[1]}", color=self.theme.text_primary, size=12), - ft.Icon(icons.SYNC_ALT, color=self.theme.green_color, size=20), - ft.Text(f"{intertransaction[2]}", color=self.theme.text_primary, size=12), + ft.Text( + f"{intertransaction[1]}", color=self.theme.text_primary, size=12), + ft.Icon(icons.SYNC_ALT, + color=self.theme.green_color, size=20), + ft.Text( + f"{intertransaction[2]}", color=self.theme.text_primary, size=12), ], alignment=ft.MainAxisAlignment.CENTER, spacing=5), ]), alignment=ft.alignment.center, @@ -89,22 +96,23 @@ def add_intertransactions(self): ) self.page.update() - def draw(self, header: HeaderSection): - self.accounts = ActiveController.controller_fetch_actives() - self.header = header.create("Intertransferencia de Activos", return_page=True) + self.header = header.create( + "Intertransferencia de Activos", return_page=True) + + self.hint_text = ft.Text( + "Selecciona la cuenta origen y la cuenta destino", color=self.theme.text_primary, size=12) - self.hint_text = ft.Text("Selecciona la cuenta origen y la cuenta destino", color=self.theme.text_primary, size=12) - self.account_from = ft.Dropdown( hint_text="Cuenta origen", hint_style=ft.TextStyle(color=self.theme.text_primary), text_style=ft.TextStyle(color=self.theme.green_color), border=ft.InputBorder.OUTLINE, bgcolor=self.theme.fg, + fill_color=self.theme.fg, filled=True, on_change=self.validate_accounts, border_radius=20, @@ -112,6 +120,8 @@ def draw(self, header: HeaderSection): ) for account in self.accounts: + if account[1] == "Prestamo": + continue # TODO: Remove this condition when loans can also be source of intertransfers self.account_from.options.append( ft.dropdown.Option( key=str(account[0]), @@ -125,6 +135,7 @@ def draw(self, header: HeaderSection): text_style=ft.TextStyle(color=self.theme.green_color), border=ft.InputBorder.OUTLINE, bgcolor=self.theme.fg, + fill_color=self.theme.fg, filled=True, on_change=self.validate_accounts, border_radius=20, @@ -132,6 +143,8 @@ def draw(self, header: HeaderSection): ) for account in self.accounts: + if account[1] == "Prestamo": + continue # TODO: Remove this condition when loans can also be destination of intertransfers self.account_to.options.append( ft.dropdown.Option( key=str(account[0]), @@ -176,7 +189,8 @@ def draw(self, header: HeaderSection): filled=True, border_radius=20, keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter(allow=True, regex_string=r"^\d*\.?\d*$"), + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), on_change=self.validate_quantity, error_text="", ) @@ -187,7 +201,8 @@ def draw(self, header: HeaderSection): ) self.transfer_button = ft.Container( - content=ft.Row([ft.Icon(icons.SEND, color=self.theme.bg), ft.Text("Intertransferir", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD)], alignment=ft.alignment.center), + content=ft.Row([ft.Icon(icons.SEND, color=self.theme.bg), ft.Text( + "Intertransferir", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD)], alignment=ft.alignment.center), bgcolor=self.theme.green_color, padding=15, border_radius=20, @@ -198,7 +213,6 @@ def draw(self, header: HeaderSection): self.add_intertransactions() - return ft.Container( ft.Column([ self.header, @@ -212,4 +226,4 @@ def draw(self, header: HeaderSection): margin=ft.margin.symmetric(horizontal=20), bgcolor=self.theme.bg, expand=True, - ) \ No newline at end of file + ) diff --git a/src/views/loan_view.py b/src/views/loan_view.py index f666c61..59d3800 100644 --- a/src/views/loan_view.py +++ b/src/views/loan_view.py @@ -1,13 +1,13 @@ -from controllers.controller import ( - LoanController, - ActiveController -) +import flet as ft +from flet import Icons as icons + +from controllers.loans.controller import LoanController +from controllers.actives.controller import ActiveController from components.dialogs import Dialogs -from flet import Icons as icons -import flet as ft from themes.themes import Theme + class LoanView: def __init__(self, theme: Theme, page: ft.Page): self.page = page @@ -18,7 +18,7 @@ def __init__(self, theme: Theme, page: ft.Page): def fetch_loan(self): self.current_loan = LoanController.fetch(self.data["loan_id"]) - + def fetch_accounts(self): self.accounts = ActiveController.controller_fetch_actives() @@ -30,26 +30,29 @@ def liquidate(self, e): if not self.account_dropdown.value: Dialogs.error_dialog(self.page, "Selecciona una cuenta") return - + account_id = self.account_dropdown.value try: - LoanController.liquidate(self.data["loan_id"], self.current_loan[1], account_id) + LoanController.liquidate( + self.data["loan_id"], self.current_loan[1], account_id) self.page.go("/") except Exception as e: Dialogs.error_dialog(self.page, str(e)) - + self.page.update() def draw(self, header): self.fetch_loan() self.fetch_accounts() - self.header = header.create(placeholder="Prestamos", return_page=True, config=True) + self.header = header.create( + placeholder="Prestamos", return_page=True, config=True) self.card = ft.Container( ft.Column([ - ft.Text(self.current_loan[2].capitalize(), size=16, weight=ft.FontWeight.W_500), + ft.Text(self.current_loan[2].capitalize( + ), size=16, weight=ft.FontWeight.W_500), ft.Divider(), @@ -70,7 +73,7 @@ def draw(self, header): alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10 ), - + ft.Row( [ @@ -84,13 +87,15 @@ def draw(self, header): ft.Row( [ - ft.Icon(icons.CHECK_CIRCLE if self.current_loan[7] else icons.CANCEL, color=self.theme.green_color if self.current_loan[7] else self.theme.red_color, size=20), - ft.Text("Cobrado" if self.current_loan[7] else "Aun sin cobrar", size=14) + ft.Icon(icons.CHECK_CIRCLE if self.current_loan[7] else icons.CANCEL, + color=self.theme.green_color if self.current_loan[7] else self.theme.red_color, size=20), + ft.Text( + "Cobrado" if self.current_loan[7] else "Aun sin cobrar", size=14) ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10 ), - + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10), padding=ft.padding.symmetric(horizontal=10, vertical=15), border_radius=20, @@ -99,7 +104,8 @@ def draw(self, header): self.account_dropdown = ft.Dropdown( hint_text="Cuenta origen", - options=[ft.dropdown.Option(text=f"{account[2]} $ {account[3]}", key=account[0]) for account in self.accounts if account[4]], + options=[ft.dropdown.Option( + text=f"{account[2]} $ {account[3]}", key=account[0]) for account in self.accounts if account[4]], text_style=ft.TextStyle(color=self.theme.green_color), bgcolor=self.theme.bg, filled=True, @@ -108,12 +114,14 @@ def draw(self, header): visible=not self.current_loan[7], ) - self.hint_text = ft.Text("Al cobrar el prestamo se sumara la cantidad a la cuenta seleccionada", color=self.theme.text_secondary, size=12) + self.hint_text = ft.Text( + "Al cobrar el prestamo se sumara la cantidad a la cuenta seleccionada", color=self.theme.text_secondary, size=12) self.space = ft.Container(ft.Column(), expand=True) self.delete_button = ft.Container( - content=ft.Text("Eliminar", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), + content=ft.Text("Eliminar", color=self.theme.bg, + size=16, weight=ft.FontWeight.BOLD), bgcolor=self.theme.red_color, padding=15, border_radius=20, @@ -123,7 +131,8 @@ def draw(self, header): ) self.liquidate_button = ft.Container( - content=ft.Text("Liquidar prestamo", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), + content=ft.Text("Liquidar prestamo", color=self.theme.bg, + size=16, weight=ft.FontWeight.BOLD), bgcolor=self.theme.green_color, padding=15, border_radius=20, @@ -144,4 +153,4 @@ def draw(self, header): ]), margin=ft.margin.symmetric(horizontal=20, vertical=10), expand=True - ) \ No newline at end of file + ) diff --git a/src/views/main_view.py b/src/views/main_view.py index eb83e54..fdd01e0 100644 --- a/src/views/main_view.py +++ b/src/views/main_view.py @@ -1,338 +1,368 @@ -from themes.themes import Theme -from controllers.controller import ( - TransactionController, - LiquidController, - BalanceController, - SearchController -) -from setup import log_dir, FLET_APP_STORAGE_DATA +from datetime import date, timedelta import flet as ft from flet import Icons as icons -import os + +from client.client import ClientStorage +from controllers.balance.controller import BalanceController +from controllers.categories.controller import CategoriesController +from controllers.controller import SearchController +from controllers.date_groups import create_week_separator, get_group_date +from controllers.liquid.controller import LiquidController +from controllers.transactions.controller import TransactionController +from controllers.transactions.statistics import fetch_summary +from dto.transactions import TransactionFilterDTO, TransactionOutputDTO +from themes.themes import Theme class MainSection: def __init__(self, theme: Theme, page): self.theme = theme self.page = page - self.stock_list = None - self.portfolio_section = None - self.header = None - - def change_mode(self, e): - old_value = self.page.client_storage.get("christianymoon.finance.portfolio_mode") - self.page.client_storage.remove("christianymoon.finance.portfolio_mode") - self.page.client_storage.set("christianymoon.finance.portfolio_mode", not old_value) + self.categories_controller = CategoriesController() + self.storage = ClientStorage(self.page) + self.portfolio_mode_state = self.storage.get_value("portfolio_mode") + self.in_out_mode_state = self.storage.get_value("in_out_mode_setting") + self.previous_date = None + + def change_portfolio_mode(self, e): + portfolio_model_old_state = self.storage.get_value("portfolio_mode") + self.storage.set_value("portfolio_mode", not portfolio_model_old_state) + self.portfolio_mode_state = self.storage.get_value("portfolio_mode") + self.set_states() + + def calculate_period_time(self, date: str, timelapse: str = None): + week_start = get_group_date(date, timelapse) + if self.previous_date != week_start: + self.previous_date = week_start + self.transaction_list.controls.append( + create_week_separator(week_start, self.theme)) + + def _charge_transaction_list(self): + filter = TransactionFilterDTO(limit=30) + transactions = TransactionController.fetch_transactions(filter) + for transaction in transactions: + self.calculate_period_time( + date=transaction.created_at, timelapse="week") + self.transaction_list.controls.append( + self._create_transaction_item(transaction)) + self.page.update() - if self.page.client_storage.get("christianymoon.finance.in_out_mode_setting"): + def set_states(self): + if self.in_out_mode_state: + if self.portfolio_mode_state: + filter = TransactionFilterDTO(is_income=True) + amount = fetch_summary(filter) + self.text_name.value = "Ingresos" + self.text_amount.value = amount + self.secondary_text_amount.visible = False - if self.page.client_storage.get("christianymoon.finance.portfolio_mode"): - amount = TransactionController.controller_fetch_sum(True) - self.portfolio_section.content.controls[0].controls[0].value = "Ingresos" - self.portfolio_section.content.controls[1].controls[0].value = amount - self.portfolio_section.content.controls[1].controls[0].color = self.theme.green_color - else: - amount = TransactionController.controller_fetch_sum(False) - self.portfolio_section.content.controls[0].controls[0].value = "Gastos" - self.portfolio_section.content.controls[1].controls[0].value = amount - self.portfolio_section.content.controls[1].controls[0].color = self.theme.text_primary - + filter = TransactionFilterDTO(is_income=False) + amount = fetch_summary(filter) + self.text_name.value = "Gastos" + self.text_amount.value = amount + + self.secondary_text_amount.visible = False else: + if self.portfolio_mode_state: + liquid_amount = LiquidController.get(formated=True) + self.text_name.value = "Liquidez" + self.text_amount.value = liquid_amount + + week_start = get_group_date( + date=date.today().strftime("%d/%m/%Y %H:%M"), timelapse="week") + filter = TransactionFilterDTO( + date_from=week_start, + date_to=week_start + timedelta(days=6), + is_income=True + ) + + self.secondary_text_amount.value = f"Ingreso Semanal: {fetch_summary(filter)}" - if self.page.client_storage.get("christianymoon.finance.portfolio_mode"): - liquid_amount = LiquidController.get( - formated=True) - self.portfolio_section.content.controls[0].controls[0].value = "Liquidez" - self.portfolio_section.content.controls[1].controls[0].value = liquid_amount - self.portfolio_section.content.controls[1].controls[0].color = self.theme.green_color - else: balance_amount = BalanceController.controller_fetch_balance( formated=True) - self.portfolio_section.content.controls[0].controls[0].value = "Balance" - self.portfolio_section.content.controls[1].controls[0].value = balance_amount - self.portfolio_section.content.controls[1].controls[0].color = self.theme.text_primary + self.text_name.value = "Capital" + self.text_amount.value = balance_amount + + week_start = get_group_date( + date=date.today().strftime("%d/%m/%Y %H:%M"), timelapse="week") + filter = TransactionFilterDTO( + date_from=week_start, + date_to=week_start + timedelta(days=6), + is_income=False + ) + self.secondary_text_amount.value = f"Gasto Semanal: {fetch_summary(filter)}" - self.page.client_storage.set("christianymoon.finance.portfolio_mode", not old_value) self.page.update() def on_search(self, e): - if e.control.value == "log": - try: - log_file = open(log_dir, "r") - logs = log_file.read() - log_file.close() - logs_ = ft.Text(logs, color=self.theme.text_primary, - size=12, expand=True) - self.stock_list.controls.clear() - self.stock_list.controls.append(logs_) - self.page.update() - except Exception as e: - error = ft.Text(f"Error al cargar logs: {e}, estado de FLET_APP_STORAGE_DATA: {FLET_APP_STORAGE_DATA}", color=self.theme.red_color) - self.stock_list.controls.clear() - self.stock_list.controls.append(error) - self.page.update() + if e.control.value == "": + self.transaction_list.controls.clear() + self._charge_transaction_list() + self.page.update() return - filter_list = SearchController.controller_search_transactions( e.control.value) - - if e.control.value == "": - self.stock_list.controls.clear() - self.add_stock_list( - TransactionController.controller_fetch_transactions() - ) - self.page.update() - return - + if len(filter_list) > 0: - self.stock_list.controls.clear() - self.add_stock_list(filter_list) + for transaction in filter_list: + self.transaction_list.controls.append( + self._create_transaction_item(transaction)) else: - self.stock_list.controls.clear() - + self.transaction_list.controls.clear() self.page.update() - def add_stock_list(self, transactions): - for item in reversed(transactions): - if item[4]: - trend_icon = icons.TRENDING_UP - trend_color = self.theme.green_color - else: - trend_icon = icons.TRENDING_DOWN - trend_color = self.theme.red_color - self.stock_list.controls.append( + def _create_transaction_item(self, transaction: TransactionOutputDTO): + category_name = self.categories_controller.get_category_name( + transaction.category) + if transaction.is_income: + trend_icon = icons.TRENDING_UP + trend_color = self.theme.green_color + else: + trend_icon = icons.TRENDING_DOWN + trend_color = self.theme.red_color + + return ft.Container( + content=ft.Row([ ft.Container( - content=ft.Row([ - ft.Container( - content=ft.Icon(icons.SHOPPING_BAG, - color=self.theme.text_primary, size=24), - width=40, - height=40, - bgcolor=self.theme.fg, - border_radius=20, - alignment=ft.alignment.center - ), - ft.Column([ - ft.Text(item[1], color=self.theme.text_primary, - size=14, weight=ft.FontWeight.W_500), - ft.Text( - item[2], color=self.theme.text_secondary, size=12), - ft.Text( - item[6], color=self.theme.text_secondary, size=12), - ], spacing=2, expand=True), - ft.Container( - content=ft.Icon( - trend_icon, color=trend_color, size=20), - width=30, - height=20 - ), - ft.Column([ - ft.Text( - f"${item[3]:.2f}", color=self.theme.text_primary, size=14, weight=ft.FontWeight.W_500), - ft.Text(f"%{item[5]:.2f}", - color=trend_color, size=12) - ], horizontal_alignment=ft.CrossAxisAlignment.END, spacing=2) - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - padding=ft.padding.symmetric(horizontal=20, vertical=15), + content=ft.Icon( + icons.SHOPPING_BAG, + color=self.theme.text_secondary, + size=24 + ), + width=40, + height=40, bgcolor=self.theme.fg, - border_radius=30, - margin=ft.margin.symmetric(horizontal=20, vertical=5), - ) - ) - - def update(self): - portfolio_mode = self.page.client_storage.get("christianymoon.finance.portfolio_mode") - in_out_mode = self.page.client_storage.get("christianymoon.finance.in_out_mode_setting") - - # Obtener color inicial - init_color = self.theme.green_color if portfolio_mode else self.theme.text_primary - - # --- MODO INGRESOS/GASTOS --- - if in_out_mode: - if portfolio_mode: - init_text = "Ingresos" - init_value = TransactionController.controller_fetch_sum(True) - else: - init_text = "Gastos" - init_value = TransactionController.controller_fetch_sum(False) - # --- MODO BALANCE/LIQUIDEZ --- - else: - if portfolio_mode: - init_text = "Liquidez" - init_value = LiquidController.get(formated=True) - else: - init_text = "Balance" - init_value = BalanceController.controller_fetch_balance(formated=True) - - # Actualizar solo los textos internos sin reconstruir todo - self.portfolio_section.content.controls[0].controls[0].value = init_text - self.portfolio_section.content.controls[1].controls[0].value = str(init_value) - self.portfolio_section.content.controls[1].controls[0].color = init_color - self.page.update() + border_radius=20, + alignment=ft.alignment.center + ), + ft.Column([ + ft.Text( + transaction.name, + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ), + ft.Text( + category_name, + color=self.theme.text_secondary, + size=12 + ), + ft.Text( + transaction.created_at, + color=self.theme.text_secondary, + size=12 + ), + ], spacing=2, expand=True), + ft.Container( + content=ft.Icon( + trend_icon, + color=trend_color, + size=20 + ), + width=30, + height=20 + ), + ft.Column([ + ft.Text( + f"${transaction.price:.2f}", + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500 + ), + ft.Text( + f"%{transaction.expense_percentage:.2f}", + color=trend_color, + size=12 + ) + ], horizontal_alignment=ft.CrossAxisAlignment.END, spacing=2) + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + padding=ft.padding.symmetric(horizontal=20, vertical=15), + bgcolor=self.theme.fg, + border_radius=30, + margin=ft.margin.symmetric(horizontal=0, vertical=5), + ) + + def _floating_button(self): + return ft.IconButton( + icon=icons.ADD, + icon_color=self.theme.fg, + bgcolor=self.theme.blue_color, + on_click=lambda e: self.page.go("/transaction"), + tooltip="Agregar Transacción", + width=50, + height=50, + ) def draw(self, header): self.header = header - search_bar = ft.Container( + self.transaction_list = ft.Column( + [], spacing=0, scroll=ft.ScrollMode.ADAPTIVE, expand=True, + ) + + self.text_name = ft.Text( + value="", color=self.theme.text_secondary, size=14) + + self.text_amount = ft.Text( + value="", color=self.theme.text_primary, size=32, weight=ft.FontWeight.BOLD) + + def on_search_focus(e): + self.search_bar.border = ft.border.only( + bottom=ft.BorderSide(2, self.theme.blue_color)) + self.search_bar.padding = ft.padding.symmetric( + horizontal=16, vertical=2) + self.search_bar.update() + + def on_search_blur(e): + self.search_bar.border = ft.border.only( + bottom=ft.BorderSide(1, "#333333")) + self.search_bar.padding = ft.padding.symmetric( + horizontal=12, vertical=0) + self.search_bar.update() + + self.search_bar = ft.Container( content=ft.Row( [ - ft.Icon(icons.SEARCH, - color=self.theme.text_secondary, size=20), ft.TextField( + prefix_icon=icons.SEARCH, hint_text="Buscar transaccion", hint_style=ft.TextStyle( color=self.theme.text_secondary), text_style=ft.TextStyle(color=self.theme.text_primary), border=ft.InputBorder.NONE, + text_align=ft.TextAlign.LEFT, expand=True, cursor_color=self.theme.text_primary, on_change=self.on_search, + on_focus=on_search_focus, + on_blur=on_search_blur, ), - - ], alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.padding.symmetric(horizontal=12, vertical=0), - margin=ft.margin.symmetric(horizontal=20, vertical=10), - bgcolor=self.theme.bg, - border_radius=30, - border=ft.border.all(1, "#333333"), - ) - - # Usar height=400 fija la altura en píxeles, pero no es adaptable a todos los dispositivos. - # Para que el contenido se ajuste dinámicamente al alto disponible, usa expand=True y elimina height. - # Ejemplo adaptable: - - self.stock_list = ft.Column( - [], spacing=0, scroll=ft.ScrollMode.ADAPTIVE, expand=True, + margin=ft.margin.symmetric(horizontal=0, vertical=0), + animate=ft.Animation(250, ft.AnimationCurve.DECELERATE), + border=ft.border.only(bottom=ft.BorderSide(1, self.theme.fg)), ) - self.add_stock_list( - TransactionController.controller_fetch_transactions()) - - init_color = self.theme.green_color if self.page.client_storage.get("christianymoon.finance.portfolio_mode") else self.theme.text_primary - init_text = "" - init_value = 0 - + self.secondary_text_amount = ft.Text( + value="$ 0.00", color=self.theme.text_secondary, size=14) - if self.page.client_storage.get("christianymoon.finance.in_out_mode_setting"): - - if self.page.client_storage.get("christianymoon.finance.portfolio_mode"): - init_text = "Ingresos" - init_value = TransactionController.controller_fetch_sum(True) - - else: - init_text = "Gastos" - init_value = TransactionController.controller_fetch_sum(False) - - - self.portfolio_section = ft.Container( + self.portfolio_section = ft.Container( content=ft.Column([ ft.Row([ - ft.Text(init_text, color=self.theme.text_primary, size=14), + self.text_name, ft.CupertinoSwitch( - value=self.page.client_storage.get("christianymoon.finance.portfolio_mode"), + active_track_color=self.theme.blue_color, + value=self.portfolio_mode_state, scale=0.6, key="switch", - on_change=self.change_mode + on_change=self.change_portfolio_mode ), ]), ft.Row([ - ft.Text(f"{init_value}", - color=init_color, size=32, weight=ft.FontWeight.BOLD), - # ft.IconButton(icons.EDIT, icon_color=self.theme.text_primary, icon_size=20, - # on_click=lambda e: self.page.go("/balance")), + self.text_amount, + ft.IconButton( + icon=icons.BAR_CHART, icon_color=self.theme.blue_color, icon_size=24, on_click=lambda e: self.page.go("/balance") + ) ], spacing=10), + ft.Row([ + self.secondary_text_amount + ], spacing=10) ], spacing=5), padding=ft.padding.symmetric(horizontal=20, vertical=20), + margin=ft.margin.symmetric(horizontal=0, vertical=8), bgcolor=self.theme.fg, border_radius=30, - margin=ft.margin.symmetric(horizontal=20, vertical=10), - ) - else: - - if self.page.client_storage.get("christianymoon.finance.portfolio_mode"): - init_text = "Liquidez" - init_value = LiquidController.get(formated=True) - else: - init_text = "Balance" - init_value = BalanceController.controller_fetch_balance(formated=True) - self.portfolio_section = ft.Container( - content=ft.Column([ - ft.Row([ - ft.Text(init_text, color=self.theme.text_secondary, size=14), - ft.CupertinoSwitch( - value=self.page.client_storage.get("christianymoon.finance.portfolio_mode"), - scale=0.6, - key="switch", - on_change=self.change_mode - ), - ]), - ft.Row([ - ft.Text(f"{init_value}", - color=init_color, size=32, weight=ft.FontWeight.BOLD), - ft.IconButton(icons.EDIT, icon_color=self.theme.text_primary, icon_size=20, - on_click=lambda e: self.page.go("/balance")), - ], spacing=10), - ], spacing=5), - padding=ft.padding.symmetric(horizontal=20, vertical=20), - bgcolor=self.theme.fg, - border_radius=30, - margin=ft.margin.symmetric(horizontal=20, vertical=10), - ) + ) category_buttons = ft.Container( content=ft.Row([ ft.Column([ - ft.IconButton(icon=icons.TRENDING_UP, icon_color=self.theme.text_primary, + ft.IconButton(icon=icons.TRENDING_UP, icon_color=self.theme.text_secondary, icon_size=24, on_click=lambda e: self.page.go("/active")), - ft.Text("Activos", color=self.theme.text_primary, size=12) - ], alignment=ft.MainAxisAlignment.CENTER), + ft.Text("Activos", color=self.theme.text_secondary, size=12) + ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER), ft.Column([ - ft.IconButton(icon=icons.TRENDING_DOWN, icon_color=self.theme.text_primary, + ft.IconButton(icon=icons.TRENDING_DOWN, icon_color=self.theme.text_secondary, icon_size=24, on_click=lambda e: self.page.go("/passive")), - ft.Text("Pasivos", color=self.theme.text_primary, size=12) - ], alignment=ft.MainAxisAlignment.CENTER), + ft.Text("Pasivos", color=self.theme.text_secondary, size=12) + ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER), ft.Column([ - ft.IconButton(icon=icons.MONETIZATION_ON, icon_color=self.theme.text_primary, + ft.IconButton(icon=icons.ACCOUNT_BALANCE, icon_color=self.theme.text_secondary, icon_size=24, on_click=lambda e: self.page.go("/borrows")), - ft.Text("Prestamos", color=self.theme.text_primary, size=12) - ], alignment=ft.MainAxisAlignment.CENTER), - ], alignment=ft.MainAxisAlignment.SPACE_EVENLY), + ft.Text("Prestamos", color=self.theme.text_secondary, size=12) + ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER), + ft.Column([ + ft.IconButton(icon=icons.AUTO_AWESOME, icon_color=self.theme.purple_color, + icon_size=24, on_click=lambda e: self.page.go("/ai_chat")), + ft.Text("Kara AI", color=self.theme.text_secondary, + size=12, text_align=ft.TextAlign.CENTER) + ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), padding=ft.padding.symmetric(horizontal=20, vertical=20), + margin=ft.margin.symmetric(horizontal=0, vertical=8), bgcolor=self.theme.fg, border_radius=30, - margin=ft.margin.symmetric(horizontal=20, vertical=10), ) top_picks_header = ft.Container( content=ft.Row([ ft.Text("Operaciones", color=self.theme.text_primary, size=16, weight=ft.FontWeight.W_500), - ft.Row([ft.IconButton(icon=icons.ADD, icon_color=self.theme.text_primary, - icon_size=24, on_click=lambda e: self.page.go("/transaction"))]), + ft.Row([ + ft.Container( + content=ft.Text( + "Ver todo", + color=self.theme.blue_color, + size=14 + ), + on_click=lambda e: self.page.go("/all_transactions") + ) + ]), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - padding=ft.padding.symmetric(horizontal=20, vertical=0), + padding=ft.padding.symmetric(horizontal=10, vertical=0), + margin=ft.margin.symmetric(horizontal=0, vertical=8), bgcolor=self.theme.bg, border_radius=30, - margin=ft.margin.symmetric(horizontal=20, vertical=10), ) - return ft.Column([ - header.create(return_page=False, config=True), - search_bar, - self.portfolio_section, - category_buttons, - top_picks_header, - self.stock_list - ], expand=True, spacing=0) \ No newline at end of file + floating_button = self._floating_button() + + self.set_states() + + self.page.run_thread( + self._charge_transaction_list + ) + + return ft.Container( + content=ft.Stack([ + ft.Column([ + header.create(return_page=False, config=True), + self.search_bar, + self.portfolio_section, + category_buttons, + top_picks_header, + self.transaction_list, + ], expand=True), + ft.Container( + content=floating_button, + right=0, + bottom=10, + width=50, + height=50, + ) + ], expand=True), + expand=True, + margin=ft.margin.symmetric(horizontal=20, vertical=0), + ) diff --git a/src/views/passives_view.py b/src/views/passives_view.py index c97bc0d..c6c52a4 100644 --- a/src/views/passives_view.py +++ b/src/views/passives_view.py @@ -1,14 +1,12 @@ -from controllers.controller import ( - PassiveController, - ActiveController, -) +import flet as ft +from flet import Icons as icons -from components.headers import HeaderSection +from controllers.passives.controller import PassiveController +from controllers.actives.controller import ActiveController +from controllers.categories.controller import CategoriesController from components.dialogs import Dialogs - +from controllers.date_groups import get_group_date, create_month_separator from themes.themes import Theme -from flet import Icons as icons -import flet as ft class PassiveSection: @@ -19,6 +17,15 @@ def __init__(self, theme: Theme, page: ft.Page): self.new_passive = None self.passive_list = None self.accounts_actives = ActiveController.controller_fetch_actives() + self.category_controller = CategoriesController() + self.previous_date = None + + def calculate_month(self, date: str, timelapse: str = None): + year, month = get_group_date(date, timelapse) + if month != self.previous_date: + self.previous_date = month + self.passive_list.controls.append( + create_month_separator(month, year, self.theme)) def handle_item_view(self, e): try: @@ -38,7 +45,6 @@ def add_passive_database(self, e): if price == "" or name == "" or category == "": return Dialogs.lost_data_dialog(self.page) - PassiveController.controller_set_passive( name, category, price) self.refresh_passives() @@ -55,7 +61,14 @@ def refresh_passives(self, *args): def add_passive_list(self, passives_list): self.passive_list.controls.clear() - for item in reversed(passives_list): + for item in passives_list: + self.calculate_month( + date=item[5], timelapse="month") + if isinstance(item[2], int) or item[2].isdigit(): + category = self.category_controller.fetch_category(int(item[2]))[ + 1] + else: + category = item[2] if item[4]: trend_icon = icons.CHECK_CIRCLE trend_color = self.theme.green_color @@ -78,7 +91,7 @@ def add_passive_list(self, passives_list): ft.Text(item[1], color=self.theme.text_primary, size=14, weight=ft.FontWeight.W_500), ft.Text( - item[2], color=self.theme.text_secondary, size=12) + category, color=self.theme.text_secondary, size=12) ], spacing=2, expand=True), ft.Container( content=ft.Icon( @@ -122,55 +135,60 @@ def draw(self, header): bgcolor=self.theme.fg, filled=True, border_radius=20, - ) + ) self.category_field = ft.TextField( - hint_text="Categoria", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.FILTER, - bgcolor=self.theme.fg, - filled=True, - border_radius=20, - ) + hint_text="Categoria", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.FILTER, + bgcolor=self.theme.fg, + filled=True, + border_radius=20, + ) self.mount_field = ft.TextField( - hint_text="Monto", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.ATTACH_MONEY, - bgcolor=self.theme.fg, - filled=True, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter( - allow=True, regex_string=r"^\d*\.?\d*$"), - border_radius=20, - ) + hint_text="Monto", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.ATTACH_MONEY, + bgcolor=self.theme.fg, + filled=True, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), + border_radius=20, + ) self.panel = ft.Row([ - ft.IconButton( - icon=icons.HANDSHAKE, - icon_color=self.theme.text_primary, - on_click=self.add_passive_database, - icon_size=24 - ), - ft.Text("Agregar Pasivo", color=self.theme.text_primary, - size=14, weight=ft.FontWeight.W_500) - ]) + + ft.Button( + text="Añadir", + icon=icons.ADD, + icon_color=self.theme.text_primary, + color=self.theme.text_primary, + bgcolor=self.theme.fg, + on_click=self.add_passive_database, + ) + ]) self.total_sum = ft.Container( ft.Row([ ft.Text( - f"Adeudo total ${self.get_total_sum():.2f} MXN", + f"${self.get_total_sum():,.2f} MXN", color=self.theme.red_color, + size=20, + style=ft.TextStyle( + weight=ft.FontWeight.BOLD + ) ) ]) ) self.passive_list = ft.Column([], spacing=0, scroll=ft.ScrollMode.AUTO, - expand=True) + expand=True) self.add_passive_list(PassiveController.controller_fetch_passives()) @@ -191,7 +209,6 @@ def draw(self, header): return self.passive_section - class PassiveItemView(): def __init__(self, theme: Theme, page: ft.Page): self.page = page @@ -202,7 +219,8 @@ def __init__(self, theme: Theme, page: ft.Page): def pay_passive(self, e): if self.account_dropdown.value: try: - PassiveController.pay_passive(self.current_passive[0], self.account_dropdown.value) + PassiveController.pay_passive( + self.current_passive[0], self.account_dropdown.value) self.page.go("/passive") except Exception as e: print(e) @@ -222,7 +240,8 @@ def draw(self, header): self.card = ft.Container( ft.Column([ - ft.Text(self.current_passive[1].capitalize(), size=16, weight=ft.FontWeight.W_500), + ft.Text(self.current_passive[1].capitalize( + ), size=16, weight=ft.FontWeight.W_500), ft.Divider(), @@ -243,7 +262,7 @@ def draw(self, header): alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10 ), - + ft.Row( [ @@ -257,13 +276,15 @@ def draw(self, header): ft.Row( [ - ft.Icon(icons.CHECK_CIRCLE if self.current_passive[4] else icons.CANCEL, color=self.theme.green_color if self.current_passive[4] else self.theme.red_color, size=20), - ft.Text("Pagado" if self.current_passive[4] else "Aun sin pagar", size=14) + ft.Icon(icons.CHECK_CIRCLE if self.current_passive[4] else icons.CANCEL, + color=self.theme.green_color if self.current_passive[4] else self.theme.red_color, size=20), + ft.Text( + "Pagado" if self.current_passive[4] else "Aun sin pagar", size=14) ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10 ), - + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=10), padding=ft.padding.symmetric(horizontal=10, vertical=15), border_radius=20, @@ -272,7 +293,8 @@ def draw(self, header): self.account_dropdown = ft.Dropdown( hint_text="Cuenta origen", - options=[ft.dropdown.Option(text=f"{account[2]} $ {account[3]}", key=account[0]) for account in self.accounts if account[4]], + options=[ft.dropdown.Option( + text=f"{account[2]} $ {account[3]}", key=account[0]) for account in self.accounts if account[4]], text_style=ft.TextStyle(color=self.theme.green_color), bgcolor=self.theme.bg, filled=True, @@ -281,11 +303,12 @@ def draw(self, header): visible=not self.current_passive[4], ) - self.hint_text = ft.Text("Al pagar este pasivo, se descontara el monto de la cuenta seleccionada.", color=self.theme.text_secondary, size=12) - + self.hint_text = ft.Text( + "Al pagar este pasivo, se descontara el monto de la cuenta seleccionada.", color=self.theme.text_secondary, size=12) self.pay_button = ft.Container( - content=ft.Text("Pagar", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), + content=ft.Text("Pagar", color=self.theme.bg, + size=16, weight=ft.FontWeight.BOLD), bgcolor=self.theme.green_color, padding=15, border_radius=20, @@ -295,7 +318,8 @@ def draw(self, header): ) self.delete_button = ft.Container( - content=ft.Text("Eliminar", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), + content=ft.Text("Eliminar", color=self.theme.bg, + size=16, weight=ft.FontWeight.BOLD), bgcolor=self.theme.red_color, padding=15, border_radius=20, diff --git a/src/views/settings_view.py b/src/views/settings_view.py index afa0063..d5549a4 100644 --- a/src/views/settings_view.py +++ b/src/views/settings_view.py @@ -1,76 +1,215 @@ -from controllers.controller import ( - DatabaseController, -) +import logging +import os -from themes.themes import Theme -from flet import Icons as icons import flet as ft -import os -import shutil -import logging +from flet import Icons as icons +from client.client import ClientStorage +from core.config import FLET_APP_STORAGE_DATA from setup import log_dir +from themes.themes import Theme class Settings: def __init__(self, theme: Theme, page: ft.Page): self.theme = theme self.page = page - self.in_out_mode_value = self.page.client_storage.get("christianymoon.finance.in_out_mode_setting") - - def handle_reset_database(self, e): - db_instance = DatabaseController() - db_instance.drop_tables() - db_instance.create_tables() - db_instance.init_seed() - self.page.go("/") + self.client = ClientStorage(self.page) + self.in_out_mode_value = self.client.get_value("in_out_mode_setting") def handle_in_out_mode(self, e): try: - value = self.page.client_storage.get("christianymoon.finance.in_out_mode_setting") - self.page.client_storage.remove("christianymoon.finance.in_out_mode_setting") - self.page.client_storage.set("christianymoon.finance.in_out_mode_setting", not value) - self.in_out_mode_value = not value - except: - logging.error(f"Error during change in out mode, {e}", exc_info=True) + old_value = self.client.get_value("in_out_mode_setting") + self.client.set_value("in_out_mode_setting", not old_value) + self.in_out_mode_value = self.client.get_value( + "in_out_mode_setting") + except Exception as error: + logging.error( + f"Error during change in out mode: {error}", exc_info=True) self.page.update() + def handle_theme(self, e): + new_value = e.control.value + self.client.set_value("dark_mode", new_value) + logging.info(f"Dark Mode changed to status {new_value}") + + dialog = ft.CupertinoAlertDialog( + title=ft.Text("Reinicie la aplicación"), + content=ft.Text("Reinicie la aplicación para aplicar los cambios"), + actions=[ + ft.CupertinoDialogAction( + "Ok", + is_default_action=True, + on_click=lambda e: self.page.close(dialog) + ) + ] + ) + self.page.open(dialog) + + def copy_to_clipboard(self, text, name): + self.page.set_clipboard(text) + self.page.open( + ft.SnackBar( + content=ft.Text(f"{name} copiada al portapapeles"), + action="Ok" + ) + ) + + def _get_opacity_color(self, color_str: str, opacity: float = 0.12) -> str: + alpha_hex = f"{int(opacity * 255):02x}" + if color_str.startswith("#"): + hex_val = color_str.lstrip("#") + if len(hex_val) == 6: + return f"#{alpha_hex}{hex_val}" + elif len(hex_val) == 8: + return f"#{alpha_hex}{hex_val[2:]}" + return color_str + + def _create_setting_card(self, title: str, subtitle: str, icon: str, icon_color: str, bg_opacity_color: str, trailing_control: ft.Control = None, on_click=None): + controls = [ + ft.Container( + content=ft.Icon(icon, color=icon_color, size=20), + bgcolor=bg_opacity_color, + width=38, + height=38, + border_radius=8, + alignment=ft.alignment.center, + ), + ft.VerticalDivider(width=10, color="transparent"), + ft.Column( + [ + ft.Text(title, color=self.theme.text_primary, + weight=ft.FontWeight.W_600, size=14), + ft.Text(subtitle, color=self.theme.text_secondary, + size=11, max_lines=2), + ], + alignment=ft.MainAxisAlignment.CENTER, + spacing=2, + expand=True, + ) + ] + if trailing_control: + controls.append(trailing_control) + elif on_click: + controls.append(ft.Icon(icons.CHEVRON_RIGHT, + color=self.theme.text_secondary, size=20)) + + return ft.Container( + content=ft.Row( + controls, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor=self.theme.fg, + padding=ft.padding.symmetric(horizontal=16, vertical=12), + border_radius=12, + on_click=on_click, + ) + def draw(self, header): + self.header = header.create("Configuración", return_page=True) + + # Switches + self.in_out_mode_switch = ft.CupertinoSwitch( + value=self.in_out_mode_value, + scale=0.6, + key="in_out_mode", + on_change=self.handle_in_out_mode, + ) + + self.change_theme_switch = ft.CupertinoSwitch( + value=self.client.get_value("dark_mode"), + scale=0.6, + key="dark_mode", + on_change=self.handle_theme + ) + + # Paths display + db_path = os.path.join(FLET_APP_STORAGE_DATA or "", "financeapp.db") + db_path_display = db_path + if len(db_path_display) > 35: + db_path_display = "..." + db_path_display[-32:] - self.header = header.create("Configuracion", return_page=True) + log_path_display = log_dir + if len(log_path_display) > 35: + log_path_display = "..." + log_path_display[-32:] - self.in_out_mode_switch = ft.Row( + # Settings list + settings_column = ft.Column( [ - ft.Text("Modo solo transacciones"), - ft.CupertinoSwitch( - value=self.in_out_mode_value, - scale=0.6, - key="in_out_mode", - on_change=self.handle_in_out_mode, + ft.Text("PREFERENCIAS", size=11, + weight=ft.FontWeight.BOLD, color=self.theme.blue_color), + self._create_setting_card( + "Modo Oscuro", + "Cambiar el tema visual de la aplicación.", + icons.BRIGHTNESS_4, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + self.change_theme_switch + ), + self._create_setting_card( + "Modo solo transacciones", + "Oculta secciones de préstamos, activos y pasivos.", + icons.RECEIPT_LONG, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + self.in_out_mode_switch ), - - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN - ) - self.space = ft.Container(ft.Column(), expand=True) + ft.Container(height=10), # Spacer + ft.Text("DATOS Y ORGANIZACIÓN", size=11, + weight=ft.FontWeight.BOLD, color=self.theme.blue_color), + self._create_setting_card( + "Gestionar Categorías", + "Administrar categorías y subcategorías.", + icons.CATEGORY_OUTLINED, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + on_click=lambda e: self.page.go("/categories") + ), + self._create_setting_card( + "Acciones de Base de Datos", + "Mantenimiento y reestablecimiento de datos.", + icons.STORAGE_OUTLINED, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + on_click=lambda e: self.page.go("/databaseactions") + ), - self.reset_button = ft.Container( - content=ft.Text("Reestablecer Base de Datos", color=self.theme.bg, size=16, weight=ft.FontWeight.BOLD), - bgcolor=self.theme.red_color, - padding=15, - border_radius=20, - alignment=ft.alignment.center, - on_click=self.handle_reset_database, + ft.Container(height=10), # Spacer + ft.Text("INFORMACIÓN DEL SISTEMA", size=11, + weight=ft.FontWeight.BOLD, color=self.theme.blue_color), + self._create_setting_card( + "Base de Datos", + db_path_display, + icons.DATA_OBJECT, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + on_click=lambda e: self.copy_to_clipboard( + db_path, "Ruta de la base de datos") + ), + self._create_setting_card( + "Archivo de Registro (Logs)", + log_path_display, + icons.INSERT_DRIVE_FILE_OUTLINED, + self.theme.text_secondary, + self._get_opacity_color(self.theme.text_secondary, 0.12), + on_click=lambda e: self.copy_to_clipboard( + log_dir, "Ruta de logs") + ), + ], + spacing=12, + scroll=ft.ScrollMode.AUTO, + expand=True, ) return ft.Container( ft.Column([ self.header, - self.in_out_mode_switch, - self.space, - self.reset_button - ]), + ft.Container(height=15), + settings_column + ], expand=True), margin=ft.margin.symmetric(horizontal=20, vertical=10), expand=True, - ) \ No newline at end of file + ) diff --git a/src/views/setup_view.py b/src/views/setup_view.py index 1636231..9c75229 100644 --- a/src/views/setup_view.py +++ b/src/views/setup_view.py @@ -1,13 +1,11 @@ -import flet as ft +import logging + +import flet as ft from flet import Icons as icons -from themes.themes import Theme -from controllers.controller import ( - UserDataController, - DatabaseController -) - -import logging +from components.dialogs import Dialogs +from controllers.controller import DatabaseController, UserDataController +from themes.themes import Theme class Setup: @@ -19,10 +17,11 @@ def __init__(self, theme: Theme, page: ft.Page): db_instance = DatabaseController() db_instance.init_seed() logging.info("Setting up mode in out in False") - self.page.client_storage.set("christianymoon.finance.in_out_mode_setting", False) + self.page.client_storage.set( + "christianymoon.finance.in_out_mode_setting", False) logging.info("Setting up portfolio mode in False") - self.page.client_storage.set("christianymoon.finance.portfolio_mode", False) - + self.page.client_storage.set( + "christianymoon.finance.portfolio_mode", False) def save_userdata(self, e): name = self.name_input.value @@ -33,12 +32,12 @@ def save_userdata(self, e): self.page.go("/") def draw(self, header): - + self.text_title = ft.Text("Configuración Inicial", color=self.theme.text_primary, - size=24, weight=ft.FontWeight.BOLD) + size=24, weight=ft.FontWeight.BOLD) self.text_subtitle = ft.Text("Parece que es la primera vez que usas la aplicación. Por favor ingresa tu nombre y género para continuar.", - color=self.theme.text_secondary, size=14) + color=self.theme.text_secondary, size=14) self.name_input = ft.TextField( hint_text="Nombre", @@ -65,7 +64,7 @@ def draw(self, header): ) self.save_button = ft.ElevatedButton("Guardar", bgcolor=self.theme.green_color, - color="#000000", on_click=self.save_userdata) + color="#000000", on_click=self.save_userdata) return ft.Container( ft.Column([ diff --git a/src/views/transaction_view.py b/src/views/transaction_view.py index 943818a..a39bece 100644 --- a/src/views/transaction_view.py +++ b/src/views/transaction_view.py @@ -1,13 +1,12 @@ -from controllers.controller import ( - TransactionController, - ActiveController, -) +import flet as ft +from flet import Icons as icons from components.dialogs import Dialogs - +from controllers.actives.controller import ActiveController +from controllers.categories.controller import CategoriesController +from controllers.transactions.controller import TransactionController, TransactionType +from dto.transactions import TransactionDTO from themes.themes import Theme -from flet import Icons as icons -import flet as ft class TransactionSection: @@ -15,29 +14,44 @@ def __init__(self, theme: Theme, page: ft.Page): self.theme = theme self.page = page self.add_transaction = None - + self.categories = [] + self.categories_controller = CategoriesController() def parse_transaction_data(self): - return { - "name": self.add_transaction.content.controls[0].value, - "category": self.add_transaction.content.controls[1].value, - "price": self.add_transaction.content.controls[2].value, - "type": self.radiogroup_ref.current.value, - "account_id": self.add_transaction.content.controls[4].value, - } + try: + category_id = self.categories_controller.get_category_by_subcategory_id( + int(self.add_transaction.content.controls[1].value))[0] + transaction = TransactionDTO( + name=self.add_transaction.content.controls[0].value, + category=category_id, + subcategory=int( + self.add_transaction.content.controls[1].value), + price=float(self.add_transaction.content.controls[2].value), + type=self.radiogroup_ref.current.value, + account_id=self.add_transaction.content.controls[4].value, + ) + return transaction + except: + raise ValueError("Faltan datos por llenar") - def add_transaction_database(self, e): - transaction_data = self.parse_transaction_data() - if transaction_data["type"] == "credit": - transaction_data["account_id"] = "Credito" - if transaction_data["price"] == "" or transaction_data["name"] == "" or transaction_data["category"] == "" or transaction_data["account_id"] is None: - return Dialogs.lost_data_dialog(self.page) + def add_to_database(self, e): try: - TransactionController.controller_set_transaction(transaction_data) + transaction = self.parse_transaction_data() + TransactionController.create_transaction(transaction) self.page.go("/") + except Exception as e: Dialogs.error_dialog(self.page, str(e)) + def handle_submit_animation(self, e): + import time + e.control.scale = 0.92 + e.control.update() + time.sleep(0.15) + e.control.scale = 1.0 + e.control.update() + self.add_to_database(e) + def radio_group_event(self, e): if e.control.value == "credit": self.add_transaction.content.controls[4].disabled = True @@ -45,6 +59,28 @@ def radio_group_event(self, e): self.add_transaction.content.controls[4].disabled = False self.page.update() + def draw_option_box(self): + options = [] + prev_category_id = 0 + for subcategory in self.categories_controller.fetch_all_subcategories(): + # if category is different from prev_category_id filter subcategory + current_category_id = subcategory[1] + if subcategory[3] != 0 and current_category_id == prev_category_id: + options.append( + ft.dropdown.Option(text=subcategory[2], key=subcategory[0], + style=ft.TextStyle(color=self.theme.text_primary)) + ) + elif subcategory[3] != 0 and current_category_id != prev_category_id: + category_name = subcategory[4] + options.append(ft.dropdown.Option( + text=category_name, key=None, style=ft.TextStyle(color=self.theme.blue_color, size=20), disabled=True)) + options.append( + ft.dropdown.Option(text=subcategory[2], key=subcategory[0], + style=ft.TextStyle(color=self.theme.text_primary)) + ) + prev_category_id = current_category_id + return options + def draw(self, header): self.accounts_actives = ActiveController.controller_fetch_actives() @@ -53,86 +89,101 @@ def draw(self, header): self.header = header.create("Transacciones", return_page=True) self.add_transaction = ft.Container( - bgcolor=self.theme.bg, - padding=10, - content=ft.Column([ - #Name 0 - ft.TextField( - hint_text="Nombre", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.PERSON, - bgcolor=self.theme.fg, - border_radius=20, - filled=True, - ), - #Category 1 - ft.TextField( - hint_text="Categoria", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.FILTER, - bgcolor=self.theme.fg, - border_radius=20, - filled=True, - ), - #Mount 2 - ft.TextField( - hint_text="Monto", - hint_style=ft.TextStyle(color=self.theme.text_secondary), - text_style=ft.TextStyle(color=self.theme.text_primary), - border=ft.InputBorder.OUTLINE, - prefix_icon=icons.ATTACH_MONEY, - bgcolor=self.theme.fg, - border_radius=20, - filled=True, - keyboard_type=ft.KeyboardType.NUMBER, - input_filter=ft.InputFilter( - allow=True, regex_string=r"^\d*\.?\d*$"), - ), - #Type 3 - ft.RadioGroup( - ref=self.radiogroup_ref, - value="spent", - content=ft.Row([ - ft.Radio(value="spent", label="Gasto"), - ft.Radio(value="income", label="Ingreso"), - ft.Radio(value="credit", label="Credito") - ]), - on_change=self.radio_group_event, - ), - #Account 4 - ft.Dropdown( - hint_text="Cuenta origen", - options=[ft.dropdown.Option(text=f"{account[2]} ${account[3]}", key=account[0]) for account in self.accounts_actives if account[4]], - text_style=ft.TextStyle(color=self.theme.green_color), - bgcolor=self.theme.fg, - border_radius=20, - filled=True, - border=ft.InputBorder.OUTLINE, - ), - #Button 5 - ft.Row([ - ft.IconButton( - icon=icons.DOUBLE_ARROW, - icon_color=self.theme.text_primary, - on_click=self.add_transaction_database, - icon_size=24 + ft.Column([ + # Name 0 + ft.TextField( + hint_text="Nombre", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.PERSON, + bgcolor=self.theme.fg, + border_radius=20, + filled=True, ), - ft.Text("Transaccionar", color=self.theme.text_primary, - size=14, weight=ft.FontWeight.W_500) - ]), - ], expand=True) + # Category 1 + ft.Dropdown( + hint_text="Categoria", + options=self.draw_option_box(), + text_style=ft.TextStyle(color=self.theme.green_color), + hint_style=ft.TextStyle(color=self.theme.text_secondary), + border_radius=20, + fill_color=self.theme.fg, + filled=True, + dense=False, + expand=True, + border=ft.InputBorder.OUTLINE, + menu_height=300, + ), + # Mount 2 + ft.TextField( + hint_text="Monto", + hint_style=ft.TextStyle(color=self.theme.text_secondary), + text_style=ft.TextStyle(color=self.theme.text_primary), + border=ft.InputBorder.OUTLINE, + prefix_icon=icons.ATTACH_MONEY, + bgcolor=self.theme.fg, + border_radius=20, + filled=True, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter( + allow=True, regex_string=r"^\d*\.?\d*$"), + ), + # Type 3 + ft.RadioGroup( + ref=self.radiogroup_ref, + value=TransactionType.SPENT.value, + content=ft.Row([ + ft.Radio(value=TransactionType.SPENT.value, label="Gasto", + label_style=ft.TextStyle(color=self.theme.text_primary), hover_color=self.theme.text_secondary, fill_color=self.theme.text_primary), + ft.Radio(value=TransactionType.INCOME.value, label="Ingreso", + label_style=ft.TextStyle(color=self.theme.text_primary), hover_color=self.theme.text_secondary, fill_color=self.theme.text_primary), + ft.Radio(value=TransactionType.CREDIT.value, label="Credito", + label_style=ft.TextStyle(color=self.theme.text_primary), hover_color=self.theme.text_secondary, fill_color=self.theme.text_primary) + ]), + on_change=self.radio_group_event, + ), + # Account 4 + ft.Dropdown( + hint_text="Cuenta origen", + options=[ft.dropdown.Option(text=f"{account[2]} ${account[3]:.2f}", key=account[0]) + for account in self.accounts_actives if account[4]], + text_style=ft.TextStyle(color=self.theme.green_color), + hint_style=ft.TextStyle(color=self.theme.text_secondary), + border_radius=20, + fill_color=self.theme.fg, + filled=True, + dense=False, + expand=True, + border=ft.InputBorder.OUTLINE, + ) + ], expand=True), + bgcolor=self.theme.bg, ) + transaction_button = ft.Row([ + ft.CupertinoButton( + text="Transaccionar", + icon=icons.DOUBLE_ARROW, + icon_color=self.theme.fg, + color=self.theme.fg, + bgcolor=self.theme.text_primary, + border_radius=20, + on_click=self.handle_submit_animation, + focus_color=self.theme.text_primary, + expand=True, + scale=1.0, + animate_scale=ft.Animation( + 150, ft.AnimationCurve.EASE_OUT_CUBIC), + ) + ]) + return ft.Container( ft.Column([ self.header, self.add_transaction, + transaction_button, ]), margin=ft.margin.symmetric(horizontal=20, vertical=10), expand=True, ) - diff --git a/src/views/user_view.py b/src/views/user_view.py new file mode 100644 index 0000000..bf3f49d --- /dev/null +++ b/src/views/user_view.py @@ -0,0 +1,223 @@ +import flet as ft +from flet import Icons as icons + +from controllers.controller import UserDataController +from core.config import DEFAULT_PROFILE_PIC, PROFILE_PIC_KEY +from themes.themes import Theme + + +class UserView: + """Vista de información del usuario. + + Por ahora muestra foto y nombre. El layout está pensado para + ir agregando más secciones (género, preferencias, etc.) sin + reestructurar el draw. + """ + + def __init__(self, theme: Theme, page: ft.Page): + self.theme = theme + self.page = page + self.userdata = UserDataController.controller_fetch_userdata() + self.file_picker = ft.FilePicker(on_result=self.change_profile_pic) + + def _get_username(self) -> str: + if not self.userdata: + return "Usuario" + return self.userdata[1] or "Usuario" + + def _get_gender(self) -> str: + if not self.userdata: + return "No definido" + return self.userdata[2] or "No definido" + + def _get_profile_pic_src(self) -> str: + stored = self.page.client_storage.get(PROFILE_PIC_KEY) + return stored if stored else DEFAULT_PROFILE_PIC + + def change_profile_pic(self, e): + if not e.files: + return + + img_path = e.files[0].path + self.page.client_storage.remove(PROFILE_PIC_KEY) + self.page.client_storage.set(PROFILE_PIC_KEY, img_path) + self.profile_image.src = self.page.client_storage.get(PROFILE_PIC_KEY) + self.page.update() + + def _open_file_picker(self, e): + self.file_picker.pick_files( + allow_multiple=False, + allowed_extensions=["jpg", "jpeg", "png", "webp", "gif"], + ) + + def _build_profile_header(self) -> ft.Control: + """Bloque principal: foto + nombre. Click en la foto abre el file picker.""" + self.profile_image = ft.Image( + src=self._get_profile_pic_src(), + fit=ft.ImageFit.COVER, + width=96, + height=96, + ) + + avatar = ft.Container( + content=ft.Stack( + [ + self.profile_image, + ft.Container( + content=ft.Icon( + icons.CAMERA_ALT, + color=self.theme.text_primary, + size=18, + ), + alignment=ft.alignment.bottom_right, + padding=ft.padding.only(right=4, bottom=4), + ), + ], + width=96, + height=96, + ), + width=96, + height=96, + border_radius=ft.border_radius.all(48), + clip_behavior=ft.ClipBehavior.HARD_EDGE, + bgcolor=self.theme.fg, + alignment=ft.alignment.center, + on_click=self._open_file_picker, + tooltip="Cambiar foto de perfil", + ) + + self.username_text = ft.Text( + self._get_username(), + color=self.theme.text_primary, + size=22, + weight=ft.FontWeight.W_600, + text_align=ft.TextAlign.CENTER, + ) + + change_photo_hint = ft.Text( + "Toca la foto para cambiarla", + color=self.theme.text_secondary, + size=12, + text_align=ft.TextAlign.CENTER, + ) + + return ft.Container( + content=ft.Column( + [ + avatar, + ft.Container(height=12), + self.username_text, + ft.Container(height=4), + change_photo_hint, + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=0, + ), + padding=ft.padding.symmetric(vertical=24), + alignment=ft.alignment.center, + expand=False, + ) + + def _build_info_section(self) -> ft.Control: + """Sección de datos del perfil. + + Punto de extensión: agregar filas con _info_row() a medida + que se incorporen más campos del usuario. + """ + rows = [ + self._info_row( + icon=icons.PERSON_OUTLINE, + label="Nombre", + value=self._get_username(), + ), + self._info_row( + icon=icons.MALE if self._get_gender() == "Masculino" else icons.FEMALE, + label="Genero", + value=self._get_gender(), + ) + # Futuro: género, email, preferencias, etc. + # self._info_row(icons.WC, "Género", gender), + ] + + return ft.Container( + content=ft.Column( + [ + ft.Text( + "INFORMACIÓN PERSONAL", + size=11, + weight=ft.FontWeight.BOLD, + color=self.theme.blue_color, + ), + ft.Container(height=4), + *rows, + ], + spacing=10, + ), + expand=False, + ) + + def _info_row(self, icon: str, label: str, value: str) -> ft.Control: + return ft.Container( + content=ft.Row( + [ + ft.Container( + content=ft.Icon( + icon, color=self.theme.text_secondary, size=20), + bgcolor=self.theme.fg, + width=38, + height=38, + border_radius=8, + alignment=ft.alignment.center, + ), + ft.VerticalDivider(width=10, color="transparent"), + ft.Column( + [ + ft.Text( + label, + color=self.theme.text_secondary, + size=11, + ), + ft.Text( + value, + color=self.theme.text_primary, + size=14, + weight=ft.FontWeight.W_500, + ), + ], + spacing=2, + expand=True, + ), + ], + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor=self.theme.fg, + padding=ft.padding.symmetric(horizontal=16, vertical=12), + border_radius=12, + ) + + def draw(self, header): + self.header = header.create("Perfil", return_page=True) + self.page.overlay.append(self.file_picker) + + body = ft.Column( + [ + self._build_profile_header(), + self._build_info_section(), + ], + spacing=8, + scroll=ft.ScrollMode.AUTO, + expand=True, + ) + + return ft.Container( + content=ft.Column( + [ + self.header, + ft.Container(height=10), + body, + ], + expand=True, + ), + margin=ft.margin.symmetric(horizontal=20, vertical=10), + expand=True, + )