diff --git a/README.md b/README.md index 4b0c06a..5afaff1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@
+AppFromAI logo + # AppFromAI ### *Describe it. Build it. Run it. Instantly.* @@ -200,6 +202,13 @@ The game engine is declarative: the AI describes the scene as an array of object AppFromAI generates the game loop, the renderer, the gamepad layout, the score state, and the collision logic — all in one shot. +**Recent game engine additions:** +- Per-object physics (`vx`, `vy`, `gravity`) applied automatically every tick — no manual physics code needed +- `fps` property on `gameView` to set frame rate declaratively (10–60 fps) +- `onCollideAction` — fired automatically on AABB overlap between any two identified objects +- `onOutOfBoundsAction` — fired when an object leaves the canvas bounds +- Global `gravity` on the `gameView` node, applied to all objects at once + **Gamepad layouts:** - `row` — horizontal button row (left / right / jump) - `dpad` — cross layout (↑ ← → ↓) for 4-directional movement @@ -403,6 +412,7 @@ src/ ## Roadmap ### 🧩 New UI components +- [x] **WebView** — embed any website or web content inline inside a generated module; the AI can generate a `webview` node that opens in an in-app browser with a single tap - [ ] **Charts** — bar, line, and pie chart components in the declarative UI tree; generated modules can visualize data without a single line of chart code - [ ] **Map view** — display GPS coordinates and routes on an interactive map, directly inside any generated module - [ ] **Rich list items** — list rows with avatar, subtitle, badge, and swipe-to-delete, so the AI can generate beautiful data-driven apps instead of plain text lists @@ -413,6 +423,7 @@ src/ - [ ] **Biometric lock** — protect individual modules with Face ID or fingerprint; one setting, zero code ### 🤝 Sharing & community +- [x] **Module rename** — tap the module name to rename it directly from the library screen, without regenerating - [ ] **Module export & import** — export any module as a single JSON file and share it via AirDrop, link, or QR code; anyone with AppFromAI can import and run it instantly - [ ] **Module marketplace** — a curated feed of community-built modules; browse, preview, and install in one tap diff --git a/app.json b/app.json index 296a91d..b6ffd44 100644 --- a/app.json +++ b/app.json @@ -23,7 +23,11 @@ "NSAppTransportSecurity": { "NSAllowsLocalNetworking": true }, - "LSApplicationQueriesSchemes": ["mailto", "tel", "sms"] + "LSApplicationQueriesSchemes": [ + "mailto", + "tel", + "sms" + ] }, "bundleIdentifier": "com.afi.appfromai" }, @@ -77,7 +81,8 @@ "icon": "./assets/icon.png", "color": "#111827" } - ] + ], + "expo-web-browser" ], "extra": { "router": {} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f3c75b5..ec2abd0 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -16,10 +16,10 @@ import { StatusBar } from 'expo-status-bar'; import { Ionicons } from '@expo/vector-icons'; import { generateModule } from '../../src/ai/aiClient'; import { saveModule } from '../../src/modules/moduleStore'; -import { getJsonResponseRetryHint } from '../../src/ai/modulePrompt'; import { useSettings } from '../../src/settings/SettingsContext'; import { DinoGame } from '../../src/components/DinoGame'; import { useI18n } from '../../src/i18n/useI18n'; +import { useDeviceLayout } from '../../src/utils/deviceLayout'; /* ── Design tokens ── */ const C = { @@ -44,6 +44,7 @@ const C = { export default function GeneraScreen() { const { settings } = useSettings(); const { t } = useI18n(); + const { isTablet } = useDeviceLayout(); const [prompt, setPrompt] = useState(''); const [focused, setFocused] = useState(false); const [loading, setLoading] = useState(false); @@ -76,7 +77,7 @@ export default function GeneraScreen() { startPulse(); try { const res = await generateModule(prompt.trim(), { - useMock: settings.useMock, + useMock: false, ...(settings.provider === 'ollama' ? { ollamaBaseUrl: settings.ollamaUrl || undefined, @@ -109,9 +110,7 @@ export default function GeneraScreen() { } }; - const providerLabel = settings.useMock - ? 'Mock' - : settings.provider === 'ollama' + const providerLabel = settings.provider === 'ollama' ? settings.ollamaModel || 'Ollama' : settings.provider === 'claude' ? settings.claudeModel || 'Claude' @@ -127,7 +126,7 @@ export default function GeneraScreen() { > @@ -138,7 +137,7 @@ export default function GeneraScreen() { AppFromAI - + {providerLabel} @@ -215,9 +214,6 @@ export default function GeneraScreen() { - {error !== t.errorEmpty ? ( - {getJsonResponseRetryHint()} - ) : null} {error} @@ -238,7 +234,7 @@ export default function GeneraScreen() { {/* ── CTA — always visible, outside ScrollView ── */} - + [ s.btn, @@ -273,6 +269,7 @@ const s = StyleSheet.create({ safe: { flex: 1, backgroundColor: C.bg }, flex: { flex: 1 }, scrollContent: { flexGrow: 1 }, + scrollTablet: { maxWidth: 640, width: '100%', alignSelf: 'center' }, /* Top bar */ topBar: { @@ -308,7 +305,6 @@ const s = StyleSheet.create({ borderColor: C.border, }, modelDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: C.primary }, - modelDotMock: { backgroundColor: '#f59e0b' }, modelText: { color: C.muted, fontSize: 12, fontWeight: '600' }, /* Hero */ @@ -483,6 +479,7 @@ const s = StyleSheet.create({ padding: 20, paddingBottom: Platform.OS === 'ios' ? 8 : 16, }, + footerTablet: { maxWidth: 640, width: '100%', alignSelf: 'center' }, btn: { backgroundColor: C.primary, borderRadius: 18, diff --git a/app/(tabs)/modules.tsx b/app/(tabs)/modules.tsx index 9ecd9a3..7cddce6 100644 --- a/app/(tabs)/modules.tsx +++ b/app/(tabs)/modules.tsx @@ -1,11 +1,13 @@ import { useCallback, useState } from 'react'; import * as Clipboard from 'expo-clipboard'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as Sharing from 'expo-sharing'; +import * as DocumentPicker from 'expo-document-picker'; import { Alert, + FlatList, Pressable, RefreshControl, - ScrollView, - Share, StyleSheet, Text, View, @@ -14,9 +16,11 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { useFocusEffect, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; -import { deleteModule, listModules } from '../../src/modules/moduleStore'; +import { deleteModule, listModules, saveModule } from '../../src/modules/moduleStore'; import type { StoredModule } from '../../src/types/generatedModule'; import { useI18n } from '../../src/i18n/useI18n'; +import { useDeviceLayout } from '../../src/utils/deviceLayout'; +import { validateGeneratedModule, toStoredModule } from '../../src/modules/moduleValidator'; const C = { bg: '#0b1120', @@ -48,8 +52,10 @@ const PERM_ICON: Record = { export default function ModulesScreen() { const router = useRouter(); const { t } = useI18n(); + const { isTablet, columns } = useDeviceLayout(); const [modules, setModules] = useState([]); const [refreshing, setRefreshing] = useState(false); + const [importMsg, setImportMsg] = useState<{ ok: boolean; text: string } | null>(null); const load = useCallback(async () => { setModules(await listModules()); @@ -88,6 +94,48 @@ export default function ModulesScreen() { [load, t] ); + const onExport = useCallback(async (item: StoredModule) => { + try { + const path = `${FileSystem.cacheDirectory}${item.manifest.name.replace(/\s+/g, '_')}.afmod`; + await FileSystem.writeAsStringAsync(path, JSON.stringify(item), { encoding: FileSystem.EncodingType.UTF8 }); + await Sharing.shareAsync(path, { mimeType: 'application/json', dialogTitle: `Esporta ${item.name}` }); + } catch { + Alert.alert('Errore', 'Esportazione non riuscita.'); + } + }, []); + + const onDownload = useCallback(async (item: StoredModule) => { + try { + const filename = `${item.manifest.name.replace(/\s+/g, '_')}.afmod`; + const destPath = `${FileSystem.documentDirectory}${filename}`; + await FileSystem.writeAsStringAsync(destPath, JSON.stringify(item, null, 2), { encoding: FileSystem.EncodingType.UTF8 }); + setImportMsg({ ok: true, text: `Salvato: ${filename}` }); + setTimeout(() => setImportMsg(null), 3500); + } catch { + setImportMsg({ ok: false, text: 'Download non riuscito.' }); + } + }, []); + + const onImport = useCallback(async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true }); + if (result.canceled) return; + const uri = result.assets[0]?.uri; + if (!uri) return; + const raw = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.UTF8 }); + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { setImportMsg({ ok: false, text: 'File non valido.' }); return; } + const validated = validateGeneratedModule(parsed); + if (!validated.ok) { setImportMsg({ ok: false, text: 'Modulo non valido.' }); return; } + await saveModule(toStoredModule(validated.module)); + await load(); + setImportMsg({ ok: true, text: 'Modulo importato.' }); + setTimeout(() => setImportMsg(null), 3000); + } catch { + setImportMsg({ ok: false, text: 'Importazione non riuscita.' }); + } + }, [load]); + return ( @@ -102,42 +150,50 @@ export default function ModulesScreen() { ) : null} + + + Importa + - + {importMsg.text} + + ) : null} + + item.id} + numColumns={columns} + columnWrapperStyle={columns > 1 ? { gap: 12, paddingHorizontal: 16 } : undefined} + contentContainerStyle={modules.length === 0 ? s.scrollEmpty : [s.scroll, isTablet && s.scrollTablet]} showsVerticalScrollIndicator={false} refreshControl={ } - > - {modules.length === 0 ? ( + ListEmptyComponent={ {t.emptyTitle} - - {t.emptyText(t.tabGenerate)} - + {t.emptyText(t.tabGenerate)} - ) : ( - modules.map((item) => ( + } + renderItem={({ item }) => ( + 1 ? { flex: 1 } : undefined}> router.push(`/module/${id}`)} - onShare={async (mod) => { - const text = mod.prompt - ? `${mod.name}\n\n${mod.prompt}` - : mod.name; - await Share.share({ message: text, title: mod.name }); - }} + onShare={onExport} + onDownload={onDownload} /> - )) + )} - + /> ); } @@ -147,11 +203,13 @@ function ModuleCard({ onDelete, onOpen, onShare, + onDownload, }: { item: StoredModule; onDelete: (item: StoredModule) => void; onOpen: (id: string) => void; onShare: (item: StoredModule) => void; + onDownload: (item: StoredModule) => void; }) { const { t } = useI18n(); const [copied, setCopied] = useState(false); @@ -221,11 +279,18 @@ function ModuleCard({ {t.openModule} onShare(item)} + style={s.footerBtn} + onPress={(e) => { e.stopPropagation?.(); onShare(item); }} + hitSlop={10} + > + + + { e.stopPropagation?.(); void onDownload(item); }} hitSlop={10} > - + @@ -245,7 +310,30 @@ const s = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', }, - headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }, + importBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + }, + importBtnText: { color: C.primary, fontSize: 13, fontWeight: '600' }, + importBanner: { + marginHorizontal: 16, + marginBottom: 8, + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 10, + borderWidth: 1, + }, + importBannerOk: { backgroundColor: '#0a1f0d', borderColor: '#16a34a' }, + importBannerErr: { backgroundColor: C.errorSurface, borderColor: C.errorBorder }, + importBannerText: { color: C.text, fontSize: 13 }, h1: { fontSize: 26, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, countBadge: { backgroundColor: C.surface, @@ -258,6 +346,7 @@ const s = StyleSheet.create({ countText: { color: C.muted, fontSize: 13, fontWeight: '700' }, scroll: { padding: 16, gap: 12, paddingBottom: 32 }, + scrollTablet: { paddingHorizontal: 0 }, scrollEmpty: { flex: 1 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 14, padding: 40 }, @@ -345,9 +434,9 @@ const s = StyleSheet.create({ paddingTop: 10, }, openText: { color: C.primary, fontSize: 13, fontWeight: '600', flex: 1 }, - shareBtn: { - width: 32, - height: 32, + footerBtn: { + width: 34, + height: 34, borderRadius: 8, backgroundColor: C.surfaceHigh, alignItems: 'center', @@ -355,4 +444,8 @@ const s = StyleSheet.create({ borderWidth: 1, borderColor: C.border, }, + footerBtnDownload: { + backgroundColor: '#1e1b4b', + borderColor: '#312e81', + }, }); diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 29d1ec4..0452460 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { Ionicons } from '@expo/vector-icons'; import { useSettings } from '../../src/settings/SettingsContext'; import { useI18n } from '../../src/i18n/useI18n'; import { LANGUAGES, type Language } from '../../src/i18n/translations'; +import { useDeviceLayout } from '../../src/utils/deviceLayout'; const C = { bg: '#0b1120', @@ -113,6 +114,7 @@ function LangOption({ export default function SettingsScreen() { const { settings, updateSettings } = useSettings(); const { t } = useI18n(); + const { isTablet } = useDeviceLayout(); const currentLang = settings.language || ''; @@ -126,7 +128,10 @@ export default function SettingsScreen() { {t.settingsTitle} - + {/* ── Language ── */}
@@ -150,25 +155,8 @@ export default function SettingsScreen() { ))}
- {/* ── Modalità ── */} -
- - updateSettings({ useMock: v })} - trackColor={{ false: C.border, true: C.primary }} - thumbColor="#fff" - /> - -
- - {/* ── Provider (hidden when mock is on) ── */} - {!settings.useMock ? ( - <> + {/* ── Provider ── */} + <>
) : null} - - ) : null} + {/* ── Info ── */}
@@ -310,7 +297,7 @@ export default function SettingsScreen() {
{/* Security notice */} - + {t.securityNotice} @@ -337,6 +324,7 @@ const s = StyleSheet.create({ h1: { fontSize: 26, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, scroll: { padding: 16, gap: 20, paddingBottom: 32 }, + scrollTablet: { maxWidth: 560, width: '100%', alignSelf: 'center' }, section: { gap: 7 }, sectionTitle: { @@ -426,4 +414,5 @@ const s = StyleSheet.create({ alignItems: 'flex-start', }, noticeText: { flex: 1, color: C.faint, fontSize: 12, lineHeight: 18 }, + noticeTablet: { maxWidth: 560, alignSelf: 'center', width: '100%' }, }); diff --git a/app/module/[id].tsx b/app/module/[id].tsx index d3f36f4..fd7ea48 100644 --- a/app/module/[id].tsx +++ b/app/module/[id].tsx @@ -12,6 +12,7 @@ import { DynamicRenderer } from '../../src/renderer/DynamicRenderer'; import { ModulePermissionGate } from '../../src/renderer/ModulePermissionGate'; import { prefetchNativePermissions } from '../../src/security/runtimePermissions'; import { useI18n } from '../../src/i18n/useI18n'; +import { useDeviceLayout } from '../../src/utils/deviceLayout'; const C = { bg: '#0b1120', @@ -21,6 +22,7 @@ const C = { export default function ModuleScreen() { const { t } = useI18n(); + const { isTablet } = useDeviceLayout(); const params = useLocalSearchParams<{ id: string | string[] }>(); const id = Array.isArray(params.id) ? params.id[0] : params.id; const [mod, setMod] = useState>>(null); @@ -114,7 +116,7 @@ export default function ModuleScreen() { busy={gateBusy} /> ) : motherApi ? ( - + ) : null} @@ -128,4 +130,5 @@ const styles = StyleSheet.create({ notFound: { color: C.text, fontSize: 18, fontWeight: '700' }, notFoundSub: { color: C.muted, fontSize: 14 }, body: { flex: 1, paddingHorizontal: 16, paddingTop: 8 }, + bodyTablet: { maxWidth: 860, alignSelf: 'center', width: '100%' }, }); diff --git a/docs/screenshots/icon.png b/docs/screenshots/icon.png new file mode 100644 index 0000000..f055520 Binary files /dev/null and b/docs/screenshots/icon.png differ diff --git a/package-lock.json b/package-lock.json index 3733287..047e752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.22", "expo-haptics": "~15.0.8", "expo-linking": "~8.0.11", @@ -28,11 +29,13 @@ "expo-speech": "~14.0.8", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.11", "react": "^19.1.0", "react-dom": "^19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-webview": "13.15.0", "zod": "^4.3.6" }, "devDependencies": { @@ -5138,6 +5141,15 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.22", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", @@ -5652,6 +5664,16 @@ } } }, + "node_modules/expo-web-browser": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -8900,6 +8922,32 @@ "react-native": "*" } }, + "node_modules/react-native-webview": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", + "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-webview/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", diff --git a/package.json b/package.json index 8357302..8ddfc65 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.22", "expo-haptics": "~15.0.8", "expo-linking": "~8.0.11", @@ -30,11 +31,13 @@ "expo-speech": "~14.0.8", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.11", "react": "^19.1.0", "react-dom": "^19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-webview": "13.15.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/ai/aiClient.ts b/src/ai/aiClient.ts index 0f9167f..6f86390 100644 --- a/src/ai/aiClient.ts +++ b/src/ai/aiClient.ts @@ -208,9 +208,8 @@ async function generateWithOllama( typeof res.data === 'object' && res.data != null && 'error' in res.data ? JSON.stringify((res.data as { error: unknown }).error) : JSON.stringify(res.data).slice(0, 400); - const msg = `Ollama HTTP ${res.status}: ${detail}`; - reportGenAppError(`aiClient.${providerLabel}.http`, msg, { status: res.status, detail }); - return { ok: false, error: msg }; + reportGenAppError(`aiClient.${providerLabel}.http`, `Ollama HTTP ${res.status}`, { status: res.status, detail }); + return { ok: false, error: res.status === 404 ? 'Modello Ollama non trovato, controlla il nome nelle impostazioni.' : 'Ollama ha risposto con un errore.' }; } const content = res.data?.message?.content; @@ -253,23 +252,18 @@ async function generateWithOllama( continue; } + return { ok: true, data: validated.module }; } - return { - ok: false, - error: `Ollama: modulo non valido anche dopo retry: ${lastGenerationError}. Contenuto (prime 500 char): ${lastContent.slice(0, 500)}`, - }; + return { ok: false, error: 'Ollama non ha generato un modulo valido, riprova o semplifica la richiesta.' }; } catch (e) { const err = e as Error & { code?: string }; + reportGenAppError(`aiClient.${providerLabel}.catch`, e, { code: err.code, root }); if (err.code === 'ECONNREFUSED' || err.message?.includes('Network Error')) { - const msg = - `Impossibile contattare Ollama all'indirizzo ${root}. Su telefono reale non usare localhost: è il telefono, non il Mac/PC. Metti l'IP del computer, es. http://192.168.x.x:11434. Sul Mac/PC avvia Ollama in ascolto sulla rete con OLLAMA_HOST=0.0.0.0 prima di ollama serve. Controlla firewall e porta 11434. Emulatore Android: http://10.0.2.2:11434.`; - reportGenAppError(`aiClient.${providerLabel}.network`, e, { code: err.code, root }); - return { ok: false, error: msg }; + return { ok: false, error: 'Impossibile connettersi a Ollama, controlla URL e rete.' }; } - reportGenAppError(`aiClient.${providerLabel}.catch`, e, {}); - return { ok: false, error: err.message || `Errore di rete verso Ollama` }; + return { ok: false, error: 'Ollama ha risposto con un errore.' }; } } @@ -349,9 +343,12 @@ async function generateWithOpenAICompatible( if (res.status < 200 || res.status >= 300) { const detail = JSON.stringify(res.data).slice(0, 400); - const displayDetail = detail && detail !== '""' ? ` Dettaglio: ${detail}` : ''; - const msg = `HTTP ${res.status}: ${detail}`; - reportGenAppError(`aiClient.${providerLabel}.http`, msg, { status: res.status, provider: providerLabel }); + reportGenAppError(`aiClient.${providerLabel}.http`, `HTTP ${res.status}`, { status: res.status, provider: providerLabel, detail }); + const msg = res.status === 401 || res.status === 403 + ? 'API key non valida o scaduta.' + : res.status === 429 + ? 'Limite di richieste raggiunto, riprova tra poco.' + : 'Il provider AI ha risposto con un errore.'; return { ok: false, error: msg }; } @@ -403,13 +400,11 @@ async function generateWithOpenAICompatible( continue; } + return { ok: true, data: validated.module }; } - return { - ok: false, - error: `Modulo non valido anche dopo retry: ${lastGenerationError}. Contenuto (prime 500 char): ${lastContent.slice(0, 500)}`, - }; + return { ok: false, error: 'Il provider AI non ha generato un modulo valido, riprova o semplifica la richiesta.' }; } async function generateWithClaude( @@ -463,8 +458,12 @@ async function generateWithClaude( if (res.status < 200 || res.status >= 300) { const detail = JSON.stringify(res.data).slice(0, 500); - const msg = `Claude API HTTP ${res.status}: ${detail}`; - reportGenAppError(`aiClient.${providerLabel}.http`, msg, { status: res.status }); + reportGenAppError(`aiClient.${providerLabel}.http`, `Claude HTTP ${res.status}`, { status: res.status, detail }); + const msg = res.status === 401 || res.status === 403 + ? 'API key Claude non valida o scaduta.' + : res.status === 429 + ? 'Limite di richieste Claude raggiunto, riprova tra poco.' + : 'Claude ha risposto con un errore.'; return { ok: false, error: msg }; } @@ -503,25 +502,18 @@ async function generateWithClaude( continue; } + return { ok: true, data: validated.module }; } - return { - ok: false, - error: `Claude: modulo non valido anche dopo retry: ${lastGenerationError}. Contenuto (prime 500 char): ${lastContent.slice(0, 500)}`, - }; + return { ok: false, error: 'Claude non ha generato un modulo valido, riprova o semplifica la richiesta.' }; } catch (e) { const err = e as Error & { code?: string }; - const msg = - err.message?.includes('Network Error') || err.code - ? `Errore di rete verso Claude API (${url}). Controlla connessione internet, API key, VPN/firewall/DNS e Base URL.` - : err.message || 'Errore Claude API'; - reportGenAppError(`aiClient.${providerLabel}.catch`, e, { - apiUrl: url.slice(0, 120), - code: err.code, - model, - }); - return { ok: false, error: msg }; + reportGenAppError(`aiClient.${providerLabel}.catch`, e, { apiUrl: url.slice(0, 120), code: err.code, model }); + if (err.message?.includes('Network Error') || err.code === 'ECONNREFUSED') { + return { ok: false, error: 'Impossibile connettersi a Claude API, controlla connessione e URL.' }; + } + return { ok: false, error: 'Claude ha risposto con un errore.' }; } } diff --git a/src/ai/modulePrompt.ts b/src/ai/modulePrompt.ts index 2e428ad..95e27ce 100644 --- a/src/ai/modulePrompt.ts +++ b/src/ai/modulePrompt.ts @@ -29,7 +29,8 @@ MANIFEST UI (albero dichiarativo) - Radice: { "type": "screen", "title": "...", "components": [ ... ] } oppure { "type": "navigator", ... } per moduli multi-pagina. - In "components" (screen, box, card) ogni elemento deve essere un OGGETTO con "type". Vietato inserire stringhe, numeri o null come elementi dell'array (errore di validazione). -- Tipi ammessi: navigator, screen, box, text, input, textarea, button, list, card, image, audioRecorder, qrScanner, gameView, gamepad. Non usare type "row" o "column" da soli: usa "box" con "direction": "row" | "column". +- Tipi ammessi: navigator, screen, box, text, input, textarea, button, list, card, image, audioRecorder, qrScanner, gameView, gamepad, webview. Non usare type "row" o "column" da soli: usa "box" con "direction": "row" | "column". +- webview: mostra un sito web INLINE nell'app (come un iframe). Props: src (URL stringa, obbligatoria), height (numero px, default 400). Esempio: { "type": "webview", "id": "wv", "src": "https://facebook.it", "height": 500 }. USA SEMPRE webview quando l'utente chiede di aprire un sito, una pagina web o un URL qualsiasi — è la scelta predefinita. Non richiede permessi. Puoi usare il dominio direttamente senza https:// (es. "src": "facebook.it") e l'app lo normalizza automaticamente. - box: contenitore flex. direction "row" (in riga) o "column" (default). Opzionali: gap, padding, wrap (boolean), alignItems (stretch|flex-start|flex-end|center|baseline), justifyContent (flex-start|flex-end|center|space-between|space-around|space-evenly), components: [ ... ]. Per griglie (calcolatrici, tastierini) usa più box annidate: una box column di righe, ogni riga una box row; sui button in riga usa "layout": { "flex": 1 } per larghezze uniformi. - layout (opzionale sui componenti): flex, flexGrow, flexShrink, width (numero o stringa tipo "32%"), minWidth, maxWidth, alignSelf, margin*, textAlign (left|center|right) per testo/campi. NON usare position absolute nel JSON: usa box + flex. - button: id, text. Se chiama codice: action (nome funzione in actions), actionInput opzionale. Se naviga: "navigate": "nomeSchermata" (oppure "__back" per tornare indietro) — in questo caso action non è necessaria. Le action hanno SEMPRE firma esattamente (api, input, state). @@ -59,7 +60,7 @@ CODE (stringa JavaScript eseguita in Hermes) - Graffe, parentesi, virgolette BILANCIATE. Chiudi correttamente module.exports e actions. - Controllo di flusso: se hai if (...) { return ...; } e poi un altro return per il ramo alternativo, usa else { return ...; }. Mai scrivere } return ... attaccato subito dopo la chiusura dell'if. - Variabili: dichiara OGNI variabile prima di usarla. Se una variabile deve essere usata dopo un if/for, dichiarala prima del blocco con let. MAI dichiarare const/let dentro un if e poi usarla fuori. Esempio corretto: let newBirdX = birdX + speed; if (newBirdX > W) { newBirdX = 0; } return { birdX: newBirdX }; -- Apri link / email / telefono: usa api.linking (dopo permesso linking), non oggetti globali Linking. +- Apri siti web: usa SEMPRE il componente webview nell'UI (src: url) — non api.linking. Per email/telefono/sms: usa api.linking con permesso "linking" in manifest. Non usare oggetti globali Linking. - Vietato nel sorgente: eval, Function, new Function, require, import/export ESM, process, global/globalThis, __dirname, __filename, fs, import dinamico, Linking., while(true), for(;;). - Calcolatrici / formule: MAI eval(...) o Function(...) sull'espressione mostrata — il modulo viene rifiutato. Usa stato (es. display, valore accumulato, operatore corrente) e nelle action applica + − × ÷ tra numeri già noti, oppure costruisci il risultato tasto per tasto come fanno le calcolatrici classiche. @@ -72,7 +73,7 @@ API DISPONIBILI (solo con permesso manifest + consenso utente) - Torcia: permesso "torch" → await api.torch.setEnabled(true|false). La torcia si spegne automaticamente all'uscita dal modulo. - GPS/posizione: permesso "location" → const pos = await api.location.getCurrentPosition(); → { latitude, longitude, accuracy, altitude, heading, speed, timestamp }. - Sensori: permesso "sensors" → api.sensors.getAccelerometer() / getGyroscope() / getMagnetometer() / getBarometer() / getLight(); ogni chiamata legge un campione → Record. Gestisci errori: non tutti i dispositivi hanno tutti i sensori. -- Link / telefono / email / SMS: permesso "linking" → api.linking.openUrl(url) / dialPhone(phone) / sendSms(phone, body) / composeEmail({ to, subject, body }); → { opened: boolean }. Solo tel:, sms:, mailto: — nessun URL http. +- Link / telefono / email / SMS: permesso "linking" → api.linking.openUrl(url) / dialPhone(phone) / sendSms(phone, body) / composeEmail({ to, subject, body }); → { opened: boolean }. ATTENZIONE: "linking" deve essere in manifest.permissions — se mancante la chiamata viene silenziosamente ignorata. Per aprire siti web usa SEMPRE il componente webview (nessun permesso necessario); riserva api.linking solo per tel:, sms:, mailto:. - Storage key-value: permesso "storage" → api.storage.save(key, value) / load(key) / list() / delete(key). - Network: permesso "network" → api.network.fetch(url, { method, headers, body }) → { ok, status, text }. - Notifiche locali: permesso "notifications" → await api.notifications.schedule(title, body, secondsFromNow) → id. Tre argomenti separati: titolo (stringa), testo (stringa), secondi (numero). @@ -101,24 +102,28 @@ STATO SICURO (obbligatorio — Hermes lancia ReferenceError se accedi a propriet MINI-GIOCHI (gameView) - Usa { "type": "gameView", "bind": "scene", "width": 320, "height": 480, "tickMs": 50, "tickAction": "onTick", "onTapAction": "onTap" } per creare un canvas di gioco animato. - bind: chiave di stato che contiene l'array di oggetti scena. Inizialmente [] (il renderer lo inizializza automaticamente). -- tickMs: millisecondi tra un tick e l'altro. 50 = 20fps (consigliato). Min 16ms. -- tickAction: action chiamata ad ogni tick del game loop. Riceve lo stato corrente e restituisce il patch (nuovi valori + nuova scena). È qui che vive tutta la fisica e la logica di gioco. +- tickMs: millisecondi tra un tick e l'altro. 50 = 20fps (consigliato). Min 16ms. Alternativa: "fps": 30 (il renderer calcola il tickMs automaticamente, range 10-60). +- tickAction: action chiamata ad ogni tick del game loop. Riceve lo stato corrente e restituisce il patch (nuovi valori + nuova scena). È qui che vive la logica di gioco. - onTapAction: action chiamata quando l'utente tocca il canvas. Nell'input riceve { x, y, jump } dove x/y sono coordinate del tocco in pixel e jump vale -8 come impulso standard. -- La scena è un array di oggetti disegnabili (la chiave bind deve contenere questo array nello stato): - - rettangolo: { "type": "rect", "x": 10, "y": 20, "w": 50, "h": 30, "color": "#ff0000", "radius": 4 } - - cerchio: { "type": "circle", "x": 160, "y": 100, "r": 20, "color": "#00ff88" } +- FISICA AUTOMATICA: se aggiungi a un oggetto scena i campi "vx", "vy", "gravity" (es. { "type": "circle", "id": "ball", "x": 100, "y": 50, "r": 12, "vx": 3, "vy": 0, "gravity": 0.5 }), il renderer applica automaticamente ad ogni tick: vy += gravity, x += vx, y += vy. L'oggetto deve avere un "id" per la collision detection automatica. Con la fisica automatica, in onTick non devi ricalcolare x/y/vx/vy — li leggi già aggiornati da state. +- FISICA AUTOMATICA — gravity a livello nodo: aggiungi "gravity": 0.4 direttamente sul nodo gameView per applicarla a tutti gli oggetti con vy. Con gravity sul nodo, puoi omettere gravity sui singoli oggetti. +- onCollideAction: action chiamata automaticamente quando due oggetti con "id" si sovrappongono. Riceve { a: idA, b: idB }. Utile per collisioni pallina-muro, giocatore-nemico, ecc. Aggiungila al nodo gameView: "onCollideAction": "onCollide". +- onOutOfBoundsAction: action chiamata quando un oggetto con "id" esce dai bordi. Riceve { id, x, y }. Utile per riposizionare proiettili o nemici usciti dallo schermo. Aggiungila al nodo gameView: "onOutOfBoundsAction": "onOut". +- bgColor: colore di sfondo del canvas (default '#101827'). Esempio: "bgColor": "#1a1a2e". +- La scena è un array di oggetti disegnabili: + - rettangolo: { "type": "rect", "id": "player", "x": 10, "y": 20, "w": 50, "h": 30, "color": "#ff0000", "radius": 4, "vx": 0, "vy": 0 } + - cerchio: { "type": "circle", "id": "ball", "x": 160, "y": 100, "r": 20, "color": "#00ff88", "vx": 3, "vy": -2 } - testo: { "type": "text", "x": 10, "y": 10, "text": "Score: 0", "color": "#ffffff", "fontSize": 16, "fontWeight": "700", "align": "left" } -- Nell'action onTick: leggi posizioni/velocità da state con fallback, calcola fisica (gravity, collisioni), costruisci un nuovo array scene, restituisci tutto nel patch. - Ogni gioco deve avere una scena visibile dal primo tick: sfondo colorato, personaggio/oggetto principale, ostacoli o target se presenti, testo score/status. Mai restituire scene vuota o solo uno sfondo nero. -- Nei giochi, onTick deve restituire SEMPRE un patch in ogni tick, anche quando non succede nulla. Evita di mettere il return principale solo dentro un if (es. solo quando mangia/collide). -- Nei giochi, calcola prima tutte le nuove variabili con let/const in alto: newX, newY, newVx, newVy, scene. Non usare mai una variabile "new..." o "jump" se non è stata dichiarata nella stessa action prima dell'uso. +- Nei giochi, onTick deve restituire SEMPRE un patch in ogni tick, anche quando non succede nulla. +- Nei giochi, calcola prima tutte le nuove variabili con let/const in alto: newX, newY, newVx, newVy, scene. Non usare mai una variabile se non è stata dichiarata nella stessa action prima dell'uso. - Nei giochi tipo Flappy: in onTap usa const jump = parseFloat(String(input.jump ?? '-8')) || -8; return { birdVy: jump }; Non scrivere return { birdVy: jump } senza dichiarare jump. - IMPORTANTE: non usare while(true) o loop infiniti. Il ticker viene chiamato automaticamente dal framework. - REGOLA CRITICA LUNGHEZZA: onTick deve stare in MAX 20 righe di codice. Se è più lungo, semplifica il gioco. Codice troppo lungo causa troncamento JSON e il modulo non funziona. -- USA SEMPRE Math.min/Math.max per bounds e collisioni — MAI if chains ripetuti. Esempio bounds: const nx = Math.max(R, Math.min(W-R, x+vx)); — questo è UNA riga invece di 4 if. NON scrivere mai lo stesso if più di una volta. -- Per giochi con gravità: applica vy += gravity ogni tick, poi y += vy; usa Math.min per il pavimento: const ny = Math.min(H-R, y+vy); const nvy = ny >= H-R ? 0 : vy + gravity; +- USA SEMPRE Math.min/Math.max per bounds e collisioni — MAI if chains ripetuti. Esempio bounds: const nx = Math.max(R, Math.min(W-R, x+vx)); — questo è UNA riga invece di 4 if. +- Per giochi con gravità MANUALE (senza fisica automatica): applica vy += gravity ogni tick, poi y += vy; usa Math.min per il pavimento: const ny = Math.min(H-R, y+vy); const nvy = ny >= H-R ? 0 : vy + gravity; - Per collisioni semplici: usa un singolo if per ciascuna interazione (es. pallina-bordo, personaggio-pavimento), non ripetere. -- Esempio pattern tick (bounce): const x=parseFloat(String(state.bx??'160')); const y=parseFloat(String(state.by??'80')); const vx=parseFloat(String(state.vx??'3')); const vy=parseFloat(String(state.vy??'3')); const W=320,H=420,R=10; const nx=x+vx; const ny=y+vy; const nvx=(nxW-R)?-vx:vx; const nvy=(nyH-R)?-vy:vy; const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#111'},{type:'circle',x:Math.max(R,Math.min(W-R,nx)),y:Math.max(R,Math.min(H-R,ny)),r:R,color:'#6cf'}]; return {bx:Math.max(R,Math.min(W-R,nx)),by:Math.max(R,Math.min(H-R,ny)),vx:nvx,vy:nvy,scene}; +- Esempio pattern tick (bounce senza fisica automatica): const x=parseFloat(String(state.bx??'160')); const y=parseFloat(String(state.by??'80')); const vx=parseFloat(String(state.vx??'3')); const vy=parseFloat(String(state.vy??'3')); const W=320,H=420,R=10; const nx=x+vx; const ny=y+vy; const nvx=(nxW-R)?-vx:vx; const nvy=(nyH-R)?-vy:vy; const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#111'},{type:'circle',id:'ball',x:Math.max(R,Math.min(W-R,nx)),y:Math.max(R,Math.min(H-R,ny)),r:R,color:'#6cf'}]; return {bx:Math.max(R,Math.min(W-R,nx)),by:Math.max(R,Math.min(H-R,ny)),vx:nvx,vy:nvy,scene}; GAMEPAD (controlli on-screen per giochi) - Usa { "type": "gamepad", "direction": "row"|"dpad"|"split", "buttons": [...], "buttonSize": 64 } per aggiungere pulsanti fisici a schermo. diff --git a/src/capabilities/linking.ts b/src/capabilities/linking.ts index 1f14c9d..c260611 100644 --- a/src/capabilities/linking.ts +++ b/src/capabilities/linking.ts @@ -1,16 +1,13 @@ import * as Linking from 'expo-linking'; -const ALLOWED = ['mailto:', 'tel:', 'sms:']; +const BLOCKED = ['javascript:', 'file:', 'blob:']; function assertAllowedUrl(url: string): string { const t = url.trim(); const low = t.toLowerCase(); - if (low.startsWith('javascript:') || low.startsWith('file:') || low.startsWith('blob:')) { + if (BLOCKED.some((p) => low.startsWith(p))) { throw new Error('Schema URL non consentito'); } - if (!ALLOWED.some((p) => low.startsWith(p))) { - throw new Error('Solo mailto:, tel: e sms: sono consentiti (usa api.linking).'); - } return t; } diff --git a/src/modules/moduleValidator.ts b/src/modules/moduleValidator.ts index 3fcb8f2..b3a8261 100644 --- a/src/modules/moduleValidator.ts +++ b/src/modules/moduleValidator.ts @@ -26,6 +26,8 @@ function collectButtonActions(node: UiNode): string[] { const acts: string[] = []; if (node.tickAction) acts.push(node.tickAction); if (node.onTapAction) acts.push(node.onTapAction); + if (node.onCollideAction) acts.push(node.onCollideAction); + if (node.onOutOfBoundsAction) acts.push(node.onOutOfBoundsAction); return acts; } if ('components' in node && Array.isArray(node.components)) { @@ -60,34 +62,31 @@ export function validateGeneratedModule(raw: unknown): ValidateResult { ? Object.keys(normalized as object).slice(0, 30) : [], }); - return { ok: false, error: errText }; + return { ok: false, error: 'Il modulo generato ha una struttura non valida.' }; } const scan = scanGeneratedCode(parsed.data.code); if (!scan.ok) { - const err = formatCodeScanError(scan.reason); reportGenAppError('moduleValidator.codeScan', new Error(scan.reason), { codeHead: parsed.data.code.slice(0, 800), }); - return { ok: false, error: err }; + return { ok: false, error: 'Il codice contiene istruzioni non permesse.' }; } - // Cross-valida: ogni action dichiarata nei button deve esistere nel codice compilato. const compileResult = tryCompileModuleActions(parsed.data.code); if (!compileResult.ok) { reportGenAppError('moduleValidator.compile', new Error(compileResult.error), { codeHead: parsed.data.code.slice(0, 800), }); - return { ok: false, error: compileResult.error }; + return { ok: false, error: 'Il codice del modulo non è eseguibile.' }; } const uiActions = [...new Set(collectButtonActions(parsed.data.ui))]; const missing = uiActions.filter((a) => !(a in compileResult.actions)); if (missing.length > 0) { - const err = `Azioni nell'UI non trovate nel codice: ${missing.join(', ')}. Rigenerare il modulo.`; - reportGenAppError('moduleValidator.actionMismatch', new Error(err), { + reportGenAppError('moduleValidator.actionMismatch', new Error(`Azioni mancanti: ${missing.join(', ')}`), { uiActions, compiledActions: Object.keys(compileResult.actions), }); - return { ok: false, error: err }; + return { ok: false, error: 'Le azioni del modulo non corrispondono al codice.' }; } return { ok: true, module: parsed.data }; diff --git a/src/renderer/DynamicRenderer.tsx b/src/renderer/DynamicRenderer.tsx index b40171c..61904c6 100644 --- a/src/renderer/DynamicRenderer.tsx +++ b/src/renderer/DynamicRenderer.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { getJsonResponseRetryHint } from '../ai/modulePrompt'; import type { MotherApi } from '../capabilities/types'; import type { NavigatorScreen, UiNode } from '../types/uiNodes'; import { reportGenAppError } from '../debug/genAppDebug'; @@ -24,6 +23,8 @@ function collectButtonActions(node: UiNode): string[] { const acts: string[] = []; if (node.tickAction) acts.push(node.tickAction); if (node.onTapAction) acts.push(node.onTapAction); + if (node.onCollideAction) acts.push(node.onCollideAction); + if (node.onOutOfBoundsAction) acts.push(node.onOutOfBoundsAction); return acts; } if ('components' in node && Array.isArray(node.components)) { @@ -112,19 +113,10 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { const canGoBack = screenStack.length > 1; const onNavigate = useCallback((target: string) => { - console.log('[Navigator] onNavigate called, target:', target); if (target === '__back') { - setScreenStack((s) => { - const next = s.length > 1 ? s.slice(0, -1) : s; - console.log('[Navigator] back, stack:', next); - return next; - }); + setScreenStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); } else { - setScreenStack((s) => { - const next = [...s, target]; - console.log('[Navigator] push, stack:', next); - return next; - }); + setScreenStack((s) => [...s, target]); } }, []); @@ -132,10 +124,6 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { (patch: Record) => { const { __navigate, ...rest } = patch as Record & { __navigate?: unknown }; if (typeof __navigate === 'string') { - console.log('[patchState] __navigate:', __navigate, '| isNavigator:', isNavigator); - if (!isNavigator) { - console.warn('[patchState] __navigate ignorato: il root UI è "screen", non "navigator". Genera il modulo con type:"navigator" e le schermate in "screens".'); - } onNavigate(__navigate); } if (Object.keys(rest).length > 0) { @@ -158,7 +146,7 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { const delta = await runAction(actions, actionName, motherApi, input, stateRef.current); if (delta && typeof delta === 'object') patchState(delta as Record); } catch (e) { - setError((e as Error).message ?? 'Errore sconosciuto'); + setError('Azione non riuscita, riprova.'); reportGenAppError('DynamicRenderer.action', e, { action: actionName }); } finally { setBusyAction(null); @@ -186,7 +174,7 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { }) .catch((e) => { if (cancelled) return; - setError((e as Error).message ?? 'Errore onFocus'); + setError('Errore durante il caricamento della schermata.'); reportGenAppError('DynamicRenderer.onFocus', e, { action: actionName, screen: currentScreenKey }); }) .finally(() => { @@ -201,7 +189,6 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { return ( {t.rendererError} - {getJsonResponseRetryHint()} {compileError} ); @@ -253,7 +240,6 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { {nodeToRender ? renderNode(nodeToRender, ctx, isNavigator ? currentScreenKey : 'root') : null} {error ? ( - {getJsonResponseRetryHint()} {error} ) : null} diff --git a/src/renderer/components.tsx b/src/renderer/components.tsx index 131f000..ea55dc1 100644 --- a/src/renderer/components.tsx +++ b/src/renderer/components.tsx @@ -12,6 +12,7 @@ import { type TextStyle, type ViewStyle, } from 'react-native'; +import * as WebBrowser from 'expo-web-browser'; import type { UiLayoutProps, UiNode, UiStyleProps, UiTheme } from '../types/uiNodes'; export type ResolvedTheme = Required; @@ -37,6 +38,35 @@ export function resolveTheme(theme?: UiTheme): ResolvedTheme { }; } +const PRIMARY_FIELDS = ['name', 'nome', 'title', 'titolo', 'label', 'text', 'testo', 'description', 'descrizione', 'value', 'valore', 'item', 'elemento']; + +function resolveListItem(item: unknown): { primary: string; secondary?: string } { + let resolved: unknown = item; + if (typeof item === 'string') { + const t = item.trim(); + if (t.startsWith('{') && t.endsWith('}')) { + try { resolved = JSON.parse(t); } catch { /* keep as string */ } + } + } + if (typeof resolved === 'string') return { primary: resolved }; + if (typeof resolved !== 'object' || resolved === null) return { primary: String(resolved) }; + const obj = resolved as Record; + let primary = ''; + for (const f of PRIMARY_FIELDS) { + if (typeof obj[f] === 'string' && obj[f]) { primary = obj[f] as string; break; } + } + if (!primary) { + const first = Object.values(obj).find((v) => typeof v === 'string' && v); + primary = typeof first === 'string' ? first : JSON.stringify(obj); + } + const secondary = Object.entries(obj) + .filter(([, v]) => (typeof v === 'string' || typeof v === 'number') && v !== primary && v !== '') + .slice(0, 4) + .map(([, v]) => String(v)) + .join(' · ') || undefined; + return { primary, secondary }; +} + function layoutToViewStyle(layout?: UiLayoutProps): ViewStyle { if (!layout) return {}; const s: ViewStyle = {}; @@ -309,13 +339,20 @@ function GamepadNode({ } // ───────────────────────────────────────────────────────────────────────────── -// GameView — canvas dichiarativa + game loop ticker + tap input +// GameView — canvas dichiarativa + game loop ticker + tap input + physics // ───────────────────────────────────────────────────────────────────────────── -type SceneRect = { type: 'rect'; x: number; y: number; w: number; h: number; color?: string; radius?: number }; -type SceneCircle = { type: 'circle'; x: number; y: number; r: number; color?: string }; -type SceneText = { type: 'text'; x: number; y: number; text: string; color?: string; fontSize?: number; fontWeight?: string; align?: 'left' | 'center' | 'right' }; -type SceneObj = SceneRect | SceneCircle | SceneText; +type SceneObj = { + type: 'rect' | 'circle' | 'text'; + id?: string; + x: number; y: number; + vx?: number; vy?: number; + gravity?: number; + w?: number; h?: number; r?: number; + color?: string; radius?: number; + text?: string; fontSize?: number; fontWeight?: string; + align?: 'left' | 'center' | 'right'; +}; function renderSceneObj(raw: unknown, i: number): ReactNode { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; @@ -405,9 +442,16 @@ function buildFallbackScene(w: number, h: number, hasTick: boolean): SceneObj[] ]; } -// Suppress unused-type warnings for the scene type aliases above. void (0 as unknown as SceneObj); +function aabbOverlap(a: SceneObj, b: SceneObj): boolean { + const ax1 = a.x, ax2 = a.x + (a.w ?? (a.r ?? 10) * 2); + const ay1 = a.y, ay2 = a.y + (a.h ?? (a.r ?? 10) * 2); + const bx1 = b.x, bx2 = b.x + (b.w ?? (b.r ?? 10) * 2); + const by1 = b.y, by2 = b.y + (b.h ?? (b.r ?? 10) * 2); + return ax1 < bx2 && ax2 > bx1 && ay1 < by2 && ay2 > by1; +} + function GameViewNode({ node, ctx, @@ -417,30 +461,91 @@ function GameViewNode({ ctx: RenderCtx; nodeKey: string; }) { - // Usa un ref per onButton così il ticker non ri-crea l'interval ad ogni render. const onButtonRef = useRef(ctx.onButton); onButtonRef.current = ctx.onButton; + const stateRef = useRef(ctx.state); + stateRef.current = ctx.state; + const setStateRef = useRef(ctx.setState); + setStateRef.current = ctx.setState; const tickBusyRef = useRef(false); + const effectiveTickMs = node.tickMs + ? Math.max(16, node.tickMs) + : node.fps + ? Math.round(1000 / Math.min(60, Math.max(10, node.fps))) + : 50; + + const gameGravity = node.gravity ?? 0; + useEffect(() => { if (ctx.hasError) return; - if (!node.tickAction || !node.tickMs) return; - const ms = Math.max(16, node.tickMs); + if (!node.tickAction) return; + + const gw = node.width ?? 320; + const gh = node.height ?? 300; + const runTick = () => { - if (tickBusyRef.current) return; // salta tick se il precedente è ancora in volo + if (tickBusyRef.current) return; tickBusyRef.current = true; + + // Physics pre-step: applica gravità e velocità agli oggetti con vx/vy + const rawScene = stateRef.current[node.bind]; + if (Array.isArray(rawScene) && (gameGravity !== 0 || rawScene.some((o: unknown) => { + const obj = o as SceneObj; + return (obj.vx != null && obj.vx !== 0) || (obj.vy != null && obj.vy !== 0) || obj.gravity != null; + }))) { + const newScene = rawScene.map((raw: unknown) => { + const o = raw as SceneObj; + if (o.vx == null && o.vy == null && o.gravity == null) return o; + const g = o.gravity ?? gameGravity; + const nvx = o.vx ?? 0; + const nvy = (o.vy ?? 0) + g; + return { ...o, x: o.x + nvx, y: o.y + nvy, vx: nvx, vy: nvy }; + }); + + // Collision detection (AABB) tra oggetti con id + if (node.onCollideAction) { + const named = newScene.filter((o: unknown) => (o as SceneObj).id) as SceneObj[]; + for (let i = 0; i < named.length; i++) { + for (let j = i + 1; j < named.length; j++) { + if (aabbOverlap(named[i], named[j])) { + void onButtonRef.current(node.onCollideAction!, { a: named[i].id, b: named[j].id }); + } + } + } + } + + // Out of bounds detection + if (node.onOutOfBoundsAction) { + for (const raw of newScene) { + const o = raw as SceneObj; + if (!o.id) continue; + const ow = o.w ?? (o.r ?? 10) * 2; + const oh = o.h ?? (o.r ?? 10) * 2; + if (o.x + ow < 0 || o.x > gw || o.y + oh < 0 || o.y > gh) { + void onButtonRef.current(node.onOutOfBoundsAction!, { id: o.id, x: o.x, y: o.y }); + } + } + } + + setStateRef.current({ [node.bind]: newScene }); + } + onButtonRef.current(node.tickAction!, {}).finally(() => { tickBusyRef.current = false; }); }; + runTick(); - const id = setInterval(runTick, ms); + const id = setInterval(runTick, effectiveTickMs); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ctx.hasError, node.tickAction, node.tickMs]); + }, [ctx.hasError, node.tickAction, effectiveTickMs, node.bind, gameGravity, node.onCollideAction, node.onOutOfBoundsAction]); const gw = node.width ?? 320; const gh = node.height ?? 300; + const bgColor = node.bgColor ?? '#101827'; + const borderColor = node.borderColor ?? '#2d3f5c'; const rawScene = ctx.state[node.bind]; const scene: unknown[] = @@ -453,12 +558,12 @@ function GameViewNode({ style={{ width: gw, height: gh, - backgroundColor: '#101827', + backgroundColor: bgColor, overflow: 'hidden', borderRadius: 12, alignSelf: 'center', borderWidth: 1, - borderColor: '#2d3f5c', + borderColor, ...layoutToViewStyle(node.layout), }} > @@ -476,7 +581,7 @@ function GameViewNode({ }} > - Il gioco si e' fermato. Controlla l'errore sotto e rigenera il modulo. + Il gioco si è fermato. Controlla l'errore e rigenera il modulo. ) : null} @@ -640,15 +745,10 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea ); const onPress = () => { - console.log('[Button] pressed', { id: node.id, navigate: node.navigate, action: actionName }); if (node.navigate) { - console.log('[Button] calling onNavigate →', node.navigate); ctx.onNavigate(node.navigate); } else if (actionName) { - console.log('[Button] calling onButton →', actionName); void ctx.onButton(actionName, node.actionInput ?? {}); - } else { - console.log('[Button] no navigate and no action — nothing to do'); } }; @@ -681,11 +781,16 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea ListEmptyComponent={ {node.emptyText ?? 'Nessun elemento'} } - renderItem={({ item }) => ( - - {typeof item === 'string' ? item : JSON.stringify(item)} - - )} + renderItem={({ item }) => { + console.log('[list] item type:', typeof item, '| value:', JSON.stringify(item)); + const { primary, secondary } = resolveListItem(item); + return ( + + {primary} + {secondary ? {secondary} : null} + + ); + }} /> ); } @@ -776,6 +881,54 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea case 'gamepad': return ; + case 'webview': { + const rawUrl = node.src ?? ''; + const webUrl = rawUrl && !rawUrl.startsWith('http://') && !rawUrl.startsWith('https://') + ? 'https://' + rawUrl + : rawUrl; + const displayUrl = webUrl.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, ''); + const openBrowser = async () => { + if (!webUrl) return; + try { + await WebBrowser.openBrowserAsync(webUrl); + } catch { + // silently ignore + } + }; + return ( + { void openBrowser(); }} + style={({ pressed }) => [ + applyStyle( + { + borderWidth: 1, + borderColor: t.border, + borderRadius: 14, + padding: 14, + backgroundColor: t.surface, + gap: 10, + alignItems: 'center', + flexDirection: 'row', + opacity: pressed ? 0.75 : 1, + }, + node.style + ), + layoutToViewStyle(node.layout) as ViewStyle, + ]} + > + 🌐 + + + {displayUrl || webUrl} + + Tocca per aprire nel browser + + Apri → + + ); + } + default: return null; } diff --git a/src/types/generatedModule.ts b/src/types/generatedModule.ts index 91dbcea..f4c034d 100644 --- a/src/types/generatedModule.ts +++ b/src/types/generatedModule.ts @@ -226,8 +226,23 @@ export const uiNodeSchema: ZodType = z.lazy(() => { width: z.number().optional(), height: z.number().optional(), tickMs: z.number().optional(), + fps: z.number().min(10).max(60).optional(), tickAction: z.string().optional(), onTapAction: z.string().optional(), + gravity: z.number().optional(), + bgColor: z.string().optional(), + borderColor: z.string().optional(), + onCollideAction: z.string().optional(), + onOutOfBoundsAction: z.string().optional(), + }) + .extend({ layout: uiLayoutPropsSchema.optional() }), + z + .object({ + type: z.literal('webview'), + id: z.string().optional(), + src: z.string().min(4), + height: z.number().optional(), + style: uiStylePropsSchema.optional(), }) .extend({ layout: uiLayoutPropsSchema.optional() }), ]); diff --git a/src/types/uiNodes.ts b/src/types/uiNodes.ts index 9d71ea2..3ff13ca 100644 --- a/src/types/uiNodes.ts +++ b/src/types/uiNodes.ts @@ -146,15 +146,60 @@ export type UiNode = | { type: 'gameView'; id?: string; - /** Chiave di stato che contiene l'array di oggetti scena da disegnare. */ + /** Chiave di stato che contiene l'array di SceneObject da disegnare. */ bind: string; width?: number; height?: number; - /** Millisecondi tra un tick e l'altro (default 50 = 20fps). Min 16ms. */ + /** Millisecondi tra un tick e l'altro (default 50 = 20fps). Min 16ms. Precede fps. */ tickMs?: number; + /** Frame rate target (10-60, default 20). Ignorato se tickMs è definito. */ + fps?: number; /** Nome dell'action chiamata ad ogni tick del loop di gioco. */ tickAction?: string; /** Nome dell'action chiamata al tap sul canvas; riceve { x, y } nell'input. */ onTapAction?: string; + /** Accelerazione gravitazionale verso il basso applicata automaticamente agli oggetti con vy (default 0). */ + gravity?: number; + /** Colore di sfondo del canvas (default '#101827'). */ + bgColor?: string; + /** Colore del bordo del canvas. */ + borderColor?: string; + /** Action chiamata quando due oggetti con id si sovrappongono; riceve { a: idA, b: idB }. */ + onCollideAction?: string; + /** Action chiamata quando un oggetto con id esce dai bordi del canvas; riceve { id, x, y }. */ + onOutOfBoundsAction?: string; + layout?: UiLayoutProps; + } + | { + type: 'webview'; + id?: string; + /** URL da aprire nella WebView. */ + src: string; + /** Altezza della WebView in px (default 400). */ + height?: number; + style?: UiStyleProps; layout?: UiLayoutProps; }; + +/** Oggetto della scena di gioco. I campi vx/vy/gravity abilitano fisica automatica nel renderer. */ +export type SceneObject = { + type: 'rect' | 'circle' | 'text'; + id?: string; + x: number; + y: number; + /** Velocità orizzontale (px/tick). */ + vx?: number; + /** Velocità verticale (px/tick). */ + vy?: number; + /** Override locale della gravità per questo oggetto. */ + gravity?: number; + w?: number; + h?: number; + r?: number; + color?: string; + radius?: number; + text?: string; + fontSize?: number; + fontWeight?: string; + align?: 'left' | 'center' | 'right'; +}; diff --git a/src/utils/deviceLayout.ts b/src/utils/deviceLayout.ts new file mode 100644 index 0000000..bf84018 --- /dev/null +++ b/src/utils/deviceLayout.ts @@ -0,0 +1,19 @@ +import { useWindowDimensions } from 'react-native'; + +export type DeviceLayout = { + isTablet: boolean; + screenWidth: number; + screenHeight: number; + columns: number; +}; + +export function useDeviceLayout(): DeviceLayout { + const { width, height } = useWindowDimensions(); + const isTablet = width >= 768; + return { + isTablet, + screenWidth: width, + screenHeight: height, + columns: isTablet ? 2 : 1, + }; +} diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts new file mode 100644 index 0000000..3d9f175 --- /dev/null +++ b/src/utils/errorMessages.ts @@ -0,0 +1,31 @@ +export function toUserMessage(error: unknown, scope: string): string { + const msg = error instanceof Error ? error.message : String(error ?? ''); + + if (scope.startsWith('aiClient.ollama')) { + if (msg.includes('ECONNREFUSED') || msg.includes('Network Error') || msg.includes('ETIMEDOUT')) + return 'Impossibile connettersi a Ollama, controlla URL e rete.'; + if (msg.includes('timeout')) return 'Timeout della richiesta a Ollama.'; + if (msg.includes('401') || msg.includes('403')) return 'Autenticazione Ollama fallita.'; + return 'Ollama ha risposto con un errore.'; + } + if (scope.startsWith('aiClient.claude')) { + if (msg.includes('Network Error') || msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) + return 'Impossibile connettersi a Claude API, controlla connessione e URL.'; + if (msg.includes('401') || msg.includes('403')) return 'API key Claude non valida o scaduta.'; + if (msg.includes('429')) return 'Limite di richieste Claude raggiunto, riprova tra poco.'; + return 'Claude ha risposto con un errore.'; + } + if (scope.startsWith('aiClient.openai')) { + if (msg.includes('Network Error') || msg.includes('ECONNREFUSED')) + return 'Impossibile connettersi al provider AI.'; + if (msg.includes('401') || msg.includes('403')) return 'API key non valida.'; + return 'Il provider AI ha risposto con un errore.'; + } + if (scope === 'validator.zod') return 'Il modulo generato ha una struttura non valida.'; + if (scope === 'validator.codeScan') return 'Il codice contiene istruzioni non permesse.'; + if (scope === 'validator.compile') return 'Il codice del modulo non è eseguibile.'; + if (scope === 'renderer.action') return 'Azione non riuscita, riprova.'; + if (scope === 'renderer.onFocus') return 'Errore durante il caricamento della schermata.'; + + return 'Si è verificato un errore, riprova.'; +}