From 8c0baf9a21139ff80f00d1072f143da90af8296a Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 18:23:17 +0200 Subject: [PATCH 1/6] Settings screen improvements Created loading screen Added form editing functionality --- package-lock.json | 812 +++++++++++++++++++++++++++++++ package.json | 4 + src/i18n/translations.ts | 290 +++++++++++ src/modules/moduleStore.ts | 41 ++ src/modules/moduleValidator.ts | 120 ++++- src/renderer/DynamicRenderer.tsx | 31 +- src/renderer/components.tsx | 627 +++++++++++++++++++----- src/settings/settingsStore.ts | 6 +- src/types/generatedModule.ts | 9 + src/types/uiNodes.ts | 12 +- 10 files changed, 1804 insertions(+), 148 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3733287..adafcbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", + "@shopify/react-native-skia": "2.2.12", "axios": "^1.15.2", "babel-preset-expo": "~54.0.10", "expo": "~54.0.33", @@ -31,8 +32,10 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-native": "0.81.5", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", "zod": "^4.3.6" }, "devDependencies": { @@ -1353,6 +1356,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", @@ -3235,6 +3254,540 @@ "node": ">= 20.19.4" } }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz", + "integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.85.3", + "hermes-parser": "0.33.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-plugin-codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", + "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.85.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/babel-preset": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz", + "integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.85.3", + "babel-plugin-syntax-hermes-parser": "0.33.3", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/@react-native/codegen": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", + "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.33.3", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", + "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-parser": "0.33.3" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.33.3" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz", + "integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/js-polyfills": "0.85.3", + "@react-native/metro-babel-transformer": "0.85.3", + "metro-config": "^0.84.3", + "metro-runtime": "^0.84.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", + "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/metro-config/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/metro-config/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-babel-transformer": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-cache": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", + "license": "MIT", + "peer": true, + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-cache-key": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-config": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-file-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-minify-terser": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-resolver": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-runtime": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-source-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.4", + "nullthrows": "^1.1.1", + "ob1": "0.84.4", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-symbolicate": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.4", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-transform-plugins": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/metro-transform-worker": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-config/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/metro-config/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@react-native/metro-config/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/metro-config/node_modules/ob1": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, "node_modules/@react-native/normalize-colors": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", @@ -3376,6 +3929,32 @@ "nanoid": "^3.3.11" } }, + "node_modules/@shopify/react-native-skia": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz", + "integrity": "sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A==", + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.31.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=19.0", + "react-native": ">=0.78", + "react-native-reanimated": ">=3.19.1" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -3543,6 +4122,12 @@ "@urql/core": "^5.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -4140,6 +4725,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4342,6 +4933,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4680,6 +5280,56 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4829,6 +5479,61 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4897,6 +5602,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -7620,6 +8337,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -8204,6 +8927,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -8875,6 +9610,21 @@ "react-native": "*" } }, + "node_modules/react-native-reanimated": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", + "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "^7.7.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.78 - 0.82", + "react-native-worklets": "0.5 - 0.8" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", @@ -8900,6 +9650,47 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", + "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.81 - 0.85" + } + }, "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", @@ -8984,6 +9775,27 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index 8357302..3baf7ba 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", + "@shopify/react-native-skia": "2.2.12", "axios": "^1.15.2", "babel-preset-expo": "~54.0.10", "expo": "~54.0.33", @@ -33,8 +34,11 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-native": "0.81.5", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-webview": "13.15.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index ffe31c0..ecfb2b3 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -79,6 +79,36 @@ export type Strings = { permAllow: string; permDeny: string; + // Generating modal + genModalBuilding: string; + genModalDone: string; + genStep1: string; + genStep2: string; + genStep3: string; + genHint: string; + genStepPending: string; + genStepActive: string; + genStepDone: string; + + // Human-readable error messages (function receives raw error string) + humanizeError: (raw: string) => string; + + // Style editor + styleEditorTitle: string; + styleEditorColors: string; + styleEditorTexts: string; + styleEditorSave: string; + styleEditorApply: string; + styleEditorNoEditable: string; + + // Regenerate sheet + regenBtn: string; + regenSheetTitle: string; + regenOriginalLabel: string; + regenTweakLabel: string; + regenTweakPlaceholder: string; + regenSuccess: string; + // Renderer rendererError: string; rendererRootError: string; @@ -154,6 +184,58 @@ const it: Strings = { permAllow: 'Consenti e apri', permDeny: 'Nega accesso', + genModalBuilding: 'Stiamo costruendo la tua app…', + genModalDone: 'App creata!', + genStep1: 'Invio al modello', + genStep2: 'Elaborazione', + genStep3: 'Creazione', + genHint: 'Questo può richiedere 10–60 secondi a seconda del modello', + genStepPending: 'in attesa', + genStepActive: 'in corso…', + genStepDone: 'fatto', + humanizeError: (raw) => { + if (/kv.?cache|kv cache/i.test(raw)) + return 'Il modello è sovraccarico: la richiesta è troppo lunga. Prova a descrivere l\'app in modo più breve.'; + if (/HTTP 401|invalid.{0,20}api.{0,10}key|api.{0,10}key.{0,10}invalid|authentication/i.test(raw)) + return 'API key non valida. Controlla la chiave nelle Impostazioni → provider.'; + if (/HTTP 429|rate.?limit|quota/i.test(raw)) + return 'Troppe richieste in poco tempo. Aspetta qualche secondo e riprova.'; + if (/HTTP 5\d\d|internal server|bad gateway/i.test(raw)) + return 'Il server AI ha avuto un problema temporaneo. Riprova tra qualche secondo.'; + if (/ECONNREFUSED|ERR_NETWORK|Network Error|impossibile contattare|connection refused/i.test(raw)) + return 'Impossibile raggiungere il server AI. Controlla la connessione e l\'URL nelle Impostazioni.'; + if (/timeout|timed out/i.test(raw)) + return 'Il modello ha impiegato troppo tempo. Prova con una richiesta più semplice o riprova tra poco.'; + if (/modulo non valido|validazione schema/i.test(raw)) + return 'Il modello non ha generato un\'app valida. Riprova riformulando la richiesta.'; + if (/json|JSON/i.test(raw)) + return 'Il modello ha risposto in modo non leggibile. Riprova — di solito funziona al secondo tentativo.'; + if (/compilazione|JavaScript.*non valido|compile/i.test(raw)) + return 'Il codice generato non è valido. Prova con una descrizione più semplice.'; + if (/vuota|vuoto|empty/i.test(raw)) + return 'Il modello ha risposto con un messaggio vuoto. Riprova.'; + if (/URL.*non valido|url.{0,10}invalid/i.test(raw)) + return 'L\'URL del server non è valido. Controlla le Impostazioni.'; + if (/inserisci.*api.*key|api key.*mancante/i.test(raw)) + return 'Manca la API key. Aggiungila nelle Impostazioni.'; + const trimmed = raw.replace(/\s*\(prime \d+ char\).*$/s, '').trim(); + return trimmed.length > 180 ? trimmed.slice(0, 177) + '…' : trimmed; + }, + + styleEditorTitle: 'Modifica stile', + styleEditorColors: 'COLORI', + styleEditorTexts: 'TESTI', + styleEditorSave: 'Salva modifiche', + styleEditorApply: 'Applica', + styleEditorNoEditable: 'Nessun elemento modificabile trovato in questo modulo.', + + regenBtn: 'Rigenera', + regenSheetTitle: 'Rigenera modulo', + regenOriginalLabel: 'Prompt originale', + regenTweakLabel: 'Accorgimenti (opzionale)', + regenTweakPlaceholder: 'Es: aggiungi un timer, cambia i colori, aumenta la velocità…', + regenSuccess: 'Modulo aggiornato!', + rendererError: 'Modulo non caricabile', rendererRootError: 'La radice UI deve essere screen o navigator.', back: 'Indietro', @@ -228,6 +310,58 @@ const en: Strings = { permAllow: 'Allow and open', permDeny: 'Deny access', + genModalBuilding: 'Building your app…', + genModalDone: 'App created!', + genStep1: 'Sending to model', + genStep2: 'Processing', + genStep3: 'Creating', + genHint: 'This may take 10–60 seconds depending on the model', + genStepPending: 'waiting', + genStepActive: 'in progress…', + genStepDone: 'done', + humanizeError: (raw) => { + if (/kv.?cache|kv cache/i.test(raw)) + return 'The model is overloaded: the request is too long. Try describing the app more briefly.'; + if (/HTTP 401|invalid.{0,20}api.{0,10}key|api.{0,10}key.{0,10}invalid|authentication/i.test(raw)) + return 'Invalid API key. Check the key in Settings → provider.'; + if (/HTTP 429|rate.?limit|quota/i.test(raw)) + return 'Too many requests. Wait a few seconds and try again.'; + if (/HTTP 5\d\d|internal server|bad gateway/i.test(raw)) + return 'The AI server had a temporary issue. Try again in a few seconds.'; + if (/ECONNREFUSED|ERR_NETWORK|Network Error|connection refused/i.test(raw)) + return 'Cannot reach the AI server. Check your connection and the URL in Settings.'; + if (/timeout|timed out/i.test(raw)) + return 'The model took too long. Try a simpler request or retry shortly.'; + if (/modulo non valido|validazione schema/i.test(raw)) + return 'The model did not generate a valid app. Try rephrasing the request.'; + if (/json|JSON/i.test(raw)) + return 'The model responded in an unreadable format. Retry — it usually works on the second try.'; + if (/compilazione|JavaScript.*non valido|compile/i.test(raw)) + return 'The generated code is not valid. Try a simpler description.'; + if (/empty/i.test(raw)) + return 'The model returned an empty response. Please retry.'; + if (/URL.*non valido|url.{0,10}invalid/i.test(raw)) + return 'The server URL is invalid. Check Settings.'; + if (/inserisci.*api.*key|api key.*mancante/i.test(raw)) + return 'API key is missing. Add it in Settings.'; + const trimmed = raw.replace(/\s*\(prime \d+ char\).*$/s, '').trim(); + return trimmed.length > 180 ? trimmed.slice(0, 177) + '…' : trimmed; + }, + + styleEditorTitle: 'Edit style', + styleEditorColors: 'COLOURS', + styleEditorTexts: 'TEXTS', + styleEditorSave: 'Save changes', + styleEditorApply: 'Apply', + styleEditorNoEditable: 'No editable elements found in this module.', + + regenBtn: 'Regenerate', + regenSheetTitle: 'Regenerate module', + regenOriginalLabel: 'Original prompt', + regenTweakLabel: 'Tweaks (optional)', + regenTweakPlaceholder: 'E.g.: add a timer, change colours, increase speed…', + regenSuccess: 'Module updated!', + rendererError: 'Module could not load', rendererRootError: 'UI root must be screen or navigator.', back: 'Back', @@ -302,6 +436,58 @@ const es: Strings = { permAllow: 'Permitir y abrir', permDeny: 'Denegar acceso', + genModalBuilding: 'Construyendo tu app…', + genModalDone: '¡App creada!', + genStep1: 'Enviando al modelo', + genStep2: 'Procesamiento', + genStep3: 'Creación', + genHint: 'Esto puede tardar 10–60 segundos según el modelo', + genStepPending: 'en espera', + genStepActive: 'en curso…', + genStepDone: 'hecho', + humanizeError: (raw) => { + if (/kv.?cache|kv cache/i.test(raw)) + return 'El modelo está sobrecargado: la solicitud es demasiado larga. Intenta describir la app más brevemente.'; + if (/HTTP 401|invalid.{0,20}api.{0,10}key|api.{0,10}key.{0,10}invalid|authentication/i.test(raw)) + return 'Clave API no válida. Revísala en Ajustes → proveedor.'; + if (/HTTP 429|rate.?limit|quota/i.test(raw)) + return 'Demasiadas solicitudes. Espera unos segundos y vuelve a intentarlo.'; + if (/HTTP 5\d\d|internal server|bad gateway/i.test(raw)) + return 'El servidor de IA tuvo un problema temporal. Inténtalo de nuevo en unos segundos.'; + if (/ECONNREFUSED|ERR_NETWORK|Network Error|connection refused/i.test(raw)) + return 'No se puede conectar con el servidor de IA. Revisa la conexión y la URL en Ajustes.'; + if (/timeout|timed out/i.test(raw)) + return 'El modelo tardó demasiado. Prueba con una solicitud más simple o inténtalo de nuevo.'; + if (/modulo non valido|validazione schema/i.test(raw)) + return 'El modelo no generó una app válida. Reformula la solicitud.'; + if (/json|JSON/i.test(raw)) + return 'El modelo respondió en un formato ilegible. Reintenta — normalmente funciona al segundo intento.'; + if (/compilazione|JavaScript.*non valido|compile/i.test(raw)) + return 'El código generado no es válido. Prueba con una descripción más simple.'; + if (/empty/i.test(raw)) + return 'El modelo respondió con un mensaje vacío. Inténtalo de nuevo.'; + if (/URL.*non valido|url.{0,10}invalid/i.test(raw)) + return 'La URL del servidor no es válida. Revisa los Ajustes.'; + if (/inserisci.*api.*key|api key.*mancante/i.test(raw)) + return 'Falta la clave API. Añádela en Ajustes.'; + const trimmed = raw.replace(/\s*\(prime \d+ char\).*$/s, '').trim(); + return trimmed.length > 180 ? trimmed.slice(0, 177) + '…' : trimmed; + }, + + styleEditorTitle: 'Editar estilo', + styleEditorColors: 'COLORES', + styleEditorTexts: 'TEXTOS', + styleEditorSave: 'Guardar cambios', + styleEditorApply: 'Aplicar', + styleEditorNoEditable: 'No se encontraron elementos editables en este módulo.', + + regenBtn: 'Regenerar', + regenSheetTitle: 'Regenerar módulo', + regenOriginalLabel: 'Prompt original', + regenTweakLabel: 'Ajustes (opcional)', + regenTweakPlaceholder: 'Ej: añade un temporizador, cambia los colores, aumenta la velocidad…', + regenSuccess: '¡Módulo actualizado!', + rendererError: 'El módulo no pudo cargarse', rendererRootError: 'La raíz de la UI debe ser screen o navigator.', back: 'Atrás', @@ -376,6 +562,58 @@ const fr: Strings = { permAllow: 'Autoriser et ouvrir', permDeny: 'Refuser l\'accès', + genModalBuilding: 'Construction de ton app…', + genModalDone: 'App créée !', + genStep1: 'Envoi au modèle', + genStep2: 'Traitement', + genStep3: 'Création', + genHint: 'Cela peut prendre 10–60 secondes selon le modèle', + genStepPending: 'en attente', + genStepActive: 'en cours…', + genStepDone: 'fait', + humanizeError: (raw) => { + if (/kv.?cache|kv cache/i.test(raw)) + return 'Le modèle est surchargé : la requête est trop longue. Essaie de décrire l\'app plus brièvement.'; + if (/HTTP 401|invalid.{0,20}api.{0,10}key|api.{0,10}key.{0,10}invalid|authentication/i.test(raw)) + return 'Clé API invalide. Vérifie-la dans Réglages → fournisseur.'; + if (/HTTP 429|rate.?limit|quota/i.test(raw)) + return 'Trop de requêtes. Attends quelques secondes et réessaie.'; + if (/HTTP 5\d\d|internal server|bad gateway/i.test(raw)) + return 'Le serveur IA a eu un problème temporaire. Réessaie dans quelques secondes.'; + if (/ECONNREFUSED|ERR_NETWORK|Network Error|connection refused/i.test(raw)) + return 'Impossible de joindre le serveur IA. Vérifie la connexion et l\'URL dans les Réglages.'; + if (/timeout|timed out/i.test(raw)) + return 'Le modèle a mis trop de temps. Essaie avec une requête plus simple ou réessaie.'; + if (/modulo non valido|validazione schema/i.test(raw)) + return 'Le modèle n\'a pas généré une app valide. Reformule la demande.'; + if (/json|JSON/i.test(raw)) + return 'Le modèle a répondu dans un format illisible. Réessaie — ça marche généralement au deuxième essai.'; + if (/compilazione|JavaScript.*non valido|compile/i.test(raw)) + return 'Le code généré n\'est pas valide. Essaie une description plus simple.'; + if (/empty/i.test(raw)) + return 'Le modèle a renvoyé une réponse vide. Réessaie.'; + if (/URL.*non valido|url.{0,10}invalid/i.test(raw)) + return 'L\'URL du serveur n\'est pas valide. Vérifie les Réglages.'; + if (/inserisci.*api.*key|api key.*mancante/i.test(raw)) + return 'La clé API est manquante. Ajoute-la dans les Réglages.'; + const trimmed = raw.replace(/\s*\(prime \d+ char\).*$/s, '').trim(); + return trimmed.length > 180 ? trimmed.slice(0, 177) + '…' : trimmed; + }, + + styleEditorTitle: 'Modifier le style', + styleEditorColors: 'COULEURS', + styleEditorTexts: 'TEXTES', + styleEditorSave: 'Enregistrer', + styleEditorApply: 'Appliquer', + styleEditorNoEditable: 'Aucun élément modifiable trouvé dans ce module.', + + regenBtn: 'Regénérer', + regenSheetTitle: 'Regénérer le module', + regenOriginalLabel: 'Prompt original', + regenTweakLabel: 'Ajustements (optionnel)', + regenTweakPlaceholder: 'Ex : ajoute un minuteur, change les couleurs, augmente la vitesse…', + regenSuccess: 'Module mis à jour !', + rendererError: 'Impossible de charger le module', rendererRootError: 'La racine UI doit être screen ou navigator.', back: 'Retour', @@ -450,6 +688,58 @@ const de: Strings = { permAllow: 'Erlauben und öffnen', permDeny: 'Zugriff verweigern', + genModalBuilding: 'Deine App wird erstellt…', + genModalDone: 'App erstellt!', + genStep1: 'Senden an Modell', + genStep2: 'Verarbeitung', + genStep3: 'Erstellung', + genHint: 'Dies kann je nach Modell 10–60 Sekunden dauern', + genStepPending: 'wartend', + genStepActive: 'läuft…', + genStepDone: 'fertig', + humanizeError: (raw) => { + if (/kv.?cache|kv cache/i.test(raw)) + return 'Das Modell ist überlastet: die Anfrage ist zu lang. Versuche die App kürzer zu beschreiben.'; + if (/HTTP 401|invalid.{0,20}api.{0,10}key|api.{0,10}key.{0,10}invalid|authentication/i.test(raw)) + return 'Ungültiger API-Schlüssel. Überprüfe ihn in Einstellungen → Anbieter.'; + if (/HTTP 429|rate.?limit|quota/i.test(raw)) + return 'Zu viele Anfragen. Warte einige Sekunden und versuche es erneut.'; + if (/HTTP 5\d\d|internal server|bad gateway/i.test(raw)) + return 'Der KI-Server hatte ein vorübergehendes Problem. Versuche es in einigen Sekunden erneut.'; + if (/ECONNREFUSED|ERR_NETWORK|Network Error|connection refused/i.test(raw)) + return 'Der KI-Server ist nicht erreichbar. Überprüfe die Verbindung und die URL in den Einstellungen.'; + if (/timeout|timed out/i.test(raw)) + return 'Das Modell hat zu lange gebraucht. Versuche eine einfachere Anfrage oder probiere es erneut.'; + if (/modulo non valido|validazione schema/i.test(raw)) + return 'Das Modell hat keine gültige App generiert. Formuliere die Anfrage neu.'; + if (/json|JSON/i.test(raw)) + return 'Das Modell hat in einem unlesbaren Format geantwortet. Versuche es erneut — beim zweiten Versuch klappt es meist.'; + if (/compilazione|JavaScript.*non valido|compile/i.test(raw)) + return 'Der generierte Code ist ungültig. Versuche eine einfachere Beschreibung.'; + if (/empty/i.test(raw)) + return 'Das Modell hat eine leere Antwort gesendet. Bitte erneut versuchen.'; + if (/URL.*non valido|url.{0,10}invalid/i.test(raw)) + return 'Die Server-URL ist ungültig. Überprüfe die Einstellungen.'; + if (/inserisci.*api.*key|api key.*mancante/i.test(raw)) + return 'API-Schlüssel fehlt. Füge ihn in den Einstellungen hinzu.'; + const trimmed = raw.replace(/\s*\(prime \d+ char\).*$/s, '').trim(); + return trimmed.length > 180 ? trimmed.slice(0, 177) + '…' : trimmed; + }, + + styleEditorTitle: 'Stil bearbeiten', + styleEditorColors: 'FARBEN', + styleEditorTexts: 'TEXTE', + styleEditorSave: 'Änderungen speichern', + styleEditorApply: 'Anwenden', + styleEditorNoEditable: 'Keine bearbeitbaren Elemente in diesem Modul gefunden.', + + regenBtn: 'Neu generieren', + regenSheetTitle: 'Modul neu generieren', + regenOriginalLabel: 'Originaler Prompt', + regenTweakLabel: 'Anpassungen (optional)', + regenTweakPlaceholder: 'Z.B.: Timer hinzufügen, Farben ändern, Geschwindigkeit erhöhen…', + regenSuccess: 'Modul aktualisiert!', + rendererError: 'Modul konnte nicht geladen werden', rendererRootError: 'UI-Wurzel muss screen oder navigator sein.', back: 'Zurück', diff --git a/src/modules/moduleStore.ts b/src/modules/moduleStore.ts index 87aeaea..6d957d4 100644 --- a/src/modules/moduleStore.ts +++ b/src/modules/moduleStore.ts @@ -51,6 +51,47 @@ export async function deleteModule(id: string): Promise { await writeAll(all); } +export async function renameModule(id: string, newName: string): Promise { + const trimmed = newName.trim(); + if (!trimmed) return; + const all = await readAll(); + const idx = all.findIndex((m) => m.id === id); + if (idx >= 0) { + all[idx] = { ...all[idx], name: trimmed }; + await writeAll(all); + } +} + +export async function replaceModule( + oldId: string, + payload: unknown, + prompt?: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const v = validateGeneratedModule(payload); + if (!v.ok) return { ok: false, error: v.error }; + const stored = { ...toStoredModule(v.module, prompt), id: oldId }; + const all = await readAll(); + const idx = all.findIndex((m) => m.id === oldId); + if (idx >= 0) { + all[idx] = stored; + } else { + all.push(stored); + } + await writeAll(all); + return { ok: true }; +} + +export async function updateModuleStyle( + id: string, + newUi: StoredModule['ui'] +): Promise { + const all = await readAll(); + const idx = all.findIndex((m) => m.id === id); + if (idx < 0) return; + all[idx] = { ...all[idx], ui: newUi }; + await writeAll(all); +} + export async function upsertFromRawJson(json: string): Promise<{ ok: true } | { ok: false; error: string }> { let data: unknown; try { diff --git a/src/modules/moduleValidator.ts b/src/modules/moduleValidator.ts index 3fcb8f2..185508b 100644 --- a/src/modules/moduleValidator.ts +++ b/src/modules/moduleValidator.ts @@ -9,6 +9,26 @@ import { reportGenAppError } from '../debug/genAppDebug'; import { scanGeneratedCode } from '../security/codeScanner'; import { tryCompileModuleActions } from './moduleRunner'; +function hasWebGameNode(node: UiNode): boolean { + if (node.type === 'webGame') return true; + if (node.type === 'navigator') return Object.values(node.screens).some((s) => s.components.some(hasWebGameNode)); + if ('components' in node && Array.isArray(node.components)) return node.components.some(hasWebGameNode); + return false; +} + +/** + * Detects browser-style game code that should run in WebView, not Hermes. + * Catches cases where the AI generates webGame code but forgets the webGame UI node. + */ +function looksLikeWebGameCode(code: string): boolean { + return ( + /requestAnimationFrame\s*\(/.test(code) || + /canvas\.addEventListener\s*\(/.test(code) || + /document\.getElementById\s*\(/.test(code) || + /ctx\s*\.\s*(fillRect|drawImage|clearRect|beginPath|arc|stroke)\s*\(/.test(code) + ); +} + function collectButtonActions(node: UiNode): string[] { // Se il button ha navigate, l'action è opzionale e non va validata if (node.type === 'button') return node.action && !node.navigate ? [node.action] : []; @@ -44,6 +64,44 @@ function formatCodeScanError(reason: string): string { return reason; } +/** + * When browser-style game code is detected but the UI lacks a webGame node, + * inject one so DynamicRenderer routes to WebView instead of Hermes. + */ +function fixUiForWebGame(ui: UiNode): UiNode { + if (ui.type === 'screen') { + // Keep any gamepad nodes (they send events via postMessage to WebView) + const gamepads = ui.components.filter((c) => c.type === 'gamepad'); + return { + ...ui, + gap: 0, + components: [ + { type: 'webGame' as const, id: 'game', width: 360, height: 600 }, + ...gamepads, + ], + }; + } + if (ui.type === 'navigator') { + // Fix the initial screen inside the navigator + const initialKey = ui.initialScreen; + const screens = { ...ui.screens }; + if (screens[initialKey]) { + const s = screens[initialKey]; + const gamepads = s.components.filter((c) => c.type === 'gamepad'); + screens[initialKey] = { + ...s, + gap: 0, + components: [ + { type: 'webGame' as const, id: 'game', width: 360, height: 600 }, + ...gamepads, + ], + }; + } + return { ...ui, screens }; + } + return ui; +} + export type ValidateResult = | { ok: true; module: GeneratedModulePayload } | { ok: false; error: string }; @@ -62,32 +120,46 @@ export function validateGeneratedModule(raw: unknown): ValidateResult { }); return { ok: false, error: errText }; } - 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 }; - } + // Detect webGame by UI node OR by browser-style code patterns (AI sometimes forgets webGame UI node) + const uiHasWebGame = hasWebGameNode(parsed.data.ui); + const codeIsWebGame = looksLikeWebGameCode(parsed.data.code); + const isWebGame = uiHasWebGame || codeIsWebGame; - // 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 }; + if (isWebGame && !uiHasWebGame) { + // AI generated browser-style game code but forgot the webGame UI node. + // Inject a webGame node so DynamicRenderer knows to skip Hermes and use WebView. + const fixedUi = fixUiForWebGame(parsed.data.ui); + return { ok: true, module: { ...parsed.data, ui: fixedUi } }; } - 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), { - uiActions, - compiledActions: Object.keys(compileResult.actions), - }); - return { ok: false, error: err }; + + if (!isWebGame) { + 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 }; + } + + // 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 }; + } + 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), { + uiActions, + compiledActions: Object.keys(compileResult.actions), + }); + return { ok: false, error: err }; + } } return { ok: true, module: parsed.data }; diff --git a/src/renderer/DynamicRenderer.tsx b/src/renderer/DynamicRenderer.tsx index b40171c..0c498b3 100644 --- a/src/renderer/DynamicRenderer.tsx +++ b/src/renderer/DynamicRenderer.tsx @@ -9,6 +9,18 @@ import { runAction, tryCompileModuleActions, type CompiledActions } from '../mod import { renderNode, resolveTheme, type ResolvedTheme } from './components'; import { useI18n } from '../i18n/useI18n'; +function hasWebGameNode(node: UiNode): boolean { + if (node.type === 'webGame') return true; + if (node.type === 'navigator') return Object.values(node.screens).some((s) => s.components.some(hasWebGameNode)); + if ('components' in node && Array.isArray(node.components)) return node.components.some(hasWebGameNode); + return false; +} + +// Same patterns as looksLikeWebGameCode in moduleValidator — kept in sync as fallback +// for modules saved before the validator fix was deployed. +const WEBGAME_CODE_RE = + /requestAnimationFrame\s*\(|canvas\.addEventListener\s*\(|document\.getElementById\s*\(|ctx\s*\.\s*(fillRect|drawImage|clearRect|beginPath|arc|stroke)\s*\(/; + function collectButtonActions(node: UiNode): string[] { if (node.type === 'button') return node.action && !node.navigate ? [node.action] : []; if (node.type === 'navigator') { @@ -84,9 +96,16 @@ type Props = { export function DynamicRenderer({ ui, code, motherApi }: Props) { const { t } = useI18n(); - const compiled = useMemo(() => tryCompileModuleActions(code), [code]); + const isWebGame = useMemo(() => hasWebGameNode(ui) || WEBGAME_CODE_RE.test(code), [ui, code]); + + // WebGame modules: code runs inside WebView, skip sandbox compilation + const compiled = useMemo( + () => isWebGame ? { ok: true as const, actions: {} } : tryCompileModuleActions(code), + [code, isWebGame] + ); const compileError = useMemo(() => { + if (isWebGame) return null; // WebGame: no sandbox, no action check if (!compiled.ok) return compiled.error; const uiActions = [...new Set(collectButtonActions(ui))]; const missing = uiActions.filter((a) => !(a in compiled.actions)); @@ -94,7 +113,7 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { return `Azioni nell'UI non trovate nel codice: ${missing.join(', ')}. Elimina il modulo e rigeneralo.`; } return null; - }, [compiled, ui]); + }, [compiled, ui, isWebGame]); const actions: CompiledActions | null = compiled.ok ? compiled.actions : null; const [state, setState] = useState>(() => buildInitialState(ui)); @@ -229,7 +248,7 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { theme = resolveTheme(); } - const ctx = { state, setState: patchState, onButton, onNavigate, busyAction, theme, hasError: Boolean(error) }; + const ctx = { state, setState: patchState, onButton, onNavigate, busyAction, theme, hasError: Boolean(error), webgameCode: isWebGame ? code : undefined }; // ── Which node to render ── const nodeToRender: UiNode | null = @@ -250,7 +269,11 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) { )} - {nodeToRender ? renderNode(nodeToRender, ctx, isNavigator ? currentScreenKey : 'root') : null} + {isWebGame && !hasWebGameNode(ui) + ? renderNode({ type: 'webGame' as const, id: 'game', width: 360, height: 600 }, ctx, 'root') + : nodeToRender + ? renderNode(nodeToRender, ctx, isNavigator ? currentScreenKey : 'root') + : null} {error ? ( {getJsonResponseRetryHint()} diff --git a/src/renderer/components.tsx b/src/renderer/components.tsx index 131f000..7fe7504 100644 --- a/src/renderer/components.tsx +++ b/src/renderer/components.tsx @@ -1,4 +1,6 @@ -import { ReactNode, useEffect, useRef } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useCallback } from 'react'; +import { WebView } from 'react-native-webview'; +import type { WebViewMessageEvent } from 'react-native-webview'; import { ActivityIndicator, FlatList, @@ -12,6 +14,25 @@ import { type TextStyle, type ViewStyle, } from 'react-native'; +import { + Canvas, + Rect as SkRect, + RoundedRect as SkRoundedRect, + Circle as SkCircle, + Oval as SkOval, + Line as SkLine, + Path as SkPath, + Fill as SkFill, + Group as SkGroup, + Text as SkText, + matchFont, + LinearGradient as SkLinearGradient, + RadialGradient as SkRadialGradient, + BlurMask, + Shadow, + vec, + type SkFont, +} from '@shopify/react-native-skia'; import type { UiLayoutProps, UiNode, UiStyleProps, UiTheme } from '../types/uiNodes'; export type ResolvedTheme = Required; @@ -98,12 +119,30 @@ export type RenderCtx = { busyAction: string | null; theme: ResolvedTheme; hasError: boolean; + /** Codice raw del modulo webGame (browser JS con canvas 2D) */ + webgameCode?: string; + /** Ref al WebView per inviare eventi gamepad */ + webViewRef?: React.RefObject; }; function getBound(state: Record, key: string): string { const v = state[key]; if (v == null) return ''; - return typeof v === 'string' ? v : JSON.stringify(v); + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + if (Array.isArray(v)) return v.length > 0 ? `(${v.length})` : ''; + if (typeof v === 'object') { + const obj = v as Record; + // Prova a estrarre un campo leggibile + const primary = obj.name ?? obj.title ?? obj.label ?? obj.text ?? obj.description; + if (primary != null) return String(primary); + // Fallback: mostra coppie chiave: valore senza id + return Object.entries(obj) + .filter(([k, val]) => k !== 'id' && val !== null && val !== undefined && val !== '') + .map(([k, val]) => `${k}: ${String(val)}`) + .join(' · '); + } + return String(v); } // ───────────────────────────────────────────────────────────────────────────── @@ -117,22 +156,39 @@ function GamepadButton({ size, onAction, theme, + webViewRef, }: { btn: GamepadBtnDef; size: number; onAction: (action: string) => void; theme: ResolvedTheme; + webViewRef?: React.RefObject; }) { const holdRef = useRef | null>(null); const onButtonRef = useRef(onAction); onButtonRef.current = onAction; + const sendWebBtn = (id: string, pressed: boolean) => { + // Use postMessage (reliable on Android) instead of injectJavaScript + webViewRef?.current?.postMessage(JSON.stringify({ type: 'btn', id, on: pressed })); + }; + const startPress = () => { if (!btn.action) return; - onButtonRef.current(btn.action); - if (btn.hold) { - const ms = Math.max(16, btn.holdMs ?? 80); - holdRef.current = setInterval(() => onButtonRef.current(btn.action), ms); + if (webViewRef) { + // WebGame mode: send button events directly to WebView + sendWebBtn(btn.action, true); + if (btn.hold) { + const ms = Math.max(16, btn.holdMs ?? 80); + holdRef.current = setInterval(() => sendWebBtn(btn.action, true), ms); + } + } else { + // Normal sandbox mode + onButtonRef.current(btn.action); + if (btn.hold) { + const ms = Math.max(16, btn.holdMs ?? 80); + holdRef.current = setInterval(() => onButtonRef.current(btn.action), ms); + } } }; @@ -141,6 +197,9 @@ function GamepadButton({ clearInterval(holdRef.current); holdRef.current = null; } + if (webViewRef && btn.action) { + sendWebBtn(btn.action, false); + } }; // useEffect cleanup al dismount @@ -198,6 +257,7 @@ function GamepadNode({ const gap = Math.round(size * 0.15); const dir = node.direction ?? 'row'; const t = ctx.theme; + const wvRef = ctx.webViewRef; const onAction = (action: string) => { if (action) void ctx.onButton(action, {}); @@ -217,7 +277,7 @@ function GamepadNode({ }, layoutToViewStyle(node.layout)]} > {node.buttons.map((btn) => ( - + ))} ); @@ -241,12 +301,12 @@ function GamepadNode({ > {leftBtns.map((btn) => ( - + ))} {rightBtns.map((btn) => ( - + ))} @@ -276,19 +336,19 @@ function GamepadNode({ {/* Top row: empty | up | empty */} - + {/* Middle row: left | empty | right */} - + - + {/* Bottom row: empty | down | empty */} - + @@ -297,7 +357,7 @@ function GamepadNode({ {extraBtns.length > 0 && ( {extraBtns.map((btn) => ( - + ))} )} @@ -309,105 +369,241 @@ function GamepadNode({ } // ───────────────────────────────────────────────────────────────────────────── -// GameView — canvas dichiarativa + game loop ticker + tap input +// GameView 3.0 — Skia GPU-powered 2D game engine +// +// Scene object types: +// rect, roundedRect, circle, oval, line, path, text, group, tilemap +// Effects: glow, shadow, gradient (on rect/circle/oval) +// Special: { type:'camera', x, y } // ───────────────────────────────────────────────────────────────────────────── -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; +function n(v: unknown, def = 0): number { return isFinite(Number(v)) ? Number(v) : def; } +function s(v: unknown, def = ''): string { return v != null ? String(v) : def; } +function op(v: unknown): number { const x = Number(v ?? 1); return isFinite(x) ? Math.max(0, Math.min(1, x)) : 1; } + +// Font cache — matchFont per size +const _fontCache = new Map(); +function getFont(size: number, bold = false): SkFont | null { + const key = `${size}-${bold}`; + if (_fontCache.has(key)) return _fontCache.get(key)!; + try { + const font = matchFont({ + fontFamily: 'Helvetica', + fontSize: size, + fontWeight: bold ? 'bold' : 'normal', + }); + _fontCache.set(key, font ?? null); + return font ?? null; + } catch { + _fontCache.set(key, null); + return null; + } +} + +// Parse gradient: color can be '#f00' or ['#f00','#00f'] or ['#f00','#0f0','#00f'] +function isGradient(color: unknown): color is string[] { + return Array.isArray(color) && color.length >= 2; +} -function renderSceneObj(raw: unknown, i: number): ReactNode { +function renderSkiaObj(raw: unknown, key: string): ReactNode { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const o = raw as Record; - const t = String(o.type ?? ''); + const t = s(o.type); + + // ── Effetti opzionali ────────────────────────────────────────────────────── + const glowRadius = n(o.glow, 0); + const shadowArr = Array.isArray(o.shadow) ? o.shadow as unknown[] : null; + const opacity = op(o.opacity); + const rotation = n(o.rotation, 0); + + const effects: ReactNode[] = []; + if (glowRadius > 0) { + effects.push(); + } + if (shadowArr && shadowArr.length >= 3) { + effects.push(); + } + // ── rect ────────────────────────────────────────────────────────────────── if (t === 'rect') { + const x = n(o.x); const y = n(o.y); + const w = Math.max(1, n(o.w, 10)); const h = Math.max(1, n(o.h, 10)); + const r = n(o.radius, 0); + const transform = rotation !== 0 + ? [{ translateX: x + w/2 }, { translateY: y + h/2 }, { rotate: rotation * Math.PI / 180 }, { translateX: -(x + w/2) }, { translateY: -(y + h/2) }] + : undefined; + + const shape = r > 0 + ? {effects} + : {effects}; + + if (isGradient(o.color)) { + const angle = n(o.gradientAngle, 180); // 0=left-right, 90=top-bottom, 180=bottom-top + const rad = angle * Math.PI / 180; + const cx = x + w / 2; const cy = y + h / 2; + const dx = Math.sin(rad) * w / 2; const dy = Math.cos(rad) * h / 2; + return ( + + {r > 0 + ? {effects} + : {effects} + } + {r > 0 + ? + + + : + + + } + + ); + } + return shape; + } + + // ── circle ───────────────────────────────────────────────────────────────── + if (t === 'circle') { + const cx = n(o.x); const cy = n(o.y); const r = Math.max(1, n(o.r, 10)); + if (isGradient(o.color)) { + return ( + + + + {effects} + + + ); + } + return {effects}; + } + + // ── oval ─────────────────────────────────────────────────────────────────── + if (t === 'oval' || t === 'ellipse') { + const cx = n(o.cx ?? o.x); const cy = n(o.cy ?? o.y); + const rx = Math.max(1, n(o.rx, 20)); const ry = Math.max(1, n(o.ry, 10)); return ( - + + {effects} + ); } - if (t === 'circle') { - const r = Number(o.r ?? 10); + // ── line ─────────────────────────────────────────────────────────────────── + if (t === 'line') { return ( - + + {glowRadius > 0 ? : null} + ); } + // ── path ─────────────────────────────────────────────────────────────────── + if (t === 'path') { + const pathStr = s(o.d); + if (!pathStr) return null; + try { + return ( + + {effects} + + ); + } catch { return null; } + } + + // ── text ─────────────────────────────────────────────────────────────────── if (t === 'text') { + const fontSize = Math.max(8, n(o.fontSize, 14)); + const bold = s(o.fontWeight, '400') === '700' || s(o.fontWeight) === 'bold' || n(o.fontWeight) >= 700; + const font = getFont(fontSize, bold); + if (!font) return null; + const txt = s(o.text); + let tx = n(o.x); + const align = s(o.align, 'left'); + if (align === 'center' || align === 'right') { + try { + const w = font.measureText(txt).width; + if (align === 'center') tx -= w / 2; + else tx -= w; + } catch { /* ignore */ } + } return ( - - {String(o.text ?? '')} - + + {glowRadius > 0 + ? + + + : null} + + + ); + } + + // ── group ────────────────────────────────────────────────────────────────── + if (t === 'group') { + const children = Array.isArray(o.children) ? o.children : []; + const gx = n(o.x); const gy = n(o.y); + const rot = n(o.rotation, 0); + const sx = isFinite(Number(o.scaleX)) ? Number(o.scaleX) : 1; + const sy = isFinite(Number(o.scaleY)) ? Number(o.scaleY) : 1; + const transform: Parameters[0]['transform'] = []; + if (gx !== 0 || gy !== 0) transform.push({ translateX: gx }, { translateY: gy }); + if (rot !== 0) transform.push({ rotate: rot * Math.PI / 180 }); + if (sx !== 1) transform.push({ scaleX: sx }); + if (sy !== 1) transform.push({ scaleY: sy }); + return ( + + {children.map((c, ci) => renderSkiaObj(c, `${key}-${ci}`))} + ); } + // ── tilemap ──────────────────────────────────────────────────────────────── + if (t === 'tilemap') { + const rows = Array.isArray(o.tiles) ? o.tiles as number[][] : []; + const palette = Array.isArray(o.palette) ? o.palette as string[] : []; + const ts = Math.max(1, n(o.tileSize, 16)); + const ox = n(o.x, 0); const oy = n(o.y, 0); + const tiles: ReactNode[] = []; + rows.forEach((row, ri) => { + if (!Array.isArray(row)) return; + row.forEach((cell, ci) => { + if (!cell || cell === 0) return; + const color = palette[cell - 1] ?? '#888888'; + tiles.push( + + ); + }); + }); + return {tiles}; + } + return null; } -function buildFallbackScene(w: number, h: number, hasTick: boolean): SceneObj[] { +function buildFallbackScene(w: number, h: number, hasTick: boolean): unknown[] { return [ - { type: 'rect', x: 0, y: 0, w, h, color: '#101827', radius: 12 }, - { type: 'rect', x: 10, y: 10, w: w - 20, h: h - 20, color: '#17233a', radius: 10 }, - { type: 'circle', x: Math.round(w * 0.5), y: Math.round(h * 0.42), r: 28, color: '#6366f1' }, - { - type: 'text', - x: 20, - y: Math.round(h * 0.58), - text: hasTick ? 'Avvio gioco...' : 'Gioco non inizializzato', - color: '#e8edf5', - fontSize: 18, - fontWeight: '800', - }, - { - type: 'text', - x: 20, - y: Math.round(h * 0.58) + 30, - text: hasTick ? 'Tocca lo schermo per giocare' : 'Rigenera il modulo con gameView + onTick', - color: '#7a92b3', - fontSize: 13, - fontWeight: '600', - }, + { type: 'rect', x: 0, y: 0, w, h, color: ['#0f172a', '#1e293b'], gradientAngle: 90 }, + { type: 'circle', x: w * 0.5, y: h * 0.38, r: 32, color: '#6366f1', glow: 20 }, + { type: 'text', x: w * 0.5, y: h * 0.57, text: hasTick ? 'Starting...' : 'Not initialized', color: '#e8edf5', fontSize: 18, fontWeight: '700', align: 'center' }, + { type: 'text', x: w * 0.5, y: h * 0.57 + 28, text: hasTick ? 'Tap to play' : 'Add gameView + onTick', color: '#7a92b3', fontSize: 13, align: 'center' }, ]; } -// Suppress unused-type warnings for the scene type aliases above. -void (0 as unknown as SceneObj); - function GameViewNode({ node, ctx, @@ -417,22 +613,28 @@ 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 tickBusyRef = useRef(false); + const lastTickRef = useRef(Date.now()); + // ── Game loop ────────────────────────────────────────────────────────────── useEffect(() => { if (ctx.hasError) return; if (!node.tickAction || !node.tickMs) return; const ms = Math.max(16, node.tickMs); + const runTick = () => { - if (tickBusyRef.current) return; // salta tick se il precedente è ancora in volo + if (tickBusyRef.current) return; tickBusyRef.current = true; - onButtonRef.current(node.tickAction!, {}).finally(() => { + const now = Date.now(); + const dt = Math.min((now - lastTickRef.current) / 1000, 0.1); // delta in secondi, max 100ms + lastTickRef.current = now; + onButtonRef.current(node.tickAction!, { dt, dtMs: Math.round(dt * 1000) }).finally(() => { tickBusyRef.current = false; }); }; + runTick(); const id = setInterval(runTick, ms); return () => clearInterval(id); @@ -440,13 +642,31 @@ function GameViewNode({ }, [ctx.hasError, node.tickAction, node.tickMs]); const gw = node.width ?? 320; - const gh = node.height ?? 300; + const gh = node.height ?? 480; const rawScene = ctx.state[node.bind]; - const scene: unknown[] = - Array.isArray(rawScene) && rawScene.length > 0 - ? rawScene - : buildFallbackScene(gw, gh, Boolean(node.tickAction)); + + // ── Estrai camera dalla scena se presente ───────────────────────────────── + let camX = 0; + let camY = 0; + let sceneObjs: unknown[] = []; + + if (Array.isArray(rawScene) && rawScene.length > 0) { + for (const obj of rawScene) { + if (obj && typeof obj === 'object' && (obj as Record).type === 'camera') { + const c = obj as Record; + camX = isFinite(Number(c.x)) ? Number(c.x) : 0; + camY = isFinite(Number(c.y)) ? Number(c.y) : 0; + } else { + sceneObjs.push(obj); + } + } + } else { + sceneObjs = buildFallbackScene(gw, gh, Boolean(node.tickAction)); + } + + // ── Raccolta di swipe ───────────────────────────────────────────────────── + const touchStartRef = useRef<{ x: number; y: number } | null>(null); const canvas = ( - {scene.map((obj, i) => renderSceneObj(obj, i))} + {/* Skia GPU canvas — pointerEvents none so touches reach the parent Pressable */} + + + {sceneObjs.map((obj, i) => renderSkiaObj(obj, String(i)))} + + + + {/* Error overlay */} {ctx.hasError ? ( - - - Il gioco si e' fermato. Controlla l'errore sotto e rigenera il modulo. + + + Game stopped. Check error and regenerate. ) : null} ); - if (node.onTapAction) { + if (node.onTapAction || node.onSwipeAction) { return ( { + if (!node.onTapAction) return; const { locationX, locationY } = e.nativeEvent; - void onButtonRef.current(node.onTapAction!, { - x: Math.round(locationX), - y: Math.round(locationY), + void onButtonRef.current(node.onTapAction, { + x: Math.round(locationX + camX), + y: Math.round(locationY + camY), jump: -8, }); }} + onTouchStart={(e) => { + const t = e.nativeEvent.touches[0]; + if (t) touchStartRef.current = { x: t.locationX, y: t.locationY }; + }} + onTouchEnd={(e) => { + if (!node.onSwipeAction || !touchStartRef.current) return; + const t = e.nativeEvent.changedTouches[0]; + if (!t) return; + const dx = t.locationX - touchStartRef.current.x; + const dy = t.locationY - touchStartRef.current.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 20) return; // troppo corto, è un tap + const dir = Math.abs(dx) > Math.abs(dy) + ? (dx > 0 ? 'right' : 'left') + : (dy > 0 ? 'down' : 'up'); + touchStartRef.current = null; + void onButtonRef.current(node.onSwipeAction, { dir, dx: Math.round(dx), dy: Math.round(dy) }); + }} > {canvas} @@ -504,6 +744,119 @@ function GameViewNode({ return {canvas}; } +// ───────────────────────────────────────────────────────────────────────────── +// WebGameNode — canvas 2D inside WebView at true 60fps, no sandbox overhead +// ───────────────────────────────────────────────────────────────────────────── + +function buildWebGameHtml(code: string, width: number, height: number): string { + // Only escape to prevent premature script tag closing + const safeCode = code.replace(/<\/script>/gi, '<\\/script>'); + return ` + + + + + + + + + +`; +} + +function WebGameNode({ + node, + ctx, + nodeKey, +}: { + node: Extract; + ctx: RenderCtx; + nodeKey: string; +}) { + const webViewRef = useRef(null); + // Expose webViewRef to ctx so GamepadNode can send button events + ctx.webViewRef = webViewRef; + + const gw = node.width ?? 360; + const gh = node.height ?? 600; + const code = ctx.webgameCode ?? '// no game code'; + const html = useMemo(() => buildWebGameHtml(code, gw, gh), [code, gw, gh]); + + const onMessage = useCallback((event: WebViewMessageEvent) => { + try { + const data = JSON.parse(event.nativeEvent.data) as { type: string; patch?: Record }; + if (data.type === 'state' && data.patch) { + ctx.setState(data.patch); + } + } catch { /* ignore parse errors */ } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +} + // ───────────────────────────────────────────────────────────────────────────── export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): ReactNode { @@ -681,11 +1034,48 @@ 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 }) => { + let mainLabel = ''; + let subLabel = ''; + if (typeof item === 'string') { + mainLabel = item; + } else if (item && typeof item === 'object' && !Array.isArray(item)) { + const obj = item as Record; + // Cerca un campo "principale" da mostrare in grassetto + const primary = obj.name ?? obj.title ?? obj.label ?? obj.text ?? obj.description; + if (primary != null) { + mainLabel = String(primary); + // Tutti gli altri campi (esclusi id e il campo principale) come sottotitolo + subLabel = Object.entries(obj) + .filter(([k, v]) => + !['id', 'name', 'title', 'label', 'text', 'description'].includes(k) && + v !== null && v !== undefined && v !== '' + ) + .map(([k, v]) => { + if (typeof v === 'boolean') return `${k}: ${v ? '✓' : '✗'}`; + return `${k}: ${String(v)}`; + }) + .join(' · '); + } else { + // Nessun campo noto → mostra tutto come coppie chiave: valore + mainLabel = Object.entries(obj) + .filter(([k, v]) => k !== 'id' && v !== null && v !== undefined && v !== '') + .map(([k, v]) => { + if (typeof v === 'boolean') return `${k}: ${v ? '✓' : '✗'}`; + return `${k}: ${String(v)}`; + }) + .join(' · '); + } + } else { + mainLabel = String(item ?? ''); + } + return ( + + {mainLabel} + {subLabel ? {subLabel} : null} + + ); + }} /> ); } @@ -770,6 +1160,9 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea ); + case 'webGame': + return ; + case 'gameView': return ; diff --git a/src/settings/settingsStore.ts b/src/settings/settingsStore.ts index 2e0d302..df1aeae 100644 --- a/src/settings/settingsStore.ts +++ b/src/settings/settingsStore.ts @@ -4,11 +4,12 @@ import { getDefaultOllamaBaseUrl, getDefaultOllamaModel } from '../config'; const KEY = 'afi:settings:v1'; export type AppSettings = { - useMock: boolean; provider: 'ollama' | 'openai' | 'claude'; ollamaUrl: string; ollamaModel: string; openaiUrl: string; + openaiKey: string; + openaiModel: string; claudeBaseUrl: string; claudeApiKey: string; claudeModel: string; @@ -18,11 +19,12 @@ export type AppSettings = { export function defaultSettings(): AppSettings { return { - useMock: false, provider: 'ollama', ollamaUrl: getDefaultOllamaBaseUrl(), ollamaModel: getDefaultOllamaModel(), openaiUrl: '', + openaiKey: '', + openaiModel: 'gpt-4o-mini', claudeBaseUrl: 'https://api.anthropic.com/v1', claudeApiKey: '', claudeModel: 'claude-sonnet-4-20250514', diff --git a/src/types/generatedModule.ts b/src/types/generatedModule.ts index 91dbcea..4a4a0d8 100644 --- a/src/types/generatedModule.ts +++ b/src/types/generatedModule.ts @@ -200,6 +200,14 @@ export const uiNodeSchema: ZodType = z.lazy(() => { hint: z.string().optional(), }) .extend({ layout: uiLayoutPropsSchema.optional() }), + z + .object({ + type: z.literal('webGame'), + id: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }) + .extend({ layout: uiLayoutPropsSchema.optional() }), z .object({ type: z.literal('gamepad'), @@ -228,6 +236,7 @@ export const uiNodeSchema: ZodType = z.lazy(() => { tickMs: z.number().optional(), tickAction: z.string().optional(), onTapAction: z.string().optional(), + onSwipeAction: z.string().optional(), }) .extend({ layout: uiLayoutPropsSchema.optional() }), ]); diff --git a/src/types/uiNodes.ts b/src/types/uiNodes.ts index 9d71ea2..d3d198d 100644 --- a/src/types/uiNodes.ts +++ b/src/types/uiNodes.ts @@ -118,6 +118,14 @@ export type UiNode = | { type: 'image'; id?: string; bind: string; height?: number; layout?: UiLayoutProps } | { type: 'audioRecorder'; id?: string; statusBind?: string; layout?: UiLayoutProps } | { type: 'qrScanner'; id?: string; hint?: string; layout?: UiLayoutProps } + | { + /** Motore gioco WebView — canvas 2D + requestAnimationFrame 60fps nativi */ + type: 'webGame'; + id?: string; + width?: number; + height?: number; + layout?: UiLayoutProps; + } | { type: 'gamepad'; id?: string; @@ -154,7 +162,9 @@ export type UiNode = tickMs?: 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. */ + /** Nome dell'action chiamata al tap sul canvas; riceve { x, y, jump:-8 } nell'input. */ onTapAction?: string; + /** Nome dell'action chiamata allo swipe sul canvas; riceve { dir:'left'|'right'|'up'|'down', dx, dy }. */ + onSwipeAction?: string; layout?: UiLayoutProps; }; From 5373c20a23012b62299b7d07fc5d0b33c455f577 Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 18:23:32 +0200 Subject: [PATCH 2/6] Settings screen improvements Created loading screen Added form editing functionality --- app/(tabs)/index.tsx | 222 +++++++-------------- app/(tabs)/modules.tsx | 104 ++++++++-- app/(tabs)/settings.tsx | 421 +++++++++++++--------------------------- app/_layout.tsx | 27 +++ app/module/[id].tsx | 298 ++++++++++++++++++++++++++-- src/ai/aiClient.ts | 25 ++- src/ai/modulePrompt.ts | 193 ++++++++++++------ 7 files changed, 763 insertions(+), 527 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f3c75b5..3840305 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,5 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { - ActivityIndicator, Animated, KeyboardAvoidingView, Platform, @@ -16,10 +15,9 @@ 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 { GeneratingModal } from '../../src/components/GeneratingModal'; /* ── Design tokens ── */ const C = { @@ -41,12 +39,14 @@ const C = { successBorder: '#065f46', }; +// ── Main screen ───────────────────────────────────────────────────────────── export default function GeneraScreen() { const { settings } = useSettings(); const { t } = useI18n(); const [prompt, setPrompt] = useState(''); const [focused, setFocused] = useState(false); const [loading, setLoading] = useState(false); + const [generationDone, setGenerationDone] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const pulseAnim = useRef(new Animated.Value(1)).current; @@ -72,11 +72,13 @@ export default function GeneraScreen() { } setError(null); setSuccess(false); + setGenerationDone(false); setLoading(true); startPulse(); try { const res = await generateModule(prompt.trim(), { - useMock: settings.useMock, + useMock: false, + language: settings.language || undefined, ...(settings.provider === 'ollama' ? { ollamaBaseUrl: settings.ollamaUrl || undefined, @@ -89,37 +91,52 @@ export default function GeneraScreen() { claudeApiKey: settings.claudeApiKey || undefined, claudeModel: settings.claudeModel || 'claude-sonnet-4-20250514', } - : { apiUrl: settings.openaiUrl || undefined }), + : { + apiUrl: settings.openaiUrl || undefined, + apiKey: settings.openaiKey || undefined, + apiModel: settings.openaiModel || undefined, + }), }); if (!res.ok) { - setError(res.error); + setError(t.humanizeError(res.error)); return; } const saved = await saveModule(res.data, prompt.trim()); if (!saved.ok) { - setError(saved.error); + setError(t.humanizeError(saved.error)); return; } + setGenerationDone(true); + // Brief pause showing all steps done, then close modal + await new Promise((r) => setTimeout(r, 1200)); setPrompt(''); setSuccess(true); setTimeout(() => setSuccess(false), 5000); } finally { setLoading(false); + setGenerationDone(false); stopPulse(); } }; - const providerLabel = settings.useMock - ? 'Mock' - : settings.provider === 'ollama' - ? settings.ollamaModel || 'Ollama' - : settings.provider === 'claude' - ? settings.claudeModel || 'Claude' - : 'OpenAI'; + const providerLabel = + settings.provider === 'ollama' + ? settings.ollamaModel || 'Ollama' + : settings.provider === 'claude' + ? settings.claudeModel || 'Claude' + : settings.openaiModel || 'OpenAI'; return ( + + {/* ── Loading overlay ── */} + + AppFromAI - + {providerLabel} @@ -158,10 +175,8 @@ export default function GeneraScreen() { {/* ── Prompt area ── */} - {/* Corner decoration */} - - - {/* Toolbar inside the box */} {t.charCount(prompt.length)} {prompt.length > 0 && !loading ? ( @@ -213,19 +226,14 @@ export default function GeneraScreen() { {error ? ( - - - {error !== t.errorEmpty ? ( - {getJsonResponseRetryHint()} - ) : null} - {error} - + + {error} ) : success ? ( - + {t.successTitle} {t.successHint} @@ -234,10 +242,10 @@ export default function GeneraScreen() { ) : null} - {loading ? : } + - {/* ── CTA — always visible, outside ScrollView ── */} + {/* ── CTA ── */} [ @@ -248,20 +256,15 @@ export default function GeneraScreen() { disabled={loading} onPress={onGenerate} > - {loading ? ( - - - {t.btnGenerating} - - ) : ( - - - {t.btnGenerate} + + + {loading ? t.btnGenerating : t.btnGenerate} + {!loading && ( - - )} + )} + @@ -269,12 +272,12 @@ export default function GeneraScreen() { ); } +// ── Main screen styles ──────────────────────────────────────────────────────── const s = StyleSheet.create({ safe: { flex: 1, backgroundColor: C.bg }, flex: { flex: 1 }, scrollContent: { flexGrow: 1 }, - /* Top bar */ topBar: { flexDirection: 'row', alignItems: 'center', @@ -308,33 +311,17 @@ 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 */ hero: { paddingHorizontal: 20, paddingTop: 28, paddingBottom: 24, gap: 6, }, - heroLine1: { - fontSize: 36, - fontWeight: '300', - color: C.muted, - letterSpacing: -1, - }, - heroLine2Row: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - }, - heroLine2: { - fontSize: 48, - fontWeight: '900', - color: C.text, - letterSpacing: -2, - }, + heroLine1: { fontSize: 36, fontWeight: '300', color: C.muted, letterSpacing: -1 }, + heroLine2Row: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + heroLine2: { fontSize: 48, fontWeight: '900', color: C.text, letterSpacing: -2 }, heroAccent: { backgroundColor: C.primary, borderRadius: 10, @@ -342,23 +329,10 @@ const s = StyleSheet.create({ paddingVertical: 6, marginTop: 4, }, - heroAccentText: { - color: '#fff', - fontSize: 22, - fontWeight: '900', - letterSpacing: 1, - }, - heroSub: { - color: C.muted, - fontSize: 14, - lineHeight: 21, - marginTop: 4, - }, + heroAccentText: { color: '#fff', fontSize: 22, fontWeight: '900', letterSpacing: 1 }, + heroSub: { color: C.muted, fontSize: 14, lineHeight: 21, marginTop: 4 }, - /* Prompt */ - promptOuter: { - paddingHorizontal: 20, - }, + promptOuter: { paddingHorizontal: 20 }, promptBox: { backgroundColor: C.surface, borderRadius: 20, @@ -368,32 +342,16 @@ const s = StyleSheet.create({ position: 'relative', overflow: 'hidden', }, - promptBoxFocused: { - borderColor: C.borderFocus, - }, + promptBoxFocused: { borderColor: C.borderFocus }, cornerTL: { - position: 'absolute', - top: 0, - left: 0, - width: 24, - height: 24, - borderTopLeftRadius: 20, - borderTopWidth: 2, - borderLeftWidth: 2, - borderColor: C.primary, - opacity: 0.4, + position: 'absolute', top: 0, left: 0, width: 24, height: 24, + borderTopLeftRadius: 20, borderTopWidth: 2, borderLeftWidth: 2, + borderColor: C.primary, opacity: 0.4, }, cornerBR: { - position: 'absolute', - bottom: 0, - right: 0, - width: 24, - height: 24, - borderBottomRightRadius: 20, - borderBottomWidth: 2, - borderRightWidth: 2, - borderColor: C.primary, - opacity: 0.4, + position: 'absolute', bottom: 0, right: 0, width: 24, height: 24, + borderBottomRightRadius: 20, borderBottomWidth: 2, borderRightWidth: 2, + borderColor: C.primary, opacity: 0.4, }, promptInput: { padding: 18, @@ -403,7 +361,6 @@ const s = StyleSheet.create({ lineHeight: 26, minHeight: 130, textAlignVertical: 'top', - fontFamily: Platform.OS === 'ios' ? 'System' : undefined, }, promptToolbar: { flexDirection: 'row', @@ -414,17 +371,9 @@ const s = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: C.border, }, - charCount: { - color: C.faint, - fontSize: 11, - fontVariant: ['tabular-nums'], - }, + charCount: { color: C.faint, fontSize: 11, fontVariant: ['tabular-nums'] }, - examples: { - paddingHorizontal: 20, - paddingTop: 14, - gap: 10, - }, + examples: { paddingHorizontal: 20, paddingTop: 14, gap: 10 }, exampleChip: { flexDirection: 'row', alignItems: 'center', @@ -436,31 +385,14 @@ const s = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 11, }, - exampleChipPressed: { - backgroundColor: C.surfaceHigh, - borderColor: C.borderFocus, - }, + exampleChipPressed: { backgroundColor: C.surfaceHigh, borderColor: C.borderFocus }, exampleIndex: { - width: 22, - height: 22, - borderRadius: 11, - overflow: 'hidden', - backgroundColor: C.primary, - color: '#fff', - textAlign: 'center', - lineHeight: 22, - fontSize: 12, - fontWeight: '900', - }, - exampleText: { - flex: 1, - color: C.text, - fontSize: 13, - lineHeight: 18, - fontWeight: '600', + width: 22, height: 22, borderRadius: 11, overflow: 'hidden', + backgroundColor: C.primary, color: '#fff', textAlign: 'center', + lineHeight: 22, fontSize: 12, fontWeight: '900', }, + exampleText: { flex: 1, color: C.text, fontSize: 13, lineHeight: 18, fontWeight: '600' }, - /* Feedback */ feedbackBox: { paddingHorizontal: 20, paddingTop: 12 }, feedbackInner: { flexDirection: 'row', @@ -473,12 +405,11 @@ const s = StyleSheet.create({ feedbackError: { backgroundColor: C.errorBg, borderColor: C.errorBorder }, feedbackSuccess: { backgroundColor: C.successBg, borderColor: C.successBorder }, feedbackText: { flex: 1, gap: 4 }, - feedbackMsg: { color: C.error, fontSize: 13, fontWeight: '600' }, + feedbackMsg: { flex: 1, color: C.error, fontSize: 14, fontWeight: '500', lineHeight: 20 }, feedbackHint: { color: C.muted, fontSize: 12, lineHeight: 17 }, spacer: { height: 20 }, - /* Footer / CTA */ footer: { padding: 20, paddingBottom: Platform.OS === 'ios' ? 8 : 16, @@ -496,22 +427,7 @@ const s = StyleSheet.create({ }, btnLoading: { opacity: 0.65, shadowOpacity: 0 }, btnPressed: { opacity: 0.88, transform: [{ scale: 0.98 }] }, - btnInner: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - paddingHorizontal: 8, - }, - btnText: { - color: '#fff', - fontSize: 17, - fontWeight: '700', - letterSpacing: 0.2, - flex: 1, - }, - btnArrow: { - backgroundColor: 'rgba(255,255,255,0.15)', - borderRadius: 10, - padding: 4, - }, + btnInner: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 8 }, + btnText: { color: '#fff', fontSize: 17, fontWeight: '700', letterSpacing: 0.2, flex: 1 }, + btnArrow: { backgroundColor: 'rgba(255,255,255,0.15)', borderRadius: 10, padding: 4 }, }); diff --git a/app/(tabs)/modules.tsx b/app/(tabs)/modules.tsx index 9ecd9a3..2adbe21 100644 --- a/app/(tabs)/modules.tsx +++ b/app/(tabs)/modules.tsx @@ -8,13 +8,14 @@ import { Share, StyleSheet, Text, + TextInput, View, } from 'react-native'; 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, renameModule } from '../../src/modules/moduleStore'; import type { StoredModule } from '../../src/types/generatedModule'; import { useI18n } from '../../src/i18n/useI18n'; @@ -88,6 +89,14 @@ export default function ModulesScreen() { [load, t] ); + const onRename = useCallback( + async (item: StoredModule, newName: string) => { + await renameModule(item.id, newName); + await load(); + }, + [load] + ); + return ( @@ -127,6 +136,7 @@ export default function ModulesScreen() { key={item.id} item={item} onDelete={onDelete} + onRename={onRename} onOpen={(id) => router.push(`/module/${id}`)} onShare={async (mod) => { const text = mod.prompt @@ -145,16 +155,20 @@ export default function ModulesScreen() { function ModuleCard({ item, onDelete, + onRename, onOpen, onShare, }: { item: StoredModule; onDelete: (item: StoredModule) => void; + onRename: (item: StoredModule, newName: string) => void; onOpen: (id: string) => void; onShare: (item: StoredModule) => void; }) { const { t } = useI18n(); const [copied, setCopied] = useState(false); + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(item.name); const onCopyId = async () => { await Clipboard.setStringAsync(item.id); @@ -162,6 +176,18 @@ function ModuleCard({ setTimeout(() => setCopied(false), 2000); }; + const confirmRename = () => { + if (editName.trim() && editName.trim() !== item.name) { + onRename(item, editName.trim()); + } + setEditing(false); + }; + + const cancelRename = () => { + setEditName(item.name); + setEditing(false); + }; + const dateStr = new Date(item.createdAt).toLocaleDateString(t.dateLocale, { day: '2-digit', month: 'short', @@ -175,21 +201,52 @@ function ModuleCard({ style={({ pressed }) => [s.card, pressed && s.cardPressed]} onPress={() => onOpen(item.id)} > - {/* Top row: title + delete */} + {/* Top row: title + edit + delete */} - {item.name} - - v{item.version} - + {editing ? ( + <> + + + + + + + + + ) : ( + <> + {item.name} + + v{item.version} + + { setEditName(item.name); setEditing(true); }} + hitSlop={10} + > + + + + )} - onDelete(item)} - hitSlop={10} - > - - + {!editing && ( + onDelete(item)} + hitSlop={10} + > + + + )} {/* Date */} @@ -287,6 +344,27 @@ const s = StyleSheet.create({ cardTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }, cardTitleRow: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, cardTitle: { color: C.text, fontSize: 17, fontWeight: '700', flexShrink: 1 }, + titleInput: { + flex: 1, + color: C.text, + fontSize: 17, + fontWeight: '700', + borderBottomWidth: 1.5, + borderBottomColor: C.primary, + paddingVertical: 2, + paddingHorizontal: 0, + minWidth: 80, + }, + iconBtn: { + width: 28, + height: 28, + borderRadius: 7, + backgroundColor: C.surfaceHigh, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: C.border, + }, vBadge: { backgroundColor: '#1e1b4b', borderRadius: 6, diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 29d1ec4..e40c7ce 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,8 +1,16 @@ -import React from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import React, { useState } from 'react'; +import { + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + 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 { useRouter } from 'expo-router'; import { useSettings } from '../../src/settings/SettingsContext'; import { useI18n } from '../../src/i18n/useI18n'; import { LANGUAGES, type Language } from '../../src/i18n/translations'; @@ -10,15 +18,15 @@ import { LANGUAGES, type Language } from '../../src/i18n/translations'; const C = { bg: '#0b1120', surface: '#1a2236', - surfaceHigh: '#22304a', border: '#2d3f5c', primary: '#6366f1', text: '#e8edf5', muted: '#7a92b3', faint: '#3d5070', + success: '#34d399', + overlay: 'rgba(0,0,0,0.7)', }; -/* ── Reusable Section wrapper ── */ function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( @@ -28,83 +36,30 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } -/* ── Single settings row ── */ -function Row({ +function NavRow({ label, hint, + active, last, - children, + onPress, }: { label: string; hint?: string; + active?: boolean; last?: boolean; - children: React.ReactNode; + onPress: () => void; }) { return ( - + {label} {hint ? {hint} : null} - {children} - - ); -} - -/* ── Provider radio button ── */ -function ProviderOption({ - value, - current, - label, - hint, - onSelect, - last, -}: { - value: 'ollama' | 'openai' | 'claude'; - current: 'ollama' | 'openai' | 'claude'; - label: string; - hint: string; - onSelect: () => void; - last?: boolean; -}) { - const active = value === current; - return ( - - - {label} - {hint} - - - {active ? : null} - - - ); -} - -/* ── Language option ── */ -function LangOption({ - code, - label, - flag, - current, - onSelect, - last, -}: { - code: string; - label: string; - flag: string; - current: string; - onSelect: () => void; - last?: boolean; -}) { - const active = code === current; - return ( - - - {flag} {label} - - - {active ? : null} + + {active ? ( + + ) : null} + ); @@ -113,14 +68,25 @@ function LangOption({ export default function SettingsScreen() { const { settings, updateSettings } = useSettings(); const { t } = useI18n(); + const router = useRouter(); + const [langModal, setLangModal] = useState(false); const currentLang = settings.language || ''; + const currentLangObj = LANGUAGES.find((l) => l.code === currentLang); + const langLabel = currentLangObj + ? `${currentLangObj.flag} ${currentLangObj.label}` + : '🌐 Auto'; + + const providerHint = (p: 'ollama' | 'openai' | 'claude') => { + if (p === 'ollama') return settings.ollamaUrl || 'Non configurato'; + if (p === 'openai') return settings.openaiUrl || 'Non configurato'; + return settings.claudeModel || 'Non configurato'; + }; return ( - {/* Page header */} {t.settingsTitle} @@ -130,186 +96,43 @@ export default function SettingsScreen() { {/* ── Language ── */}
- updateSettings({ language: '' })} - /> - {LANGUAGES.map((lang, idx) => ( - updateSettings({ language: lang.code as Language })} - last={idx === LANGUAGES.length - 1} - /> - ))} + setLangModal(true)}> + {langLabel} + +
- {/* ── Modalità ── */} -
- + router.push('/settings/ollama')} + /> + router.push('/settings/openai')} + /> + - updateSettings({ useMock: v })} - trackColor={{ false: C.border, true: C.primary }} - thumbColor="#fff" - /> - + onPress={() => router.push('/settings/claude')} + />
- {/* ── Provider (hidden when mock is on) ── */} - {!settings.useMock ? ( - <> -
- updateSettings({ provider: 'ollama' })} - /> - updateSettings({ provider: 'openai' })} - /> - updateSettings({ provider: 'claude' })} - last - /> -
- - {/* ── Ollama config ── */} - {settings.provider === 'ollama' ? ( -
- - {t.ollamaUrlLabel} - updateSettings({ ollamaUrl: v })} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - returnKeyType="done" - /> - {t.ollamaUrlHint} - - - - {t.ollamaModelLabel} - updateSettings({ ollamaModel: v })} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="done" - /> - {t.ollamaModelHint} - -
- ) : null} - - {/* ── OpenAI config ── */} - {settings.provider === 'openai' ? ( -
- - {t.openAiUrlLabel} - updateSettings({ openaiUrl: v })} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - returnKeyType="done" - /> - -
- ) : null} - - {/* ── Claude config ── */} - {settings.provider === 'claude' ? ( -
- - {t.claudeUrlLabel} - updateSettings({ claudeBaseUrl: v })} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - returnKeyType="done" - /> - {t.claudeUrlHint} - - - - {t.claudeKeyLabel} - updateSettings({ claudeApiKey: v })} - autoCapitalize="none" - autoCorrect={false} - secureTextEntry - returnKeyType="done" - /> - - - - {t.claudeModelLabel} - updateSettings({ claudeModel: v })} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="done" - /> - -
- ) : null} - - ) : null} - {/* ── Info ── */}
- + + {t.appVersionLabel} 1.0.0 - +
- {/* Security notice */} {t.securityNotice} @@ -317,6 +140,40 @@ export default function SettingsScreen() { + + {/* ── Language picker modal ── */} + setLangModal(false)} + > + setLangModal(false)}> + e.stopPropagation()}> + {t.sectionLanguage} + + {/* Auto */} + { updateSettings({ language: '' }); setLangModal(false); }} + > + 🌐 Auto + {currentLang === '' && } + + + {LANGUAGES.map((lang) => ( + { updateSettings({ language: lang.code as Language }); setLangModal(false); }} + > + {lang.flag} {lang.label} + {currentLang === lang.code && } + + ))} + + +
); } @@ -353,11 +210,6 @@ const s = StyleSheet.create({ borderColor: C.border, overflow: 'hidden', }, - sectionDivider: { - height: StyleSheet.hairlineWidth, - backgroundColor: C.border, - marginHorizontal: 16, - }, row: { flexDirection: 'row', @@ -373,47 +225,15 @@ const s = StyleSheet.create({ }, rowLabels: { flex: 1, gap: 2 }, rowLabel: { color: C.text, fontSize: 15, fontWeight: '500' }, - rowLabelActive: { color: C.primary }, rowHint: { color: C.muted, fontSize: 12, lineHeight: 17 }, - valueText: { color: C.muted, fontSize: 14 }, - - radio: { - width: 22, - height: 22, - borderRadius: 11, - borderWidth: 2, - borderColor: C.border, - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - }, - radioActive: { borderColor: C.primary }, - radioDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: C.primary }, - - inputGroup: { padding: 16, gap: 8 }, - inputLabel: { - color: C.muted, - fontSize: 11, - fontWeight: '700', - letterSpacing: 0.8, - textTransform: 'uppercase', - }, - input: { - backgroundColor: C.bg, - borderRadius: 10, - borderWidth: 1, - borderColor: C.border, - paddingHorizontal: 14, - paddingVertical: 12, - color: C.text, - fontSize: 14, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - }, - inputHint: { color: C.faint, fontSize: 11, lineHeight: 16 }, - inputHintMono: { - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - color: C.muted, + rowRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + activeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: C.success, }, + valueText: { color: C.muted, fontSize: 14 }, notice: { flexDirection: 'row', @@ -426,4 +246,39 @@ const s = StyleSheet.create({ alignItems: 'flex-start', }, noticeText: { flex: 1, color: C.faint, fontSize: 12, lineHeight: 18 }, + + /* Modal */ + modalOverlay: { + flex: 1, + backgroundColor: C.overlay, + justifyContent: 'flex-end', + }, + modalSheet: { + backgroundColor: C.surface, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 20, + paddingBottom: 36, + borderWidth: 1, + borderColor: C.border, + gap: 4, + }, + modalTitle: { + fontSize: 13, + fontWeight: '700', + color: C.faint, + letterSpacing: 1.1, + textTransform: 'uppercase', + marginBottom: 8, + }, + langRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 4, + borderRadius: 10, + }, + langRowActive: { backgroundColor: C.primary + '18' }, + langRowText: { fontSize: 16, color: C.text }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index abfb343..61ebeb6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -19,6 +19,33 @@ export default function RootLayout() { headerShadowVisible: false, }} /> + + + diff --git a/app/module/[id].tsx b/app/module/[id].tsx index d3f36f4..7573ba9 100644 --- a/app/module/[id].tsx +++ b/app/module/[id].tsx @@ -1,9 +1,22 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Stack, useLocalSearchParams } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { getModule } from '../../src/modules/moduleStore'; +import { Ionicons } from '@expo/vector-icons'; +import { generateModule } from '../../src/ai/aiClient'; +import { getModule, replaceModule } from '../../src/modules/moduleStore'; +import { useSettings } from '../../src/settings/SettingsContext'; import type { MotherPermission } from '../../src/types/generatedModule'; import { CapabilityRegistry } from '../../src/capabilities/capabilityRegistry'; import { createMotherApi } from '../../src/capabilities/motherApi'; @@ -12,15 +25,29 @@ 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 { GeneratingModal } from '../../src/components/GeneratingModal'; +import { StyleEditorSheet } from '../../src/components/StyleEditorSheet'; +import { updateModuleStyle } from '../../src/modules/moduleStore'; +import { applyColorChanges, applyTextChanges } from '../../src/modules/styleEditor'; const C = { bg: '#0b1120', + surface: '#1a2236', + border: '#2d3f5c', + primary: '#6366f1', text: '#e8edf5', muted: '#7a92b3', + faint: '#3d5070', + success: '#34d399', + error: '#f87171', + errorBg: '#180808', + errorBorder: '#6b1a1a', + overlay: 'rgba(0,0,0,0.7)', }; export default function ModuleScreen() { const { t } = useI18n(); + const { settings } = useSettings(); const params = useLocalSearchParams<{ id: string | string[] }>(); const id = Array.isArray(params.id) ? params.id[0] : params.id; const [mod, setMod] = useState>>(null); @@ -30,6 +57,17 @@ export default function ModuleScreen() { const [grantedIds, setGrantedIds] = useState([]); const [gateBusy, setGateBusy] = useState(false); + // ── Style editor state ──────────────────────────────────────────────────── + const [styleSheet, setStyleSheet] = useState(false); + + // ── Regen state ────────────────────────────────────────────────────────── + const [regenSheet, setRegenSheet] = useState(false); + const [tweakText, setTweakText] = useState(''); + const [regenLoading, setRegenLoading] = useState(false); + const [regenDone, setRegenDone] = useState(false); + const [regenError, setRegenError] = useState(null); + const regenPromptRef = useRef(''); + const load = useCallback(async () => { if (!id) return; setLoading(true); @@ -42,17 +80,10 @@ export default function ModuleScreen() { setLoading(false); }, [id]); - useEffect(() => { - void load(); - }, [load]); + useEffect(() => { void load(); }, [load]); - // Registry fresh per ogni modulo: pulisce tutti i sensori attivi all'unmount. const registry = useMemo(() => new CapabilityRegistry(), [mod?.id]); - useEffect(() => { - return () => { - void registry.cleanupAll(); - }; - }, [registry]); + useEffect(() => { return () => { void registry.cleanupAll(); }; }, [registry]); const onAllow = async () => { if (!mod || gateBusy) return; @@ -82,6 +113,76 @@ export default function ModuleScreen() { }); }, [mod, grantedIds, registry]); + // ── Regen handler ───────────────────────────────────────────────────────── + const onRegenConfirm = async () => { + if (!mod || !id) return; + const originalPrompt = mod.prompt || ''; + const combined = tweakText.trim() + ? `${originalPrompt}\n\nModifiche richieste: ${tweakText.trim()}` + : originalPrompt; + if (!combined.trim()) return; + + regenPromptRef.current = combined; + setRegenSheet(false); + setRegenError(null); + setRegenDone(false); + setRegenLoading(true); + + try { + const res = await generateModule(combined, { + useMock: false, + language: settings.language || undefined, + ...(settings.provider === 'ollama' + ? { ollamaBaseUrl: settings.ollamaUrl || undefined, ollamaModel: settings.ollamaModel || undefined } + : settings.provider === 'claude' + ? { + apiProvider: 'claude', + claudeBaseUrl: settings.claudeBaseUrl || undefined, + claudeApiKey: settings.claudeApiKey || undefined, + claudeModel: settings.claudeModel || 'claude-sonnet-4-20250514', + } + : { + apiUrl: settings.openaiUrl || undefined, + apiKey: settings.openaiKey || undefined, + apiModel: settings.openaiModel || undefined, + }), + }); + + if (!res.ok) { + setRegenError(t.humanizeError(res.error)); + return; + } + + const saved = await replaceModule(id, res.data, originalPrompt); + if (!saved.ok) { + setRegenError(t.humanizeError(saved.error)); + return; + } + + setRegenDone(true); + await new Promise((r) => setTimeout(r, 1200)); + setTweakText(''); + await load(); + } finally { + setRegenLoading(false); + setRegenDone(false); + } + }; + + // ── Style save handler ──────────────────────────────────────────────────── + const onStyleSave = async ( + colorChanges: Map, + textChanges: Map + ) => { + if (!mod || !id) return; + setStyleSheet(false); + let newUi: unknown = mod.ui; + newUi = applyColorChanges(newUi, colorChanges); + newUi = applyTextChanges(newUi, textChanges); + await updateModuleStyle(id, newUi as Parameters[1]); + await load(); + }; + if (loading) { return ( @@ -104,8 +205,47 @@ export default function ModuleScreen() { return ( - + ( + + setStyleSheet(true)} hitSlop={10}> + + + setRegenSheet(true)} + hitSlop={10} + style={styles.headerBtn} + > + + {t.regenBtn} + + + ), + }} + /> + + {/* Generating modal shown during regen */} + + + {/* Error banner after regen */} + {regenError ? ( + + + {regenError} + setRegenError(null)} hitSlop={10}> + + + + ) : null} + {needsGate && !answered ? ( ) : null} + + {/* Style editor sheet */} + setStyleSheet(false)} + onSave={(colorChanges, textChanges) => void onStyleSave(colorChanges, textChanges)} + /> + + {/* Regen bottom sheet */} + setRegenSheet(false)} + > + setRegenSheet(false)}> + e.stopPropagation()}> + + + {t.regenSheetTitle} + + + {/* Original prompt */} + {mod.prompt ? ( + + {t.regenOriginalLabel} + + {mod.prompt} + + + ) : null} + + {/* Tweaks input */} + + {t.regenTweakLabel} + + + + {/* Actions */} + + setRegenSheet(false)}> + {t.cancel} + + void onRegenConfirm()}> + + {t.regenBtn} + + + + + +
); } @@ -128,4 +330,74 @@ 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 }, + + headerBtns: { flexDirection: 'row', alignItems: 'center', gap: 16, paddingRight: 4 }, + headerBtn: { flexDirection: 'row', alignItems: 'center', gap: 5 }, + headerBtnLabel: { color: C.primary, fontSize: 15, fontWeight: '600' }, + + errorBanner: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + backgroundColor: C.errorBg, + borderBottomWidth: 1, + borderBottomColor: C.errorBorder, + paddingHorizontal: 16, + paddingVertical: 12, + }, + errorBannerText: { flex: 1, color: C.error, fontSize: 13, lineHeight: 18 }, + + overlay: { flex: 1, backgroundColor: C.overlay, justifyContent: 'flex-end' }, + sheet: { + backgroundColor: C.surface, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 20, + paddingBottom: Platform.OS === 'ios' ? 36 : 24, + borderWidth: 1, + borderColor: C.border, + gap: 16, + maxHeight: '85%', + }, + sheetHandle: { + width: 36, height: 4, borderRadius: 2, + backgroundColor: C.faint, alignSelf: 'center', marginBottom: 4, + }, + sheetTitle: { + fontSize: 16, fontWeight: '700', color: C.text, letterSpacing: -0.3, + }, + + block: { gap: 8, marginBottom: 4 }, + blockLabel: { + fontSize: 11, fontWeight: '700', color: C.faint, + letterSpacing: 1.1, textTransform: 'uppercase', + }, + originalBox: { + backgroundColor: C.bg, borderRadius: 10, + borderWidth: 1, borderColor: C.border, + padding: 12, + }, + originalText: { color: C.muted, fontSize: 14, lineHeight: 20 }, + + tweakInput: { + backgroundColor: C.bg, borderRadius: 10, + borderWidth: 1, borderColor: C.border, + paddingHorizontal: 14, paddingVertical: 12, + color: C.text, fontSize: 14, lineHeight: 20, + minHeight: 90, textAlignVertical: 'top', + fontFamily: Platform.OS === 'ios' ? 'System' : 'sans-serif', + }, + + actions: { flexDirection: 'row', gap: 12, marginTop: 4 }, + cancelBtn: { + flex: 1, borderRadius: 12, paddingVertical: 14, + backgroundColor: C.bg, borderWidth: 1, borderColor: C.border, + alignItems: 'center', + }, + cancelBtnText: { color: C.muted, fontSize: 15, fontWeight: '600' }, + regenBtn: { + flex: 2, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 8, borderRadius: 12, paddingVertical: 14, backgroundColor: C.primary, + }, + regenBtnText: { color: '#fff', fontSize: 15, fontWeight: '700' }, }); diff --git a/src/ai/aiClient.ts b/src/ai/aiClient.ts index 0f9167f..735fbb7 100644 --- a/src/ai/aiClient.ts +++ b/src/ai/aiClient.ts @@ -8,6 +8,8 @@ import { validateGeneratedModule } from '../modules/moduleValidator'; export type AiClientOptions = { /** Se true, nessuna chiamata HTTP: solo mock euristici su keyword. */ useMock: boolean; + /** Lingua UI da usare nei moduli generati (es. 'it', 'en', 'es'). Default 'it'. */ + language?: string; /** * Se useMock è false e `ollamaBaseUrl` è valorizzato → usa Ollama `/api/chat`. * Altrimenti usa Claude Messages API oppure OpenAI-compatible (chat completions). @@ -156,6 +158,7 @@ async function generateWithOllama( options?: { apiKey?: string; defaultModel?: string; + language?: string; } ): Promise<{ ok: true; data: GeneratedModulePayload } | { ok: false; error: string }> { const root = normalizeOllamaBase(baseUrl); @@ -165,7 +168,7 @@ async function generateWithOllama( } const url = root.endsWith('/api') ? `${root}/chat` : `${root}/api/chat`; - const prompt = buildModuleGenerationPrompt(userPrompt); + const prompt = buildModuleGenerationPrompt(userPrompt, options?.language); const headers: Record = { 'Content-Type': 'application/json' }; if (options?.apiKey?.trim()) { headers.Authorization = `Bearer ${options.apiKey.trim()}`; @@ -280,9 +283,10 @@ async function generateWithOpenAICompatible( apiModel = 'gpt-4o-mini', apiMaxTokens?: number, apiExtraBody?: Record, - providerLabel: 'openai' | 'openai-compatible' = 'openai' + providerLabel: 'openai' | 'openai-compatible' = 'openai', + language?: string ): Promise<{ ok: true; data: GeneratedModulePayload } | { ok: false; error: string }> { - const prompt = buildModuleGenerationPrompt(userPrompt); + const prompt = buildModuleGenerationPrompt(userPrompt, language); const headers: Record = { 'Content-Type': 'application/json', @@ -417,7 +421,8 @@ async function generateWithClaude( baseUrl: string, apiKey: string | undefined, model = 'claude-sonnet-4-20250514', - providerLabel: 'claude' = 'claude' + providerLabel: 'claude' = 'claude', + language?: string ): Promise<{ ok: true; data: GeneratedModulePayload } | { ok: false; error: string }> { const key = apiKey?.trim(); const trimmedBase = baseUrl.trim().replace(/\/+$/, ''); @@ -427,7 +432,7 @@ async function generateWithClaude( } const url = root.endsWith('/v1') ? joinUrl(root, 'messages') : joinUrl(root, 'v1/messages'); - const prompt = buildModuleGenerationPrompt(userPrompt); + const prompt = buildModuleGenerationPrompt(userPrompt, language); const headers: Record = { 'Content-Type': 'application/json', 'x-api-key': key || 'ollama', @@ -542,7 +547,7 @@ export async function generateModule( const ollamaUrl = opts.ollamaBaseUrl?.trim(); if (ollamaUrl) { - return generateWithOllama(userPrompt, ollamaUrl, opts.ollamaModel?.trim() || 'gemma4e4'); + return generateWithOllama(userPrompt, ollamaUrl, opts.ollamaModel?.trim() || 'gemma4e4', { language: opts.language }); } if (opts.apiProvider === 'claude') { @@ -550,7 +555,9 @@ export async function generateModule( userPrompt, opts.claudeBaseUrl?.trim() || 'https://api.anthropic.com/v1', opts.claudeApiKey, - opts.claudeModel || 'claude-sonnet-4-20250514' + opts.claudeModel || 'claude-sonnet-4-20250514', + 'claude', + opts.language ); } @@ -562,7 +569,9 @@ export async function generateModule( opts.apiKey, opts.apiModel, opts.apiMaxTokens, - opts.apiExtraBody + opts.apiExtraBody, + 'openai', + opts.language ); } catch (e) { const err = e as Error & { code?: string }; diff --git a/src/ai/modulePrompt.ts b/src/ai/modulePrompt.ts index 2e428ad..6ab4075 100644 --- a/src/ai/modulePrompt.ts +++ b/src/ai/modulePrompt.ts @@ -29,7 +29,7 @@ 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, webGame, gamepad. Non usare type "row" o "column" da soli: usa "box" con "direction": "row" | "column". - 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). @@ -98,42 +98,114 @@ STATO SICURO (obbligatorio — Hermes lancia ReferenceError se accedi a propriet - Stato interno (calcolatrici, wizard, multi-step): tutti i valori intermedi devono avere fallback in ogni action che li legge. - Esempio corretto per calcolatrice: const display = String(state.display ?? '0'); const op = String(state.op ?? ''); const prev = parseFloat(String(state.prev ?? '0')) || 0; -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. -- 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" } - - 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 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; -- 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}; - -GAMEPAD (controlli on-screen per giochi) -- Usa { "type": "gamepad", "direction": "row"|"dpad"|"split", "buttons": [...], "buttonSize": 64 } per aggiungere pulsanti fisici a schermo. -- direction: "row" = tutti in riga orizzontale (default). "dpad" = croce direzionale (primo bottone = su, secondo = sinistra, terzo = destra, quarto = giù; extra a destra). "split" = metà sinistra / metà destra dello schermo (utile per 2 pollici). -- buttons: array di { "id": "btn-left", "label": "◀", "action": "moveLeft", "hold": true, "holdMs": 80 }. - - hold: true = l'action si ripete automaticamente finché il tasto è premuto. Utile per movimenti continui. - - holdMs: millisecondi tra una ripetizione e l'altra (default 80ms). Min 16ms. - - label: emoji o testo corto. Usa ◀ ▶ ▲ ▼ o A B. - - action: nome dell'action da chiamare — deve esistere nel codice. -- buttonSize: dimensione quadrata in pixel dei bottoni (default 64). Usa 72-80 per touchscreen. -- Il gamepad non ha bind di stato, restituisce solo eventi tramite action. -- Metti il gamepad DOPO il gameView nella lista components della schermata. -- Esempio gamepad row con hold: { "type": "gamepad", "direction": "row", "buttonSize": 72, "buttons": [{ "id": "btn-l", "label": "◀", "action": "moveLeft", "hold": true, "holdMs": 80 }, { "id": "btn-r", "label": "▶", "action": "moveRight", "hold": true, "holdMs": 80 }, { "id": "btn-jump", "label": "▲", "action": "jump" }] } -- Esempio dpad (4 direzioni): { "type": "gamepad", "direction": "dpad", "buttonSize": 64, "buttons": [{ "id": "up", "label": "▲", "action": "moveUp", "hold": true }, { "id": "left", "label": "◀", "action": "moveLeft", "hold": true }, { "id": "right", "label": "▶", "action": "moveRight", "hold": true }, { "id": "down", "label": "▼", "action": "moveDown", "hold": true }] } +MOTORE GIOCHI — WebView Canvas 2D (60fps nativi, nessun overhead) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +REGOLA FONDAMENTALE: per OGNI gioco usa SEMPRE { "type": "webGame" } nell'UI. +Il codice gira nel WebView con canvas 2D e requestAnimationFrame — vero 60fps. +NON usare gameView/tickAction/bind per i giochi. Usa webGame. +STRUTTURA UI (fissa per tutti i giochi): +{ + "type": "screen", "title": "Nome Gioco", "gap": 0, + "theme": { "bg": "#000" }, + "components": [ + { "type": "webGame", "id": "game", "width": 360, "height": 600 } + ] +} +NON aggiungere altri componenti React Native (button, text, gamepad) se non strettamente necessario per UI fuori dal canvas. + +STRUTTURA CODE (JavaScript browser standard — NON module.exports): +Il code è JavaScript puro che gira nel WebView. Variabili globali GIÀ disponibili (non ridichiararle): +- canvas, ctx, WIDTH, HEIGHT — canvas già inizializzato e scalato +- sendState(patch) — opzionale: invia dati allo stato React Native (score, ecc.) +- window.__onBtn(id, pressed) — opzionale: ricevi eventi da gamepad React Native esterno + +⚠️ VIETATO ASSOLUTO nel code webGame: +- NON scrivere: var canvas, var ctx, var WIDTH, var HEIGHT, var sendState, var window + (sono già globals — ridichiarle le azzera a undefined) +- NON scrivere function init() per inizializzare il canvas (non serve, è già pronto) +- NON chiamare document.getElementById('game') (canvas è già la variabile pronta) +- NON usare: require, import, module.exports, eval, while(true) +Il code deve partire direttamente con variabili di stato e loop, senza setup. + +REGOLE CODE webGame: +- Usa requestAnimationFrame per il loop principale +- INPUT: gestisci TUTTO il touch direttamente sul canvas (più affidabile) +- Gestisci game over e restart dentro il loop (nessun reload pagina) +- Disegna i controlli virtuali DENTRO il canvas se servono pulsanti visibili + +I 3 PATTERN BASE — INPUT via TOUCH DIRETTO sul canvas (NESSUN bridge esterno): + +PATTERN TAP (Flappy Bird, gravity, pallina che salta): +canvas.addEventListener('touchstart',function(e){e.preventDefault();if(!alive){restart();}else{vy=-9;}},{passive:false}); +var x=80,y=HEIGHT/2,vy=0,pipes=[],score=0,alive=true,dist=0; +function restart(){y=HEIGHT/2;vy=0;pipes=[];score=0;alive=true;dist=0;} +function loop(){ + if(!alive){drawGameOver();requestAnimationFrame(loop);return;} + vy+=0.45;y+=vy;dist++; + if(dist%90===0)pipes.push({x:WIDTH+10,gap:100+Math.floor(Math.random()*(HEIGHT-240))}); + pipes.forEach(function(p){p.x-=3;}); + pipes=pipes.filter(function(p){return p.x>-54;}); + pipes.forEach(function(p){if(!p.passed&&p.x+54HEIGHT-14||pipes.some(function(p){return x+14>p.x&&x-14p.gap+65);}); + if(dead)alive=false; + ctx.fillStyle='#87CEEB';ctx.fillRect(0,0,WIDTH,HEIGHT); + pipes.forEach(function(p){ctx.fillStyle='#22c55e';ctx.fillRect(p.x,0,54,p.gap-65);ctx.fillRect(p.x,p.gap+65,54,HEIGHT);}); + ctx.beginPath();ctx.arc(x,y,14,0,Math.PI*2);ctx.fillStyle='#facc15';ctx.fill(); + ctx.fillStyle='#fff';ctx.font='bold 24px sans-serif';ctx.textAlign='center';ctx.fillText(score,WIDTH/2,36); + if(!alive)drawGameOver(); + requestAnimationFrame(loop); +} +function drawGameOver(){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,WIDTH,HEIGHT);ctx.fillStyle='#f87171';ctx.font='bold 32px sans-serif';ctx.textAlign='center';ctx.fillText('GAME OVER',WIDTH/2,HEIGHT/2-20);ctx.fillStyle='#fff';ctx.font='20px sans-serif';ctx.fillText('Score: '+score,WIDTH/2,HEIGHT/2+16);ctx.fillStyle='#aaa';ctx.font='15px sans-serif';ctx.fillText('Tap to restart',WIDTH/2,HEIGHT/2+48);} +requestAnimationFrame(loop); + +PATTERN VERTICALE (dodge/space shooter — touch SPLIT: metà sx=sinistra, metà dx=destra): +var touchLeft=false,touchRight=false; +canvas.addEventListener('touchstart',function(e){e.preventDefault();for(var i=0;i=GROUND){py=GROUND;pvy=0;onGround=true;} + dist++;if(dist%110===0)obstacles.push({x:WIDTH+20,y:GROUND-40,w:28,h:40}); + obstacles.forEach(function(o){o.x-=5;}); + obstacles=obstacles.filter(function(o){return o.x>-50;}); + if(obstacles.some(function(o){return px+24>o.x&&pxo.y&&pyW-R)?-vx:vx;const nvy=(nyH-R)?-vy:vy;const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#111122'},{type:'circle',x:Math.max(R,Math.min(W-R,nx)),y:Math.max(R,Math.min(H-R,ny)),r:R,color:'#6366f1'},{type:'text',x:10,y:10,text:'Rimbalzi: '+score,color:'#fff',fontSize:14}];return{bx:Math.max(R,Math.min(W-R,nx)),by:Math.max(R,Math.min(H-R,ny)),vx:nvx,vy:nvy,score,scene};},async onTap(api,input,state){return{vx:-parseFloat(String(state.vx??'4')),vy:-parseFloat(String(state.vy??'3'))};}}};" + "code": "var by=HEIGHT/2,bvy=0,pipes=[],score=0,alive=true,dist=0;function restart(){by=HEIGHT/2;bvy=0;pipes=[];score=0;alive=true;dist=0;}canvas.addEventListener('touchstart',function(e){e.preventDefault();if(!alive){restart();}else{bvy=-9;}},{passive:false});var last=0;function loop(ts){var dt=Math.min((ts-last)/1000,0.1);last=ts;if(alive){bvy+=0.45;by+=bvy;dist++;if(dist%90===0){pipes.push({x:WIDTH+10,gapY:100+Math.floor(Math.random()*(HEIGHT-220))});}pipes.forEach(function(p){p.x-=3;});pipes=pipes.filter(function(p){return p.x>-54;});pipes.forEach(function(p){if(!p.passed&&p.x+54<80){p.passed=true;score++;sendState({score:score});}});var dead=by<14||by>HEIGHT-14||pipes.some(function(p){return 80+14>p.x&&80-14p.gapY+65);});if(dead)alive=false;}ctx.fillStyle='#87CEEB';ctx.fillRect(0,0,WIDTH,HEIGHT);ctx.fillStyle='#5DBB3F';ctx.fillRect(0,HEIGHT-40,WIDTH,40);pipes.forEach(function(p){ctx.fillStyle='#22c55e';ctx.fillRect(p.x,0,54,p.gapY-65);ctx.fillRect(p.x,p.gapY+65,54,HEIGHT);ctx.fillStyle='#16a34a';ctx.fillRect(p.x-3,p.gapY-65-20,60,20);ctx.fillRect(p.x-3,p.gapY+65,60,20);});ctx.beginPath();ctx.arc(80,by,14,0,Math.PI*2);ctx.fillStyle='#facc15';ctx.fill();ctx.strokeStyle='#ca8a04';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#fff';ctx.font='bold 28px sans-serif';ctx.textAlign='center';ctx.fillText(score,WIDTH/2,40);if(!alive){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,WIDTH,HEIGHT);ctx.fillStyle='#f87171';ctx.font='bold 32px sans-serif';ctx.textAlign='center';ctx.fillText('GAME OVER',WIDTH/2,HEIGHT/2-30);ctx.fillStyle='#fff';ctx.font='bold 20px sans-serif';ctx.fillText('Score: '+score,WIDTH/2,HEIGHT/2+10);ctx.fillStyle='#aaa';ctx.font='15px sans-serif';ctx.fillText('Tap to restart',WIDTH/2,HEIGHT/2+45);}requestAnimationFrame(loop);}requestAnimationFrame(loop);" }`; /** Blocco da mostrare sopra agli errori di generazione/validazione/compilazione (stesso "contratto" del modello). */ export function getJsonResponseRetryHint(): string { return `Rigenera un UNICO JSON valido (solo chiavi manifest, ui, code — niente markdown né testo extra). -• gameView: { "type": "gameView", "bind": "scene", "width": 320, "height": 420, "tickMs": 40, "tickAction": "onTick", "onTapAction": "onTap" }. onTick riceve state, calcola fisica, ritorna patch con nuova "scene" (array di rect/circle/text). La scena deve essere visibile dal primo tick: sfondo colorato + giocatore + score, mai solo nero. onTap riceve { x, y, jump }. Niente while/for infiniti: il ticker è automatico. -• gamepad: { "type": "gamepad", "direction": "row"|"dpad"|"split", "buttonSize": 72, "buttons": [{ "id": "btn-l", "label": "◀", "action": "moveLeft", "hold": true, "holdMs": 80 }, ...] }. Mettilo dopo gameView. hold: true = ripete l'action finché il tasto è tenuto premuto. Ogni action del gamepad deve esistere nel codice. -• ui: radice "screen" per moduli a pagina singola, "navigator" per moduli multi-pagina (navigator obbligatorio se hai più schermate — vedi ESEMPIO 2). In components solo oggetti { "type": ... }, mai stringhe nell'array; box row/column + layout; button con actionInput se servono dati, non input (no quarto parametro nelle action). -• navigator: { "type": "navigator", "initialScreen": "home", "screens": { "home": { "type": "screen", "onFocus": "caricaLista", ... }, "altra": { ... } } }. I pulsanti navigano con "navigate": "nomeSchermata" senza action. Il tasto Indietro è automatico. Usa "onFocus": "nomeAction" su ogni schermata che deve ricaricare dati da storage al ritorno: l'action viene chiamata automaticamente ad ogni attivazione della schermata. -• code: stringa JS module.exports = { actions: { async nome(api, input, state) { … } } }; niente TypeScript; niente eval/Function per calcoli; virgolette nel code escape come \\" nel JSON; graffe bilanciate; dopo if { return … } usa else { return … } se serve un secondo return. -• più action: devono essere sorelle dentro actions e separate da virgola — async onTick(...) { ... }, async onTap(...) { ... }. Mai inserire onTap dentro onTick, dentro if, o dopo una graffa mancante. -• stato sicuro: ogni lettura da state con fallback — const x = parseFloat(String(state.x ?? '0')) || 0; const s = String(state.s ?? ''); mai chiamare metodi su valori letti da state senza prima assegnarli con fallback. -• variabili sicure: dichiara ogni variabile prima dell'uso. Se serve fuori da un if, dichiarala prima con let. Mai usare jump/newX/newY/newBirdX/newPipeX senza const/let precedente nella stessa action. In onTap: const jump = parseFloat(String(input.jump ?? '-8')) || -8; return { birdVy: jump }; -• audioPlayer: await api.audioPlayer.play(uri) per riprodurre; await api.audioPlayer.stop() per fermare. Stesso permesso "audioRecorder". -• notifications: tre argomenti separati — api.notifications.schedule(titolo, testo, secondi). -• manifest.permissions solo per API effettivamente usate: camera, audioRecorder, qrScanner, torch, location, sensors, linking, storage, network, notifications. -• clipboard / haptics / share / tts NON vanno in permissions — usale liberamente. -• api.files.save(key, uri) rende permanente un URI (audio/foto); api.tts.speak(testo, {language:'it-IT'}); api.haptics.impact('medium'); api.share.file(uri).`; +GIOCHI: usa SEMPRE { "type":"webGame","width":360,"height":600 } nell'UI. Il code è JavaScript browser (canvas 2D + requestAnimationFrame), NON module.exports. + Globals già pronti (NON ridichiarare): canvas, ctx, WIDTH, HEIGHT, sendState. + VIETATO: var canvas, var ctx, var WIDTH, var HEIGHT, var window, function init(){canvas=document...}. + Touch: canvas.addEventListener('touchstart',fn,{passive:false}), e.preventDefault() dentro. + Split-screen: touchLeft=(e.touches[0].clientX = { + en: 'IMPORTANT: Generate ALL UI text in English: button labels, screen titles, placeholders, status messages, emptyText, error messages. Do not use Italian words in the UI.', + es: 'IMPORTANTE: Genera TODOS los textos de la UI en español: etiquetas de botones, títulos, placeholders, mensajes de estado, emptyText.', + fr: 'IMPORTANT: Génère TOUS les textes de l\'UI en français: labels des boutons, titres, placeholders, messages de statut, emptyText.', + de: 'WICHTIG: Generiere ALLE UI-Texte auf Deutsch: Button-Labels, Titel, Platzhalter, Statusmeldungen, emptyText.', +}; + +export function buildModuleGenerationPrompt(userPrompt: string, language?: string): string { const trimmed = userPrompt.trim(); - return `Sei il generatore di moduli per AppFromAI. Obiettivo: modulo completo, valido e pronto all'uso su dispositivo mobile. + const lang = language?.toLowerCase().slice(0, 2) || 'it'; + const langDirective = LANGUAGE_DIRECTIVES[lang] ?? ''; + return `Sei il generatore di moduli per AppFromAI. Obiettivo: modulo completo, valido e pronto all'uso su dispositivo mobile. +${langDirective ? `\n${langDirective}\n` : ''} === FORMATO DI RISPOSTA (leggi tutto questo blocco prima della richiesta utente; l'output deve rispettarlo al 100%) === ${JSON_RESPONSE_RULES_IT} From 137f9e057c5a6001dbb53dbff2648ed527bc4d09 Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 18:40:40 +0200 Subject: [PATCH 3/6] Settings screen improvements Created loading screen Added form editing functionality --- app/settings/claude.tsx | 159 +++++++++++ app/settings/ollama.tsx | 148 +++++++++++ app/settings/openai.tsx | 159 +++++++++++ src/ai/builtinGameTemplates.ts | 184 +++++++++++++ src/components/GeneratingModal.tsx | 244 +++++++++++++++++ src/components/StyleEditorSheet.tsx | 398 ++++++++++++++++++++++++++++ src/modules/styleEditor.ts | 105 ++++++++ 7 files changed, 1397 insertions(+) create mode 100644 app/settings/claude.tsx create mode 100644 app/settings/ollama.tsx create mode 100644 app/settings/openai.tsx create mode 100644 src/ai/builtinGameTemplates.ts create mode 100644 src/components/GeneratingModal.tsx create mode 100644 src/components/StyleEditorSheet.tsx create mode 100644 src/modules/styleEditor.ts diff --git a/app/settings/claude.tsx b/app/settings/claude.tsx new file mode 100644 index 0000000..a3e3a8f --- /dev/null +++ b/app/settings/claude.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useSettings } from '../../src/settings/SettingsContext'; + +const C = { + bg: '#0b1120', + surface: '#1a2236', + border: '#2d3f5c', + primary: '#6366f1', + text: '#e8edf5', + muted: '#7a92b3', + faint: '#3d5070', + success: '#34d399', +}; + +export default function ClaudeSettings() { + const { settings, updateSettings } = useSettings(); + const router = useRouter(); + const isActive = settings.provider === 'claude'; + + const activate = () => { + updateSettings({ provider: 'claude' }); + router.back(); + }; + + return ( + + + + + + + {isActive ? 'Provider attivo' : 'Non selezionato come provider'} + + + + + BASE URL + updateSettings({ claudeBaseUrl: v })} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + /> + Lascia il default per usare Anthropic direttamente. + + + + API KEY + updateSettings({ claudeApiKey: v })} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + returnKeyType="done" + /> + + + + MODELLO + updateSettings({ claudeModel: v })} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="done" + /> + + Esempi: claude-sonnet-4-20250514, claude-haiku-4-5-20251001, claude-opus-4-20250514 + + + + {!isActive && ( + + + Usa Claude + + )} + + + ); +} + +const s = StyleSheet.create({ + safe: { flex: 1, backgroundColor: '#0b1120' }, + scroll: { padding: 16, gap: 16, paddingBottom: 40 }, + + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: C.surface, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: C.border, + }, + bannerActive: { borderColor: C.success + '60', backgroundColor: '#03180f' }, + bannerText: { color: C.muted, fontSize: 14 }, + bannerTextActive: { color: C.success }, + + card: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + padding: 16, + gap: 10, + }, + label: { + fontSize: 11, + fontWeight: '700', + color: C.faint, + letterSpacing: 1.1, + }, + input: { + backgroundColor: C.bg, + borderRadius: 10, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 14, + paddingVertical: 12, + color: C.text, + fontSize: 14, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + hint: { color: C.faint, fontSize: 12, lineHeight: 18 }, + + activateBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: C.primary, + borderRadius: 14, + paddingVertical: 16, + marginTop: 4, + }, + activateBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}); diff --git a/app/settings/ollama.tsx b/app/settings/ollama.tsx new file mode 100644 index 0000000..8afeb81 --- /dev/null +++ b/app/settings/ollama.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useSettings } from '../../src/settings/SettingsContext'; + +const C = { + bg: '#0b1120', + surface: '#1a2236', + border: '#2d3f5c', + primary: '#6366f1', + text: '#e8edf5', + muted: '#7a92b3', + faint: '#3d5070', + success: '#34d399', +}; + +export default function OllamaSettings() { + const { settings, updateSettings } = useSettings(); + const router = useRouter(); + const isActive = settings.provider === 'ollama'; + + const activate = () => { + updateSettings({ provider: 'ollama' }); + router.back(); + }; + + return ( + + + + {/* Status banner */} + + + + {isActive ? 'Provider attivo' : 'Non selezionato come provider'} + + + + + URL SERVER + updateSettings({ ollamaUrl: v })} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + /> + + Su telefono reale usa l'IP del tuo PC, non localhost.{'\n'} + Avvia Ollama con: OLLAMA_HOST=0.0.0.0 ollama serve + + + + + MODELLO + updateSettings({ ollamaModel: v })} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="done" + /> + + Esempi: gemma2:4b, gemma3:4b, qwen2.5-coder:7b, llama3.2:3b + + + + {!isActive && ( + + + Usa Ollama + + )} + + + ); +} + +const s = StyleSheet.create({ + safe: { flex: 1, backgroundColor: '#0b1120' }, + scroll: { padding: 16, gap: 16, paddingBottom: 40 }, + + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: C.surface, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: C.border, + }, + bannerActive: { borderColor: C.success + '60', backgroundColor: '#03180f' }, + bannerText: { color: C.muted, fontSize: 14 }, + bannerTextActive: { color: C.success }, + + card: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + padding: 16, + gap: 10, + }, + label: { + fontSize: 11, + fontWeight: '700', + color: C.faint, + letterSpacing: 1.1, + }, + input: { + backgroundColor: C.bg, + borderRadius: 10, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 14, + paddingVertical: 12, + color: C.text, + fontSize: 14, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + hint: { color: C.faint, fontSize: 12, lineHeight: 18 }, + + activateBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: C.primary, + borderRadius: 14, + paddingVertical: 16, + marginTop: 4, + }, + activateBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}); diff --git a/app/settings/openai.tsx b/app/settings/openai.tsx new file mode 100644 index 0000000..79797c7 --- /dev/null +++ b/app/settings/openai.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useSettings } from '../../src/settings/SettingsContext'; + +const C = { + bg: '#0b1120', + surface: '#1a2236', + border: '#2d3f5c', + primary: '#6366f1', + text: '#e8edf5', + muted: '#7a92b3', + faint: '#3d5070', + success: '#34d399', +}; + +export default function OpenAISettings() { + const { settings, updateSettings } = useSettings(); + const router = useRouter(); + const isActive = settings.provider === 'openai'; + + const activate = () => { + updateSettings({ provider: 'openai' }); + router.back(); + }; + + return ( + + + + + + + {isActive ? 'Provider attivo' : 'Non selezionato come provider'} + + + + + API URL + updateSettings({ openaiUrl: v })} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + /> + + Compatibile con qualsiasi endpoint OpenAI-compatible (LM Studio, Together AI, Groq…) + + + + + API KEY + updateSettings({ openaiKey: v })} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + returnKeyType="done" + /> + + + + MODELLO + updateSettings({ openaiModel: v })} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="done" + /> + Esempi: gpt-4o-mini, gpt-4o, gpt-4.1-mini + + + {!isActive && ( + + + Usa OpenAI + + )} + + + ); +} + +const s = StyleSheet.create({ + safe: { flex: 1, backgroundColor: '#0b1120' }, + scroll: { padding: 16, gap: 16, paddingBottom: 40 }, + + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: C.surface, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: C.border, + }, + bannerActive: { borderColor: C.success + '60', backgroundColor: '#03180f' }, + bannerText: { color: C.muted, fontSize: 14 }, + bannerTextActive: { color: C.success }, + + card: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + padding: 16, + gap: 10, + }, + label: { + fontSize: 11, + fontWeight: '700', + color: C.faint, + letterSpacing: 1.1, + }, + input: { + backgroundColor: C.bg, + borderRadius: 10, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 14, + paddingVertical: 12, + color: C.text, + fontSize: 14, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + hint: { color: C.faint, fontSize: 12, lineHeight: 18 }, + + activateBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: C.primary, + borderRadius: 14, + paddingVertical: 16, + marginTop: 4, + }, + activateBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}); diff --git a/src/ai/builtinGameTemplates.ts b/src/ai/builtinGameTemplates.ts new file mode 100644 index 0000000..83972ae --- /dev/null +++ b/src/ai/builtinGameTemplates.ts @@ -0,0 +1,184 @@ +/** + * Pre-built, tested game modules for common game types. + * When the user asks for a known game, these are returned directly — + * no model code-generation pass needed, ensuring 100% reliability + * and correct physics. + */ + +export type BuiltinGame = 'flappy' | 'snake' | 'pong' | 'breakout'; + +// ── UIs ────────────────────────────────────────────────────────────────────── + +const FLAPPY_UI = { + type: 'screen', title: 'Flappy Bird', gap: 0, + components: [{ type: 'gameView', id: 'gv', bind: 'scene', width: 320, height: 480, tickMs: 30, tickAction: 'onTick', onTapAction: 'onTap' }], +}; + +const SNAKE_UI = { + type: 'screen', title: 'Snake', gap: 0, + components: [{ type: 'gameView', id: 'gv', bind: 'scene', width: 320, height: 480, tickMs: 150, tickAction: 'onTick', onTapAction: 'onTap' }], +}; + +const PONG_UI = { + type: 'screen', title: 'Pong', gap: 0, + components: [{ type: 'gameView', id: 'gv', bind: 'scene', width: 320, height: 480, tickMs: 25, tickAction: 'onTick', onTapAction: 'onTap' }], +}; + +const BREAKOUT_UI = { + type: 'screen', title: 'Breakout', gap: 0, + components: [{ type: 'gameView', id: 'gv', bind: 'scene', width: 320, height: 480, tickMs: 30, tickAction: 'onTick', onTapAction: 'onTap' }], +}; + +// ── Codes ───────────────────────────────────────────────────────────────────── + +const FLAPPY_CODE = + `module.exports={actions:{` + + `onTick(api,input,state){` + + `const W=320,H=480,BX=60,BR=12,GAP=55,G=0.5;` + + `if(state.dead){` + + `const s=parseInt(String(state.score??'0'),10);` + + `return{scene:[` + + `{type:'rect',x:0,y:0,w:W,h:H,color:'#70c5ce'},` + + `{type:'text',x:W/2,y:200,text:'GAME OVER',color:'#f00',fontSize:24,align:'center'},` + + `{type:'text',x:W/2,y:240,text:'Score:'+s,color:'#fff',fontSize:18,align:'center'},` + + `{type:'text',x:W/2,y:280,text:'Tap to restart',color:'#ddd',fontSize:14,align:'center'}` + + `]};}` + + `const y=parseFloat(String(state.y??'240'));` + + `const vy=parseFloat(String(state.vy??'0'))+G;` + + `const ny=y+vy;` + + `let pipes=Array.isArray(state.pipes)?state.pipes:[];` + + `pipes=pipes.map(p=>({x:p.x-2,gap:p.gap})).filter(p=>p.x>-40);` + + `const lastX=pipes.length>0?pipes[pipes.length-1].x:0;` + + `if(lastXp.x+2>=BX&&p.xH-BR||pipes.some(p=>Math.abs(p.x-BX)p.gap+GAP));` + + `const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#70c5ce'}];` + + `pipes.forEach(p=>{` + + `scene.push({type:'rect',x:p.x-20,y:0,w:40,h:Math.max(0,p.gap-GAP),color:'#5d8a3c'});` + + `scene.push({type:'rect',x:p.x-20,y:p.gap+GAP,w:40,h:H,color:'#5d8a3c'});` + + `});` + + `scene.push({type:'circle',x:BX,y:Math.max(BR,Math.min(H-BR,ny)),r:BR,color:'#f6d622'});` + + `scene.push({type:'text',x:8,y:20,text:'Score:'+pts,color:'#fff',fontSize:16});` + + `return{y:Math.max(BR,Math.min(H-BR,ny)),vy:hit?0:vy,pipes,score:pts,dead:hit,scene};},` + + `onTap(api,input,state){` + + `if(state.dead)return{y:240,vy:0,pipes:[],score:0,dead:false};` + + `return{vy:-8};}}}`; + +const SNAKE_CODE = + `module.exports={actions:{` + + `onTick(api,input,state){` + + `const W=320,H=480,C=20,COLS=16,ROWS=24;` + + `const body=Array.isArray(state.body)?state.body:[{x:8,y:12},{x:7,y:12},{x:6,y:12}];` + + `const dx=parseInt(String(state.dx??'1'),10);` + + `const dy=parseInt(String(state.dy??'0'),10);` + + `const food=state.food&&typeof state.food==='object'?state.food:{x:5,y:5};` + + `const head={x:(body[0].x+dx+COLS)%COLS,y:(body[0].y+dy+ROWS)%ROWS};` + + `const eating=head.x===food.x&&head.y===food.y;` + + `const nb=eating?[head,...body]:[head,...body.slice(0,-1)];` + + `const dead=nb.slice(1).some(s=>s.x===head.x&&s.y===head.y);` + + `const score=parseInt(String(state.score??'0'),10)+(eating?1:0);` + + `const nf=eating?{x:Math.floor(Math.random()*COLS),y:Math.floor(Math.random()*ROWS)}:food;` + + `if(dead){return{body:[{x:8,y:12},{x:7,y:12}],dx:1,dy:0,food:nf,score:0,scene:[` + + `{type:'rect',x:0,y:0,w:W,h:H,color:'#111'},` + + `{type:'text',x:W/2,y:H/2,text:'GAME OVER Score:'+score,color:'#fff',fontSize:18,align:'center'}` + + `]};}` + + `const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#111'}];` + + `nb.forEach((s,i)=>scene.push({type:'rect',x:s.x*C,y:s.y*C,w:C-1,h:C-1,color:i===0?'#0f0':'#0a0'}));` + + `scene.push({type:'rect',x:nf.x*C,y:nf.y*C,w:C-1,h:C-1,color:'#f00'});` + + `scene.push({type:'text',x:4,y:16,text:'Score:'+score,color:'#fff',fontSize:14});` + + `return{body:nb,dx,dy,food:nf,score,scene};},` + + `onTap(api,input,state){` + + `const dx=parseInt(String(state.dx??'1'),10);` + + `if(dx!==0)return{dx:0,dy:-1};` + + `return{dx:1,dy:0};}}}`; + +const PONG_CODE = + `module.exports={actions:{` + + `onTick(api,input,state){` + + `const W=320,H=480,PR=8,PW=12,PH=60,HALF=PH/2;` + + `const bx=parseFloat(String(state.bx??'160'));` + + `const by=parseFloat(String(state.by??'240'));` + + `const vx=parseFloat(String(state.vx??'4'));` + + `const vy=parseFloat(String(state.vy??'3'));` + + `const py=parseFloat(String(state.py??'240'));` + + `const ay=Math.max(HALF,Math.min(H-HALF,by));` + + `const nbx=bx+vx;const nby=by+vy;` + + `const hitP=nbx=py-HALF&&nby<=py+HALF;` + + `const hitA=nbx>W-PW-PR&&nby>=ay-HALF&&nby<=ay+HALF;` + + `const hitW=nbxW-PR;` + + `const nvx=(hitP||hitA||hitW)?-vx:vx;` + + `const nvy=(nbyH-PR)?-vy:vy;` + + `const scored=nbx<0||nbx>W;` + + `const score=parseInt(String(state.score??'0'),10)+(scored?1:0);` + + `const rnbx=scored?160:Math.max(PR,Math.min(W-PR,nbx));` + + `const rnby=scored?240:Math.max(PR,Math.min(H-PR,nby));` + + `const scene=[` + + `{type:'rect',x:0,y:0,w:W,h:H,color:'#111'},` + + `{type:'rect',x:0,y:py-HALF,w:PW,h:PH,color:'#fff'},` + + `{type:'rect',x:W-PW,y:ay-HALF,w:PW,h:PH,color:'#f44'},` + + `{type:'circle',x:rnbx,y:rnby,r:PR,color:'#ff0'},` + + `{type:'text',x:W/2,y:16,text:'Score:'+score,color:'#fff',fontSize:16,align:'center'}` + + `];` + + `return{bx:rnbx,by:rnby,vx:scored?4:nvx,vy:scored?3:nvy,py,score,scene};},` + + `onTap(api,input,state){` + + `const by=parseFloat(String(state.by??'240'));` + + `return{py:by};}}}`; + +const BREAKOUT_CODE = + `module.exports={actions:{` + + `onTick(api,input,state){` + + `const W=320,H=480,BR=8,PW=70,PH=10,BW=28,BH=12,ROWS=5,COLS=9;` + + `const bx=parseFloat(String(state.bx??'160'));` + + `const by=parseFloat(String(state.by??'300'));` + + `const vx=parseFloat(String(state.vx??'3'));` + + `const vy=parseFloat(String(state.vy??'-3'));` + + `const px=parseFloat(String(state.px??'125'));` + + `let bricks=Array.isArray(state.bricks)?state.bricks:[];` + + `if(bricks.length===0){for(let r=0;r=H-PH-30&&nby=px&&nbx<=px+PW;` + + `let nvx=vx,nvy=vy,hitBrick=false;` + + `bricks=bricks.map(b=>{` + + `if(!b.alive)return b;` + + `const bx2=b.c*(BW+2)+10;const by2=b.r*(BH+4)+50;` + + `if(nbx+BR>bx2&&nbx-BRby2&&nby-BRW-BR)nvx=-nvx;` + + `if(nby0)nvy=-nvy;` + + `const score=parseInt(String(state.score??'0'),10)+(hitBrick?1:0);` + + `if(nby>H){return{bx:160,by:300,vx:3,vy:-3,px,bricks:[],score,scene:[` + + `{type:'rect',x:0,y:0,w:W,h:H,color:'#111'},` + + `{type:'text',x:W/2,y:H/2,text:'GAME OVER Score:'+score,color:'#fff',fontSize:18,align:'center'},` + + `{type:'text',x:W/2,y:H/2+36,text:'Tap to play again',color:'#aaa',fontSize:14,align:'center'}` + + `]};}` + + `const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#111'}];` + + `const colors=['#f44','#f84','#ff4','#4f4','#44f'];` + + `bricks.forEach(b=>{if(!b.alive)return;` + + `scene.push({type:'rect',x:b.c*(BW+2)+10,y:b.r*(BH+4)+50,w:BW,h:BH,color:colors[b.r%5]});});` + + `scene.push({type:'rect',x:px,y:H-PH-30,w:PW,h:PH,color:'#fff'});` + + `scene.push({type:'circle',x:Math.max(BR,Math.min(W-BR,nbx)),y:Math.max(BR,Math.min(H-BR,nby)),r:BR,color:'#ff0'});` + + `scene.push({type:'text',x:4,y:16,text:'Score:'+score,color:'#fff',fontSize:14});` + + `return{bx:Math.max(BR,Math.min(W-BR,nbx)),by:Math.max(BR,Math.min(H-BR,nby)),vx:nvx,vy:nvy,px,bricks,score,scene};},` + + `onTap(api,input,state){` + + `const px=parseFloat(String(state.px??'125'));` + + `const W=320,PW=70;` + + `return{px:Math.max(0,Math.min(W-PW,px+40))};}}}`; + +// ── Public API ──────────────────────────────────────────────────────────────── + +export const BUILTIN_GAMES: Record = { + flappy: { ui: FLAPPY_UI, code: FLAPPY_CODE }, + snake: { ui: SNAKE_UI, code: SNAKE_CODE }, + pong: { ui: PONG_UI, code: PONG_CODE }, + breakout: { ui: BREAKOUT_UI, code: BREAKOUT_CODE }, +}; + +export function detectBuiltinGame(prompt: string): BuiltinGame | null { + const p = prompt.toLowerCase(); + if (/\b(flappy|flap)\b/.test(p)) return 'flappy'; + if (/\bsnake\b/.test(p)) return 'snake'; + if (/\bpong\b/.test(p)) return 'pong'; + if (/\b(breakout|brick|arkanoid|break\s*out)\b/.test(p)) return 'breakout'; + return null; +} diff --git a/src/components/GeneratingModal.tsx b/src/components/GeneratingModal.tsx new file mode 100644 index 0000000..c49e1e8 --- /dev/null +++ b/src/components/GeneratingModal.tsx @@ -0,0 +1,244 @@ +import { useEffect, useRef, useState } from 'react'; +import { Animated, Easing, Modal, StyleSheet, Text, 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 { useI18n } from '../i18n/useI18n'; + +const C = { + bg: '#07101f', + surface: '#111d30', + border: '#1e3352', + primary: '#6366f1', + text: '#e2eaf6', + muted: '#6a87ae', + faint: '#2d4464', +}; + +const STEP_DELAYS = [0, 2200, 4500]; + +type StepState = 'pending' | 'active' | 'done'; + +function StepRow({ + label, + state, + last, + badgePending, + badgeActive, + badgeDone, +}: { + label: string; + state: StepState; + last?: boolean; + badgePending: string; + badgeActive: string; + badgeDone: string; +}) { + const pulseAnim = useRef(new Animated.Value(1)).current; + const scaleAnim = useRef(new Animated.Value(0.6)).current; + + useEffect(() => { + if (state === 'active') { + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { toValue: 0.3, duration: 700, useNativeDriver: true }), + Animated.timing(pulseAnim, { toValue: 1, duration: 700, useNativeDriver: true }), + ]) + ).start(); + } else { + pulseAnim.stopAnimation(); + Animated.timing(pulseAnim, { toValue: 1, duration: 200, useNativeDriver: true }).start(); + } + if (state === 'done') { + scaleAnim.setValue(0.6); + Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, speed: 18 }).start(); + } + }, [state]); + + const isDone = state === 'done'; + const isActive = state === 'active'; + + return ( + + {!last && } + + + {isDone ? ( + + + + ) : isActive ? ( + + ) : ( + + )} + + + + + {label} + + + + {isDone ? ( + {badgeDone} + ) : isActive ? ( + {badgeActive} + ) : ( + {badgePending} + )} + + ); +} + +function OrbLoader() { + const rot = useRef(new Animated.Value(0)).current; + useEffect(() => { + Animated.loop( + Animated.timing(rot, { toValue: 1, duration: 2400, easing: Easing.linear, useNativeDriver: true }) + ).start(); + }, []); + const spin = rot.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }); + return ( + + + + ); +} + +export function GeneratingModal({ + visible, + done, + prompt, +}: { + visible: boolean; + done: boolean; + prompt: string; +}) { + const { t } = useI18n(); + const stepLabels = [t.genStep1, t.genStep2, t.genStep3]; + const [activeStep, setActiveStep] = useState(0); + const timers = useRef[]>([]); + + useEffect(() => { + if (visible && !done) { + setActiveStep(0); + timers.current.forEach(clearTimeout); + timers.current = []; + STEP_DELAYS.forEach((delay, i) => { + timers.current.push(setTimeout(() => setActiveStep(i), delay)); + }); + } + if (done) { + timers.current.forEach(clearTimeout); + setActiveStep(stepLabels.length); + } + return () => timers.current.forEach(clearTimeout); + }, [visible, done]); + + const stepState = (i: number): StepState => { + if (activeStep > i) return 'done'; + if (activeStep === i) return 'active'; + return 'pending'; + }; + + return ( + + + + + + + AppFromAI + + + + {done ? t.genModalDone : t.genModalBuilding} + + + + + {prompt} + + + + {stepLabels.map((label, i) => ( + + ))} + + + {!done && {t.genHint}} + + + + ); +} + +const ls = StyleSheet.create({ + safe: { flex: 1, backgroundColor: C.bg }, + content: { flex: 1, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 32, gap: 24 }, + + logoRow: { flexDirection: 'row', alignItems: 'center', gap: 14 }, + orb: { + width: 36, height: 36, borderRadius: 18, + borderWidth: 2, borderColor: C.primary, + alignItems: 'center', justifyContent: 'flex-start', + }, + orbDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: C.primary, marginTop: -4 }, + logoText: { fontSize: 22, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, + + headline: { fontSize: 28, fontWeight: '800', color: C.text, letterSpacing: -0.8, lineHeight: 34 }, + + promptPreview: { + flexDirection: 'row', gap: 8, alignItems: 'flex-start', + backgroundColor: C.surface, borderRadius: 12, padding: 12, + borderWidth: 1, borderColor: C.border, + }, + promptPreviewText: { flex: 1, color: C.muted, fontSize: 13, lineHeight: 19 }, + + stepsCard: { + backgroundColor: C.surface, borderRadius: 20, + borderWidth: 1, borderColor: C.border, overflow: 'hidden', + }, + + stepRow: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 20, paddingVertical: 18, gap: 14, position: 'relative', + }, + stepRowBorder: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: C.border }, + connectorLine: { + position: 'absolute', left: 30, top: '50%', bottom: -18, + width: 2, backgroundColor: C.border, + }, + + indicatorWrap: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', zIndex: 1 }, + indicatorPending: { width: 14, height: 14, borderRadius: 7, backgroundColor: C.faint }, + indicatorActive: { + width: 20, height: 20, borderRadius: 10, backgroundColor: C.primary, + shadowColor: C.primary, shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, shadowRadius: 8, elevation: 6, + }, + indicatorDone: { + width: 24, height: 24, borderRadius: 12, backgroundColor: '#16a34a', + alignItems: 'center', justifyContent: 'center', + }, + + stepTextWrap: { flex: 1 }, + stepLabel: { fontSize: 15, fontWeight: '500', color: C.faint }, + stepLabelActive: { color: C.text, fontWeight: '700' }, + stepLabelDone: { color: C.muted }, + + stepBadgePending: { fontSize: 11, color: C.faint }, + stepBadgeActive: { fontSize: 11, color: C.primary, fontWeight: '600' }, + stepBadgeDone: { fontSize: 11, color: '#16a34a', fontWeight: '600' }, + + hint: { color: C.faint, fontSize: 13, lineHeight: 19, textAlign: 'center' }, +}); diff --git a/src/components/StyleEditorSheet.tsx b/src/components/StyleEditorSheet.tsx new file mode 100644 index 0000000..4131539 --- /dev/null +++ b/src/components/StyleEditorSheet.tsx @@ -0,0 +1,398 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useI18n } from '../i18n/useI18n'; +import { + PALETTE, + extractColors, + extractTexts, + isValidHex, +} from '../modules/styleEditor'; +import type { ColorEntry, TextEntry } from '../modules/styleEditor'; + +const C = { + bg: '#0b1120', + surface: '#1a2236', + surfaceHigh: '#1f2d45', + border: '#2d3f5c', + primary: '#6366f1', + text: '#e8edf5', + muted: '#7a92b3', + faint: '#3d5070', + success: '#34d399', + overlay: 'rgba(0,0,0,0.7)', +}; + +// ── Color row with inline palette picker ───────────────────────────────────── +function ColorRow({ + entry, + pendingColor, + onSelect, +}: { + entry: ColorEntry; + pendingColor: string | undefined; + onSelect: (original: string, newColor: string) => void; +}) { + const { t } = useI18n(); + const [expanded, setExpanded] = useState(false); + const [hexInput, setHexInput] = useState(pendingColor ?? entry.original); + const displayColor = pendingColor ?? entry.original; + const changed = !!pendingColor && pendingColor !== entry.original; + + useEffect(() => { + setHexInput(pendingColor ?? entry.original); + }, [pendingColor, entry.original]); + + const handlePaletteSelect = (color: string) => { + onSelect(entry.original, color); + setExpanded(false); + }; + + const handleHexApply = () => { + if (isValidHex(hexInput)) { + onSelect(entry.original, hexInput.toLowerCase()); + setExpanded(false); + } + }; + + return ( + + setExpanded((v) => !v)}> + {/* Old color swatch */} + + + {changed ? ( + <> + + + + ) : null} + + {displayColor} + + {entry.count}× + + {changed ? : null} + + + + {expanded && ( + + {/* Palette grid: 6 swatches per row */} + + {PALETTE.map((color) => { + const isSelected = displayColor.toLowerCase() === color; + return ( + handlePaletteSelect(color)} + > + {isSelected ? ( + + ) : null} + + ); + })} + + + {/* Hex input row */} + + + + + {t.styleEditorApply} + + + + )} + + ); +} + +// ── Main sheet ──────────────────────────────────────────────────────────────── +export function StyleEditorSheet({ + visible, + ui, + onClose, + onSave, +}: { + visible: boolean; + ui: unknown; + onClose: () => void; + onSave: (colorChanges: Map, textChanges: Map) => void; +}) { + const { t } = useI18n(); + const colors = useMemo(() => extractColors(ui), [ui]); + const texts = useMemo(() => extractTexts(ui), [ui]); + + const [colorChanges, setColorChanges] = useState>(new Map()); + const [textChanges, setTextChanges] = useState>(new Map()); + + useEffect(() => { + if (visible) { + setColorChanges(new Map()); + setTextChanges(new Map()); + } + }, [visible]); + + const handleColorSelect = (original: string, newColor: string) => { + setColorChanges((prev) => new Map(prev).set(original, newColor)); + }; + + const handleTextChange = (pathKey: string, newValue: string) => { + setTextChanges((prev) => new Map(prev).set(pathKey, newValue)); + }; + + const hasChanges = colorChanges.size > 0 || textChanges.size > 0; + const hasContent = colors.length > 0 || texts.length > 0; + + return ( + + + e.stopPropagation()}> + + {t.styleEditorTitle} + + + {!hasContent ? ( + {t.styleEditorNoEditable} + ) : ( + <> + {/* ── Colors ── */} + {colors.length > 0 && ( + + {t.styleEditorColors} + + {colors.map((entry, i) => ( + + + + ))} + + + )} + + {/* ── Texts ── */} + {texts.length > 0 && ( + + {t.styleEditorTexts} + + {texts.map(({ pathKey, value }, i) => ( + + handleTextChange(pathKey, v)} + placeholderTextColor={C.faint} + returnKeyType="done" + /> + {textChanges.has(pathKey) && ( + { + setTextChanges((prev) => { + const next = new Map(prev); + next.delete(pathKey); + return next; + }); + }} + > + + + )} + + ))} + + + )} + + )} + + {/* ── Actions ── */} + + + {t.cancel} + + onSave(colorChanges, textChanges)} + > + + {t.styleEditorSave} + + + + + + + ); +} + +const rs = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: C.overlay, justifyContent: 'flex-end' }, + sheet: { + backgroundColor: C.surface, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 20, + paddingBottom: Platform.OS === 'ios' ? 36 : 24, + borderWidth: 1, + borderColor: C.border, + maxHeight: '88%', + }, + handle: { + width: 36, height: 4, borderRadius: 2, + backgroundColor: C.faint, alignSelf: 'center', marginBottom: 12, + }, + title: { fontSize: 16, fontWeight: '700', color: C.text, marginBottom: 16 }, + scrollContent: { gap: 16, paddingBottom: 8 }, + + section: { gap: 8 }, + sectionLabel: { + fontSize: 11, fontWeight: '700', color: C.faint, + letterSpacing: 1.2, paddingHorizontal: 4, + }, + sectionCard: { + backgroundColor: C.bg, + borderRadius: 16, borderWidth: 1, borderColor: C.border, + overflow: 'hidden', + }, + itemDivider: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: C.border, + }, + + emptyText: { color: C.faint, fontSize: 13, lineHeight: 19, textAlign: 'center', paddingVertical: 24 }, + + /* Color row */ + colorBlock: {}, + colorRow: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 14, paddingVertical: 14, gap: 8, + }, + swatch: { width: 26, height: 26, borderRadius: 6, borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)' }, + hexLabel: { flex: 1, color: C.muted, fontSize: 13, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }, + badge: { + backgroundColor: C.surface, borderRadius: 6, + paddingHorizontal: 6, paddingVertical: 2, + }, + badgeText: { color: C.faint, fontSize: 11 }, + changedDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: C.primary }, + + /* Picker */ + picker: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: C.border, + padding: 14, gap: 12, + backgroundColor: C.surfaceHigh, + }, + paletteGrid: { + flexDirection: 'row', flexWrap: 'wrap', gap: 8, + }, + paletteSwatch: { + width: 38, height: 38, borderRadius: 8, + borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', + alignItems: 'center', justifyContent: 'center', + }, + paletteSwatchSelected: { + borderWidth: 2, borderColor: '#fff', + }, + + hexRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + hexPreview: { width: 32, height: 32, borderRadius: 6, borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }, + hexInput: { + flex: 1, + backgroundColor: C.bg, borderRadius: 8, borderWidth: 1, borderColor: C.border, + paddingHorizontal: 10, paddingVertical: 8, + color: C.text, fontSize: 13, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + applyBtn: { + backgroundColor: C.primary, borderRadius: 8, + paddingHorizontal: 12, paddingVertical: 8, + }, + applyBtnDisabled: { opacity: 0.35 }, + applyBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' }, + + /* Text rows */ + textRow: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 14, paddingVertical: 10, gap: 8, + }, + textInput: { + flex: 1, color: C.text, fontSize: 14, + paddingVertical: 4, + }, + + /* Actions */ + actions: { flexDirection: 'row', gap: 12, marginTop: 4 }, + cancelBtn: { + flex: 1, borderRadius: 12, paddingVertical: 14, + backgroundColor: C.bg, borderWidth: 1, borderColor: C.border, alignItems: 'center', + }, + cancelBtnText: { color: C.muted, fontSize: 15, fontWeight: '600' }, + saveBtn: { + flex: 2, flexDirection: 'row', alignItems: 'center', + justifyContent: 'center', gap: 8, + borderRadius: 12, paddingVertical: 14, backgroundColor: C.primary, + }, + saveBtnDisabled: { opacity: 0.35 }, + saveBtnText: { color: '#fff', fontSize: 15, fontWeight: '700' }, +}); diff --git a/src/modules/styleEditor.ts b/src/modules/styleEditor.ts new file mode 100644 index 0000000..51b528e --- /dev/null +++ b/src/modules/styleEditor.ts @@ -0,0 +1,105 @@ +const TEXT_KEYS = new Set(['text', 'label', 'title', 'placeholder', 'hint', 'caption']); +const HEX_RE = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/; + +export type ColorEntry = { original: string; count: number }; +export type TextEntry = { pathKey: string; path: string[]; value: string }; + +export function extractColors(ui: unknown): ColorEntry[] { + const map = new Map(); + + // Check any string value anywhere in the JSON — theme uses keys like + // bg/surface/primary/muted which aren't in a predefined set, so we match + // by value shape (hex) rather than by key name. + function walk(node: unknown) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { node.forEach(walk); return; } + for (const [, val] of Object.entries(node as Record)) { + if (typeof val === 'string' && HEX_RE.test(val)) { + const lo = val.toLowerCase(); + map.set(lo, (map.get(lo) ?? 0) + 1); + } else { + walk(val); + } + } + } + + walk(ui); + return [...map.entries()] + .map(([original, count]) => ({ original, count })) + .sort((a, b) => b.count - a.count); +} + +export function extractTexts(ui: unknown): TextEntry[] { + const results: TextEntry[] = []; + + function walk(node: unknown, path: string[]) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, [...path, String(i)])); + return; + } + for (const [key, val] of Object.entries(node as Record)) { + if ( + TEXT_KEYS.has(key) && + typeof val === 'string' && + val.trim().length >= 2 && + !HEX_RE.test(val) && + !val.startsWith('http') && + !/^[a-z_]+$/.test(val) + ) { + const pathArr = [...path, key]; + results.push({ pathKey: pathArr.join('.'), path: pathArr, value: val }); + } else { + walk(val, [...path, key]); + } + } + } + + walk(ui, []); + return results; +} + +export function applyColorChanges(ui: unknown, changes: Map): unknown { + if (changes.size === 0) return ui; + let json = JSON.stringify(ui); + for (const [from, to] of changes.entries()) { + const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + json = json.replace(new RegExp(escaped, 'gi'), to); + } + return JSON.parse(json); +} + +export function applyTextChanges(ui: unknown, changes: Map): unknown { + if (changes.size === 0) return ui; + const clone: Record = JSON.parse(JSON.stringify(ui)); + + for (const [pathKey, newValue] of changes.entries()) { + const path = pathKey.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let node: any = clone; + for (let i = 0; i < path.length - 1; i++) { + if (node == null) break; + const seg = path[i]; + node = Array.isArray(node) ? node[parseInt(seg, 10)] : node[seg]; + } + if (node != null) { + const last = path[path.length - 1]; + if (Array.isArray(node)) node[parseInt(last, 10)] = newValue; + else node[last] = newValue; + } + } + + return clone; +} + +export function isValidHex(s: string): boolean { + return HEX_RE.test(s); +} + +export const PALETTE: string[] = [ + '#0b1120', '#111827', '#1e293b', '#27272a', '#292524', '#1c1917', + '#334155', '#4b5563', '#6b7280', '#78716c', '#9ca3af', '#cbd5e1', + '#f1f5f9', '#f8fafc', '#ffffff', '#fef9c3', '#fce7f3', '#e0f2fe', + '#6366f1', '#8b5cf6', '#a855f7', '#ec4899', '#ef4444', '#f97316', + '#f59e0b', '#84cc16', '#22c55e', '#10b981', '#06b6d4', '#3b82f6', +]; From 98db1587b745a612eeff551c17230b90faeb69ec Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 18:50:26 +0200 Subject: [PATCH 4/6] Settings screen improvements Created loading screen Added form editing functionality --- app/(tabs)/index.tsx | 1 + app/(tabs)/settings.tsx | 2 +- package-lock.json | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6e4c74d..69f326b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -18,6 +18,7 @@ import { saveModule } from '../../src/modules/moduleStore'; import { useSettings } from '../../src/settings/SettingsContext'; import { useI18n } from '../../src/i18n/useI18n'; import { GeneratingModal } from '../../src/components/GeneratingModal'; +import {useDeviceLayout} from "../../src/utils/deviceLayout"; /* ── Design tokens ── */ const C = { diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 7877e15..d550226 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -94,7 +94,7 @@ export default function SettingsScreen() { diff --git a/package-lock.json b/package-lock.json index 99a1bb8..369b393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", + "react-native-webview": "13.15.0", "zod": "^4.3.6" }, "devDependencies": { @@ -9686,6 +9687,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-worklets": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", From 0bff6ffa951925f6cb64c651d7912c79d32ea25f Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 19:20:29 +0200 Subject: [PATCH 5/6] Settings screen improvements Created loading screen Added form editing functionality --- app/(tabs)/modules.tsx | 381 ++++++++++++++++-------------------- app/module/[id].tsx | 7 +- package-lock.json | 48 ----- src/modules/moduleStore.ts | 136 ++++++------- src/renderer/components.tsx | 94 +-------- 5 files changed, 249 insertions(+), 417 deletions(-) diff --git a/app/(tabs)/modules.tsx b/app/(tabs)/modules.tsx index 1848234..181efd0 100644 --- a/app/(tabs)/modules.tsx +++ b/app/(tabs)/modules.tsx @@ -1,13 +1,11 @@ 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, TextInput, @@ -20,8 +18,6 @@ import { Ionicons } from '@expo/vector-icons'; import { deleteModule, listModules, renameModule } 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', @@ -53,19 +49,17 @@ 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()); }, []); useFocusEffect( - useCallback(() => { - void load(); - }, [load]) + useCallback(() => { + void load(); + }, [load]) ); const onRefresh = useCallback(async () => { @@ -75,111 +69,101 @@ export default function ModulesScreen() { }, [load]); const onDelete = useCallback( - (item: StoredModule) => { - Alert.alert( - t.deleteModuleTitle, - t.deleteConfirmMsg(item.name), - [ - { text: t.cancel, style: 'cancel' }, - { - text: t.deleteConfirm, - style: 'destructive', - onPress: async () => { - await deleteModule(item.id); - await load(); - }, - }, - ] - ); - }, - [load, t] + (item: StoredModule) => { + Alert.alert( + t.deleteModuleTitle, + t.deleteConfirmMsg(item.name), + [ + { text: t.cancel, style: 'cancel' }, + { + text: t.deleteConfirm, + style: 'destructive', + onPress: async () => { + await deleteModule(item.id); + await load(); + }, + }, + ] + ); + }, + [load, t] ); const onRename = useCallback( - async (item: StoredModule, newName: string) => { - await renameModule(item.id, newName); - await load(); - }, - [load] + async (item: StoredModule, newName: string) => { + await renameModule(item.id, newName); + await load(); + }, + [load] ); return ( - - + + - {/* Header */} - - - {t.modulesTitle} - {modules.length > 0 ? ( - - {modules.length} - - ) : null} - - - - Importa - - - - {importMsg ? ( - - {importMsg.text} + {/* Header */} + + + {t.modulesTitle} + {modules.length > 0 ? ( + + {modules.length} + + ) : null} + - ) : 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={ - - } - ListEmptyComponent={ - - - - - {t.emptyTitle} - {t.emptyText(t.tabGenerate)} - - } - renderItem={({ item }) => ( - 1 ? { flex: 1 } : undefined}> - router.push(`/module/${id}`)} - onShare={onExport} - onDownload={onDownload} - /> - - )} - /> - + + } + > + {modules.length === 0 ? ( + + + + + {t.emptyTitle} + + {t.emptyText(t.tabGenerate)} + + + ) : ( + modules.map((item) => ( + 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 }); + }} + /> + )) + )} + + ); } function ModuleCard({ - item, - onDelete, - onRename, - onOpen, - onShare, - onDownload, -}: { + item, + onDelete, + onRename, + onOpen, + onShare, + }: { item: StoredModule; onDelete: (item: StoredModule) => void; onRename: (item: StoredModule, newName: string) => void; onOpen: (id: string) => void; onShare: (item: StoredModule) => void; - onDownload: (item: StoredModule) => void; }) { const { t } = useI18n(); const [copied, setCopied] = useState(false); @@ -213,102 +197,95 @@ function ModuleCard({ }); return ( - [s.card, pressed && s.cardPressed]} - onPress={() => onOpen(item.id)} - > - {/* Top row: title + edit + delete */} - - - {editing ? ( - <> - - - - - - - - - ) : ( - <> - {item.name} - - v{item.version} - + [s.card, pressed && s.cardPressed]} + onPress={() => onOpen(item.id)} + > + {/* Top row: title + edit + delete */} + + + {editing ? ( + <> + + + + + + + + + ) : ( + <> + {item.name} + + v{item.version} + + { setEditName(item.name); setEditing(true); }} + hitSlop={10} + > + + + + )} + + {!editing && ( { setEditName(item.name); setEditing(true); }} - hitSlop={10} + style={s.deleteBtn} + onPress={() => onDelete(item)} + hitSlop={10} > - + - )} - {!editing && ( - onDelete(item)} - hitSlop={10} - > - - - )} - - {/* Date */} - {dateStr} + {/* Date */} + {dateStr} - {/* ID + copia */} - - {item.id} - - + {/* ID + copia */} + + {item.id} + + - {/* Permissions */} - {item.permissions.length > 0 ? ( - - {item.permissions.map((p) => ( - - {PERM_ICON[p] ?? '•'} {p} + {/* Permissions */} + {item.permissions.length > 0 ? ( + + {item.permissions.map((p) => ( + + {PERM_ICON[p] ?? '•'} {p} + + ))} - ))} - - ) : null} + ) : null} - {/* Footer */} - - {t.openModule} - - { e.stopPropagation?.(); onShare(item); }} - hitSlop={10} - > - - - { e.stopPropagation?.(); void onDownload(item); }} - hitSlop={10} - > - - - - + {/* Footer */} + + {t.openModule} + + onShare(item)} + hitSlop={10} + > + + + + ); } @@ -325,30 +302,7 @@ const s = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', }, - 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 }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, h1: { fontSize: 26, fontWeight: '800', color: C.text, letterSpacing: -0.5 }, countBadge: { backgroundColor: C.surface, @@ -361,7 +315,6 @@ 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 }, @@ -470,9 +423,9 @@ const s = StyleSheet.create({ paddingTop: 10, }, openText: { color: C.primary, fontSize: 13, fontWeight: '600', flex: 1 }, - footerBtn: { - width: 34, - height: 34, + shareBtn: { + width: 32, + height: 32, borderRadius: 8, backgroundColor: C.surfaceHigh, alignItems: 'center', @@ -480,8 +433,4 @@ const s = StyleSheet.create({ borderWidth: 1, borderColor: C.border, }, - footerBtnDownload: { - backgroundColor: '#1e1b4b', - borderColor: '#312e81', - }, }); diff --git a/app/module/[id].tsx b/app/module/[id].tsx index 3895c8b..0cca7a4 100644 --- a/app/module/[id].tsx +++ b/app/module/[id].tsx @@ -29,6 +29,7 @@ import { GeneratingModal } from '../../src/components/GeneratingModal'; import { StyleEditorSheet } from '../../src/components/StyleEditorSheet'; import { updateModuleStyle } from '../../src/modules/moduleStore'; import { applyColorChanges, applyTextChanges } from '../../src/modules/styleEditor'; +import {DeviceLayout, useDeviceLayout} from "../../src/utils/deviceLayout"; const C = { bg: '#0b1120', @@ -56,6 +57,8 @@ export default function ModuleScreen() { const [answered, setAnswered] = useState(false); const [grantedIds, setGrantedIds] = useState([]); const [gateBusy, setGateBusy] = useState(false); + const { isTablet } = useDeviceLayout(); + // ── Style editor state ──────────────────────────────────────────────────── const [styleSheet, setStyleSheet] = useState(false); @@ -330,7 +333,9 @@ 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: { + paddingHorizontal: 24, + }, headerBtns: { flexDirection: 'row', alignItems: 'center', gap: 16, paddingRight: 4 }, headerBtn: { flexDirection: 'row', alignItems: 'center', gap: 5 }, headerBtnLabel: { color: C.primary, fontSize: 15, fontWeight: '600' }, diff --git a/package-lock.json b/package-lock.json index 369b393..adafcbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "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,7 +29,6 @@ "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", @@ -38,7 +36,6 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", - "react-native-webview": "13.15.0", "zod": "^4.3.6" }, "devDependencies": { @@ -5858,15 +5855,6 @@ "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", @@ -6381,16 +6369,6 @@ } } }, - "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", @@ -9687,32 +9665,6 @@ "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-worklets": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", diff --git a/src/modules/moduleStore.ts b/src/modules/moduleStore.ts index 6d957d4..fb79fa2 100644 --- a/src/modules/moduleStore.ts +++ b/src/modules/moduleStore.ts @@ -5,101 +5,103 @@ import { validateGeneratedModule, toStoredModule } from './moduleValidator'; import { STORAGE_KEY } from './moduleTypes'; async function readAll(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - if (!raw) return []; - try { - const data = JSON.parse(raw) as StoredModule[]; - return Array.isArray(data) ? data : []; - } catch { - return []; - } + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (!raw) return []; + try { + const data = JSON.parse(raw) as StoredModule[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } } async function writeAll(modules: StoredModule[]) { - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(modules)); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(modules)); } export async function listModules(): Promise { - return readAll(); + return readAll(); } export async function getModule(id: string): Promise { - const all = await readAll(); - return all.find((m) => m.id === id) ?? null; + const all = await readAll(); + return all.find((m) => m.id === id) ?? null; } export async function saveModule( - payload: unknown, - prompt?: string + payload: unknown, + prompt?: string ): Promise<{ ok: true } | { ok: false; error: string }> { - const v = validateGeneratedModule(payload); - if (!v.ok) return { ok: false, error: v.error }; - const stored = toStoredModule(v.module, prompt); - const all = await readAll(); - const idx = all.findIndex((m) => m.id === stored.id); - if (idx >= 0) { - all[idx] = stored; - } else { - all.push(stored); - } - await writeAll(all); - return { ok: true }; + const v = validateGeneratedModule(payload); + if (!v.ok) return { ok: false, error: v.error }; + const stored = toStoredModule(v.module, prompt); + const all = await readAll(); + const idx = all.findIndex((m) => m.id === stored.id); + if (idx >= 0) { + all[idx] = stored; + } else { + all.push(stored); + } + await writeAll(all); + return { ok: true }; } export async function deleteModule(id: string): Promise { - const all = (await readAll()).filter((m) => m.id !== id); - await writeAll(all); + const all = (await readAll()).filter((m) => m.id !== id); + await writeAll(all); } export async function renameModule(id: string, newName: string): Promise { - const trimmed = newName.trim(); - if (!trimmed) return; - const all = await readAll(); - const idx = all.findIndex((m) => m.id === id); - if (idx >= 0) { - all[idx] = { ...all[idx], name: trimmed }; - await writeAll(all); - } + const trimmed = newName.trim(); + if (!trimmed) return; + const all = await readAll(); + const idx = all.findIndex((m) => m.id === id); + if (idx >= 0) { + all[idx] = { ...all[idx], name: trimmed }; + await writeAll(all); + } } export async function replaceModule( - oldId: string, - payload: unknown, - prompt?: string + oldId: string, + payload: unknown, + prompt?: string ): Promise<{ ok: true } | { ok: false; error: string }> { - const v = validateGeneratedModule(payload); - if (!v.ok) return { ok: false, error: v.error }; - const stored = { ...toStoredModule(v.module, prompt), id: oldId }; - const all = await readAll(); - const idx = all.findIndex((m) => m.id === oldId); - if (idx >= 0) { - all[idx] = stored; - } else { - all.push(stored); - } - await writeAll(all); - return { ok: true }; + const v = validateGeneratedModule(payload); + if (!v.ok) return { ok: false, error: v.error }; + const stored = { ...toStoredModule(v.module, prompt), id: oldId }; + const all = await readAll(); + const idx = all.findIndex((m) => m.id === oldId); + if (idx >= 0) { + all[idx] = stored; + } else { + all.push(stored); + } + await writeAll(all); + return { ok: true }; } export async function updateModuleStyle( - id: string, - newUi: StoredModule['ui'] + id: string, + newUi: StoredModule['ui'] ): Promise { - const all = await readAll(); - const idx = all.findIndex((m) => m.id === id); - if (idx < 0) return; - all[idx] = { ...all[idx], ui: newUi }; - await writeAll(all); + const all = await readAll(); + const idx = all.findIndex((m) => m.id === id); + if (idx < 0) return; + all[idx] = { ...all[idx], ui: newUi }; + await writeAll(all); } + + export async function upsertFromRawJson(json: string): Promise<{ ok: true } | { ok: false; error: string }> { - let data: unknown; - try { - data = JSON.parse(json); - } catch (e) { - const msg = `JSON non valido: ${(e as Error).message}`; - reportGenAppError('moduleStore.upsertJsonParse', e, { jsonHead: json.slice(0, 400) }); - return { ok: false, error: msg }; - } - return saveModule(data); + let data: unknown; + try { + data = JSON.parse(json); + } catch (e) { + const msg = `JSON non valido: ${(e as Error).message}`; + reportGenAppError('moduleStore.upsertJsonParse', e, { jsonHead: json.slice(0, 400) }); + return { ok: false, error: msg }; + } + return saveModule(data); } diff --git a/src/renderer/components.tsx b/src/renderer/components.tsx index 384a5a2..7fe7504 100644 --- a/src/renderer/components.tsx +++ b/src/renderer/components.tsx @@ -58,35 +58,6 @@ 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 = {}; @@ -644,10 +615,6 @@ function GameViewNode({ }) { 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 lastTickRef = useRef(Date.now()); @@ -669,10 +636,10 @@ function GameViewNode({ }; runTick(); - const id = setInterval(runTick, effectiveTickMs); + const id = setInterval(runTick, ms); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ctx.hasError, node.tickAction, effectiveTickMs, node.bind, gameGravity, node.onCollideAction, node.onOutOfBoundsAction]); + }, [ctx.hasError, node.tickAction, node.tickMs]); const gw = node.width ?? 320; const gh = node.height ?? 480; @@ -706,12 +673,12 @@ function GameViewNode({ style={{ width: gw, height: gh, - backgroundColor: bgColor, + backgroundColor: '#101827', overflow: 'hidden', borderRadius: 12, alignSelf: 'center', borderWidth: 1, - borderColor, + borderColor: '#2d3f5c', ...layoutToViewStyle(node.layout), }} > @@ -1026,10 +993,15 @@ 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'); } }; @@ -1197,54 +1169,6 @@ 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; } From b11bc9880193ff5ed9b55d2d9d4be0df57309747 Mon Sep 17 00:00:00 2001 From: aallocca <16933662+BrickBios@users.noreply.github.com> Date: Sat, 23 May 2026 19:28:27 +0200 Subject: [PATCH 6/6] Settings screen improvements Created loading screen Added form editing functionality --- src/modules/moduleValidator.ts | 1 + src/types/uiNodes.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/modules/moduleValidator.ts b/src/modules/moduleValidator.ts index 659e564..3730cf8 100644 --- a/src/modules/moduleValidator.ts +++ b/src/modules/moduleValidator.ts @@ -44,6 +44,7 @@ function collectButtonActions(node: UiNode): string[] { } if (node.type === 'gameView') { const acts: string[] = []; + if (node.tickAction) acts.push(node.tickAction); if (node.onTapAction) acts.push(node.onTapAction); if (node.onCollideAction) acts.push(node.onCollideAction); diff --git a/src/types/uiNodes.ts b/src/types/uiNodes.ts index da4927f..a4ec222 100644 --- a/src/types/uiNodes.ts +++ b/src/types/uiNodes.ts @@ -169,6 +169,8 @@ export type UiNode = /** Nome dell'action chiamata allo swipe sul canvas; riceve { dir:'left'|'right'|'up'|'down', dx, dy }. */ onSwipeAction?: string; layout?: UiLayoutProps; + onCollideAction?: string; + onOutOfBoundsAction?: string; }; /** Oggetto della scena di gioco. I campi vx/vy/gravity abilitano fisica automatica nel renderer. */