g360-cli 1.13.0 → 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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/assets/config/g360-skills.json +39 -0
  3. package/src/assets/config/project-types.json +15 -4
  4. package/src/assets/templates/python-flet/README.md +6 -1
  5. package/src/assets/templates/python-flet/pyproject.toml +1 -2
  6. package/src/assets/templates/python-flet-polished/README.md +170 -0
  7. package/src/assets/templates/python-flet-polished/assets/data/sample_data.json +12 -0
  8. package/src/assets/templates/python-flet-polished/assets/fonts/Inter-Variable.ttf +0 -0
  9. package/src/assets/templates/python-flet-polished/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
  10. package/src/assets/templates/python-flet-polished/assets/images/app.ico +0 -0
  11. package/src/assets/templates/python-flet-polished/assets/images/logo.svg +9 -0
  12. package/src/assets/templates/python-flet-polished/build-portable.bat +42 -0
  13. package/src/assets/templates/python-flet-polished/create_shortcut.vbs +15 -0
  14. package/src/assets/templates/python-flet-polished/g360_flet/__init__.py +0 -0
  15. package/src/assets/templates/python-flet-polished/g360_flet/g360_signature.py +210 -0
  16. package/src/assets/templates/python-flet-polished/launch.vbs +3 -0
  17. package/src/assets/templates/python-flet-polished/launch_minimized.bat +3 -0
  18. package/src/assets/templates/python-flet-polished/main.py +47 -0
  19. package/src/assets/templates/python-flet-polished/pyproject.toml +18 -0
  20. package/src/assets/templates/python-flet-polished/requirements.txt +3 -0
  21. package/src/assets/templates/python-flet-polished/run.bat +189 -0
  22. package/src/assets/templates/python-flet-polished/skill.json +28 -0
  23. package/src/assets/templates/python-flet-polished/src/__init__.py +0 -0
  24. package/src/assets/templates/python-flet-polished/src/app.py +343 -0
  25. package/src/assets/templates/python-flet-polished/src/config/__init__.py +0 -0
  26. package/src/assets/templates/python-flet-polished/src/config/theme.py +122 -0
  27. package/src/assets/templates/python-flet-polished/src/core/__init__.py +0 -0
  28. package/src/assets/templates/python-flet-polished/src/core/constants.py +49 -0
  29. package/src/assets/templates/python-flet-polished/src/core/processor.py +128 -0
  30. package/src/assets/templates/python-flet-polished/src/core/skill.json +41 -0
  31. package/src/assets/templates/python-flet-polished/src/ui/__init__.py +0 -0
  32. package/src/assets/templates/python-flet-polished/src/ui/dashboard.py +308 -0
  33. package/src/assets/templates/python-flet-polished/src/ui/kpi_card.py +69 -0
  34. package/src/assets/templates/python-flet-polished/src/ui/search_overlay.py +143 -0
  35. package/src/assets/templates/python-flet-polished/sync_portable.py +71 -0
  36. package/src/cli.js +4 -4
  37. package/src/commands/init.js +33 -2
  38. package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
  41. package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
  42. package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
  43. package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
  44. package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
  45. package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
  46. package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
  47. package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
  48. package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
  49. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
  50. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
  51. package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
  52. package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
  53. package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
  54. package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
  55. package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
  56. package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
  57. package/src/assets/ingestion/core/__pycache__/ingestion.cpython-312.pyc +0 -0
  58. package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-312.pyc +0 -0
  59. package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-312.pyc +0 -0
  61. 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,3 @@
1
+ flet[desktop]==0.28.3
2
+ requests>=2.31.0
3
+ openpyxl>=3.0.0
@@ -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
+ }
@@ -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)