diff --git a/README.md b/README.md
index 4b0c06a..5afaff1 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
+

+
# 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.';
+}