g360-cli 1.13.1 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/assets/config/g360-skills.json +39 -0
- package/src/assets/config/project-types.json +15 -4
- package/src/assets/templates/python-flet/README.md +6 -1
- package/src/assets/templates/python-flet/pyproject.toml +1 -2
- package/src/assets/templates/python-flet-polished/README.md +170 -0
- package/src/assets/templates/python-flet-polished/assets/data/sample_data.json +12 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/Inter-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/app.ico +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/logo.svg +9 -0
- package/src/assets/templates/python-flet-polished/build-portable.bat +42 -0
- package/src/assets/templates/python-flet-polished/create_shortcut.vbs +15 -0
- package/src/assets/templates/python-flet-polished/g360_flet/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/g360_flet/g360_signature.py +210 -0
- package/src/assets/templates/python-flet-polished/launch.vbs +3 -0
- package/src/assets/templates/python-flet-polished/launch_minimized.bat +3 -0
- package/src/assets/templates/python-flet-polished/main.py +47 -0
- package/src/assets/templates/python-flet-polished/pyproject.toml +18 -0
- package/src/assets/templates/python-flet-polished/requirements.txt +3 -0
- package/src/assets/templates/python-flet-polished/run.bat +189 -0
- package/src/assets/templates/python-flet-polished/skill.json +28 -0
- package/src/assets/templates/python-flet-polished/src/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/app.py +343 -0
- package/src/assets/templates/python-flet-polished/src/config/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/config/theme.py +122 -0
- package/src/assets/templates/python-flet-polished/src/core/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/core/constants.py +49 -0
- package/src/assets/templates/python-flet-polished/src/core/processor.py +128 -0
- package/src/assets/templates/python-flet-polished/src/core/skill.json +41 -0
- package/src/assets/templates/python-flet-polished/src/ui/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/ui/dashboard.py +308 -0
- package/src/assets/templates/python-flet-polished/src/ui/kpi_card.py +69 -0
- package/src/assets/templates/python-flet-polished/src/ui/search_overlay.py +143 -0
- package/src/assets/templates/python-flet-polished/sync_portable.py +71 -0
- package/src/cli.js +4 -4
- package/src/commands/init.js +33 -2
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/src/assets/ingestion/core/__pycache__/ingestion.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-314.pyc +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
from logging.handlers import RotatingFileHandler
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import flet as ft
|
|
10
|
+
|
|
11
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
12
|
+
if str(BASE_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(BASE_DIR))
|
|
14
|
+
|
|
15
|
+
from src.app import G360App # noqa: E402
|
|
16
|
+
from src.core.constants import LOG_PATH # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger("g360.app")
|
|
20
|
+
if not _logger.handlers:
|
|
21
|
+
_fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
|
22
|
+
_handler = RotatingFileHandler(LOG_PATH, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
|
23
|
+
_handler.setFormatter(_fmt)
|
|
24
|
+
_logger.addHandler(_handler)
|
|
25
|
+
_logger.setLevel(logging.INFO)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _log(msg: str):
|
|
29
|
+
_logger.info(msg)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(page: ft.Page):
|
|
33
|
+
app = G360App(page)
|
|
34
|
+
page.on_close = lambda _: app.shutdown() if hasattr(app, "shutdown") else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
_log("MAIN_START: __name__ == __main__")
|
|
39
|
+
try:
|
|
40
|
+
_log("PRE: llamando a ft.app(main) desktop...")
|
|
41
|
+
ft.app(main, view=ft.AppView.FLET_APP)
|
|
42
|
+
_log("POST: ft.app(main) retorno OK")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
_log(f"[FATAL] Error en ft.run:\n{traceback.format_exc()}")
|
|
45
|
+
print(f"\n[FATAL] Error al iniciar la aplicacion: {e}", flush=True)
|
|
46
|
+
traceback.print_exc()
|
|
47
|
+
input("\nPresione Enter para salir...")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "g360-app-polished"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "G360 Desktop Application - Polished Template"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"flet[desktop]==0.28.3",
|
|
8
|
+
"requests>=2.31.0",
|
|
9
|
+
"openpyxl>=3.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
build = [
|
|
14
|
+
"pyinstaller>=6.0.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.uv]
|
|
18
|
+
package = false
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
setlocal enabledelayedexpansion
|
|
3
|
+
cd /d "%~dp0"
|
|
4
|
+
|
|
5
|
+
set LOG_FILE=run_log.txt
|
|
6
|
+
echo [%DATE% %TIME%] Inicio App G360 >> %LOG_FILE%
|
|
7
|
+
echo.
|
|
8
|
+
echo === G360 App - Inicio ===
|
|
9
|
+
echo.
|
|
10
|
+
|
|
11
|
+
REM --- Quick path: skip full setup if venv already exists ---
|
|
12
|
+
if exist ".venv\Scripts\python.exe" (
|
|
13
|
+
echo Entorno ya configurado. Iniciando rapidamente...
|
|
14
|
+
echo [%DATE% %TIME%] Quick-start activado >> %LOG_FILE%
|
|
15
|
+
where uv >nul 2>&1 && uv sync >> %LOG_FILE% 2>&1
|
|
16
|
+
goto :launch_app
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
REM ============================================
|
|
20
|
+
REM [PRE] Verificar conectividad a internet
|
|
21
|
+
echo [%DATE% %TIME%] [PRE] Verificando conexion a internet... >> %LOG_FILE%
|
|
22
|
+
echo [PRE] Verificando conexion a internet...
|
|
23
|
+
|
|
24
|
+
set "INTERNET_OK=0"
|
|
25
|
+
for /f "delims=" %%p in ('powershell -NoProfile -Command "try { (Invoke-WebRequest -Uri 'https://astral.sh' -UseBasicParsing -TimeoutSec 5).StatusCode } catch { 500 }" 2^>nul') do set "INTERNET_OK=%%p"
|
|
26
|
+
|
|
27
|
+
if "!INTERNET_OK!"=="200" (
|
|
28
|
+
echo Conexion a internet OK.
|
|
29
|
+
) else (
|
|
30
|
+
echo [%DATE% %TIME%] [ERROR] Sin conexion a internet >> %LOG_FILE%
|
|
31
|
+
echo ERROR: No se detecto conexion a internet.
|
|
32
|
+
echo Revise su conexion y vuelva a intentar.
|
|
33
|
+
pause
|
|
34
|
+
exit /b 1
|
|
35
|
+
)
|
|
36
|
+
echo.
|
|
37
|
+
|
|
38
|
+
REM ============================================
|
|
39
|
+
REM [1/5] Verificar / Instalar uv
|
|
40
|
+
echo [%DATE% %TIME%] [1/5] Verificando uv... >> %LOG_FILE%
|
|
41
|
+
echo [1/5] Verificando uv...
|
|
42
|
+
|
|
43
|
+
set "UV_EXE=%~dp0uv.exe"
|
|
44
|
+
set "UV_OK=0"
|
|
45
|
+
|
|
46
|
+
where uv >nul 2>&1
|
|
47
|
+
if not errorlevel 1 (
|
|
48
|
+
echo uv encontrado en PATH.
|
|
49
|
+
set "UV_OK=1"
|
|
50
|
+
goto :uv_done
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if exist "%UV_EXE%" (
|
|
54
|
+
echo Usando uv.exe local...
|
|
55
|
+
set "PATH=%~dp0;%PATH%"
|
|
56
|
+
set "UV_OK=1"
|
|
57
|
+
goto :uv_done
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
echo uv no encontrado. Descargando...
|
|
61
|
+
call :download_uv
|
|
62
|
+
if not "!UV_OK!"=="1" (
|
|
63
|
+
echo [%DATE% %TIME%] [ERROR] No se pudo instalar uv >> %LOG_FILE%
|
|
64
|
+
echo ERROR: No se pudo instalar uv.
|
|
65
|
+
pause
|
|
66
|
+
exit /b 1
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
:uv_done
|
|
70
|
+
echo [%DATE% %TIME%] [1/5] uv OK >> %LOG_FILE%
|
|
71
|
+
echo.
|
|
72
|
+
|
|
73
|
+
REM ============================================
|
|
74
|
+
REM [2/5] Verificar / Instalar Python 3.11
|
|
75
|
+
echo [%DATE% %TIME%] [2/5] Verificando Python 3.11... >> %LOG_FILE%
|
|
76
|
+
echo [2/5] Verificando Python 3.11...
|
|
77
|
+
|
|
78
|
+
set "PYTHON_OK=0"
|
|
79
|
+
uv python list --only-installed 2>nul | find "3.11" >nul
|
|
80
|
+
if not errorlevel 1 (
|
|
81
|
+
echo Python 3.11 encontrado.
|
|
82
|
+
set "PYTHON_OK=1"
|
|
83
|
+
goto :python_done
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
echo Python 3.11 no encontrado. Instalando con uv...
|
|
87
|
+
call :install_python
|
|
88
|
+
if not "!PYTHON_OK!"=="1" (
|
|
89
|
+
echo [%DATE% %TIME%] [ERROR] No se pudo instalar Python 3.11 >> %LOG_FILE%
|
|
90
|
+
pause
|
|
91
|
+
exit /b 1
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
:python_done
|
|
95
|
+
echo [%DATE% %TIME%] [2/5] Python OK >> %LOG_FILE%
|
|
96
|
+
echo.
|
|
97
|
+
|
|
98
|
+
REM ============================================
|
|
99
|
+
REM [3/5] Crear entorno virtual e instalar dependencias
|
|
100
|
+
echo [%DATE% %TIME%] [3/5] Configurando entorno virtual... >> %LOG_FILE%
|
|
101
|
+
echo [3/5] Configurando entorno virtual...
|
|
102
|
+
|
|
103
|
+
if not exist ".venv\Scripts\python.exe" (
|
|
104
|
+
echo Creando entorno virtual...
|
|
105
|
+
uv venv .venv --python 3.11 >> %LOG_FILE% 2>&1
|
|
106
|
+
if errorlevel 1 (
|
|
107
|
+
echo [%DATE% %TIME%] [ERROR] No se pudo crear el entorno virtual >> %LOG_FILE%
|
|
108
|
+
pause
|
|
109
|
+
exit /b 1
|
|
110
|
+
)
|
|
111
|
+
echo Entorno virtual creado.
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
echo Instalando dependencias...
|
|
115
|
+
uv sync >> %LOG_FILE% 2>&1
|
|
116
|
+
if errorlevel 1 (
|
|
117
|
+
echo [%DATE% %TIME%] [ERROR] Error al sincronizar dependencias >> %LOG_FILE%
|
|
118
|
+
pause
|
|
119
|
+
exit /b 1
|
|
120
|
+
)
|
|
121
|
+
echo [%DATE% %TIME%] [3/5] Dependencias instaladas >> %LOG_FILE%
|
|
122
|
+
echo.
|
|
123
|
+
|
|
124
|
+
REM ============================================
|
|
125
|
+
REM [4/5] Crear acceso directo
|
|
126
|
+
echo [%DATE% %TIME%] [4/5] Creando acceso directo... >> %LOG_FILE%
|
|
127
|
+
echo [4/5] Creando acceso directo...
|
|
128
|
+
|
|
129
|
+
if exist "create_shortcut.vbs" (
|
|
130
|
+
cscript //nologo create_shortcut.vbs >> %LOG_FILE% 2>&1
|
|
131
|
+
echo Acceso directo creado.
|
|
132
|
+
) else (
|
|
133
|
+
echo create_shortcut.vbs no encontrado - omitiendo.
|
|
134
|
+
)
|
|
135
|
+
echo.
|
|
136
|
+
|
|
137
|
+
REM ============================================
|
|
138
|
+
REM [5/5] Iniciar aplicacion
|
|
139
|
+
echo [%DATE% %TIME%] [5/5] Iniciando App G360... >> %LOG_FILE%
|
|
140
|
+
echo [5/5] Iniciando App G360...
|
|
141
|
+
echo.
|
|
142
|
+
|
|
143
|
+
:launch_app
|
|
144
|
+
echo [%DATE% %TIME%] Lanzando aplicacion... >> %LOG_FILE%
|
|
145
|
+
.venv\Scripts\python.exe main.py
|
|
146
|
+
if errorlevel 1 (
|
|
147
|
+
echo [%DATE% %TIME%] [ERROR] La aplicacion fallo >> %LOG_FILE%
|
|
148
|
+
echo.
|
|
149
|
+
echo La aplicacion fallo. Revise %LOG_FILE% para mas detalles.
|
|
150
|
+
pause
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
echo [%DATE% %TIME%] App terminada normalmente >> %LOG_FILE%
|
|
154
|
+
exit /b
|
|
155
|
+
|
|
156
|
+
REM --- Subroutines ---
|
|
157
|
+
|
|
158
|
+
:download_uv
|
|
159
|
+
echo Descargando uv...
|
|
160
|
+
powershell -ExecutionPolicy ByPass -NoProfile -Command "try { Invoke-WebRequest -Uri 'https://astral.sh/uv/install.ps1' -OutFile '%~dp0uv-install.ps1' -UseBasicParsing; exit 0 } catch { exit 1 }"
|
|
161
|
+
if errorlevel 1 (
|
|
162
|
+
echo Error al descargar uv. Reintentando...
|
|
163
|
+
timeout /t 3 /nobreak >nul
|
|
164
|
+
exit /b 1
|
|
165
|
+
)
|
|
166
|
+
echo Instalando uv localmente...
|
|
167
|
+
powershell -ExecutionPolicy ByPass -NoProfile -c ". '%~dp0uv-install.ps1'" >> %LOG_FILE% 2>&1
|
|
168
|
+
del /f /q "%~dp0uv-install.ps1" >nul 2>&1
|
|
169
|
+
if exist "%UV_EXE%" (
|
|
170
|
+
echo uv instalado localmente.
|
|
171
|
+
set "PATH=%~dp0;%PATH%"
|
|
172
|
+
set "UV_OK=1"
|
|
173
|
+
exit /b 0
|
|
174
|
+
)
|
|
175
|
+
echo uv no se encontro despues de la instalacion.
|
|
176
|
+
exit /b 1
|
|
177
|
+
|
|
178
|
+
:install_python
|
|
179
|
+
for /l %%i in (1,1,2) do (
|
|
180
|
+
echo Intento %%i: Instalando Python 3.11...
|
|
181
|
+
uv python install 3.11 >> %LOG_FILE% 2>&1
|
|
182
|
+
if not errorlevel 1 (
|
|
183
|
+
set "PYTHON_OK=1"
|
|
184
|
+
exit /b 0
|
|
185
|
+
)
|
|
186
|
+
echo Intento %%i fallido. Reintentando en 5s...
|
|
187
|
+
timeout /t 5 /nobreak >nul
|
|
188
|
+
)
|
|
189
|
+
exit /b 1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "g360-flet-polished",
|
|
3
|
+
"device": "pc",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"updatedAt": "2026-08-13",
|
|
6
|
+
"brand": "g360",
|
|
7
|
+
"description": "G360 Flet Polished - Plantilla base con patrones de UI avanzada",
|
|
8
|
+
"framework": "flet",
|
|
9
|
+
"portable": true,
|
|
10
|
+
"colors": {
|
|
11
|
+
"bg": "#0b1220",
|
|
12
|
+
"surface": "#1a2333",
|
|
13
|
+
"accent": "#34d399",
|
|
14
|
+
"text": "#f0f6fc",
|
|
15
|
+
"muted": "#8b949e",
|
|
16
|
+
"success": "#34d399",
|
|
17
|
+
"warning": "#f59e0b",
|
|
18
|
+
"error": "#ef4444"
|
|
19
|
+
},
|
|
20
|
+
"effects": {
|
|
21
|
+
"glassmorphism": true,
|
|
22
|
+
"rounded": "12px"
|
|
23
|
+
},
|
|
24
|
+
"signature": {
|
|
25
|
+
"mode": "powered",
|
|
26
|
+
"text": "powered by G360"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import flet as ft
|
|
12
|
+
|
|
13
|
+
from src.config.theme import get_colors, load_theme_preference, save_theme_preference
|
|
14
|
+
from src.core.constants import (
|
|
15
|
+
AUTO_REFRESH_INTERVAL,
|
|
16
|
+
CACHE_FILE,
|
|
17
|
+
VERSION_CACHE_FILE,
|
|
18
|
+
VERSION_CHECK_INTERVAL,
|
|
19
|
+
WINDOW_WIDTH,
|
|
20
|
+
WINDOW_HEIGHT,
|
|
21
|
+
WINDOW_MIN_WIDTH,
|
|
22
|
+
WINDOW_MIN_HEIGHT,
|
|
23
|
+
get_local_version,
|
|
24
|
+
get_app_name,
|
|
25
|
+
)
|
|
26
|
+
from src.ui.dashboard import Dashboard
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
_log_logger = logging.getLogger("g360.app")
|
|
30
|
+
if not _log_logger.handlers:
|
|
31
|
+
from logging.handlers import RotatingFileHandler
|
|
32
|
+
_log_path = Path(__file__).resolve().parent.parent.parent / "run_log.txt"
|
|
33
|
+
_fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
|
34
|
+
_handler = RotatingFileHandler(_log_path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
|
35
|
+
_handler.setFormatter(_fmt)
|
|
36
|
+
_log_logger.addHandler(_handler)
|
|
37
|
+
_log_logger.setLevel(logging.INFO)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _log(msg: str):
|
|
41
|
+
_log_logger.info(msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_cache() -> tuple[dict, str | None]:
|
|
45
|
+
"""Carga el cache persistente. Retorna (raw_data, timestamp)."""
|
|
46
|
+
if not CACHE_FILE.exists():
|
|
47
|
+
return {}, None
|
|
48
|
+
try:
|
|
49
|
+
with open(CACHE_FILE, encoding="utf-8") as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
raw = data.get("raw_data", {})
|
|
52
|
+
ts = data.get("timestamp")
|
|
53
|
+
if raw:
|
|
54
|
+
return raw, ts
|
|
55
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as ex:
|
|
56
|
+
_log(f"_load_cache: corrupt cache file, removing: {ex}")
|
|
57
|
+
try:
|
|
58
|
+
CACHE_FILE.unlink(missing_ok=True)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return {}, None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _hash_data(raw_data: dict) -> str:
|
|
67
|
+
"""SHA-256 del raw_data para detectar cambios sin descargar dos veces."""
|
|
68
|
+
serialized = json.dumps(raw_data, sort_keys=True, ensure_ascii=False).encode()
|
|
69
|
+
return hashlib.sha256(serialized).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _data_changed(new_data: dict, last_hash: str | None) -> tuple[bool, str]:
|
|
73
|
+
"""Retorna (changed, nuevo_hash)."""
|
|
74
|
+
h = _hash_data(new_data)
|
|
75
|
+
return h != last_hash, h
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_cache(raw_data: dict, api_timestamp: str | None = None):
|
|
79
|
+
"""Guarda raw_data completo."""
|
|
80
|
+
try:
|
|
81
|
+
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
payload = {
|
|
83
|
+
"raw_data": raw_data,
|
|
84
|
+
"timestamp": api_timestamp or datetime.now().isoformat(),
|
|
85
|
+
"api_timestamp": api_timestamp,
|
|
86
|
+
}
|
|
87
|
+
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
89
|
+
except Exception as ex:
|
|
90
|
+
_log(f"_save_cache: ERROR {ex}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class G360App:
|
|
94
|
+
"""
|
|
95
|
+
Clase principal de la aplicacion G360 con Flet.
|
|
96
|
+
|
|
97
|
+
Patrones implementados:
|
|
98
|
+
- Dual theme (dark/light) con persistencia en ~/.g360/
|
|
99
|
+
- Auto-refresh con lock thread-safe
|
|
100
|
+
- Cache con hash diff para evitar rebuild innecesarios
|
|
101
|
+
- Logger RotatingFileHandler
|
|
102
|
+
- Shutdown limpio via page.on_close
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, page: ft.Page):
|
|
106
|
+
try:
|
|
107
|
+
self.page = page
|
|
108
|
+
self._raw_data: dict = {}
|
|
109
|
+
self._theme_mode = load_theme_preference()
|
|
110
|
+
self._setup_page()
|
|
111
|
+
self.dashboard = Dashboard(page, self._theme_mode)
|
|
112
|
+
if hasattr(self.dashboard, "set_on_theme_toggle"):
|
|
113
|
+
self.dashboard.set_on_theme_toggle(self._toggle_theme)
|
|
114
|
+
self.dashboard.set_on_refresh(self._on_refresh)
|
|
115
|
+
self._cache_timestamp: str | None = None
|
|
116
|
+
self._stale_data: bool = False
|
|
117
|
+
self._local_version = get_local_version()
|
|
118
|
+
self._last_auto_refresh: float = 0.0
|
|
119
|
+
self._auto_refresh_stop: threading.Event | None = None
|
|
120
|
+
self._download_lock = threading.Lock()
|
|
121
|
+
self._last_data_hash: str | None = None
|
|
122
|
+
self._build()
|
|
123
|
+
except Exception:
|
|
124
|
+
_log(f"[FATAL] G360App.__init__:\n{traceback.format_exc()}")
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
def _setup_page(self):
|
|
128
|
+
app_name = get_app_name()
|
|
129
|
+
self.page.title = f"{app_name.title()} - G360"
|
|
130
|
+
self.page.theme_mode = ft.ThemeMode.DARK if self._theme_mode == "dark" else ft.ThemeMode.LIGHT
|
|
131
|
+
self.page.bgcolor = get_colors(self._theme_mode)["background"]
|
|
132
|
+
self.page.padding = 0
|
|
133
|
+
self.page.window_width = WINDOW_WIDTH
|
|
134
|
+
self.page.window_height = WINDOW_HEIGHT
|
|
135
|
+
self.page.window_min_width = WINDOW_MIN_WIDTH
|
|
136
|
+
self.page.window_min_height = WINDOW_MIN_HEIGHT
|
|
137
|
+
try:
|
|
138
|
+
self.page.window_center()
|
|
139
|
+
except AttributeError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
fonts_dir = Path(__file__).resolve().parent.parent.parent / "assets" / "fonts"
|
|
143
|
+
self.page.fonts = {
|
|
144
|
+
"Inter": str(fonts_dir / "Inter-Variable.ttf"),
|
|
145
|
+
"JetBrains Mono": str(fonts_dir / "JetBrainsMono-Variable.ttf"),
|
|
146
|
+
}
|
|
147
|
+
self.page.theme = ft.Theme(font_family="Inter")
|
|
148
|
+
|
|
149
|
+
def _build(self):
|
|
150
|
+
_log("_build: starting dashboard.build()...")
|
|
151
|
+
view = self.dashboard.build()
|
|
152
|
+
_log("_build: dashboard.build() OK")
|
|
153
|
+
self.page.add(view)
|
|
154
|
+
self.page.update()
|
|
155
|
+
_log("_build: page.add + update OK")
|
|
156
|
+
self.dashboard.register_overlay()
|
|
157
|
+
_log("_build: overlay (FilePickers) registrado")
|
|
158
|
+
|
|
159
|
+
sample_path = Path(__file__).resolve().parent.parent / "assets" / "data" / "sample_data.json"
|
|
160
|
+
cache, ts = _load_cache()
|
|
161
|
+
if cache:
|
|
162
|
+
_log(f"_build: loading cached data...")
|
|
163
|
+
self._raw_data = cache
|
|
164
|
+
self._cache_timestamp = ts
|
|
165
|
+
_, initial_hash = _data_changed(cache, None)
|
|
166
|
+
self._last_data_hash = initial_hash
|
|
167
|
+
self.dashboard.update_data(cache, cache_timestamp=ts)
|
|
168
|
+
self.page.update()
|
|
169
|
+
_log(f"_build: cache loaded OK ({len(cache)} items)")
|
|
170
|
+
elif sample_path.exists():
|
|
171
|
+
_log("_build: cache empty, loading sample_data.json...")
|
|
172
|
+
with open(sample_path, encoding="utf-8") as f:
|
|
173
|
+
raw = json.load(f)
|
|
174
|
+
self._raw_data = raw
|
|
175
|
+
self.dashboard.update_data(raw)
|
|
176
|
+
self.page.update()
|
|
177
|
+
_log("_build: sample_data loaded OK")
|
|
178
|
+
else:
|
|
179
|
+
_log("_build: neither cache nor sample_data.json found")
|
|
180
|
+
self.dashboard._show_empty_state("Sin datos disponibles")
|
|
181
|
+
self.dashboard._set_empty_state_status("Esperando datos", self.dashboard.c["warning"])
|
|
182
|
+
|
|
183
|
+
_log("_build: scheduling _delayed_load...")
|
|
184
|
+
self.page.run_task(self._delayed_load)
|
|
185
|
+
self._start_auto_refresh()
|
|
186
|
+
_log("_build: done")
|
|
187
|
+
|
|
188
|
+
def _toggle_theme(self):
|
|
189
|
+
self._theme_mode = "light" if self._theme_mode == "dark" else "dark"
|
|
190
|
+
save_theme_preference(self._theme_mode)
|
|
191
|
+
self.page.theme_mode = ft.ThemeMode.DARK if self._theme_mode == "dark" else ft.ThemeMode.LIGHT
|
|
192
|
+
self.page.bgcolor = get_colors(self._theme_mode)["background"]
|
|
193
|
+
self.dashboard.update_theme(self._theme_mode)
|
|
194
|
+
self.page.update()
|
|
195
|
+
|
|
196
|
+
async def _delayed_load(self):
|
|
197
|
+
import asyncio
|
|
198
|
+
await asyncio.sleep(0.5)
|
|
199
|
+
await self._load_data()
|
|
200
|
+
|
|
201
|
+
async def _on_refresh(self):
|
|
202
|
+
await self._load_data(is_manual=True)
|
|
203
|
+
|
|
204
|
+
def _start_auto_refresh(self):
|
|
205
|
+
try:
|
|
206
|
+
self._auto_refresh_stop = threading.Event()
|
|
207
|
+
t = threading.Thread(
|
|
208
|
+
target=self._auto_refresh_loop,
|
|
209
|
+
daemon=True,
|
|
210
|
+
name="auto-refresh",
|
|
211
|
+
)
|
|
212
|
+
t.start()
|
|
213
|
+
self._auto_refresh_thread = t
|
|
214
|
+
_log(f"_start_auto_refresh: hilo iniciado ({AUTO_REFRESH_INTERVAL}s)")
|
|
215
|
+
except Exception as ex:
|
|
216
|
+
_log(f"_start_auto_refresh: ERROR {ex}")
|
|
217
|
+
|
|
218
|
+
def shutdown(self):
|
|
219
|
+
"""Detiene el auto-refresh de forma limpia antes de cerrar."""
|
|
220
|
+
if self._auto_refresh_stop:
|
|
221
|
+
_log("_shutdown: deteniendo auto-refresh...")
|
|
222
|
+
self._auto_refresh_stop.set()
|
|
223
|
+
t = getattr(self, "_auto_refresh_thread", None)
|
|
224
|
+
if t and t.is_alive():
|
|
225
|
+
t.join(timeout=3)
|
|
226
|
+
_log("_shutdown: auto-refresh detenido")
|
|
227
|
+
|
|
228
|
+
def _auto_refresh_loop(self):
|
|
229
|
+
while not self._auto_refresh_stop.is_set():
|
|
230
|
+
time.sleep(1)
|
|
231
|
+
try:
|
|
232
|
+
self._on_auto_refresh_tick()
|
|
233
|
+
except Exception as ex:
|
|
234
|
+
_log(f"_auto_refresh_loop: ERROR {ex}")
|
|
235
|
+
|
|
236
|
+
def _on_auto_refresh_tick(self):
|
|
237
|
+
try:
|
|
238
|
+
if not self._cache_timestamp:
|
|
239
|
+
return
|
|
240
|
+
self.dashboard._update_refresh_status(self._cache_timestamp)
|
|
241
|
+
if time.time() - self._last_auto_refresh < AUTO_REFRESH_INTERVAL:
|
|
242
|
+
return
|
|
243
|
+
self._last_auto_refresh = time.time()
|
|
244
|
+
_log("_on_auto_refresh_tick: ejecutando auto-refresh")
|
|
245
|
+
self.page.run_task(self._load_data, is_manual=False)
|
|
246
|
+
except Exception as ex:
|
|
247
|
+
_log(f"_on_auto_refresh_tick: ERROR {ex}")
|
|
248
|
+
|
|
249
|
+
async def _load_data(self, is_manual=False):
|
|
250
|
+
import time
|
|
251
|
+
_t0 = time.time()
|
|
252
|
+
_log(f"_load_data: start is_manual={is_manual}")
|
|
253
|
+
self.dashboard.set_loading(True, "Descargando datos...")
|
|
254
|
+
|
|
255
|
+
if not self._download_lock.acquire(blocking=False):
|
|
256
|
+
_log("_load_data: otra descarga en curso, omitiendo")
|
|
257
|
+
self.dashboard.set_loading(False)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
import asyncio
|
|
262
|
+
loop = asyncio.get_running_loop()
|
|
263
|
+
_log("_load_data: calling _fetch_data in executor...")
|
|
264
|
+
raw = await loop.run_in_executor(None, self._fetch_data)
|
|
265
|
+
_log(f"_load_data: _fetch_data returned {type(raw).__name__}")
|
|
266
|
+
|
|
267
|
+
if not raw:
|
|
268
|
+
self.dashboard.set_loading(False)
|
|
269
|
+
self.dashboard.status_text.value = "No se obtuvieron datos"
|
|
270
|
+
self.dashboard.status_text.color = "#ef4444"
|
|
271
|
+
self.dashboard._show_snack("Error: No se obtuvieron datos", is_error=True)
|
|
272
|
+
if not self._raw_data:
|
|
273
|
+
self.dashboard._set_empty_state_status("Sin datos - API no disponible", self.dashboard.c["error"])
|
|
274
|
+
_log("_load_data: no data, showing error")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
changed, new_hash = _data_changed(raw, self._last_data_hash)
|
|
278
|
+
if not changed:
|
|
279
|
+
_log("_load_data: datos identicos, omitiendo UI refresh")
|
|
280
|
+
self.dashboard.set_loading(False)
|
|
281
|
+
self._download_lock.release()
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
self._raw_data = raw
|
|
285
|
+
self._last_data_hash = new_hash
|
|
286
|
+
_save_cache(raw)
|
|
287
|
+
self._cache_timestamp = datetime.now().isoformat()
|
|
288
|
+
self._last_auto_refresh = time.time()
|
|
289
|
+
_log("_load_data: cache saved")
|
|
290
|
+
self.dashboard.update_data(raw, cache_timestamp=self._cache_timestamp, stale=self._stale_data)
|
|
291
|
+
self.dashboard._hide_empty_state()
|
|
292
|
+
_log("_load_data: data updated in dashboard")
|
|
293
|
+
if is_manual:
|
|
294
|
+
self.dashboard._show_snack("Datos actualizados correctamente")
|
|
295
|
+
except Exception as ex:
|
|
296
|
+
_log(f"_load_data: EXCEPTION: {traceback.format_exc()}")
|
|
297
|
+
self.dashboard.status_text.value = f"Error: {str(ex)}"
|
|
298
|
+
self.dashboard.status_text.color = "#ef4444"
|
|
299
|
+
self.dashboard._show_snack(f"Fallo en la descarga: {str(ex)}", is_error=True)
|
|
300
|
+
self.dashboard.set_offline(True)
|
|
301
|
+
if not self._raw_data:
|
|
302
|
+
self.dashboard._set_empty_state_status("Error de conexión", self.dashboard.c["error"])
|
|
303
|
+
finally:
|
|
304
|
+
elapsed = time.time() - _t0
|
|
305
|
+
_log(f"_load_data: elapsed={elapsed:.1f}s")
|
|
306
|
+
self._download_lock.release()
|
|
307
|
+
if elapsed < 2:
|
|
308
|
+
self.dashboard.set_loading(True, "Actualizando vista...")
|
|
309
|
+
self.page.update()
|
|
310
|
+
import asyncio
|
|
311
|
+
await asyncio.sleep(2 - elapsed)
|
|
312
|
+
ts = self.dashboard.format_cache_timestamp(self._cache_timestamp) if not is_manual else ""
|
|
313
|
+
self.dashboard._ts_text.value = f"Ultima act. {ts}"
|
|
314
|
+
if self._stale_data:
|
|
315
|
+
self.dashboard._ts_text.color = self.dashboard.c["warning"]
|
|
316
|
+
self.dashboard._ts_text.value += " (caché)"
|
|
317
|
+
self.dashboard.status_text.value = "Datos en caché (fuera de horario)"
|
|
318
|
+
self.dashboard.status_text.color = self.dashboard.c["warning"]
|
|
319
|
+
self.dashboard._show_stale_warning()
|
|
320
|
+
else:
|
|
321
|
+
self.dashboard._ts_text.color = self.dashboard.c["accent"]
|
|
322
|
+
self.dashboard.status_text.value = "Datos actualizados"
|
|
323
|
+
self.dashboard.status_text.color = self.dashboard.c["accent"]
|
|
324
|
+
self.dashboard._hide_stale_warning()
|
|
325
|
+
self.dashboard._update_refresh_status(self._cache_timestamp)
|
|
326
|
+
self.dashboard.set_offline(False)
|
|
327
|
+
self.dashboard.set_loading(False)
|
|
328
|
+
self.page.update()
|
|
329
|
+
_log("_load_data: done")
|
|
330
|
+
|
|
331
|
+
def _fetch_data(self) -> dict | None:
|
|
332
|
+
"""
|
|
333
|
+
Metodo que debe ser sobrescrito por subclases.
|
|
334
|
+
Implementa la logica de descarga/fetch de datos.
|
|
335
|
+
Retorna dict con los datos crudos o None si falla.
|
|
336
|
+
"""
|
|
337
|
+
# Default: retorna None (no hay data)
|
|
338
|
+
# Subclases deben override这个方法
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main(page: ft.Page):
|
|
343
|
+
G360App(page)
|
|
File without changes
|