From 63c6f74db7a4712562c4e66ccc190cf121074f7b Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 18:57:38 +0700 Subject: [PATCH 01/41] Add formula visualization and string representation helpers * Implement `getFormulaString` in `Formula` to generate a visual tree structure of the formula * Add `getFlattenedAllListsString` to retrieve a trimmed extern formula string with flattened user lists * Add `getLeftChild` and `getRightChild` getters to `FormulaElement` --- .../catroid/formulaeditor/Formula.java | 66 +++++++++++++++++++ .../catroid/formulaeditor/FormulaElement.java | 8 +++ 2 files changed, 74 insertions(+) diff --git a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java index 6a56eda0d3e..6222b17e5a2 100644 --- a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java +++ b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java @@ -106,6 +106,72 @@ public void flattenAllLists() { internFormula.setInternTokenFormulaList(formulaTree.getInternTokenList()); } + public String getFormulaString() { + if (formulaTree == null) { + return ""; + } + return buildFormulaString(formulaTree); + } + + private String buildFormulaString(FormulaElement element) { + if (element == null) { + return ""; + } + + // Add current element information + StringBuilder result = new StringBuilder(); + result.append(element.getElementType().name()); + if (element.getValue() != null) { + result.append(": ").append(element.getValue()); + } + + // Process left and right children with proper tree structure + if (element.getLeftChild() != null) { + result.append("\n│ │ │ │ │ └── ").append(buildFormulaStringRecursive(element.getLeftChild(), true, element.getRightChild() != null)); + } + + if (element.getRightChild() != null) { + result.append("\n│ │ │ │ │ └── ").append(buildFormulaStringRecursive(element.getRightChild(), false, false)); + } + + return result.toString(); + } + + private String buildFormulaStringRecursive(FormulaElement element, boolean isLeftChild, boolean hasRightSibling) { + if (element == null) { + return ""; + } + + // Add current element information + StringBuilder result = new StringBuilder(); + result.append(element.getElementType().name()); + if (element.getValue() != null) { + result.append(": ").append(element.getValue()); + } + + // Process children with proper indentation + String childPrefix = "│ │ │ │ │ "; + if (element.getLeftChild() != null) { + result.append("\n").append(childPrefix).append(" └── ") + .append(buildFormulaStringRecursive(element.getLeftChild(), true, element.getRightChild() != null)); + } + + if (element.getRightChild() != null) { + result.append("\n").append(childPrefix).append(" └── ") + .append(buildFormulaStringRecursive(element.getRightChild(), false, false)); + } + + return result.toString(); + } + + public String getFlattenedAllListsString() { + FormulaElement tempTree = formulaTree.clone(); + tempTree.insertFlattenForAllUserLists(tempTree, null); + tempTree = tempTree.getRoot(); + InternFormula tempInternFormula = new InternFormula(tempTree.getInternTokenList()); + return tempInternFormula.trimExternFormulaString(CatroidApplication.getAppContext()); + } + public void updateCollisionFormulasToVersion() { internFormula.updateCollisionFormulaToVersion(CatroidApplication.getAppContext()); formulaTree.updateCollisionFormulaToVersion(ProjectManager.getInstance().getCurrentProject()); diff --git a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java index c49a9362302..266a7c94eb6 100644 --- a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java +++ b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java @@ -1052,6 +1052,14 @@ public void setValue(String value) { this.value = value; } + public FormulaElement getLeftChild() { + return leftChild; + } + + public FormulaElement getRightChild() { + return rightChild; + } + public List getUserDataRecursive(ElementType type) { ArrayList userDataNames = new ArrayList<>(); From 3c930462577af2b365e5e87300522f65a9397d70 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 19:48:29 +0700 Subject: [PATCH 02/41] Add methods to retrieve project structure as string and JSON * Add `getAllList` to generate a tree-like string representation of the project structure (Scenes, Sprites, Scripts, Bricks, and Formulas) * Add `getAllListAsJson` and `getAllListAsJsonString` to provide a structured JSON representation of the project * Include debug logging for the generated project export strings --- .../org/catrobat/catroid/ProjectManager.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java index c8c62e5591e..b8542bb7053 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java +++ b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java @@ -28,6 +28,8 @@ import android.util.Log; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import org.catrobat.catroid.common.DefaultProjectHandler; @@ -377,6 +379,98 @@ public static void flattenAllLists(Project project) { } } + public static String getAllList(Project project) { + StringBuilder builder = new StringBuilder(); + builder.append("Project: ").append(project.getName()).append("\n"); + + for (Scene scene : project.getSceneList()) { + builder.append("├── Scene: ").append(scene.getName()).append("\n"); + + for (Sprite sprite : scene.getSpriteList()) { + builder.append("│ ├── Sprite: ").append(sprite.getName()).append("\n"); + + for (Script script : sprite.getScriptList()) { + builder.append("│ │ ├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + builder.append("│ │ │ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + builder.append("│ │ │ │ ├── Formula: "); + String formulaStr = formula.getFormulaString(); + // Add proper indentation to formula tree + if (!formulaStr.isEmpty()) { + builder.append(formulaStr); + } + builder.append("\n"); + } + } + } + } + } + } + + String result = builder.toString(); + Log.d(TAG, result); + return result; + } + + public static JsonObject getAllListAsJson(Project project) { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("Project", project.getName()); + + for (Scene scene : project.getSceneList()) { + JsonObject sceneJson = new JsonObject(); + sceneJson.addProperty("Scene", scene.getName()); + + for (Sprite sprite : scene.getSpriteList()) { + JsonObject spriteJson = new JsonObject(); + spriteJson.addProperty("Sprite", sprite.getName()); + + for (Script script : sprite.getScriptList()) { + JsonObject scriptJson = new JsonObject(); + scriptJson.addProperty("Script", script.getClass().getSimpleName()); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + JsonObject brickJson = new JsonObject(); + brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + String formulaStr = formula.getFormulaString(); + if (!formulaStr.isEmpty()) { + brickJson.addProperty("Formula", formulaStr); + } + } + } + scriptJson.add(brick.getClass().getSimpleName(), brickJson); + } + spriteJson.add(script.getClass().getSimpleName(), scriptJson); + } + sceneJson.add(sprite.getName(), spriteJson); + } + jsonObject.add(scene.getName(), sceneJson); + } + return jsonObject; + } + + public static String getAllListAsJsonString(Project project) { + JsonObject json = getAllListAsJson(project); + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String prettyJson = gson.toJson(json); + Log.d(TAG, prettyJson); + return prettyJson; + } + @VisibleForTesting public static void updateCollisionFormulasTo993(Project project) { for (Scene scene : project.getSceneList()) { From ea35481b4653e96cae3e8b8597ab181d0f75d73e Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 19:51:59 +0700 Subject: [PATCH 03/41] Add AiAssistFragment and layout --- .../AiAssistFragment.kt\342\200\216.kt" | 62 +++++++++++++++++++ .../main/res/layout/fragment_ai_assist.xml | 37 +++++++++++ .../main/res/layout/list_action_buttons.xml | 1 - 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 "catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" create mode 100644 catroid/src/main/res/layout/fragment_ai_assist.xml diff --git "a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" "b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" new file mode 100644 index 00000000000..1369e215a55 --- /dev/null +++ "b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" @@ -0,0 +1,62 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import org.catrobat.catroid.databinding.FragmentAiAssistBinding + +class AiAssistFragment : Fragment() { + + private var _binding: FragmentAiAssistBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAiAssistBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + arguments?.getString("structure")?.let { structure -> + binding.textAiAssist.text = structure + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + val TAG: String = AiAssistFragment::class.java.simpleName + } +} \ No newline at end of file diff --git a/catroid/src/main/res/layout/fragment_ai_assist.xml b/catroid/src/main/res/layout/fragment_ai_assist.xml new file mode 100644 index 00000000000..29134371ec5 --- /dev/null +++ b/catroid/src/main/res/layout/fragment_ai_assist.xml @@ -0,0 +1,37 @@ + + + + + + + + \ No newline at end of file diff --git a/catroid/src/main/res/layout/list_action_buttons.xml b/catroid/src/main/res/layout/list_action_buttons.xml index 85b65c66f05..0100b76b17f 100644 --- a/catroid/src/main/res/layout/list_action_buttons.xml +++ b/catroid/src/main/res/layout/list_action_buttons.xml @@ -38,7 +38,6 @@ android:layout_margin="@dimen/material_design_spacing_large" android:src="@drawable/ic_assistant" android:tint="@color/solid_white" - android:visibility="gone" app:backgroundTint="@color/action_button" app:elevation="10dp" android:onClick="handleAiAssistButton" /> From e39a8a4159177dbc0ea94f4ecc676bda6b011169 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 19:56:48 +0700 Subject: [PATCH 04/41] Add methods to retrieve detailed project, scene, and sprite summaries as strings and JSON * Add `getAllListForSprite` and `getAllListForScene` to generate tree-like text representations of scripts and bricks * Add `getAllListAsJsonStringForSprite` and `getAllListAsJsonStringForScene` for JSON-formatted component hierarchies * Add `getProjectSummary` to provide a high-level overview of project statistics, including scene counts, sprite counts, and global variables/lists --- .../org/catrobat/catroid/ProjectManager.java | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java index b8542bb7053..abba86279d5 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java +++ b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java @@ -471,6 +471,193 @@ public static String getAllListAsJsonString(Project project) { return prettyJson; } + public static String getAllListForSprite(Sprite sprite) { + StringBuilder builder = new StringBuilder(); + builder.append("Sprite: ").append(sprite.getName()).append("\n"); + + for (Script script : sprite.getScriptList()) { + builder.append("├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + builder.append("│ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + builder.append("│ │ ├── Formula: "); + String formulaStr = formula.getFormulaString(); + if (!formulaStr.isEmpty()) { + builder.append(formulaStr); + } + builder.append("\n"); + } + } + } + } + return builder.toString(); + } + + public static String getAllListAsJsonStringForSprite(Sprite sprite) { + JsonObject spriteJson = new JsonObject(); + spriteJson.addProperty("Sprite", sprite.getName()); + + for (Script script : sprite.getScriptList()) { + JsonObject scriptJson = new JsonObject(); + scriptJson.addProperty("Script", script.getClass().getSimpleName()); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + JsonObject brickJson = new JsonObject(); + brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + String formulaStr = formula.getFormulaString(); + if (!formulaStr.isEmpty()) { + brickJson.addProperty("Formula", formulaStr); + } + } + } + scriptJson.add(brick.getClass().getSimpleName(), brickJson); + } + spriteJson.add(script.getClass().getSimpleName(), scriptJson); + } + + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + return gson.toJson(spriteJson); + } + + public static String getAllListForScene(Scene scene) { + StringBuilder builder = new StringBuilder(); + builder.append("Scene: ").append(scene.getName()).append("\n"); + + for (Sprite sprite : scene.getSpriteList()) { + builder.append("├── Sprite: ").append(sprite.getName()).append("\n"); + + for (Script script : sprite.getScriptList()) { + builder.append("│ ├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + builder.append("│ │ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + builder.append("│ │ │ ├── Formula: "); + String formulaStr = formula.getFormulaString(); + if (!formulaStr.isEmpty()) { + builder.append(formulaStr); + } + builder.append("\n"); + } + } + } + } + } + return builder.toString(); + } + + public static String getAllListAsJsonStringForScene(Scene scene) { + JsonObject sceneJson = new JsonObject(); + sceneJson.addProperty("Scene", scene.getName()); + + for (Sprite sprite : scene.getSpriteList()) { + JsonObject spriteJson = new JsonObject(); + spriteJson.addProperty("Sprite", sprite.getName()); + + for (Script script : sprite.getScriptList()) { + JsonObject scriptJson = new JsonObject(); + scriptJson.addProperty("Script", script.getClass().getSimpleName()); + + List flatList = new ArrayList<>(); + script.addToFlatList(flatList); + + for (Brick brick : flatList) { + JsonObject brickJson = new JsonObject(); + brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + + if (brick instanceof FormulaBrick) { + FormulaBrick formulaBrick = (FormulaBrick) brick; + for (Formula formula : formulaBrick.getFormulas()) { + String formulaStr = formula.getFormulaString(); + if (!formulaStr.isEmpty()) { + brickJson.addProperty("Formula", formulaStr); + } + } + } + scriptJson.add(brick.getClass().getSimpleName(), brickJson); + } + spriteJson.add(script.getClass().getSimpleName(), scriptJson); + } + sceneJson.add(sprite.getName(), spriteJson); + } + + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + return gson.toJson(sceneJson); + } + + public static String getProjectSummary(Project project) { + StringBuilder builder = new StringBuilder(); + builder.append("Project: ").append(project.getName()).append("\n"); + builder.append("Scenes: ").append(project.getSceneList().size()).append("\n"); + + int totalSprites = 0; + int totalScripts = 0; + for (Scene scene : project.getSceneList()) { + for (Sprite sprite : scene.getSpriteList()) { + totalSprites++; + totalScripts += sprite.getScriptList().size(); + } + } + builder.append("Total sprites: ").append(totalSprites).append("\n"); + builder.append("Total scripts: ").append(totalScripts).append("\n"); + + for (Scene scene : project.getSceneList()) { + builder.append("\nScene: ").append(scene.getName()).append("\n"); + for (Sprite sprite : scene.getSpriteList()) { + int scriptCount = sprite.getScriptList().size(); + builder.append(" - ").append(sprite.getName()) + .append(": ").append(scriptCount) + .append(scriptCount != 1 ? " scripts" : " script").append("\n"); + } + } + + List projectVars = project.getUserVariables(); + if (!projectVars.isEmpty()) { + builder.append("\nGlobal variables: "); + for (int i = 0; i < projectVars.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(projectVars.get(i).getName()); + } + builder.append("\n"); + } + + List projectLists = project.getUserLists(); + if (!projectLists.isEmpty()) { + builder.append("Global lists: "); + for (int i = 0; i < projectLists.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(projectLists.get(i).getName()); + } + builder.append("\n"); + } + + return builder.toString(); + } + @VisibleForTesting public static void updateCollisionFormulasTo993(Project project) { for (Scene scene : project.getSceneList()) { From 8f8f70a3aa73f1a3255c2d44288d865d43cc5b8a Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:00:43 +0700 Subject: [PATCH 05/41] Add method to serialize Sprite objects to XML string * Introduce `getXmlAsStringFromSprite(Sprite)` to allow serializing individual Sprite objects to XML strings. * Ensure thread safety by using the existing `loadSaveLock`. --- .../java/org/catrobat/catroid/io/XstreamSerializer.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java index f207b837aec..1ef5208a972 100644 --- a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java +++ b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java @@ -915,6 +915,15 @@ public String getXmlAsStringFromProject(Project project) { return xmlString; } + public String getXmlAsStringFromSprite(Sprite sprite) { + loadSaveLock.lock(); + try { + return xstream.toXML(sprite); + } finally { + loadSaveLock.unlock(); + } + } + public static String extractDefaultSceneNameFromXml(File projectDir) { File xmlFile = new File(projectDir, CODE_XML_FILE_NAME); From cccc003724152379df29a08d3733426b55df9fc8 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:05:01 +0700 Subject: [PATCH 06/41] Implement AI Assist format selection and project serialization * Add strings for AI Assist format options including Tree, JSON, XML, and Summary * Implement `handleAiAssistButton` in `SpriteActivity` and `ProjectActivity` to display a format selection dialog * Serialize project, scene, or sprite data based on user selection using `ProjectManager` and `XstreamSerializer` * Navigate to `AiAssistFragment` with the generated structure string * Hide options menu items in `SpriteActivity` when `AiAssistFragment` is active --- .../catrobat/catroid/ui/ProjectActivity.kt | 38 ++++++++++++ .../catrobat/catroid/ui/SpriteActivity.java | 59 ++++++++++++++++++- catroid/src/main/res/values/strings.xml | 11 ++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt index fabdf5eef7a..4da3c52bae1 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt @@ -48,11 +48,13 @@ import org.catrobat.catroid.databinding.ActivityRecyclerBinding import org.catrobat.catroid.databinding.DialogNewActorBinding import org.catrobat.catroid.databinding.ProgressBarBinding import org.catrobat.catroid.io.StorageOperations +import org.catrobat.catroid.io.XstreamSerializer import org.catrobat.catroid.io.asynctask.ProjectSaver import org.catrobat.catroid.merge.ImportProjectHelper import org.catrobat.catroid.stage.StageActivity import org.catrobat.catroid.stage.TestResult import org.catrobat.catroid.ui.BottomBar.showBottomBar +import org.catrobat.catroid.ui.aiassist.AiAssistFragment import org.catrobat.catroid.ui.controller.BackpackListManager import org.catrobat.catroid.ui.controller.ActorsAndObjectsManager import org.catrobat.catroid.ui.dialogs.LegoSensorConfigInfoDialog @@ -113,6 +115,9 @@ class ProjectActivity : BaseCastActivity() { showWarningForSuspiciousBricksOnce(this) showLegoSensorConfigInfo() binding.bottomBar.apply { + buttonAiAssist.setOnClickListener { + handleAiAssistButton() + } buttonAdd.setOnClickListener { handleAddButton() } @@ -333,6 +338,39 @@ class ProjectActivity : BaseCastActivity() { ).show(supportFragmentManager, NewSpriteDialogFragment.TAG) } + private fun handleAiAssistButton() { + val options = arrayOf( + getString(R.string.ai_assist_format_tree), + getString(R.string.ai_assist_format_json), + getString(R.string.ai_assist_format_xml), + getString(R.string.ai_assist_format_tree_scene), + getString(R.string.ai_assist_format_json_scene), + getString(R.string.ai_assist_format_summary) + ) + AlertDialog.Builder(this) + .setTitle(R.string.ai_assist_choose_format) + .setItems(options) { _, which -> + val project = projectManager.currentProject + val structure = when (which) { + 0 -> ProjectManager.getAllList(project) + 1 -> ProjectManager.getAllListAsJsonString(project) + 2 -> XstreamSerializer.getInstance().getXmlAsStringFromProject(project) + 3 -> ProjectManager.getAllListForScene(projectManager.currentlyEditedScene) + 4 -> ProjectManager.getAllListAsJsonStringForScene(projectManager.currentlyEditedScene) + else -> ProjectManager.getProjectSummary(project) + } + val bundle = Bundle().apply { putString("structure", structure) } + val aiAssistFragment = AiAssistFragment().apply { arguments = bundle } + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.TAG) + .addToBackStack(AiAssistFragment.TAG) + .commit() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun handleAddButton() { if (currentFragment is SceneListFragment) { handleAddSceneButton() diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index f499330634c..8905c67b3a9 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -53,11 +53,13 @@ import org.catrobat.catroid.formulaeditor.UserList; import org.catrobat.catroid.formulaeditor.UserVariable; import org.catrobat.catroid.io.StorageOperations; +import org.catrobat.catroid.io.XstreamSerializer; import org.catrobat.catroid.io.asynctask.ProjectSaver; import org.catrobat.catroid.pocketmusic.PocketMusicActivity; import org.catrobat.catroid.soundrecorder.SoundRecorderActivity; import org.catrobat.catroid.stage.StageActivity; import org.catrobat.catroid.stage.TestResult; +import org.catrobat.catroid.ui.aiassist.AiAssistFragment; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.fragment.AddBrickFragment; import org.catrobat.catroid.ui.fragment.BrickCategoryFragment; @@ -246,7 +248,11 @@ public void setUndoMenuItemVisibility(boolean isVisible) { @Override public boolean onPrepareOptionsMenu(Menu menu) { - if (getCurrentFragment() instanceof ScriptFragment) { + if (getCurrentFragment() instanceof AiAssistFragment) { + for (int i = 0; i < menu.size(); i++) { + menu.getItem(i).setVisible(false); + } + } else if (getCurrentFragment() instanceof ScriptFragment) { menu.findItem(R.id.comment_in_out).setVisible(true); showUndo(isUndoMenuItemVisible); } else if (getCurrentFragment() instanceof LookListFragment) { @@ -639,7 +645,56 @@ private void addSoundFromUri(Uri uri) { } public void handleAiAssistButton(View view) { - Log.d(TAG, "Here a Flutter module will be called in the future."); + String[] options = { + getString(R.string.ai_assist_format_tree), + getString(R.string.ai_assist_format_json), + getString(R.string.ai_assist_format_xml), + getString(R.string.ai_assist_format_tree_sprite), + getString(R.string.ai_assist_format_json_sprite), + getString(R.string.ai_assist_format_xml_sprite), + getString(R.string.ai_assist_format_summary) + }; + new AlertDialog.Builder(this) + .setTitle(R.string.ai_assist_choose_format) + .setItems(options, (dialog, which) -> { + Project project = projectManager.getCurrentProject(); + Sprite sprite = projectManager.getCurrentSprite(); + String structure; + switch (which) { + case 0: + structure = ProjectManager.getAllList(project); + break; + case 1: + structure = ProjectManager.getAllListAsJsonString(project); + break; + case 2: + structure = XstreamSerializer.getInstance().getXmlAsStringFromProject(project); + break; + case 3: + structure = ProjectManager.getAllListForSprite(sprite); + break; + case 4: + structure = ProjectManager.getAllListAsJsonStringForSprite(sprite); + break; + case 5: + structure = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); + break; + default: + structure = ProjectManager.getProjectSummary(project); + break; + } + Bundle bundle = new Bundle(); + bundle.putString("structure", structure); + AiAssistFragment aiAssistFragment = new AiAssistFragment(); + aiAssistFragment.setArguments(bundle); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) + .addToBackStack(AiAssistFragment.Companion.getTAG()) + .commit(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); } public void handleAddButton(View view) { diff --git a/catroid/src/main/res/values/strings.xml b/catroid/src/main/res/values/strings.xml index 84feaca90da..8d9a95f11c6 100644 --- a/catroid/src/main/res/values/strings.xml +++ b/catroid/src/main/res/values/strings.xml @@ -2325,4 +2325,15 @@ needs read and write access to it. You can always change permissions through you Undo sort Sort checkbox + + Choose project format + Full project (tree) + Full project (JSON) + Full project (XML) + Current scene (tree) + Current scene (JSON) + Current sprite (tree) + Current sprite (JSON) + Current sprite (XML) + Project summary From 980f645e69e1ab926792b21cf30ff37e0e20194e Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:09:04 +0700 Subject: [PATCH 07/41] Add Jetpack Compose support and AI Tutor dependency * Add Kotzilla Maven repository for Koin-Embedded * Apply Kotlin Compose compiler plugin * Enable Compose build feature in the catroid module * Add Catrobat AI Tutor dependency * Add Compose UI and Runtime dependencies (v1.7.5) --- build.gradle | 1 + catroid/build.gradle | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/build.gradle b/build.gradle index b2f51e7f527..28475ad9274 100644 --- a/build.gradle +++ b/build.gradle @@ -49,5 +49,6 @@ allprojects { google() mavenCentral() maven { url = "https://developer.huawei.com/repo/" } + maven { url = uri("https://repository.kotzilla.io/repository/Koin-Embedded/") } } } diff --git a/catroid/build.gradle b/catroid/build.gradle index 6ec3ce14092..c47c43496c3 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -38,6 +38,7 @@ plugins { id "io.gitlab.arturbosch.detekt" version "1.23.7" id 'com.google.devtools.ksp' version '2.0.21-1.0.25' apply true id 'checkstyle' + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' } repositories { @@ -113,6 +114,7 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true } defaultConfig { @@ -472,6 +474,13 @@ dependencies { implementation "com.google.android.gms:play-services-auth:17.0.0" + // Catrobat AI Tutor + implementation("org.catrobat:aitutor:0.0.1") + + // Compose + implementation "androidx.compose.ui:ui:1.7.5" + implementation "androidx.compose.runtime:runtime:1.7.5" + androidTestImplementation('tools.fastlane:screengrab:2.1.1') { // https://issuetracker.google.com/issues/123060356 exclude group: 'com.android.support.test.uiautomator', module: 'uiautomator-v18' From 326ea8c249b741cbdef9c1462caf9b9613082c4f Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:12:07 +0700 Subject: [PATCH 08/41] Add AiTutorView integration to AiAssistFragment - Update `fragment_ai_assist.xml` to include a `ComposeView` and wrap existing content in a `FrameLayout` - Initialize `AiTutorView` within `AiAssistFragment` using Jetpack Compose - Pass the project structure context from fragment arguments to the AI Tutor component - Configure `ViewCompositionStrategy` to manage the ComposeView lifecycle correctly --- .../AiAssistFragment.kt\342\200\216.kt" | 23 +++++++++++++++--- .../main/res/layout/fragment_ai_assist.xml | 24 +++++++++++++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git "a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" "b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" index 1369e215a55..e85f99eb34f 100644 --- "a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" +++ "b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" @@ -27,7 +27,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment +import org.catrobat.aitutor.ui.`public`.AiTutorView import org.catrobat.catroid.databinding.FragmentAiAssistBinding class AiAssistFragment : Fragment() { @@ -46,8 +52,19 @@ class AiAssistFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - arguments?.getString("structure")?.let { structure -> - binding.textAiAssist.text = structure + val structure = arguments?.getString("structure") + binding.textAiAssist.text = structure ?: "" + + binding.composeAiTutor.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + var show by remember { mutableStateOf(true) } + AiTutorView( + show = show, + onDismissRequest = { show = false }, + codeContext = structure, + ) + } } } @@ -59,4 +76,4 @@ class AiAssistFragment : Fragment() { companion object { val TAG: String = AiAssistFragment::class.java.simpleName } -} \ No newline at end of file +} diff --git a/catroid/src/main/res/layout/fragment_ai_assist.xml b/catroid/src/main/res/layout/fragment_ai_assist.xml index 29134371ec5..dc4f3b486e9 100644 --- a/catroid/src/main/res/layout/fragment_ai_assist.xml +++ b/catroid/src/main/res/layout/fragment_ai_assist.xml @@ -21,17 +21,27 @@ ~ along with this program. If not, see . --> - - - + android:layout_height="match_parent"> - \ No newline at end of file + + + + + + + From e8a263094d13ed9cfb68ff26281310b9fc6e7fc6 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:17:53 +0700 Subject: [PATCH 09/41] Update OkHttp and Okio calls to use Kotlin properties and extension functions (required for AI Tutor) --- .../catroid/content/actions/LookRequestAction.kt | 2 +- .../catroid/content/actions/WebRequestAction.kt | 2 +- .../org/catrobat/catroid/retrofit/ErrorInterceptor.kt | 6 +++--- .../org/catrobat/catroid/web/CatrobatServerCalls.kt | 10 ++++++---- .../org/catrobat/catroid/web/CatrobatWebClient.kt | 6 +++--- .../java/org/catrobat/catroid/web/WebConnection.kt | 4 ++-- .../org/catrobat/catroid/web/WebConnectionHolder.kt | 11 ++++++----- .../org/catrobat/catroid/web/requests/HttpRequests.kt | 3 ++- 8 files changed, 24 insertions(+), 20 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt b/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt index 4681ef8469c..a1f82f95428 100644 --- a/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt +++ b/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt @@ -111,7 +111,7 @@ open class LookRequestAction : WebAction() { } override fun onRequestSuccess(httpResponse: Response) { - response = httpResponse.body()?.byteStream() + response = httpResponse.body?.byteStream() val fileName = Utils.getFileNameFromHttpResponse(httpResponse) ?: Utils.getFileNameFromURL(url) fileName.split('.', limit = 2).let { name -> lookName = name[0] diff --git a/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt b/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt index 45b537c94e1..502402eb219 100644 --- a/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt +++ b/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt @@ -53,7 +53,7 @@ class WebRequestAction : WebAction() { override fun onRequestSuccess(httpResponse: Response) { response = try { - httpResponse.body()?.string() ?: "" + httpResponse.body?.string() ?: "" } catch (exception: IOException) { Log.d(javaClass.simpleName, "HTTP reponse body is empty", exception) "" diff --git a/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt b/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt index e77ee06ba69..ec98ee37d0f 100644 --- a/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt +++ b/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt @@ -33,12 +33,12 @@ class ErrorInterceptor : Interceptor { val response = chain.proceed(chain.request()) if (response.isSuccessful.not() and response.isRedirect.not()) { - val contentType = response.body()?.contentType() - val body = response.body()?.toString() ?: "" + val contentType = response.body?.contentType() + val body = response.body?.toString() ?: "" return response.newBuilder() .body(ResponseBody.create(contentType, body)) - .code(response.code()) + .code(response.code) .build() } return response diff --git a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt index 39f0f9580f6..de42361b21a 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt @@ -31,6 +31,8 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okio.Okio +import okio.buffer +import okio.sink import org.catrobat.catroid.common.Constants import org.catrobat.catroid.web.ServerAuthenticationConstants.CHECK_EMAIL_AVAILABLE_URL import org.catrobat.catroid.web.ServerAuthenticationConstants.CHECK_GOOGLE_TOKEN_URL @@ -232,7 +234,7 @@ class CatrobatServerCalls(private val okHttpClient: OkHttpClient = CatrobatWebCl val originalResponse = chain.proceed(chain.request()) val body = ProgressResponseBody( - originalResponse.body(), + originalResponse.body, progressCallback ) originalResponse.newBuilder().body(body).build() @@ -248,13 +250,13 @@ class CatrobatServerCalls(private val okHttpClient: OkHttpClient = CatrobatWebCl try { val response = httpClient.newCall(request).execute() if (response.isSuccessful) { - val bufferedSink = Okio.buffer(Okio.sink(destination)) - response.body()?.let { bufferedSink.writeAll(it.source()) } + val bufferedSink = destination.sink().buffer() + response.body?.let { bufferedSink.writeAll(it.source()) } bufferedSink.close() successCallback.onSuccess() } else { Log.v(tag, "Download not successful") - errorCallback.onError(response.code(), "Download failed! HTTP Status code was " + response.code()) + errorCallback.onError(response.code, "Download failed! HTTP Status code was " + response.code) } } catch (ioException: IOException) { Log.e(tag, Log.getStackTraceString(ioException)) diff --git a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt index 1d7e02af247..9ecc87ef49e 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt @@ -41,11 +41,11 @@ fun OkHttpClient.performCallWith(request: Request): String { var statusCode = WebConnectionException.ERROR_NETWORK try { val response = this.newCall(request).execute() - response.body()?.let { + response.body?.let { return it.string() } - statusCode = response.code() - message = response.message() + statusCode = response.code + message = response.message } catch (e: IOException) { e.message?.let { message = it diff --git a/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt b/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt index 2b6be735d91..04a2b5aaadc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt @@ -84,7 +84,7 @@ class WebConnection(private val okHttpClient: OkHttpClient, listener: WebRequest if (response.isSuccessful) { onRequestSuccess(response) } else { - onRequestError(response.code().toString()) + onRequestError(response.code.toString()) } } } @@ -92,7 +92,7 @@ class WebConnection(private val okHttpClient: OkHttpClient, listener: WebRequest } fun cancelCall() { - okHttpClient.dispatcher()?.executorService()?.execute { + okHttpClient.dispatcher.executorService.execute { call?.cancel() } } diff --git a/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt b/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt index 37b544924f9..d8272eb2cc1 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt @@ -22,9 +22,10 @@ */ package org.catrobat.catroid.web -import okhttp3.ConnectionSpec.CLEARTEXT -import okhttp3.ConnectionSpec.COMPATIBLE_TLS -import okhttp3.ConnectionSpec.MODERN_TLS +import okhttp3.ConnectionSpec +import okhttp3.ConnectionSpec.Companion.CLEARTEXT +import okhttp3.ConnectionSpec.Companion.COMPATIBLE_TLS +import okhttp3.ConnectionSpec.Companion.MODERN_TLS import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.util.ArrayList @@ -48,8 +49,8 @@ class WebConnectionHolder { .dispatcher(Dispatcher()) .build() - okHttpClient.dispatcher().maxRequests = MAX_CONNECTIONS - okHttpClient.dispatcher().maxRequestsPerHost = MAX_CONNECTIONS + okHttpClient.dispatcher.maxRequests = MAX_CONNECTIONS + okHttpClient.dispatcher.maxRequestsPerHost = MAX_CONNECTIONS } @Synchronized diff --git a/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt b/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt index 8b5b48c29f0..8a189055fb8 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt @@ -25,6 +25,7 @@ package org.catrobat.catroid.web.requests import android.util.Log import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody @@ -41,7 +42,7 @@ private const val PROJECT_DESCRIPTION_TAG = "projectDescription" private const val PROJECT_CHECKSUM_TAG = "fileChecksum" private const val USER_EMAIL = "userEmail" private const val DEVICE_LANGUAGE = "deviceLanguage" -private val MEDIA_TYPE_ZIPFILE = MediaType.parse("application/zip") +private val MEDIA_TYPE_ZIPFILE = "application/zip".toMediaTypeOrNull() private const val FILE_UPLOAD_URL = FlavoredConstants.BASE_UPLOAD_URL + "api/upload/upload.json" fun createUploadRequest( From dfcd0c18424962323fe08ed05eefd79e0ce148b9 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 18 May 2026 20:20:43 +0700 Subject: [PATCH 10/41] Update to use LifecycleRegistry and update LiveData extension (required for AI Tutor) --- .../org/catrobat/catroid/camera/CameraManager.kt | 16 ++++++++-------- .../catrobat/catroid/utils/LiveDataExtensions.kt | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt b/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt index 7620150823d..3236aaf9e81 100644 --- a/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt +++ b/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt @@ -47,7 +47,7 @@ import org.koin.java.KoinJavaComponent.get class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { private val cameraProvider = ProcessCameraProvider.getInstance(stageActivity).get() - private val lifecycle = LifecycleRegistry(this) + private val lifecycleRegistry = LifecycleRegistry(this) val previewView = PreviewView(stageActivity).apply { visibility = View.INVISIBLE } @@ -90,14 +90,14 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { CameraSelector.DEFAULT_BACK_CAMERA } currentCameraSelector = defaultCameraSelector - lifecycle.currentState = Lifecycle.State.CREATED + lifecycleRegistry.currentState = Lifecycle.State.CREATED } val isCameraFacingFront: Boolean get() = currentCameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA val isCameraActive: Boolean - get() = lifecycle.currentState in listOf(Lifecycle.State.STARTED, Lifecycle.State.RESUMED) && + get() = lifecycleRegistry.currentState in listOf(Lifecycle.State.STARTED, Lifecycle.State.RESUMED) && (cameraProvider.isBound(previewUseCase) || cameraProvider.isBound(analysisUseCase)) @Synchronized @@ -111,17 +111,17 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { @Synchronized fun destroy() { - lifecycle.currentState = Lifecycle.State.DESTROYED + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } @Synchronized fun pause() { - lifecycle.currentState = Lifecycle.State.CREATED + lifecycleRegistry.currentState = Lifecycle.State.CREATED } @Synchronized fun resume() { - lifecycle.currentState = Lifecycle.State.RESUMED + lifecycleRegistry.currentState = Lifecycle.State.RESUMED currentCamera?.cameraControl?.enableTorch(flashOn) } @@ -255,7 +255,7 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { useCase ) currentCamera?.cameraControl?.enableTorch(flashOn) - lifecycle.currentState = Lifecycle.State.STARTED + lifecycleRegistry.currentState = Lifecycle.State.STARTED true } catch (exception: IllegalStateException) { Log.e(TAG, "Could not bind use case.", exception) @@ -282,5 +282,5 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { destroy() } - override fun getLifecycle() = lifecycle + override val lifecycle: Lifecycle get() = lifecycleRegistry } diff --git a/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt b/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt index 052bd104225..a7c64c139b6 100644 --- a/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt +++ b/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt @@ -68,8 +68,8 @@ fun LiveData.getOrAwaitValue( var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer { - override fun onChanged(o: T?) { - data = o + override fun onChanged(value: T) { + data = value latch.countDown() this@getOrAwaitValue.removeObserver(this) } From fd61a2e173c3c1d426c196259e7cecd97de192c8 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 20 May 2026 18:47:16 +0700 Subject: [PATCH 11/41] Improve project structure logging and JSON serialization - Implement `Formula.getFormulaInlineString()` to generate human-readable mathematical expressions from formula trees - Enhance project summaries to include script triggers (e.g., broadcast messages, conditions, start events) - Include variable names and broadcast messages in brick descriptions within tree and JSON outputs - Handle duplicate script and brick types in JSON serialization by appending index suffixes (e.g., `Formula_2`) - Update `GsonBuilder` to disable HTML escaping for better readability of project exports - Add helper methods in `ProjectManager` to extract metadata from scripts and bricks for debugging purposes --- .../org/catrobat/catroid/ProjectManager.java | 165 +++++++++++++++--- .../catroid/formulaeditor/Formula.java | 51 ++++++ 2 files changed, 194 insertions(+), 22 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java index abba86279d5..dda466197f3 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java +++ b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java @@ -43,8 +43,16 @@ import org.catrobat.catroid.content.Script; import org.catrobat.catroid.content.Setting; import org.catrobat.catroid.content.Sprite; +import org.catrobat.catroid.content.BroadcastScript; +import org.catrobat.catroid.content.StartScript; import org.catrobat.catroid.content.WhenBounceOffScript; +import org.catrobat.catroid.content.WhenClonedScript; +import org.catrobat.catroid.content.WhenConditionScript; +import org.catrobat.catroid.content.WhenScript; +import org.catrobat.catroid.content.WhenTouchDownScript; import org.catrobat.catroid.content.backwardcompatibility.BrickTreeBuilder; +import org.catrobat.catroid.content.bricks.BroadcastBrick; +import org.catrobat.catroid.content.bricks.UserVariableBrickWithFormula; import org.catrobat.catroid.content.bricks.ArduinoSendPWMValueBrick; import org.catrobat.catroid.content.bricks.Brick; import org.catrobat.catroid.content.bricks.FormulaBrick; @@ -390,20 +398,27 @@ public static String getAllList(Project project) { builder.append("│ ├── Sprite: ").append(sprite.getName()).append("\n"); for (Script script : sprite.getScriptList()) { - builder.append("│ │ ├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + String trigger = getScriptTriggerDescription(script); + builder.append("│ │ ├── Script: ").append(script.getClass().getSimpleName()); + if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); + builder.append("\n"); List flatList = new ArrayList<>(); script.addToFlatList(flatList); for (Brick brick : flatList) { - builder.append("│ │ │ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + builder.append("│ │ │ ├── Brick: ").append(brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) builder.append(" → ").append(varName); + else if (msg != null) builder.append(" → \"").append(msg).append("\""); + builder.append("\n"); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; for (Formula formula : formulaBrick.getFormulas()) { builder.append("│ │ │ │ ├── Formula: "); String formulaStr = formula.getFormulaString(); - // Add proper indentation to formula tree if (!formulaStr.isEmpty()) { builder.append(formulaStr); } @@ -432,29 +447,46 @@ public static JsonObject getAllListAsJson(Project project) { JsonObject spriteJson = new JsonObject(); spriteJson.addProperty("Sprite", sprite.getName()); + HashMap scriptKeyCounts = new HashMap<>(); for (Script script : sprite.getScriptList()) { JsonObject scriptJson = new JsonObject(); scriptJson.addProperty("Script", script.getClass().getSimpleName()); + String trigger = getScriptTriggerDescription(script); + if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); List flatList = new ArrayList<>(); script.addToFlatList(flatList); + HashMap brickKeyCounts = new HashMap<>(); for (Brick brick : flatList) { JsonObject brickJson = new JsonObject(); brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) brickJson.addProperty("Variable", varName); + if (msg != null) brickJson.addProperty("Message", msg); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; + int formulaCount = 0; for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaString(); + String formulaStr = formula.getFormulaInlineString(); if (!formulaStr.isEmpty()) { - brickJson.addProperty("Formula", formulaStr); + formulaCount++; + brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); } } } - scriptJson.add(brick.getClass().getSimpleName(), brickJson); + String brickKey = brick.getClass().getSimpleName(); + int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; + brickKeyCounts.put(brickKey, brickCount); + scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); } - spriteJson.add(script.getClass().getSimpleName(), scriptJson); + + String scriptKey = script.getClass().getSimpleName(); + int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; + scriptKeyCounts.put(scriptKey, scriptCount); + spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); } sceneJson.add(sprite.getName(), spriteJson); } @@ -465,7 +497,7 @@ public static JsonObject getAllListAsJson(Project project) { public static String getAllListAsJsonString(Project project) { JsonObject json = getAllListAsJson(project); - Gson gson = new GsonBuilder().setPrettyPrinting().create(); + Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); String prettyJson = gson.toJson(json); Log.d(TAG, prettyJson); return prettyJson; @@ -476,13 +508,21 @@ public static String getAllListForSprite(Sprite sprite) { builder.append("Sprite: ").append(sprite.getName()).append("\n"); for (Script script : sprite.getScriptList()) { - builder.append("├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + String trigger = getScriptTriggerDescription(script); + builder.append("├── Script: ").append(script.getClass().getSimpleName()); + if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); + builder.append("\n"); List flatList = new ArrayList<>(); script.addToFlatList(flatList); for (Brick brick : flatList) { - builder.append("│ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + builder.append("│ ├── Brick: ").append(brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) builder.append(" → ").append(varName); + else if (msg != null) builder.append(" → \"").append(msg).append("\""); + builder.append("\n"); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; @@ -504,32 +544,49 @@ public static String getAllListAsJsonStringForSprite(Sprite sprite) { JsonObject spriteJson = new JsonObject(); spriteJson.addProperty("Sprite", sprite.getName()); + HashMap scriptKeyCounts = new HashMap<>(); for (Script script : sprite.getScriptList()) { JsonObject scriptJson = new JsonObject(); scriptJson.addProperty("Script", script.getClass().getSimpleName()); + String trigger = getScriptTriggerDescription(script); + if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); List flatList = new ArrayList<>(); script.addToFlatList(flatList); + HashMap brickKeyCounts = new HashMap<>(); for (Brick brick : flatList) { JsonObject brickJson = new JsonObject(); brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) brickJson.addProperty("Variable", varName); + if (msg != null) brickJson.addProperty("Message", msg); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; + int formulaCount = 0; for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaString(); + String formulaStr = formula.getFormulaInlineString(); if (!formulaStr.isEmpty()) { - brickJson.addProperty("Formula", formulaStr); + formulaCount++; + brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); } } } - scriptJson.add(brick.getClass().getSimpleName(), brickJson); + String brickKey = brick.getClass().getSimpleName(); + int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; + brickKeyCounts.put(brickKey, brickCount); + scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); } - spriteJson.add(script.getClass().getSimpleName(), scriptJson); + + String scriptKey = script.getClass().getSimpleName(); + int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; + scriptKeyCounts.put(scriptKey, scriptCount); + spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); } - Gson gson = new GsonBuilder().setPrettyPrinting().create(); + Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); return gson.toJson(spriteJson); } @@ -541,13 +598,21 @@ public static String getAllListForScene(Scene scene) { builder.append("├── Sprite: ").append(sprite.getName()).append("\n"); for (Script script : sprite.getScriptList()) { - builder.append("│ ├── Script: ").append(script.getClass().getSimpleName()).append("\n"); + String trigger = getScriptTriggerDescription(script); + builder.append("│ ├── Script: ").append(script.getClass().getSimpleName()); + if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); + builder.append("\n"); List flatList = new ArrayList<>(); script.addToFlatList(flatList); for (Brick brick : flatList) { - builder.append("│ │ ├── Brick: ").append(brick.getClass().getSimpleName()).append("\n"); + builder.append("│ │ ├── Brick: ").append(brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) builder.append(" → ").append(varName); + else if (msg != null) builder.append(" → \"").append(msg).append("\""); + builder.append("\n"); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; @@ -574,34 +639,51 @@ public static String getAllListAsJsonStringForScene(Scene scene) { JsonObject spriteJson = new JsonObject(); spriteJson.addProperty("Sprite", sprite.getName()); + HashMap scriptKeyCounts = new HashMap<>(); for (Script script : sprite.getScriptList()) { JsonObject scriptJson = new JsonObject(); scriptJson.addProperty("Script", script.getClass().getSimpleName()); + String trigger = getScriptTriggerDescription(script); + if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); List flatList = new ArrayList<>(); script.addToFlatList(flatList); + HashMap brickKeyCounts = new HashMap<>(); for (Brick brick : flatList) { JsonObject brickJson = new JsonObject(); brickJson.addProperty("Brick", brick.getClass().getSimpleName()); + String varName = getBrickVariableName(brick); + String msg = getBrickBroadcastMessage(brick); + if (varName != null) brickJson.addProperty("Variable", varName); + if (msg != null) brickJson.addProperty("Message", msg); if (brick instanceof FormulaBrick) { FormulaBrick formulaBrick = (FormulaBrick) brick; + int formulaCount = 0; for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaString(); + String formulaStr = formula.getFormulaInlineString(); if (!formulaStr.isEmpty()) { - brickJson.addProperty("Formula", formulaStr); + formulaCount++; + brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); } } } - scriptJson.add(brick.getClass().getSimpleName(), brickJson); + String brickKey = brick.getClass().getSimpleName(); + int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; + brickKeyCounts.put(brickKey, brickCount); + scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); } - spriteJson.add(script.getClass().getSimpleName(), scriptJson); + + String scriptKey = script.getClass().getSimpleName(); + int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; + scriptKeyCounts.put(scriptKey, scriptCount); + spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); } sceneJson.add(sprite.getName(), spriteJson); } - Gson gson = new GsonBuilder().setPrettyPrinting().create(); + Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); return gson.toJson(sceneJson); } @@ -658,6 +740,45 @@ public static String getProjectSummary(Project project) { return builder.toString(); } + private static String getScriptTriggerDescription(Script script) { + if (script instanceof StartScript) { + return "when program started"; + } else if (script instanceof BroadcastScript) { + return "receives: \"" + ((BroadcastScript) script).getBroadcastMessage() + "\""; + } else if (script instanceof WhenConditionScript) { + Formula condition = ((WhenConditionScript) script).getFormulaMap().get(Brick.BrickField.IF_CONDITION); + if (condition != null) { + String condStr = condition.getFormulaInlineString(); + if (!condStr.isEmpty()) return "when: " + condStr; + } + return "when condition"; + } else if (script instanceof WhenBounceOffScript) { + return "bounces off: " + ((WhenBounceOffScript) script).getSpriteToBounceOffName(); + } else if (script instanceof WhenScript) { + return "when tapped"; + } else if (script instanceof WhenTouchDownScript) { + return "when screen touched"; + } else if (script instanceof WhenClonedScript) { + return "when cloned"; + } + return ""; + } + + private static String getBrickVariableName(Brick brick) { + if (brick instanceof UserVariableBrickWithFormula) { + UserVariable v = ((UserVariableBrickWithFormula) brick).getUserVariable(); + if (v != null) return v.getName(); + } + return null; + } + + private static String getBrickBroadcastMessage(Brick brick) { + if (brick instanceof BroadcastBrick) { + return ((BroadcastBrick) brick).getBroadcastMessage(); + } + return null; + } + @VisibleForTesting public static void updateCollisionFormulasTo993(Project project) { for (Scene scene : project.getSceneList()) { diff --git a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java index 6222b17e5a2..40328547a09 100644 --- a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java +++ b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java @@ -164,6 +164,57 @@ private String buildFormulaStringRecursive(FormulaElement element, boolean isLef return result.toString(); } + public String getFormulaInlineString() { + if (formulaTree == null) { + return ""; + } + return buildFormulaInline(formulaTree); + } + + private String buildFormulaInline(FormulaElement element) { + if (element == null) { + return ""; + } + FormulaElement left = element.getLeftChild(); + FormulaElement right = element.getRightChild(); + String type = element.getElementType().name(); + String value = element.getValue() != null ? element.getValue() : ""; + + if (left == null && right == null) { + return value.isEmpty() ? type : value; + } + + String op; + switch (value) { + case "EQUAL": op = "=="; break; + case "NOT_EQUAL": op = "!="; break; + case "GREATER_THAN": op = ">"; break; + case "GREATER_OR_EQUAL": op = ">="; break; + case "SMALLER_THAN": op = "<"; break; + case "SMALLER_OR_EQUAL": op = "<="; break; + case "PLUS": op = "+"; break; + case "MINUS": op = "-"; break; + case "MULT": op = "*"; break; + case "DIVIDE": op = "/"; break; + case "MOD": op = "%"; break; + case "POW": op = "^"; break; + case "LOGICAL_AND": op = "&&"; break; + case "LOGICAL_OR": op = "||"; break; + case "LOGICAL_NOT": return "!" + buildFormulaInline(right != null ? right : left); + default: op = value.isEmpty() ? type : value; break; + } + + String leftStr = left != null ? buildFormulaInline(left) : ""; + String rightStr = right != null ? buildFormulaInline(right) : ""; + if (leftStr.isEmpty()) { + return op + " " + rightStr; + } + if (rightStr.isEmpty()) { + return leftStr + " " + op; + } + return leftStr + " " + op + " " + rightStr; + } + public String getFlattenedAllListsString() { FormulaElement tempTree = formulaTree.clone(); tempTree.insertFlattenForAllUserLists(tempTree, null); From e939111e8c1c6c4a71ea3828bbf2c8bf4b0e5ef1 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:28:13 +0700 Subject: [PATCH 12/41] Enable AI Assist feature in build configuration and add Compose foundation and material3 dependencies --- catroid/build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/catroid/build.gradle b/catroid/build.gradle index c47c43496c3..e4d7a5b8212 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -163,7 +163,7 @@ android { buildConfigField "boolean", "FEATURE_USER_REPORTERS_ENABLED", "true" buildConfigField "boolean", "FEATURE_MULTIPLAYER_VARIABLES_ENABLED", "true" buildConfigField "boolean", "FEATURE_TESTBRICK_ENABLED", "true" - buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "false" + buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "true" resValue "string", "FEATURE_EMBROIDERY_PREFERENCES_ENABLED", "false" resValue "string", "FEATURE_PHIRO_PREFERENCES_ENABLED", "false" resValue "string", "FEATURE_MINDSTORMS_PREFERENCES_ENABLED", "false" @@ -224,7 +224,7 @@ android { debug { buildConfigField "boolean", "USE_ANDROID_LOCALES_FOR_SCREENSHOTS", useAndroidLocales() resValue "string", "DEBUG_MODE", "true" - buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "false" + buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "true" signingConfig = signingConfigs.debug enableAndroidTestCoverage = true } @@ -480,6 +480,8 @@ dependencies { // Compose implementation "androidx.compose.ui:ui:1.7.5" implementation "androidx.compose.runtime:runtime:1.7.5" + implementation "androidx.compose.foundation:foundation:1.7.5" + implementation "androidx.compose.material3:material3:1.3.1" androidTestImplementation('tools.fastlane:screengrab:2.1.1') { // https://issuetracker.google.com/issues/123060356 From a5048931cc670ee0a19553cb97a043148912af7f Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:29:26 +0700 Subject: [PATCH 13/41] Update ScriptFragment layout and strings for AI Tutor preview --- .../src/main/res/layout/fragment_script.xml | 68 +++++++++++-------- .../main/res/layout/list_action_buttons.xml | 1 + catroid/src/main/res/values/strings.xml | 1 + 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/catroid/src/main/res/layout/fragment_script.xml b/catroid/src/main/res/layout/fragment_script.xml index 7e4177c59b5..b137a10524d 100644 --- a/catroid/src/main/res/layout/fragment_script.xml +++ b/catroid/src/main/res/layout/fragment_script.xml @@ -21,41 +21,53 @@ ~ You should have received a copy of the GNU Affero General Public License ~ along with this program. If not, see . --> - + android:layout_weight="1"> - - - - - - + android:orientation="vertical"> - - + android:visibility="gone"/> + + + + + + + + + + + + - \ No newline at end of file + diff --git a/catroid/src/main/res/layout/list_action_buttons.xml b/catroid/src/main/res/layout/list_action_buttons.xml index 0100b76b17f..8db3411f19a 100644 --- a/catroid/src/main/res/layout/list_action_buttons.xml +++ b/catroid/src/main/res/layout/list_action_buttons.xml @@ -36,6 +36,7 @@ android:layout_alignWithParentIfMissing="true" android:layout_alignParentEnd="true" android:layout_margin="@dimen/material_design_spacing_large" + android:visibility="gone" android:src="@drawable/ic_assistant" android:tint="@color/solid_white" app:backgroundTint="@color/action_button" diff --git a/catroid/src/main/res/values/strings.xml b/catroid/src/main/res/values/strings.xml index 8d9a95f11c6..e7c61c94325 100644 --- a/catroid/src/main/res/values/strings.xml +++ b/catroid/src/main/res/values/strings.xml @@ -2336,4 +2336,5 @@ needs read and write access to it. You can always change permissions through you Current sprite (JSON) Current sprite (XML) Project summary + Invalid XML: the AI\'s response could not be read as a PocketCode sprite. From 5f53533d6c59d3bd24fc6144a977484978c12f43 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:30:10 +0700 Subject: [PATCH 14/41] Add AI Tutor diff screen and related data classes for script comparison --- .../catroid/ui/aiassist/AiAssistFragment.kt | 20 +- .../catroid/ui/aiassist/AiTutorDiffScreen.kt | 209 ++++++++++++++++++ .../catrobat/catroid/ui/aiassist/BrickDiff.kt | 34 +++ .../catroid/ui/aiassist/ScriptDiff.kt | 32 +++ 4 files changed, 294 insertions(+), 1 deletion(-) rename "catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" => catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt (73%) create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt diff --git "a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt similarity index 73% rename from "catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt index e85f99eb34f..d7f4a442d18 100644 --- "a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt\342\200\216.kt" +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt @@ -34,7 +34,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.catrobat.aitutor.ui.`public`.AiTutorView +import org.catrobat.catroid.R import org.catrobat.catroid.databinding.FragmentAiAssistBinding +import org.catrobat.catroid.io.XstreamSerializer +import org.catrobat.catroid.utils.ToastUtil class AiAssistFragment : Fragment() { @@ -53,7 +56,7 @@ class AiAssistFragment : Fragment() { super.onViewCreated(view, savedInstanceState) val structure = arguments?.getString("structure") - binding.textAiAssist.text = structure ?: "" +// binding.textAiAssist.text = structure ?: "" binding.composeAiTutor.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -63,6 +66,19 @@ class AiAssistFragment : Fragment() { show = show, onDismissRequest = { show = false }, codeContext = structure, + onClipboardPaste = { pastedText -> + val sprite = XstreamSerializer.getInstance().getSpriteFromXmlString(pastedText) + if (sprite == null) { + ToastUtil.showError(requireContext(), R.string.ai_tutor_invalid_xml) + } else { + parentFragmentManager.setFragmentResult( + AI_TUTOR_RESULT_KEY, + Bundle().apply { putString(AI_TUTOR_XML_KEY, pastedText) } + ) + show = false + parentFragmentManager.popBackStack() + } + } ) } } @@ -75,5 +91,7 @@ class AiAssistFragment : Fragment() { companion object { val TAG: String = AiAssistFragment::class.java.simpleName + const val AI_TUTOR_RESULT_KEY = "ai_tutor_result" + const val AI_TUTOR_XML_KEY = "spriteXml" } } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt new file mode 100644 index 00000000000..e6782941afe --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt @@ -0,0 +1,209 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import org.catrobat.catroid.content.Script +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.io.XstreamSerializer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AiTutorDiffScreen( + currentSprite: Sprite, + newSpriteXml: String, + onAccept: () -> Unit, + onReject: () -> Unit +) { + val newSprite = remember(newSpriteXml) { + XstreamSerializer.getInstance().getSpriteFromXmlString(newSpriteXml) + } + val diffs = remember(currentSprite, newSprite) { + newSprite?.let { diffSprites(currentSprite, it) } ?: emptyList() + } + + Scaffold( + topBar = { TopAppBar(title = { Text("AI Tutor Preview") }) }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onReject, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { Text("Reject") } + Button( + onClick = onAccept, + modifier = Modifier.weight(1f) + ) { Text("Accept") } + } + } + ) { padding -> + if (newSprite == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Could not parse AI response.") + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + diffs.forEach { scriptDiff -> + item { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = scriptDiff.oldScript?.javaClass?.simpleName ?: "(removed)", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium + ) + Text( + text = scriptDiff.newScript?.javaClass?.simpleName ?: "(added)", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium + ) + } + Divider() + } + items(scriptDiff.brickDiffs) { brickDiff -> + val bgColor = when (brickDiff.status) { + DiffStatus.ADDED -> addedBg + DiffStatus.REMOVED -> removedBg + DiffStatus.MODIFIED -> modifiedBg + DiffStatus.UNCHANGED -> Color.Transparent + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(bgColor) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = brickDiff.oldBrick?.javaClass?.simpleName ?: "", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall + ) + Text( + text = brickDiff.newBrick?.javaClass?.simpleName ?: "", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + } +} + +object AiTutorPreviewHelper { + @JvmStatic + fun showPreview( + composeView: ComposeView, + currentSprite: Sprite, + newSpriteXml: String, + onAccept: Runnable, + onReject: Runnable + ) { + composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + composeView.setContent { + AiTutorDiffScreen( + currentSprite = currentSprite, + newSpriteXml = newSpriteXml, + onAccept = { onAccept.run() }, + onReject = { onReject.run() } + ) + } + } +} + +private val addedBg = Color(0xFF4CAF50).copy(alpha = 0.25f) +private val removedBg = Color(0xFFF44336).copy(alpha = 0.25f) +private val modifiedBg = Color(0xFFFF9800).copy(alpha = 0.25f) + +private fun diffSprites(oldSprite: Sprite, newSprite: Sprite): List { + val oldScripts = oldSprite.scriptList + val newScripts = newSprite.scriptList + val maxLen = maxOf(oldScripts.size, newScripts.size) + return (0 until maxLen).map { i -> + val old = oldScripts.getOrNull(i) + val new = newScripts.getOrNull(i) + ScriptDiff(old, new, diffScripts(old, new)) + } +} + +private fun diffScripts(oldScript: Script?, newScript: Script?): List { + val oldBricks = oldScript?.brickList ?: emptyList() + val newBricks = newScript?.brickList ?: emptyList() + val maxLen = maxOf(oldBricks.size, newBricks.size) + return (0 until maxLen).map { i -> + val old = oldBricks.getOrNull(i) + val new = newBricks.getOrNull(i) + val status = when { + old == null -> DiffStatus.ADDED + new == null -> DiffStatus.REMOVED + old.javaClass == new.javaClass -> DiffStatus.UNCHANGED + else -> DiffStatus.MODIFIED + } + BrickDiff(old, new, status) + } +} \ No newline at end of file diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt new file mode 100644 index 00000000000..db446304e04 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt @@ -0,0 +1,34 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import org.catrobat.catroid.content.bricks.Brick + +data class BrickDiff( + val oldBrick: Brick?, + val newBrick: Brick?, + val status: DiffStatus +) + +enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } \ No newline at end of file diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt new file mode 100644 index 00000000000..f112dcdaaae --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt @@ -0,0 +1,32 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import org.catrobat.catroid.content.Script + +data class ScriptDiff( + val oldScript: Script?, + val newScript: Script?, + val brickDiffs: List +) \ No newline at end of file From e9c1c0d959b8e7eec3fe32363f616634ed8fadc2 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:30:40 +0700 Subject: [PATCH 15/41] Add method to parse Sprite from XML string in XstreamSerializer --- .../org/catrobat/catroid/io/XstreamSerializer.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java index 1ef5208a972..b23d52c9cfc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java +++ b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java @@ -924,6 +924,18 @@ public String getXmlAsStringFromSprite(Sprite sprite) { } } + public Sprite getSpriteFromXmlString(String xml) { + loadSaveLock.lock(); + try { + return (Sprite) xstream.fromXML(xml); + } catch (Exception e) { + Log.e(TAG, "Failed to parse sprite XML from string.", e); + return null; + } finally { + loadSaveLock.unlock(); + } + } + public static String extractDefaultSceneNameFromXml(File projectDir) { File xmlFile = new File(projectDir, CODE_XML_FILE_NAME); From 14af472f077e71405ad7a6e31cb95fa46fc5810d Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:31:26 +0700 Subject: [PATCH 16/41] Add method to parse Sprite from XML string in XstreamSerializer --- .../recyclerview/fragment/ScriptFragment.java | 118 ++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java index 059d7bdb59e..9e972352f71 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java @@ -67,6 +67,8 @@ import org.catrobat.catroid.ui.ScriptFinder; import org.catrobat.catroid.ui.SpriteActivity; import org.catrobat.catroid.ui.UiUtils; +import org.catrobat.catroid.ui.aiassist.AiAssistFragment; +import org.catrobat.catroid.ui.aiassist.AiTutorPreviewHelper; import org.catrobat.catroid.ui.controller.BackpackListManager; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.dragndrop.BrickListView; @@ -100,6 +102,7 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.compose.ui.platform.ComposeView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import androidx.fragment.app.ListFragment; @@ -180,6 +183,11 @@ public void onCreate(@Nullable Bundle savedInstanceState) { private transient List savedLocalUserVariables; private transient List savedLocalLists; + private enum LoadSource {UNDO, AI_TUTOR} + + private LoadSource pendingLoadSource = null; + private ComposeView composeAiTutorPreview; + @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuInflater inflater = mode.getMenuInflater(); @@ -268,6 +276,7 @@ private void resetActionModeParameters() { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = View.inflate(getActivity(), R.layout.fragment_script, null); listView = view.findViewById(android.R.id.list); + composeAiTutorPreview = view.findViewById(R.id.compose_ai_tutor_preview); int bottomListPadding; if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { bottomListPadding = (int) (ScreenValues.currentScreenResolution.getHeight() / 2.5); @@ -341,12 +350,26 @@ public void onDestroyView() { if (scriptFinder.isOpen() && activity != null) { activity.findViewById(R.id.toolbar).setVisibility(View.VISIBLE); } + composeAiTutorPreview = null; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); + if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { + getParentFragmentManager().setFragmentResultListener( + AiAssistFragment.AI_TUTOR_RESULT_KEY, + this, + (requestKey, result) -> { + String spriteXml = result.getString(AiAssistFragment.AI_TUTOR_XML_KEY); + if (spriteXml != null) { + showAiTutorPreview(spriteXml); + } + } + ); + } + Project currentProject = ProjectManager.getInstance().getCurrentProject(); Scene currentScene = ProjectManager.getInstance().getCurrentlyEditedScene(); Sprite currentSprite = ProjectManager.getInstance().getCurrentSprite(); @@ -403,6 +426,15 @@ public void onResume() { scrollToFocusItem(); SnackbarUtil.showHintSnackbar(getActivity(), R.string.hint_scripts); + + // Restore undo button visibility if a snapshot still exists (e.g., after returning from play). + Project resumeProject = ProjectManager.getInstance().getCurrentProject(); + if (resumeProject != null && getActivity() != null) { + File undoFile = new File(resumeProject.getDirectory(), UNDO_CODE_XML_FILE_NAME); + if (undoFile.exists()) { + ((SpriteActivity) getActivity()).setUndoMenuItemVisibility(true); + } + } } @Override @@ -926,7 +958,8 @@ public void loadProjectAfterUndoOption() { spriteActivity.setUndoMenuItemVisibility(false); spriteActivity.showUndo(false); } - new ProjectLoader(project.getDirectory(), context).setListener(this).loadProjectAsync(); + pendingLoadSource = LoadSource.UNDO; + reloadProjectFromDisk(); } catch (IOException exception) { Log.e(TAG, "Replacing project " + project.getName() + " failed.", exception); ToastUtil.showError(context, R.string.error_load_project); @@ -939,6 +972,60 @@ public void loadProjectAfterUndoOption() { } } + private void reloadProjectFromDisk() { + Project project = ProjectManager.getInstance().getCurrentProject(); + Context context = getContext(); + if (!isAdded() || context == null) { + return; + } + new ProjectLoader(project.getDirectory(), context).setListener(this).loadProjectAsync(); + } + + private void showAiTutorPreview(String newSpriteXml) { + if (composeAiTutorPreview == null) { + return; + } + Sprite currentSprite = ProjectManager.getInstance().getCurrentSprite(); + composeAiTutorPreview.setVisibility(View.VISIBLE); + AiTutorPreviewHelper.showPreview( + composeAiTutorPreview, + currentSprite, + newSpriteXml, + () -> { + composeAiTutorPreview.setVisibility(View.GONE); + applyProjectFromAiTutor(newSpriteXml); + }, + () -> composeAiTutorPreview.setVisibility(View.GONE) + ); + } + + public void applyProjectFromAiTutor(String spriteXml) { + if (!copyProjectForUndoOption()) { + ToastUtil.showError(getContext(), R.string.error_load_project); + return; + } + showUndo(true); + + Sprite newSprite = XstreamSerializer.getInstance().getSpriteFromXmlString(spriteXml); + if (newSprite == null) { + ToastUtil.showError(getContext(), R.string.ai_tutor_invalid_xml); + return; + } + + ProjectManager pm = ProjectManager.getInstance(); + Scene currentScene = pm.getCurrentlyEditedScene(); + Sprite currentSprite = pm.getCurrentSprite(); + int index = currentScene.getSpriteList().indexOf(currentSprite); + if (index >= 0) { + currentScene.getSpriteList().set(index, newSprite); + pm.setCurrentSprite(newSprite); + } + + XstreamSerializer.getInstance().saveProject(pm.getCurrentProject()); + + pendingLoadSource = LoadSource.AI_TUTOR; + reloadProjectFromDisk(); + } @Override public void onLoadFinished(boolean success) { @@ -963,20 +1050,33 @@ public void onLoadFinished(boolean success) { loadVariables(); - if (spriteActivity != null) { - spriteActivity.setUndoMenuItemVisibility(false); - spriteActivity.showUndo(false); - } + boolean isAiTutorLoad = (pendingLoadSource == LoadSource.AI_TUTOR); + pendingLoadSource = null; - File undoCodeFile = new File(ProjectManager.getInstance().getCurrentProject().getDirectory(), UNDO_CODE_XML_FILE_NAME); - if (undoCodeFile.exists() && !undoCodeFile.delete()) { - Log.w(TAG, "Could not delete undo code file: " + undoCodeFile.getAbsolutePath()); + if (!isAiTutorLoad) { + if (spriteActivity != null) { + spriteActivity.setUndoMenuItemVisibility(false); + spriteActivity.showUndo(false); + } + File undoCodeFile = new File(ProjectManager.getInstance().getCurrentProject().getDirectory(), UNDO_CODE_XML_FILE_NAME); + if (undoCodeFile.exists() && !undoCodeFile.delete()) { + Log.w(TAG, "Could not delete undo code file: " + undoCodeFile.getAbsolutePath()); + } } if (getView() == null || listView == null) { return; } refreshFragmentAfterUndo(); + + // For AI Tutor apply: set visibility AFTER refreshFragmentAfterUndo() because the detach/attach + // inside that method fires onPause(), which resets isUndoMenuItemVisible to false. + // onResume() (during reattach) already restored the flag via undo_code.xml; call showUndo(true) + // directly here since invalidateOptionsMenu() won't fire during a fragment reattach. + if (isAiTutorLoad && spriteActivity != null) { + spriteActivity.setUndoMenuItemVisibility(true); + spriteActivity.showUndo(true); + } } private void saveVariables() { @@ -997,7 +1097,7 @@ public boolean checkVariables() { Project project = projectManager.getCurrentProject(); return (project != null && hasProjectVariablesChanged(project)) - || (currentSprite != null && hasSpriteVariablesChanged(currentSprite)); + || (currentSprite != null && hasSpriteVariablesChanged(currentSprite)); } private boolean hasProjectVariablesChanged(Project project) { From 0a72d64705838f6dbd3d96872a8195a590dbe6c8 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:32:22 +0700 Subject: [PATCH 17/41] Hide AI Assist button in BottomBar when navigating to Looks or Sounds fragments if AI Assist feature is enabled --- .../ui/SpriteActivityOnTabSelectedListener.kt | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt index 127725f1bc4..c19a74b3dd9 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt @@ -39,6 +39,7 @@ import org.catrobat.catroid.ui.SpriteActivity.FRAGMENT_SOUNDS import org.catrobat.catroid.ui.recyclerview.fragment.LookListFragment import org.catrobat.catroid.ui.recyclerview.fragment.ScriptFragment import org.catrobat.catroid.ui.recyclerview.fragment.SoundListFragment +import org.catrobat.catroid.BuildConfig import kotlin.reflect.KFunction1 @SuppressWarnings("EmptyFunctionBlock") @@ -82,16 +83,26 @@ fun SpriteActivity.loadFragment(fragmentPosition: Int) { when (fragmentPosition) { FRAGMENT_SCRIPTS -> showScripts(fragmentTransaction) - FRAGMENT_LOOKS -> fragmentTransaction.replace( - R.id.fragment_container, - LookListFragment(), - LookListFragment.TAG - ) - FRAGMENT_SOUNDS -> fragmentTransaction.replace( - R.id.fragment_container, - SoundListFragment(), - SoundListFragment.TAG - ) + FRAGMENT_LOOKS -> { + if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { + BottomBar.hideAiAssistButton(this) + } + fragmentTransaction.replace( + R.id.fragment_container, + LookListFragment(), + LookListFragment.TAG + ) + } + FRAGMENT_SOUNDS -> { + if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { + BottomBar.hideAiAssistButton(this) + } + fragmentTransaction.replace( + R.id.fragment_container, + SoundListFragment(), + SoundListFragment.TAG + ) + } else -> throw IllegalArgumentException("Invalid fragmentPosition in Activity.") } From 9dce406611c99f175a11d3521607af9127d2c334 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:32:50 +0700 Subject: [PATCH 18/41] Refactor AI Assist button handling to directly pass current sprite XML to AiAssistFragment --- .../catrobat/catroid/ui/SpriteActivity.java | 115 ++++++++++-------- 1 file changed, 65 insertions(+), 50 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index 8905c67b3a9..8b9348a5595 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -645,56 +645,71 @@ private void addSoundFromUri(Uri uri) { } public void handleAiAssistButton(View view) { - String[] options = { - getString(R.string.ai_assist_format_tree), - getString(R.string.ai_assist_format_json), - getString(R.string.ai_assist_format_xml), - getString(R.string.ai_assist_format_tree_sprite), - getString(R.string.ai_assist_format_json_sprite), - getString(R.string.ai_assist_format_xml_sprite), - getString(R.string.ai_assist_format_summary) - }; - new AlertDialog.Builder(this) - .setTitle(R.string.ai_assist_choose_format) - .setItems(options, (dialog, which) -> { - Project project = projectManager.getCurrentProject(); - Sprite sprite = projectManager.getCurrentSprite(); - String structure; - switch (which) { - case 0: - structure = ProjectManager.getAllList(project); - break; - case 1: - structure = ProjectManager.getAllListAsJsonString(project); - break; - case 2: - structure = XstreamSerializer.getInstance().getXmlAsStringFromProject(project); - break; - case 3: - structure = ProjectManager.getAllListForSprite(sprite); - break; - case 4: - structure = ProjectManager.getAllListAsJsonStringForSprite(sprite); - break; - case 5: - structure = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); - break; - default: - structure = ProjectManager.getProjectSummary(project); - break; - } - Bundle bundle = new Bundle(); - bundle.putString("structure", structure); - AiAssistFragment aiAssistFragment = new AiAssistFragment(); - aiAssistFragment.setArguments(bundle); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) - .addToBackStack(AiAssistFragment.Companion.getTAG()) - .commit(); - }) - .setNegativeButton(R.string.cancel, null) - .show(); + // Format selection dialog commented out — AI Tutor now always receives the current sprite XML directly. + // The dialog code is preserved below for reference. +// String[] options = { +// getString(R.string.ai_assist_format_tree), +// getString(R.string.ai_assist_format_json), +// getString(R.string.ai_assist_format_xml), +// getString(R.string.ai_assist_format_tree_sprite), +// getString(R.string.ai_assist_format_json_sprite), +// getString(R.string.ai_assist_format_xml_sprite), +// getString(R.string.ai_assist_format_summary) +// }; +// new AlertDialog.Builder(this) +// .setTitle(R.string.ai_assist_choose_format) +// .setItems(options, (dialog, which) -> { +// Project project = projectManager.getCurrentProject(); +// Sprite sprite = projectManager.getCurrentSprite(); +// String structure; +// switch (which) { +// case 0: +// structure = ProjectManager.getAllList(project); +// break; +// case 1: +// structure = ProjectManager.getAllListAsJsonString(project); +// break; +// case 2: +// structure = XstreamSerializer.getInstance().getXmlAsStringFromProject(project); +// break; +// case 3: +// structure = ProjectManager.getAllListForSprite(sprite); +// break; +// case 4: +// structure = ProjectManager.getAllListAsJsonStringForSprite(sprite); +// break; +// case 5: +// structure = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); +// break; +// default: +// structure = ProjectManager.getProjectSummary(project); +// break; +// } +// Bundle bundle = new Bundle(); +// bundle.putString("structure", structure); +// AiAssistFragment aiAssistFragment = new AiAssistFragment(); +// aiAssistFragment.setArguments(bundle); +// getSupportFragmentManager() +// .beginTransaction() +// .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) +// .addToBackStack(AiAssistFragment.Companion.getTAG()) +// .commit(); +// }) +// .setNegativeButton(R.string.cancel, null) +// .show(); + + Sprite sprite = projectManager.getCurrentSprite(); + String spriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); + + Bundle bundle = new Bundle(); + bundle.putString("structure", spriteXml); + AiAssistFragment aiAssistFragment = new AiAssistFragment(); + aiAssistFragment.setArguments(bundle); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) + .addToBackStack(AiAssistFragment.Companion.getTAG()) + .commit(); } public void handleAddButton(View view) { From e875f68de0fb46cb8dd52c7dc50e50e72ffa8acf Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 1 Jun 2026 15:33:36 +0700 Subject: [PATCH 19/41] Comment out AI Assist button handler in ProjectActivity --- .../src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt index 4da3c52bae1..c77eefed79b 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt @@ -116,7 +116,7 @@ class ProjectActivity : BaseCastActivity() { showLegoSensorConfigInfo() binding.bottomBar.apply { buttonAiAssist.setOnClickListener { - handleAiAssistButton() +// handleAiAssistButton() } buttonAdd.setOnClickListener { handleAddButton() From 24c038c5026c95e7aa39e3fb4ec5fec10e45614e Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:35:43 +0700 Subject: [PATCH 20/41] Refactor AI Tutor integration and remove dedicated AiAssistFragment - Remove `AiAssistFragment` and its associated layout `fragment_ai_assist.xml` - Delete `handleAiAssistButton` and project serialization logic from `ProjectActivity` - Replace the local `compose_ai_tutor_preview` in `fragment_script.xml` with a global `compose_ai_overlay` in `activity_sprite.xml` - Update `activity_sprite.xml` to use a `FrameLayout` root to host the AI Tutor flow as a full-screen overlay - Simplify `fragment_script.xml` by removing redundant nested layouts and the deleted Compose view components --- .../catrobat/catroid/ui/ProjectActivity.kt | 38 -------- .../catroid/ui/aiassist/AiAssistFragment.kt | 97 ------------------- .../src/main/res/layout/activity_sprite.xml | 45 ++++++--- .../main/res/layout/fragment_ai_assist.xml | 47 --------- .../src/main/res/layout/fragment_script.xml | 68 ++++++------- 5 files changed, 57 insertions(+), 238 deletions(-) delete mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt delete mode 100644 catroid/src/main/res/layout/fragment_ai_assist.xml diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt index c77eefed79b..fabdf5eef7a 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/ProjectActivity.kt @@ -48,13 +48,11 @@ import org.catrobat.catroid.databinding.ActivityRecyclerBinding import org.catrobat.catroid.databinding.DialogNewActorBinding import org.catrobat.catroid.databinding.ProgressBarBinding import org.catrobat.catroid.io.StorageOperations -import org.catrobat.catroid.io.XstreamSerializer import org.catrobat.catroid.io.asynctask.ProjectSaver import org.catrobat.catroid.merge.ImportProjectHelper import org.catrobat.catroid.stage.StageActivity import org.catrobat.catroid.stage.TestResult import org.catrobat.catroid.ui.BottomBar.showBottomBar -import org.catrobat.catroid.ui.aiassist.AiAssistFragment import org.catrobat.catroid.ui.controller.BackpackListManager import org.catrobat.catroid.ui.controller.ActorsAndObjectsManager import org.catrobat.catroid.ui.dialogs.LegoSensorConfigInfoDialog @@ -115,9 +113,6 @@ class ProjectActivity : BaseCastActivity() { showWarningForSuspiciousBricksOnce(this) showLegoSensorConfigInfo() binding.bottomBar.apply { - buttonAiAssist.setOnClickListener { -// handleAiAssistButton() - } buttonAdd.setOnClickListener { handleAddButton() } @@ -338,39 +333,6 @@ class ProjectActivity : BaseCastActivity() { ).show(supportFragmentManager, NewSpriteDialogFragment.TAG) } - private fun handleAiAssistButton() { - val options = arrayOf( - getString(R.string.ai_assist_format_tree), - getString(R.string.ai_assist_format_json), - getString(R.string.ai_assist_format_xml), - getString(R.string.ai_assist_format_tree_scene), - getString(R.string.ai_assist_format_json_scene), - getString(R.string.ai_assist_format_summary) - ) - AlertDialog.Builder(this) - .setTitle(R.string.ai_assist_choose_format) - .setItems(options) { _, which -> - val project = projectManager.currentProject - val structure = when (which) { - 0 -> ProjectManager.getAllList(project) - 1 -> ProjectManager.getAllListAsJsonString(project) - 2 -> XstreamSerializer.getInstance().getXmlAsStringFromProject(project) - 3 -> ProjectManager.getAllListForScene(projectManager.currentlyEditedScene) - 4 -> ProjectManager.getAllListAsJsonStringForScene(projectManager.currentlyEditedScene) - else -> ProjectManager.getProjectSummary(project) - } - val bundle = Bundle().apply { putString("structure", structure) } - val aiAssistFragment = AiAssistFragment().apply { arguments = bundle } - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.TAG) - .addToBackStack(AiAssistFragment.TAG) - .commit() - } - .setNegativeButton(R.string.cancel, null) - .show() - } - - private fun handleAddButton() { if (currentFragment is SceneListFragment) { handleAddSceneButton() diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt deleted file mode 100644 index d7f4a442d18..00000000000 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2026 The Catrobat Team - * () - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * An additional term exception under section 7 of the GNU Affero - * General Public License, version 3, is available at - * http://developer.catrobat.org/license_additional_term - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.catrobat.catroid.ui.aiassist - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import org.catrobat.aitutor.ui.`public`.AiTutorView -import org.catrobat.catroid.R -import org.catrobat.catroid.databinding.FragmentAiAssistBinding -import org.catrobat.catroid.io.XstreamSerializer -import org.catrobat.catroid.utils.ToastUtil - -class AiAssistFragment : Fragment() { - - private var _binding: FragmentAiAssistBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAiAssistBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val structure = arguments?.getString("structure") -// binding.textAiAssist.text = structure ?: "" - - binding.composeAiTutor.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - var show by remember { mutableStateOf(true) } - AiTutorView( - show = show, - onDismissRequest = { show = false }, - codeContext = structure, - onClipboardPaste = { pastedText -> - val sprite = XstreamSerializer.getInstance().getSpriteFromXmlString(pastedText) - if (sprite == null) { - ToastUtil.showError(requireContext(), R.string.ai_tutor_invalid_xml) - } else { - parentFragmentManager.setFragmentResult( - AI_TUTOR_RESULT_KEY, - Bundle().apply { putString(AI_TUTOR_XML_KEY, pastedText) } - ) - show = false - parentFragmentManager.popBackStack() - } - } - ) - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - companion object { - val TAG: String = AiAssistFragment::class.java.simpleName - const val AI_TUTOR_RESULT_KEY = "ai_tutor_result" - const val AI_TUTOR_XML_KEY = "spriteXml" - } -} diff --git a/catroid/src/main/res/layout/activity_sprite.xml b/catroid/src/main/res/layout/activity_sprite.xml index 252910463d2..62f0be01a08 100644 --- a/catroid/src/main/res/layout/activity_sprite.xml +++ b/catroid/src/main/res/layout/activity_sprite.xml @@ -22,26 +22,39 @@ ~ along with this program. If not, see . --> - + android:layout_height="match_parent"> - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:id="@+id/activity_sprite"> + + - + android:layout_height="match_parent"> + + - - - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/catroid/src/main/res/layout/fragment_ai_assist.xml b/catroid/src/main/res/layout/fragment_ai_assist.xml deleted file mode 100644 index dc4f3b486e9..00000000000 --- a/catroid/src/main/res/layout/fragment_ai_assist.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - diff --git a/catroid/src/main/res/layout/fragment_script.xml b/catroid/src/main/res/layout/fragment_script.xml index b137a10524d..9c76ff3db43 100644 --- a/catroid/src/main/res/layout/fragment_script.xml +++ b/catroid/src/main/res/layout/fragment_script.xml @@ -21,53 +21,41 @@ ~ You should have received a copy of the GNU Affero General Public License ~ along with this program. If not, see . --> - + android:layout_weight="1" + android:orientation="vertical" > - + android:layout_height="wrap_content" + android:visibility="gone"/> - + + - - + - - - - - - - - + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:gravity="center" + android:text="@string/fragment_script_text_description" + android:textSize="?attr/x_large" /> + - + From 489342cce41852224e958e253f2838763d19421d Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:37:36 +0700 Subject: [PATCH 21/41] Add method to retrieve Sprite from XML string with exception handling --- .../org/catrobat/catroid/io/XstreamSerializer.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java index b23d52c9cfc..04f82e8a914 100644 --- a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java +++ b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java @@ -925,12 +925,18 @@ public String getXmlAsStringFromSprite(Sprite sprite) { } public Sprite getSpriteFromXmlString(String xml) { - loadSaveLock.lock(); try { - return (Sprite) xstream.fromXML(xml); + return getSpriteFromXmlStringOrThrow(xml); } catch (Exception e) { Log.e(TAG, "Failed to parse sprite XML from string.", e); return null; + } + } + + public Sprite getSpriteFromXmlStringOrThrow(String xml) { + loadSaveLock.lock(); + try { + return (Sprite) xstream.fromXML(xml); } finally { loadSaveLock.unlock(); } From 8618d74ddf70a702d8111fdd154cabb51e7e6f83 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:39:07 +0700 Subject: [PATCH 22/41] Implement AI Assist overlay flow and communication callbacks --- .../catroid/ui/aiassist/AiAssistOverlay.kt | 145 ++++++++++++++++++ ...ickDiff.kt => AiAssistOverlayCallbacks.kt} | 18 ++- 2 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{BrickDiff.kt => AiAssistOverlayCallbacks.kt} (74%) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt new file mode 100644 index 00000000000..e5e2e521aaf --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt @@ -0,0 +1,145 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import org.catrobat.aitutor.ui.public.AiTutorView +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.io.XstreamSerializer + +private sealed class Stage { + /** Showing the library's AiTutorView. [outputContext] carries a prior error to feed back to the AI. */ + data class Tutor( + val outputContext: String?, + val modifiedSpriteXml: String? = null + ) : Stage() + + /** Showing the side-by-side diff for a valid sprite. */ + data class Diff(val xml: String) : Stage() + + /** Showing the validation-error dialog for an unrenderable sprite. */ + data class Error(val xml: String, val reason: String) : Stage() +} + +/** + * Hosts the full AI Assist flow as a single overlay: AiTutorView → (on paste) validate → + * either the full-screen diff preview or a compact error dialog. + */ +@Composable +private fun AiAssistFlow( + spriteXml: String?, + currentSprite: Sprite, + callbacks: AiAssistOverlayCallbacks +) { + val context = LocalContext.current + var stage by remember { mutableStateOf(Stage.Tutor(null)) } + + val tutorStage = stage as? Stage.Tutor + AiTutorView( + show = tutorStage != null, + onDismissRequest = { if (stage is Stage.Tutor) callbacks.close() }, + codeContext = if (tutorStage?.modifiedSpriteXml != null) { + "The AI previously suggested the following sprite, but it couldn't be applied: " + + "\n\n${tutorStage.modifiedSpriteXml}\n\n" + + "The original sprite before modification was:\n\n$spriteXml\n\n" + + "Please fix the issues and return only a valid sprite XML." + } else { + spriteXml + }, + outputContext = tutorStage?.outputContext, + onClipboardPaste = { pastedText -> + val result = try { + val sprite = XstreamSerializer.getInstance().getSpriteFromXmlStringOrThrow(pastedText) + if (sprite == null) { + AiTutorSpriteValidator.Result.Invalid("The pasted text is not a valid Pocket Code sprite.") + } else { + AiTutorSpriteValidator.validate(sprite, context) + } + } catch (e: Exception) { + AiTutorSpriteValidator.Result.Invalid( + "Couldn't read the sprite XML: ${e.message ?: e.javaClass.simpleName}" + ) + } + stage = if (result is AiTutorSpriteValidator.Result.Invalid) { + Stage.Error(pastedText, result.reason) + } else { + Stage.Diff(pastedText) + } + } + ) + + when (val current = stage) { + is Stage.Tutor -> Unit // already handled by AiTutorView's show parameter + + is Stage.Diff -> AiTutorDiffScreen( + currentSprite = currentSprite, + newSpriteXml = current.xml, + onAccept = { + callbacks.applySprite(current.xml) + callbacks.close() + }, + onReject = { callbacks.close() } + ) + + is Stage.Error -> AiTutorErrorDialog( + technicalReason = current.reason, + onBack = { callbacks.close() }, + onAskAgain = { + // Re-open the tutor, feeding the validation error back to the AI as output context. + stage = Stage.Tutor( + outputContext = + "The previous response could not be applied to Pocket Code. " + + "Please fix it and return only a valid sprite. Reason: ${ + current + .reason + }", + modifiedSpriteXml = current.xml + ) + } + ) + } +} + +/** Java-facing bridge so `SpriteActivity` can drive the Compose overlay. */ +object AiAssistOverlayHelper { + @JvmStatic + fun show( + composeView: ComposeView, + spriteXml: String?, + currentSprite: Sprite, + callbacks: AiAssistOverlayCallbacks + ) { + composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + composeView.setContent { + AiAssistFlow(spriteXml, currentSprite, callbacks) + } + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt similarity index 74% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt index db446304e04..0dc009a6fdf 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/BrickDiff.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt @@ -23,12 +23,14 @@ package org.catrobat.catroid.ui.aiassist -import org.catrobat.catroid.content.bricks.Brick - -data class BrickDiff( - val oldBrick: Brick?, - val newBrick: Brick?, - val status: DiffStatus -) +/** + * Callbacks from the AI Assist overlay back to the host activity. + * Implemented in Java (`SpriteActivity`). + */ +interface AiAssistOverlayCallbacks { + /** Apply the validated AI sprite XML to the project. */ + fun applySprite(spriteXml: String) -enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } \ No newline at end of file + /** Hide the overlay (e.g. user cancelled, rejected, or finished applying). */ + fun close() +} From f8a2fd2c5299769ebfad89619e217f527e60c09d Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:39:33 +0700 Subject: [PATCH 23/41] Add AiTutorErrorDialog composable for error handling in AI responses --- .../catroid/ui/aiassist/AiTutorErrorDialog.kt | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt new file mode 100644 index 00000000000..284e26430ae --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt @@ -0,0 +1,86 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.catrobat.catroid.R + +@Composable +fun AiTutorErrorDialog( + technicalReason: String, + onBack: () -> Unit, + onAskAgain: () -> Unit +) { + val white = colorResource(R.color.solid_white) + val accent = colorResource(R.color.accent) + val errorColor = colorResource(R.color.brick_color_red) + val buttonBackgroundColor = colorResource(R.color.button_background) + val actionButtonColor = colorResource(R.color.action_button) + + AlertDialog( + onDismissRequest = onBack, + containerColor = buttonBackgroundColor, + titleContentColor = white, + textContentColor = white, + title = { Text("This change couldn't be applied", fontWeight = FontWeight.SemiBold) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + "A brick in the AI's response is missing or malformed, so it can't be added to " + + "your project. You can ask the AI to fix it and paste the corrected sprite again." + ) + Spacer(Modifier.height(12.dp)) + Text("Details: $technicalReason", color = errorColor) + } + }, + confirmButton = { + Button( + onClick = onAskAgain, + colors = ButtonDefaults.buttonColors( + containerColor = actionButtonColor, + contentColor = white + ) + ) { Text("Ask AI again", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = onBack) { Text("Back", color = accent) } + } + ) +} \ No newline at end of file From c5c547da351746e1538b9edb82c1bdf8d1808ebc Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:39:55 +0700 Subject: [PATCH 24/41] Add AiTutorSpriteValidator for validating AI-generated sprites --- .../ui/aiassist/AiTutorSpriteValidator.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt new file mode 100644 index 00000000000..49d36178fa8 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt @@ -0,0 +1,111 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist + +import android.content.Context +import android.util.Log +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.FormulaBrick + +/** + * Validates a sprite produced by the AI Tutor before it is applied to the project. + * + * Deserialization via [org.catrobat.catroid.io.XstreamSerializer.getSpriteFromXmlString] only proves + * the XML *parses* — it does not prove the sprite is *renderable*. An AI can emit XML that deserializes + * fine but is semantically incomplete (e.g. a brick missing a required formula), which then crashes when + * the brick list renders. This validator reuses the exact code paths the UI runs so it catches precisely + * those cases. + * + * Must be called on the main thread: the render dry-run inflates views via [Brick.getView]. + */ +object AiTutorSpriteValidator { + + sealed class Result { + object Valid : Result() + data class Invalid(val reason: String) : Result() + } + + const val TAG = "AiTutorSpriteValidator" + + @JvmStatic + fun validate(sprite: Sprite, context: Context): Result { + // 1. Reconstruct parent references exactly like ProjectManager.initializeScripts() does. + try { + for (script in sprite.scriptList) { + script.setParents() + } + } catch (t: Throwable) { + Log.e(TAG, "Error setting parent references for sprite ${sprite.name}: ${t.message}", t) + return Result.Invalid("setParents failed: ${t.message}") + } + + for (script in sprite.scriptList) { + val flat = mutableListOf() + try { + script.addToFlatList(flat) + } catch (t: Throwable) { + Log.e( + TAG, + "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${t.message}", + t + ) + return Result.Invalid("Malformed script structure: ${t.message}") + } + + // 2. Explicit formula-completeness check — the dominant crash cause, with a specific + // reason. + // FormulaBrick.getView() iterates brickFieldToTextViewIdMap and looks each field up in + // formulaMap; a field present in the former but missing from the latter throws at render time. + for (brick in flat) { + if (brick is FormulaBrick) { + for (field in brick.brickFieldToTextViewIdMap.keys) { + if (brick.formulaMap[field] == null) { + return Result.Invalid( + "${brick.javaClass.simpleName} is missing a required value ($field)" + ) + } + } + } + } + + // 3. Render dry-run — catch-all safety net. Mirrors BrickAdapter: getView() on every + // brick. + for (brick in flat) { + try { + brick.getView(context) + } catch (t: Throwable) { + Log.e( + TAG, + "Error rendering brick ${brick.javaClass.simpleName} in sprite ${sprite.name}: ${t.message}", + t + ) + return Result.Invalid("${brick.javaClass.simpleName} failed to render: ${t.message}") + } + } + } + + return Result.Valid + } +} From ab970c6ebbe641ea64198b32406ed2c1c8b5d51e Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:40:53 +0700 Subject: [PATCH 25/41] Enhance AI Tutor diff screen with improved UI and brick-level alignment - Replace basic script-level diffing with a granular brick-level comparison using a Longest Common Subsequence (LCS) algorithm - Implement `buildDiffRows` to align original and AI-generated bricks, supporting ADDED, REMOVED, MODIFIED, and UNCHANGED statuses - Flatten scripts into ordered lists to include script headers, nested bricks, and markers in the comparison view - Reconcile adjacent remove/add operations of the same brick type into a single "MODIFIED" state for better readability - Update `AiTutorDiffScreen` UI with a summary header, color-coded legend, and humanized brick names and field labels - Improve visual styling using project-specific theme colors, rounded corners, and formatted subtitles for formula changes - Add defensive error handling and logging for script flattening and formula string extraction - Remove the now-obsolete `ScriptDiff` data class and `AiTutorPreviewHelper` object --- .../catroid/ui/aiassist/AiTutorDiffScreen.kt | 508 ++++++++++++++---- .../catroid/ui/aiassist/ScriptDiff.kt | 32 -- 2 files changed, 401 insertions(+), 139 deletions(-) delete mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt index e6782941afe..8438eff62b5 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt @@ -23,35 +23,60 @@ package org.catrobat.catroid.ui.aiassist +import android.content.Context +import android.util.Log import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.catrobat.catroid.content.Script +import androidx.compose.ui.unit.sp +import org.catrobat.catroid.R import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.FormulaBrick +import org.catrobat.catroid.content.bricks.ScriptBrick import org.catrobat.catroid.io.XstreamSerializer +enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } + +private data class DiffRow(val old: Brick?, val new: Brick?, val status: DiffStatus) + +object AiTutorDiffScreen { + const val TAG = "AiTutorDiffScreen" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AiTutorDiffScreen( @@ -60,94 +85,92 @@ fun AiTutorDiffScreen( onAccept: () -> Unit, onReject: () -> Unit ) { + val context = LocalContext.current + + val background = colorResource(R.color.app_background) + val barColor = colorResource(R.color.toolbar_background) + val white = colorResource(R.color.solid_white) + val accent = colorResource(R.color.accent) + val actionColor = colorResource(R.color.action_button) + val newSprite = remember(newSpriteXml) { XstreamSerializer.getInstance().getSpriteFromXmlString(newSpriteXml) } - val diffs = remember(currentSprite, newSprite) { - newSprite?.let { diffSprites(currentSprite, it) } ?: emptyList() + val rows = remember(currentSprite, newSprite) { + if (newSprite == null) emptyList() else buildDiffRows(currentSprite, newSprite, context) } + val added = rows.count { it.status == DiffStatus.ADDED } + val removed = rows.count { it.status == DiffStatus.REMOVED } + val modified = rows.count { it.status == DiffStatus.MODIFIED } + Scaffold( - topBar = { TopAppBar(title = { Text("AI Tutor Preview") }) }, + containerColor = background, + topBar = { + CenterAlignedTopAppBar( + title = { Text("Review AI changes", color = white, fontWeight = FontWeight.SemiBold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = barColor, + titleContentColor = white + ) + ) + }, bottomBar = { Row( modifier = Modifier + .background(barColor) .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Button( + OutlinedButton( onClick = onReject, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + colors = ButtonDefaults.outlinedButtonColors(contentColor = white) ) { Text("Reject") } Button( onClick = onAccept, - modifier = Modifier.weight(1f) - ) { Text("Accept") } + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = actionColor, + contentColor = white + ) + ) { Text("Accept", fontWeight = FontWeight.Bold) } } } ) { padding -> if (newSprite == null) { - Box( + CenteredMessage( + text = "Could not read the AI's response as a sprite.", + color = white, modifier = Modifier .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center - ) { - Text("Could not parse AI response.") - } + .padding(padding) + ) + } else if (rows.all { it.status == DiffStatus.UNCHANGED }) { + CenteredMessage( + text = "The AI returned the same sprite,\nnothing would change.", + color = white, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) } else { LazyColumn( modifier = Modifier .fillMaxSize() .padding(padding) + .padding(horizontal = 12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - diffs.forEach { scriptDiff -> - item { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - text = scriptDiff.oldScript?.javaClass?.simpleName ?: "(removed)", - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium - ) - Text( - text = scriptDiff.newScript?.javaClass?.simpleName ?: "(added)", - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium - ) - } - Divider() - } - items(scriptDiff.brickDiffs) { brickDiff -> - val bgColor = when (brickDiff.status) { - DiffStatus.ADDED -> addedBg - DiffStatus.REMOVED -> removedBg - DiffStatus.MODIFIED -> modifiedBg - DiffStatus.UNCHANGED -> Color.Transparent - } - Row( - modifier = Modifier - .fillMaxWidth() - .background(bgColor) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = brickDiff.oldBrick?.javaClass?.simpleName ?: "", - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall - ) - Text( - text = brickDiff.newBrick?.javaClass?.simpleName ?: "", - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall - ) - } + item { SummaryAndLegend(added, removed, modified, white, accent) } + item { ColumnHeader(white) } + itemsIndexed(rows) { _, row -> + if (isScriptHeaderRow(row)) { + ScriptHeaderRow(row, accent) + } else { + BrickDiffRow(row, context, white, accent) } } } @@ -155,55 +178,326 @@ fun AiTutorDiffScreen( } } -object AiTutorPreviewHelper { - @JvmStatic - fun showPreview( - composeView: ComposeView, - currentSprite: Sprite, - newSpriteXml: String, - onAccept: Runnable, - onReject: Runnable +@Composable +private fun CenteredMessage(text: String, color: Color, modifier: Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = text, + color = color, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp) + ) + } +} + +@Composable +private fun SummaryAndLegend( + added: Int, + removed: Int, + modified: Int, + white: Color, + accent: Color +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "$added added · $removed removed · $modified modified", + color = accent, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + LegendChip("Added", colorResource(R.color.brick_color_green), white) + LegendChip("Removed", colorResource(R.color.brick_color_red), white) + LegendChip("Modified", colorResource(R.color.brick_color_yellow), white) + } + } +} + +@Composable +private fun LegendChip(label: String, color: Color, textColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color) + ) + Spacer(Modifier.width(4.dp)) + Text(label, color = textColor, fontSize = 12.sp) + } +} + +@Composable +private fun ColumnHeader(white: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) ) { - composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - composeView.setContent { - AiTutorDiffScreen( - currentSprite = currentSprite, - newSpriteXml = newSpriteXml, - onAccept = { onAccept.run() }, - onReject = { onReject.run() } + Text( + "Before", + color = white.copy(alpha = 0.6f), + fontSize = 12.sp, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + Text( + "After", + color = white.copy(alpha = 0.6f), + fontSize = 12.sp, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ScriptHeaderRow(row: DiffRow, accent: Color) { + val brick = row.new ?: row.old + val title = brick?.let { humanizeBrickName(it.javaClass.simpleName) } ?: "Script" + val tint = statusColor(row.status) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(colorResource(R.color.button_background)) + .then( + if (tint != null) Modifier.border( + 2.dp, + tint, + RoundedCornerShape(6.dp) + ) else Modifier ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text(title, color = accent, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } +} + +@Composable +private fun BrickDiffRow(row: DiffRow, context: Context, white: Color, accent: Color) { + val tint = statusColor(row.status) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(tint?.copy(alpha = 0.28f) ?: Color.Transparent) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BrickCell(row.old, context, white, accent, Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + BrickCell(row.new, context, white, accent, Modifier.weight(1f)) + } +} + +@Composable +private fun BrickCell( + brick: Brick?, + context: Context, + white: Color, + accent: Color, + modifier: Modifier +) { + if (brick == null) { + Text("—", color = white.copy(alpha = 0.35f), modifier = modifier) + return + } + Column(modifier = modifier) { + Text( + text = humanizeBrickName(brick.javaClass.simpleName), + color = white, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val subtitle = brickSubtitle(brick, context) + if (subtitle != null) { + Text( + text = subtitle, + color = accent, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun statusColor(status: DiffStatus): Color? = when (status) { + DiffStatus.ADDED -> colorResource(R.color.brick_color_green) + DiffStatus.REMOVED -> colorResource(R.color.brick_color_red) + DiffStatus.MODIFIED -> colorResource(R.color.brick_color_yellow) + DiffStatus.UNCHANGED -> null +} + +private fun isScriptHeaderRow(row: DiffRow): Boolean = + (row.new ?: row.old) is ScriptBrick + +/** + * Flattens both sprites to a single ordered brick list each (script heads, nested bricks, else/end + * markers — exactly what the editor shows), aligns them with an LCS so unchanged bricks line up on the + * same row, then merges a removed-then-added pair of the same brick type into a single MODIFIED row. + */ +private fun buildDiffRows(oldSprite: Sprite, newSprite: Sprite, context: Context): List { + val oldFlat = flatten(oldSprite) + val newFlat = flatten(newSprite) + val signaturesOld = oldFlat.map { brickSignature(it, context) } + val signaturesNew = newFlat.map { brickSignature(it, context) } + + val n = oldFlat.size + val m = newFlat.size + val lcs = Array(n + 1) { IntArray(m + 1) } + for (i in n - 1 downTo 0) { + for (j in m - 1 downTo 0) { + lcs[i][j] = if (signaturesOld[i] == signaturesNew[j]) { + lcs[i + 1][j + 1] + 1 + } else { + maxOf(lcs[i + 1][j], lcs[i][j + 1]) + } + } + } + + val aligned = mutableListOf() + var i = 0 + var j = 0 + while (i < n && j < m) { + when { + signaturesOld[i] == signaturesNew[j] -> { + aligned.add(DiffRow(oldFlat[i], newFlat[j], DiffStatus.UNCHANGED)); i++; j++ + } + + lcs[i + 1][j] >= lcs[i][j + 1] -> { + aligned.add(DiffRow(oldFlat[i], null, DiffStatus.REMOVED)); i++ + } + + else -> { + aligned.add(DiffRow(null, newFlat[j], DiffStatus.ADDED)); j++ + } + } + } + while (i < n) aligned.add(DiffRow(oldFlat[i++], null, DiffStatus.REMOVED)) + while (j < m) aligned.add(DiffRow(null, newFlat[j++], DiffStatus.ADDED)) + + return reconcileChangeBlocks(aligned) +} + +/** + * Pairs in-place edits as MODIFIED. The LCS emits all removes of a change region before all adds, so a + * removed brick and its matching added brick (same class, changed value) are usually not adjacent. We + * therefore reconcile per **change block** — a maximal run of consecutive non-UNCHANGED rows (UNCHANGED + * rows are anchors that also keep script boundaries intact) — pairing each removed brick with the first + * unused added brick of the same class. + */ +private fun reconcileChangeBlocks(rows: List): List { + val out = mutableListOf() + var k = 0 + while (k < rows.size) { + if (rows[k].status == DiffStatus.UNCHANGED) { + out.add(rows[k]) + k++ + continue } + val block = mutableListOf() + while (k < rows.size && rows[k].status != DiffStatus.UNCHANGED) { + block.add(rows[k]) + k++ + } + out.addAll(reconcileBlock(block)) } + return out } -private val addedBg = Color(0xFF4CAF50).copy(alpha = 0.25f) -private val removedBg = Color(0xFFF44336).copy(alpha = 0.25f) -private val modifiedBg = Color(0xFFFF9800).copy(alpha = 0.25f) +private fun reconcileBlock(block: List): List { + val removed = block.filter { it.status == DiffStatus.REMOVED } + val added = block.filter { it.status == DiffStatus.ADDED } + val usedAdded = BooleanArray(added.size) + val result = mutableListOf() + + // Removed bricks first (in original order): MODIFIED if a same-class added brick is still available. + for (r in removed) { + val matchIdx = added.indices.firstOrNull { a -> + !usedAdded[a] && added[a].new?.javaClass == r.old?.javaClass + } + if (matchIdx != null) { + usedAdded[matchIdx] = true + result.add(DiffRow(r.old, added[matchIdx].new, DiffStatus.MODIFIED)) + } else { + result.add(r) + } + } + // Leftover purely-added bricks, in original order. + for (a in added.indices) { + if (!usedAdded[a]) { + result.add(added[a]) + } + } + return result +} -private fun diffSprites(oldSprite: Sprite, newSprite: Sprite): List { - val oldScripts = oldSprite.scriptList - val newScripts = newSprite.scriptList - val maxLen = maxOf(oldScripts.size, newScripts.size) - return (0 until maxLen).map { i -> - val old = oldScripts.getOrNull(i) - val new = newScripts.getOrNull(i) - ScriptDiff(old, new, diffScripts(old, new)) +private fun flatten(sprite: Sprite): List { + val list = mutableListOf() + for (script in sprite.scriptList) { + try { + script.addToFlatList(list) + } catch (e: Exception) { + Log.e( + AiTutorDiffScreen.TAG, + "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${e.message}", + e + ) + // Defensive: a malformed script shouldn't crash the preview. + } } + return list } -private fun diffScripts(oldScript: Script?, newScript: Script?): List { - val oldBricks = oldScript?.brickList ?: emptyList() - val newBricks = newScript?.brickList ?: emptyList() - val maxLen = maxOf(oldBricks.size, newBricks.size) - return (0 until maxLen).map { i -> - val old = oldBricks.getOrNull(i) - val new = newBricks.getOrNull(i) - val status = when { - old == null -> DiffStatus.ADDED - new == null -> DiffStatus.REMOVED - old.javaClass == new.javaClass -> DiffStatus.UNCHANGED - else -> DiffStatus.MODIFIED +private fun brickSignature(brick: Brick, context: Context): String { + val sb = StringBuilder(brick.javaClass.simpleName) + if (brick is FormulaBrick) { + val map = brick.formulaMap + for (field in map.keys.sortedBy { it.toString() }) { + sb.append('|').append(field.toString()).append('=') + .append(formulaText(brick, field, context)) } - BrickDiff(old, new, status) } -} \ No newline at end of file + return sb.toString() +} + +private fun brickSubtitle(brick: Brick, context: Context): String? { + if (brick !is FormulaBrick) return null + val map = brick.formulaMap + val parts = map.keys.sortedBy { it.toString() }.mapNotNull { field -> + val value = formulaText(brick, field, context) + if (value.isBlank()) null else "${humanizeField(field.toString())}: $value" + } + return if (parts.isEmpty()) null else parts.joinToString(", ") +} + +private fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = + try { + brick.formulaMap[field]?.getTrimmedFormulaString(context).orEmpty() + } catch (e: Exception) { + Log.e( + AiTutorDiffScreen.TAG, + "Error getting formula text for ${brick.javaClass.simpleName} field $field", + e + ) + "" + } + +private fun humanizeBrickName(simpleName: String): String { + val base = simpleName.removeSuffix("Brick") + if (base.isEmpty()) return simpleName + return base + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") + .trim() +} + +private fun humanizeField(fieldName: String): String = + fieldName.split('_').joinToString(" ") { part -> + part.lowercase().replaceFirstChar { it.uppercase() } + } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt deleted file mode 100644 index f112dcdaaae..00000000000 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/ScriptDiff.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Catroid: An on-device visual programming system for Android devices - * Copyright (C) 2010-2026 The Catrobat Team - * () - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * An additional term exception under section 7 of the GNU Affero - * General Public License, version 3, is available at - * http://developer.catrobat.org/license_additional_term - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package org.catrobat.catroid.ui.aiassist - -import org.catrobat.catroid.content.Script - -data class ScriptDiff( - val oldScript: Script?, - val newScript: Script?, - val brickDiffs: List -) \ No newline at end of file From e1f737f151dc7af1c01e388398be94e39136d6c0 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 3 Jun 2026 06:45:50 +0700 Subject: [PATCH 26/41] Replace AI Assist fragment with overlay and enhance sprite validation - Replace `AiAssistFragment` with a Compose-based overlay managed by `AiAssistOverlayHelper` in `SpriteActivity` - Update `SpriteActivity` to handle overlay visibility and hardware back button behavior - Remove deprecated AI Tutor preview logic and fragment result listeners from `ScriptFragment` - Integrate `AiTutorSpriteValidator` in `ScriptFragment` to ensure AI-generated sprites are renderable before application - Clean up options menu handling logic in `SpriteActivity` previously required for the AI Assist fragment - Dispose of Compose compositions when hiding the AI overlay to manage resources correctly --- .../catrobat/catroid/ui/SpriteActivity.java | 109 +++++++----------- .../recyclerview/fragment/ScriptFragment.java | 48 ++------ 2 files changed, 52 insertions(+), 105 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index 8b9348a5595..fcbca8c31ff 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -59,7 +59,8 @@ import org.catrobat.catroid.soundrecorder.SoundRecorderActivity; import org.catrobat.catroid.stage.StageActivity; import org.catrobat.catroid.stage.TestResult; -import org.catrobat.catroid.ui.aiassist.AiAssistFragment; +import org.catrobat.catroid.ui.aiassist.AiAssistOverlayCallbacks; +import org.catrobat.catroid.ui.aiassist.AiAssistOverlayHelper; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.fragment.AddBrickFragment; import org.catrobat.catroid.ui.fragment.BrickCategoryFragment; @@ -85,6 +86,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.compose.ui.platform.ComposeView; import androidx.fragment.app.Fragment; import static org.catrobat.catroid.common.Constants.DEFAULT_IMAGE_EXTENSION; @@ -163,6 +165,8 @@ public class SpriteActivity extends BaseActivity { private boolean isUndoMenuItemVisible = false; + private ComposeView aiOverlay; + @Override public void onCreate(Bundle savedInstanceState) { if (savedInstanceState != null) { @@ -180,6 +184,7 @@ public void onCreate(Bundle savedInstanceState) { currentScene = projectManager.getCurrentlyEditedScene(); setContentView(R.layout.activity_sprite); + aiOverlay = findViewById(R.id.compose_ai_overlay); setSupportActionBar(findViewById(R.id.toolbar)); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(createActionBarTitle()); @@ -248,11 +253,7 @@ public void setUndoMenuItemVisibility(boolean isVisible) { @Override public boolean onPrepareOptionsMenu(Menu menu) { - if (getCurrentFragment() instanceof AiAssistFragment) { - for (int i = 0; i < menu.size(); i++) { - menu.getItem(i).setVisible(false); - } - } else if (getCurrentFragment() instanceof ScriptFragment) { + if (getCurrentFragment() instanceof ScriptFragment) { menu.findItem(R.id.comment_in_out).setVisible(true); showUndo(isUndoMenuItemVisible); } else if (getCurrentFragment() instanceof LookListFragment) { @@ -301,6 +302,11 @@ protected void onSaveInstanceState(Bundle outState) { @Override public void onBackPressed() { + if (isAiOverlayVisible()) { + hideAiOverlay(); + return; + } + saveProject(); Fragment currentFragment = getCurrentFragment(); @@ -645,71 +651,38 @@ private void addSoundFromUri(Uri uri) { } public void handleAiAssistButton(View view) { - // Format selection dialog commented out — AI Tutor now always receives the current sprite XML directly. - // The dialog code is preserved below for reference. -// String[] options = { -// getString(R.string.ai_assist_format_tree), -// getString(R.string.ai_assist_format_json), -// getString(R.string.ai_assist_format_xml), -// getString(R.string.ai_assist_format_tree_sprite), -// getString(R.string.ai_assist_format_json_sprite), -// getString(R.string.ai_assist_format_xml_sprite), -// getString(R.string.ai_assist_format_summary) -// }; -// new AlertDialog.Builder(this) -// .setTitle(R.string.ai_assist_choose_format) -// .setItems(options, (dialog, which) -> { -// Project project = projectManager.getCurrentProject(); -// Sprite sprite = projectManager.getCurrentSprite(); -// String structure; -// switch (which) { -// case 0: -// structure = ProjectManager.getAllList(project); -// break; -// case 1: -// structure = ProjectManager.getAllListAsJsonString(project); -// break; -// case 2: -// structure = XstreamSerializer.getInstance().getXmlAsStringFromProject(project); -// break; -// case 3: -// structure = ProjectManager.getAllListForSprite(sprite); -// break; -// case 4: -// structure = ProjectManager.getAllListAsJsonStringForSprite(sprite); -// break; -// case 5: -// structure = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); -// break; -// default: -// structure = ProjectManager.getProjectSummary(project); -// break; -// } -// Bundle bundle = new Bundle(); -// bundle.putString("structure", structure); -// AiAssistFragment aiAssistFragment = new AiAssistFragment(); -// aiAssistFragment.setArguments(bundle); -// getSupportFragmentManager() -// .beginTransaction() -// .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) -// .addToBackStack(AiAssistFragment.Companion.getTAG()) -// .commit(); -// }) -// .setNegativeButton(R.string.cancel, null) -// .show(); - + if (aiOverlay == null) { + return; + } Sprite sprite = projectManager.getCurrentSprite(); String spriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); - Bundle bundle = new Bundle(); - bundle.putString("structure", spriteXml); - AiAssistFragment aiAssistFragment = new AiAssistFragment(); - aiAssistFragment.setArguments(bundle); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.fragment_container, aiAssistFragment, AiAssistFragment.Companion.getTAG()) - .addToBackStack(AiAssistFragment.Companion.getTAG()) - .commit(); + aiOverlay.setVisibility(View.VISIBLE); + AiAssistOverlayHelper.show(aiOverlay, spriteXml, sprite, new AiAssistOverlayCallbacks() { + @Override + public void applySprite(String newSpriteXml) { + Fragment current = getCurrentFragment(); + if (current instanceof ScriptFragment) { + ((ScriptFragment) current).applyProjectFromAiTutor(newSpriteXml); + } + } + + @Override + public void close() { + hideAiOverlay(); + } + }); + } + + public boolean isAiOverlayVisible() { + return aiOverlay != null && aiOverlay.getVisibility() == View.VISIBLE; + } + + public void hideAiOverlay() { + if (aiOverlay != null) { + aiOverlay.setVisibility(View.GONE); + aiOverlay.disposeComposition(); + } } public void handleAddButton(View view) { diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java index 9e972352f71..b30ff6a274c 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java @@ -67,8 +67,7 @@ import org.catrobat.catroid.ui.ScriptFinder; import org.catrobat.catroid.ui.SpriteActivity; import org.catrobat.catroid.ui.UiUtils; -import org.catrobat.catroid.ui.aiassist.AiAssistFragment; -import org.catrobat.catroid.ui.aiassist.AiTutorPreviewHelper; +import org.catrobat.catroid.ui.aiassist.AiTutorSpriteValidator; import org.catrobat.catroid.ui.controller.BackpackListManager; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.dragndrop.BrickListView; @@ -102,7 +101,6 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; -import androidx.compose.ui.platform.ComposeView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import androidx.fragment.app.ListFragment; @@ -186,7 +184,6 @@ public void onCreate(@Nullable Bundle savedInstanceState) { private enum LoadSource {UNDO, AI_TUTOR} private LoadSource pendingLoadSource = null; - private ComposeView composeAiTutorPreview; @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { @@ -276,7 +273,6 @@ private void resetActionModeParameters() { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = View.inflate(getActivity(), R.layout.fragment_script, null); listView = view.findViewById(android.R.id.list); - composeAiTutorPreview = view.findViewById(R.id.compose_ai_tutor_preview); int bottomListPadding; if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { bottomListPadding = (int) (ScreenValues.currentScreenResolution.getHeight() / 2.5); @@ -350,26 +346,12 @@ public void onDestroyView() { if (scriptFinder.isOpen() && activity != null) { activity.findViewById(R.id.toolbar).setVisibility(View.VISIBLE); } - composeAiTutorPreview = null; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { - getParentFragmentManager().setFragmentResultListener( - AiAssistFragment.AI_TUTOR_RESULT_KEY, - this, - (requestKey, result) -> { - String spriteXml = result.getString(AiAssistFragment.AI_TUTOR_XML_KEY); - if (spriteXml != null) { - showAiTutorPreview(spriteXml); - } - } - ); - } - Project currentProject = ProjectManager.getInstance().getCurrentProject(); Scene currentScene = ProjectManager.getInstance().getCurrentlyEditedScene(); Sprite currentSprite = ProjectManager.getInstance().getCurrentSprite(); @@ -981,24 +963,6 @@ private void reloadProjectFromDisk() { new ProjectLoader(project.getDirectory(), context).setListener(this).loadProjectAsync(); } - private void showAiTutorPreview(String newSpriteXml) { - if (composeAiTutorPreview == null) { - return; - } - Sprite currentSprite = ProjectManager.getInstance().getCurrentSprite(); - composeAiTutorPreview.setVisibility(View.VISIBLE); - AiTutorPreviewHelper.showPreview( - composeAiTutorPreview, - currentSprite, - newSpriteXml, - () -> { - composeAiTutorPreview.setVisibility(View.GONE); - applyProjectFromAiTutor(newSpriteXml); - }, - () -> composeAiTutorPreview.setVisibility(View.GONE) - ); - } - public void applyProjectFromAiTutor(String spriteXml) { if (!copyProjectForUndoOption()) { ToastUtil.showError(getContext(), R.string.error_load_project); @@ -1012,6 +976,16 @@ public void applyProjectFromAiTutor(String spriteXml) { return; } + // Defensive fail-safe: the heavy validation already ran at paste time, but re-validate here on a + // separately parsed instance so no caller can ever apply an unrenderable sprite. Runs on the main + // thread (Accept callback), as the render dry-run requires. + Sprite spriteToValidate = XstreamSerializer.getInstance().getSpriteFromXmlString(spriteXml); + if (spriteToValidate == null + || AiTutorSpriteValidator.validate(spriteToValidate, getContext()) instanceof AiTutorSpriteValidator.Result.Invalid) { + ToastUtil.showError(getContext(), R.string.ai_tutor_invalid_xml); + return; + } + ProjectManager pm = ProjectManager.getInstance(); Scene currentScene = pm.getCurrentlyEditedScene(); Sprite currentSprite = pm.getCurrentSprite(); From 1c25c389e370d30a815e1d4c3c2c8bd1e39dc10b Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Fri, 12 Jun 2026 20:08:47 +0700 Subject: [PATCH 27/41] Remove unused formula string building methods from Formula and FormulaElement classes --- .../catroid/formulaeditor/Formula.java | 117 ------------------ .../catroid/formulaeditor/FormulaElement.java | 8 -- 2 files changed, 125 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java index 40328547a09..6a56eda0d3e 100644 --- a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java +++ b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/Formula.java @@ -106,123 +106,6 @@ public void flattenAllLists() { internFormula.setInternTokenFormulaList(formulaTree.getInternTokenList()); } - public String getFormulaString() { - if (formulaTree == null) { - return ""; - } - return buildFormulaString(formulaTree); - } - - private String buildFormulaString(FormulaElement element) { - if (element == null) { - return ""; - } - - // Add current element information - StringBuilder result = new StringBuilder(); - result.append(element.getElementType().name()); - if (element.getValue() != null) { - result.append(": ").append(element.getValue()); - } - - // Process left and right children with proper tree structure - if (element.getLeftChild() != null) { - result.append("\n│ │ │ │ │ └── ").append(buildFormulaStringRecursive(element.getLeftChild(), true, element.getRightChild() != null)); - } - - if (element.getRightChild() != null) { - result.append("\n│ │ │ │ │ └── ").append(buildFormulaStringRecursive(element.getRightChild(), false, false)); - } - - return result.toString(); - } - - private String buildFormulaStringRecursive(FormulaElement element, boolean isLeftChild, boolean hasRightSibling) { - if (element == null) { - return ""; - } - - // Add current element information - StringBuilder result = new StringBuilder(); - result.append(element.getElementType().name()); - if (element.getValue() != null) { - result.append(": ").append(element.getValue()); - } - - // Process children with proper indentation - String childPrefix = "│ │ │ │ │ "; - if (element.getLeftChild() != null) { - result.append("\n").append(childPrefix).append(" └── ") - .append(buildFormulaStringRecursive(element.getLeftChild(), true, element.getRightChild() != null)); - } - - if (element.getRightChild() != null) { - result.append("\n").append(childPrefix).append(" └── ") - .append(buildFormulaStringRecursive(element.getRightChild(), false, false)); - } - - return result.toString(); - } - - public String getFormulaInlineString() { - if (formulaTree == null) { - return ""; - } - return buildFormulaInline(formulaTree); - } - - private String buildFormulaInline(FormulaElement element) { - if (element == null) { - return ""; - } - FormulaElement left = element.getLeftChild(); - FormulaElement right = element.getRightChild(); - String type = element.getElementType().name(); - String value = element.getValue() != null ? element.getValue() : ""; - - if (left == null && right == null) { - return value.isEmpty() ? type : value; - } - - String op; - switch (value) { - case "EQUAL": op = "=="; break; - case "NOT_EQUAL": op = "!="; break; - case "GREATER_THAN": op = ">"; break; - case "GREATER_OR_EQUAL": op = ">="; break; - case "SMALLER_THAN": op = "<"; break; - case "SMALLER_OR_EQUAL": op = "<="; break; - case "PLUS": op = "+"; break; - case "MINUS": op = "-"; break; - case "MULT": op = "*"; break; - case "DIVIDE": op = "/"; break; - case "MOD": op = "%"; break; - case "POW": op = "^"; break; - case "LOGICAL_AND": op = "&&"; break; - case "LOGICAL_OR": op = "||"; break; - case "LOGICAL_NOT": return "!" + buildFormulaInline(right != null ? right : left); - default: op = value.isEmpty() ? type : value; break; - } - - String leftStr = left != null ? buildFormulaInline(left) : ""; - String rightStr = right != null ? buildFormulaInline(right) : ""; - if (leftStr.isEmpty()) { - return op + " " + rightStr; - } - if (rightStr.isEmpty()) { - return leftStr + " " + op; - } - return leftStr + " " + op + " " + rightStr; - } - - public String getFlattenedAllListsString() { - FormulaElement tempTree = formulaTree.clone(); - tempTree.insertFlattenForAllUserLists(tempTree, null); - tempTree = tempTree.getRoot(); - InternFormula tempInternFormula = new InternFormula(tempTree.getInternTokenList()); - return tempInternFormula.trimExternFormulaString(CatroidApplication.getAppContext()); - } - public void updateCollisionFormulasToVersion() { internFormula.updateCollisionFormulaToVersion(CatroidApplication.getAppContext()); formulaTree.updateCollisionFormulaToVersion(ProjectManager.getInstance().getCurrentProject()); diff --git a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java index 266a7c94eb6..c49a9362302 100644 --- a/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java +++ b/catroid/src/main/java/org/catrobat/catroid/formulaeditor/FormulaElement.java @@ -1052,14 +1052,6 @@ public void setValue(String value) { this.value = value; } - public FormulaElement getLeftChild() { - return leftChild; - } - - public FormulaElement getRightChild() { - return rightChild; - } - public List getUserDataRecursive(ElementType type) { ArrayList userDataNames = new ArrayList<>(); From 19adc6bc9acece80ee44a8cde197f1e14c346934 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Fri, 12 Jun 2026 20:09:09 +0700 Subject: [PATCH 28/41] Refactor sprite XML parsing by simplifying getSpriteFromXmlString method --- .../org/catrobat/catroid/io/XstreamSerializer.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java index 04f82e8a914..b23d52c9cfc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java +++ b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java @@ -925,18 +925,12 @@ public String getXmlAsStringFromSprite(Sprite sprite) { } public Sprite getSpriteFromXmlString(String xml) { + loadSaveLock.lock(); try { - return getSpriteFromXmlStringOrThrow(xml); + return (Sprite) xstream.fromXML(xml); } catch (Exception e) { Log.e(TAG, "Failed to parse sprite XML from string.", e); return null; - } - } - - public Sprite getSpriteFromXmlStringOrThrow(String xml) { - loadSaveLock.lock(); - try { - return (Sprite) xstream.fromXML(xml); } finally { loadSaveLock.unlock(); } From ab356f8a9f1578f61db1285396973fd1e4426b44 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Tue, 16 Jun 2026 22:25:40 +0700 Subject: [PATCH 29/41] Remove AI Assist strings and adjust layout visibility in XML files --- catroid/src/main/res/layout/fragment_script.xml | 2 +- catroid/src/main/res/layout/list_action_buttons.xml | 2 +- catroid/src/main/res/values/strings.xml | 12 ------------ 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/catroid/src/main/res/layout/fragment_script.xml b/catroid/src/main/res/layout/fragment_script.xml index 9c76ff3db43..7e4177c59b5 100644 --- a/catroid/src/main/res/layout/fragment_script.xml +++ b/catroid/src/main/res/layout/fragment_script.xml @@ -58,4 +58,4 @@ android:textSize="?attr/x_large" /> - + \ No newline at end of file diff --git a/catroid/src/main/res/layout/list_action_buttons.xml b/catroid/src/main/res/layout/list_action_buttons.xml index 8db3411f19a..85b65c66f05 100644 --- a/catroid/src/main/res/layout/list_action_buttons.xml +++ b/catroid/src/main/res/layout/list_action_buttons.xml @@ -36,9 +36,9 @@ android:layout_alignWithParentIfMissing="true" android:layout_alignParentEnd="true" android:layout_margin="@dimen/material_design_spacing_large" - android:visibility="gone" android:src="@drawable/ic_assistant" android:tint="@color/solid_white" + android:visibility="gone" app:backgroundTint="@color/action_button" app:elevation="10dp" android:onClick="handleAiAssistButton" /> diff --git a/catroid/src/main/res/values/strings.xml b/catroid/src/main/res/values/strings.xml index e7c61c94325..84feaca90da 100644 --- a/catroid/src/main/res/values/strings.xml +++ b/catroid/src/main/res/values/strings.xml @@ -2325,16 +2325,4 @@ needs read and write access to it. You can always change permissions through you Undo sort Sort checkbox - - Choose project format - Full project (tree) - Full project (JSON) - Full project (XML) - Current scene (tree) - Current scene (JSON) - Current sprite (tree) - Current sprite (JSON) - Current sprite (XML) - Project summary - Invalid XML: the AI\'s response could not be read as a PocketCode sprite. From 3156f3534cbe681ce14b4f647dda26d8e5dbe19b Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Tue, 16 Jun 2026 22:26:32 +0700 Subject: [PATCH 30/41] Refactor AI Tutor integration by removing unused validation and updating overlay imports --- .../org/catrobat/catroid/ProjectManager.java | 402 ------------------ .../catrobat/catroid/ui/SpriteActivity.java | 8 +- .../recyclerview/fragment/ScriptFragment.java | 22 +- 3 files changed, 5 insertions(+), 427 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java index dda466197f3..c8c62e5591e 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java +++ b/catroid/src/main/java/org/catrobat/catroid/ProjectManager.java @@ -28,8 +28,6 @@ import android.util.Log; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import org.catrobat.catroid.common.DefaultProjectHandler; @@ -43,16 +41,8 @@ import org.catrobat.catroid.content.Script; import org.catrobat.catroid.content.Setting; import org.catrobat.catroid.content.Sprite; -import org.catrobat.catroid.content.BroadcastScript; -import org.catrobat.catroid.content.StartScript; import org.catrobat.catroid.content.WhenBounceOffScript; -import org.catrobat.catroid.content.WhenClonedScript; -import org.catrobat.catroid.content.WhenConditionScript; -import org.catrobat.catroid.content.WhenScript; -import org.catrobat.catroid.content.WhenTouchDownScript; import org.catrobat.catroid.content.backwardcompatibility.BrickTreeBuilder; -import org.catrobat.catroid.content.bricks.BroadcastBrick; -import org.catrobat.catroid.content.bricks.UserVariableBrickWithFormula; import org.catrobat.catroid.content.bricks.ArduinoSendPWMValueBrick; import org.catrobat.catroid.content.bricks.Brick; import org.catrobat.catroid.content.bricks.FormulaBrick; @@ -387,398 +377,6 @@ public static void flattenAllLists(Project project) { } } - public static String getAllList(Project project) { - StringBuilder builder = new StringBuilder(); - builder.append("Project: ").append(project.getName()).append("\n"); - - for (Scene scene : project.getSceneList()) { - builder.append("├── Scene: ").append(scene.getName()).append("\n"); - - for (Sprite sprite : scene.getSpriteList()) { - builder.append("│ ├── Sprite: ").append(sprite.getName()).append("\n"); - - for (Script script : sprite.getScriptList()) { - String trigger = getScriptTriggerDescription(script); - builder.append("│ │ ├── Script: ").append(script.getClass().getSimpleName()); - if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); - builder.append("\n"); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - for (Brick brick : flatList) { - builder.append("│ │ │ ├── Brick: ").append(brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) builder.append(" → ").append(varName); - else if (msg != null) builder.append(" → \"").append(msg).append("\""); - builder.append("\n"); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - for (Formula formula : formulaBrick.getFormulas()) { - builder.append("│ │ │ │ ├── Formula: "); - String formulaStr = formula.getFormulaString(); - if (!formulaStr.isEmpty()) { - builder.append(formulaStr); - } - builder.append("\n"); - } - } - } - } - } - } - - String result = builder.toString(); - Log.d(TAG, result); - return result; - } - - public static JsonObject getAllListAsJson(Project project) { - JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("Project", project.getName()); - - for (Scene scene : project.getSceneList()) { - JsonObject sceneJson = new JsonObject(); - sceneJson.addProperty("Scene", scene.getName()); - - for (Sprite sprite : scene.getSpriteList()) { - JsonObject spriteJson = new JsonObject(); - spriteJson.addProperty("Sprite", sprite.getName()); - - HashMap scriptKeyCounts = new HashMap<>(); - for (Script script : sprite.getScriptList()) { - JsonObject scriptJson = new JsonObject(); - scriptJson.addProperty("Script", script.getClass().getSimpleName()); - String trigger = getScriptTriggerDescription(script); - if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - HashMap brickKeyCounts = new HashMap<>(); - for (Brick brick : flatList) { - JsonObject brickJson = new JsonObject(); - brickJson.addProperty("Brick", brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) brickJson.addProperty("Variable", varName); - if (msg != null) brickJson.addProperty("Message", msg); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - int formulaCount = 0; - for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaInlineString(); - if (!formulaStr.isEmpty()) { - formulaCount++; - brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); - } - } - } - String brickKey = brick.getClass().getSimpleName(); - int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; - brickKeyCounts.put(brickKey, brickCount); - scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); - } - - String scriptKey = script.getClass().getSimpleName(); - int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; - scriptKeyCounts.put(scriptKey, scriptCount); - spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); - } - sceneJson.add(sprite.getName(), spriteJson); - } - jsonObject.add(scene.getName(), sceneJson); - } - return jsonObject; - } - - public static String getAllListAsJsonString(Project project) { - JsonObject json = getAllListAsJson(project); - Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); - String prettyJson = gson.toJson(json); - Log.d(TAG, prettyJson); - return prettyJson; - } - - public static String getAllListForSprite(Sprite sprite) { - StringBuilder builder = new StringBuilder(); - builder.append("Sprite: ").append(sprite.getName()).append("\n"); - - for (Script script : sprite.getScriptList()) { - String trigger = getScriptTriggerDescription(script); - builder.append("├── Script: ").append(script.getClass().getSimpleName()); - if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); - builder.append("\n"); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - for (Brick brick : flatList) { - builder.append("│ ├── Brick: ").append(brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) builder.append(" → ").append(varName); - else if (msg != null) builder.append(" → \"").append(msg).append("\""); - builder.append("\n"); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - for (Formula formula : formulaBrick.getFormulas()) { - builder.append("│ │ ├── Formula: "); - String formulaStr = formula.getFormulaString(); - if (!formulaStr.isEmpty()) { - builder.append(formulaStr); - } - builder.append("\n"); - } - } - } - } - return builder.toString(); - } - - public static String getAllListAsJsonStringForSprite(Sprite sprite) { - JsonObject spriteJson = new JsonObject(); - spriteJson.addProperty("Sprite", sprite.getName()); - - HashMap scriptKeyCounts = new HashMap<>(); - for (Script script : sprite.getScriptList()) { - JsonObject scriptJson = new JsonObject(); - scriptJson.addProperty("Script", script.getClass().getSimpleName()); - String trigger = getScriptTriggerDescription(script); - if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - HashMap brickKeyCounts = new HashMap<>(); - for (Brick brick : flatList) { - JsonObject brickJson = new JsonObject(); - brickJson.addProperty("Brick", brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) brickJson.addProperty("Variable", varName); - if (msg != null) brickJson.addProperty("Message", msg); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - int formulaCount = 0; - for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaInlineString(); - if (!formulaStr.isEmpty()) { - formulaCount++; - brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); - } - } - } - String brickKey = brick.getClass().getSimpleName(); - int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; - brickKeyCounts.put(brickKey, brickCount); - scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); - } - - String scriptKey = script.getClass().getSimpleName(); - int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; - scriptKeyCounts.put(scriptKey, scriptCount); - spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); - } - - Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); - return gson.toJson(spriteJson); - } - - public static String getAllListForScene(Scene scene) { - StringBuilder builder = new StringBuilder(); - builder.append("Scene: ").append(scene.getName()).append("\n"); - - for (Sprite sprite : scene.getSpriteList()) { - builder.append("├── Sprite: ").append(sprite.getName()).append("\n"); - - for (Script script : sprite.getScriptList()) { - String trigger = getScriptTriggerDescription(script); - builder.append("│ ├── Script: ").append(script.getClass().getSimpleName()); - if (!trigger.isEmpty()) builder.append(" (").append(trigger).append(")"); - builder.append("\n"); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - for (Brick brick : flatList) { - builder.append("│ │ ├── Brick: ").append(brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) builder.append(" → ").append(varName); - else if (msg != null) builder.append(" → \"").append(msg).append("\""); - builder.append("\n"); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - for (Formula formula : formulaBrick.getFormulas()) { - builder.append("│ │ │ ├── Formula: "); - String formulaStr = formula.getFormulaString(); - if (!formulaStr.isEmpty()) { - builder.append(formulaStr); - } - builder.append("\n"); - } - } - } - } - } - return builder.toString(); - } - - public static String getAllListAsJsonStringForScene(Scene scene) { - JsonObject sceneJson = new JsonObject(); - sceneJson.addProperty("Scene", scene.getName()); - - for (Sprite sprite : scene.getSpriteList()) { - JsonObject spriteJson = new JsonObject(); - spriteJson.addProperty("Sprite", sprite.getName()); - - HashMap scriptKeyCounts = new HashMap<>(); - for (Script script : sprite.getScriptList()) { - JsonObject scriptJson = new JsonObject(); - scriptJson.addProperty("Script", script.getClass().getSimpleName()); - String trigger = getScriptTriggerDescription(script); - if (!trigger.isEmpty()) scriptJson.addProperty("Trigger", trigger); - - List flatList = new ArrayList<>(); - script.addToFlatList(flatList); - - HashMap brickKeyCounts = new HashMap<>(); - for (Brick brick : flatList) { - JsonObject brickJson = new JsonObject(); - brickJson.addProperty("Brick", brick.getClass().getSimpleName()); - String varName = getBrickVariableName(brick); - String msg = getBrickBroadcastMessage(brick); - if (varName != null) brickJson.addProperty("Variable", varName); - if (msg != null) brickJson.addProperty("Message", msg); - - if (brick instanceof FormulaBrick) { - FormulaBrick formulaBrick = (FormulaBrick) brick; - int formulaCount = 0; - for (Formula formula : formulaBrick.getFormulas()) { - String formulaStr = formula.getFormulaInlineString(); - if (!formulaStr.isEmpty()) { - formulaCount++; - brickJson.addProperty(formulaCount == 1 ? "Formula" : "Formula_" + formulaCount, formulaStr); - } - } - } - String brickKey = brick.getClass().getSimpleName(); - int brickCount = brickKeyCounts.getOrDefault(brickKey, 0) + 1; - brickKeyCounts.put(brickKey, brickCount); - scriptJson.add(brickCount == 1 ? brickKey : brickKey + "_" + brickCount, brickJson); - } - - String scriptKey = script.getClass().getSimpleName(); - int scriptCount = scriptKeyCounts.getOrDefault(scriptKey, 0) + 1; - scriptKeyCounts.put(scriptKey, scriptCount); - spriteJson.add(scriptCount == 1 ? scriptKey : scriptKey + "_" + scriptCount, scriptJson); - } - sceneJson.add(sprite.getName(), spriteJson); - } - - Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); - return gson.toJson(sceneJson); - } - - public static String getProjectSummary(Project project) { - StringBuilder builder = new StringBuilder(); - builder.append("Project: ").append(project.getName()).append("\n"); - builder.append("Scenes: ").append(project.getSceneList().size()).append("\n"); - - int totalSprites = 0; - int totalScripts = 0; - for (Scene scene : project.getSceneList()) { - for (Sprite sprite : scene.getSpriteList()) { - totalSprites++; - totalScripts += sprite.getScriptList().size(); - } - } - builder.append("Total sprites: ").append(totalSprites).append("\n"); - builder.append("Total scripts: ").append(totalScripts).append("\n"); - - for (Scene scene : project.getSceneList()) { - builder.append("\nScene: ").append(scene.getName()).append("\n"); - for (Sprite sprite : scene.getSpriteList()) { - int scriptCount = sprite.getScriptList().size(); - builder.append(" - ").append(sprite.getName()) - .append(": ").append(scriptCount) - .append(scriptCount != 1 ? " scripts" : " script").append("\n"); - } - } - - List projectVars = project.getUserVariables(); - if (!projectVars.isEmpty()) { - builder.append("\nGlobal variables: "); - for (int i = 0; i < projectVars.size(); i++) { - if (i > 0) { - builder.append(", "); - } - builder.append(projectVars.get(i).getName()); - } - builder.append("\n"); - } - - List projectLists = project.getUserLists(); - if (!projectLists.isEmpty()) { - builder.append("Global lists: "); - for (int i = 0; i < projectLists.size(); i++) { - if (i > 0) { - builder.append(", "); - } - builder.append(projectLists.get(i).getName()); - } - builder.append("\n"); - } - - return builder.toString(); - } - - private static String getScriptTriggerDescription(Script script) { - if (script instanceof StartScript) { - return "when program started"; - } else if (script instanceof BroadcastScript) { - return "receives: \"" + ((BroadcastScript) script).getBroadcastMessage() + "\""; - } else if (script instanceof WhenConditionScript) { - Formula condition = ((WhenConditionScript) script).getFormulaMap().get(Brick.BrickField.IF_CONDITION); - if (condition != null) { - String condStr = condition.getFormulaInlineString(); - if (!condStr.isEmpty()) return "when: " + condStr; - } - return "when condition"; - } else if (script instanceof WhenBounceOffScript) { - return "bounces off: " + ((WhenBounceOffScript) script).getSpriteToBounceOffName(); - } else if (script instanceof WhenScript) { - return "when tapped"; - } else if (script instanceof WhenTouchDownScript) { - return "when screen touched"; - } else if (script instanceof WhenClonedScript) { - return "when cloned"; - } - return ""; - } - - private static String getBrickVariableName(Brick brick) { - if (brick instanceof UserVariableBrickWithFormula) { - UserVariable v = ((UserVariableBrickWithFormula) brick).getUserVariable(); - if (v != null) return v.getName(); - } - return null; - } - - private static String getBrickBroadcastMessage(Brick brick) { - if (brick instanceof BroadcastBrick) { - return ((BroadcastBrick) brick).getBroadcastMessage(); - } - return null; - } - @VisibleForTesting public static void updateCollisionFormulasTo993(Project project) { for (Scene scene : project.getSceneList()) { diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index fcbca8c31ff..0a3f3f55494 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -59,8 +59,8 @@ import org.catrobat.catroid.soundrecorder.SoundRecorderActivity; import org.catrobat.catroid.stage.StageActivity; import org.catrobat.catroid.stage.TestResult; -import org.catrobat.catroid.ui.aiassist.AiAssistOverlayCallbacks; -import org.catrobat.catroid.ui.aiassist.AiAssistOverlayHelper; +import org.catrobat.catroid.ui.aiassist.overlay.AiAssistOverlayCallbacks; +import org.catrobat.catroid.ui.aiassist.overlay.AiAssistOverlayHelper; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.fragment.AddBrickFragment; import org.catrobat.catroid.ui.fragment.BrickCategoryFragment; @@ -662,8 +662,8 @@ public void handleAiAssistButton(View view) { @Override public void applySprite(String newSpriteXml) { Fragment current = getCurrentFragment(); - if (current instanceof ScriptFragment) { - ((ScriptFragment) current).applyProjectFromAiTutor(newSpriteXml); + if (current instanceof ScriptFragment scriptfragment) { + scriptfragment.applyProjectFromAiTutor(newSpriteXml); } } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java index b30ff6a274c..381fe6da570 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java @@ -67,7 +67,6 @@ import org.catrobat.catroid.ui.ScriptFinder; import org.catrobat.catroid.ui.SpriteActivity; import org.catrobat.catroid.ui.UiUtils; -import org.catrobat.catroid.ui.aiassist.AiTutorSpriteValidator; import org.catrobat.catroid.ui.controller.BackpackListManager; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.dragndrop.BrickListView; @@ -971,21 +970,6 @@ public void applyProjectFromAiTutor(String spriteXml) { showUndo(true); Sprite newSprite = XstreamSerializer.getInstance().getSpriteFromXmlString(spriteXml); - if (newSprite == null) { - ToastUtil.showError(getContext(), R.string.ai_tutor_invalid_xml); - return; - } - - // Defensive fail-safe: the heavy validation already ran at paste time, but re-validate here on a - // separately parsed instance so no caller can ever apply an unrenderable sprite. Runs on the main - // thread (Accept callback), as the render dry-run requires. - Sprite spriteToValidate = XstreamSerializer.getInstance().getSpriteFromXmlString(spriteXml); - if (spriteToValidate == null - || AiTutorSpriteValidator.validate(spriteToValidate, getContext()) instanceof AiTutorSpriteValidator.Result.Invalid) { - ToastUtil.showError(getContext(), R.string.ai_tutor_invalid_xml); - return; - } - ProjectManager pm = ProjectManager.getInstance(); Scene currentScene = pm.getCurrentlyEditedScene(); Sprite currentSprite = pm.getCurrentSprite(); @@ -1043,10 +1027,6 @@ public void onLoadFinished(boolean success) { } refreshFragmentAfterUndo(); - // For AI Tutor apply: set visibility AFTER refreshFragmentAfterUndo() because the detach/attach - // inside that method fires onPause(), which resets isUndoMenuItemVisible to false. - // onResume() (during reattach) already restored the flag via undo_code.xml; call showUndo(true) - // directly here since invalidateOptionsMenu() won't fire during a fragment reattach. if (isAiTutorLoad && spriteActivity != null) { spriteActivity.setUndoMenuItemVisibility(true); spriteActivity.showUndo(true); @@ -1071,7 +1051,7 @@ public boolean checkVariables() { Project project = projectManager.getCurrentProject(); return (project != null && hasProjectVariablesChanged(project)) - || (currentSprite != null && hasSpriteVariablesChanged(currentSprite)); + || (currentSprite != null && hasSpriteVariablesChanged(currentSprite)); } private boolean hasProjectVariablesChanged(Project project) { From d7639f01fb1cffc86f18a9097806d5f18e7430fb Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Tue, 16 Jun 2026 22:34:43 +0700 Subject: [PATCH 31/41] Refactor AI Assist package structure and enhance UI components - Organize AI Assist classes into specialized sub-packages: `overlay`, `validation`, `error`, and `diff` - Update `AiAssistOverlay` to use `getSpriteFromXmlString` for clipboard processing and update related imports - Enhance `AiTutorErrorDialog` by applying a `RoundedCornerShape` to the `AlertDialog` - Relocate `AiTutorSpriteValidator`, `AiAssistOverlay`, `AiAssistOverlayCallbacks`, and `AiTutorErrorDialog` to their respective new packages --- .../catroid/ui/aiassist/{ => error}/AiTutorErrorDialog.kt | 4 +++- .../catroid/ui/aiassist/{ => overlay}/AiAssistOverlay.kt | 7 +++++-- .../ui/aiassist/{ => overlay}/AiAssistOverlayCallbacks.kt | 2 +- .../ui/aiassist/{ => validation}/AiTutorSpriteValidator.kt | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{ => error}/AiTutorErrorDialog.kt (95%) rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{ => overlay}/AiAssistOverlay.kt (95%) rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{ => overlay}/AiAssistOverlayCallbacks.kt (96%) rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{ => validation}/AiTutorSpriteValidator.kt (98%) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt similarity index 95% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt index 284e26430ae..a6d4e8041ba 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorErrorDialog.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt @@ -21,12 +21,13 @@ * along with this program. If not, see . */ -package org.catrobat.catroid.ui.aiassist +package org.catrobat.catroid.ui.aiassist.error import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -53,6 +54,7 @@ fun AiTutorErrorDialog( val actionButtonColor = colorResource(R.color.action_button) AlertDialog( + shape = RoundedCornerShape(12.dp), onDismissRequest = onBack, containerColor = buttonBackgroundColor, titleContentColor = white, diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt similarity index 95% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt index e5e2e521aaf..00b208b3ec3 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlay.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt @@ -21,7 +21,7 @@ * along with this program. If not, see . */ -package org.catrobat.catroid.ui.aiassist +package org.catrobat.catroid.ui.aiassist.overlay import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +34,9 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import org.catrobat.aitutor.ui.public.AiTutorView import org.catrobat.catroid.content.Sprite import org.catrobat.catroid.io.XstreamSerializer +import org.catrobat.catroid.ui.aiassist.diff.AiTutorDiffScreen +import org.catrobat.catroid.ui.aiassist.error.AiTutorErrorDialog +import org.catrobat.catroid.ui.aiassist.validation.AiTutorSpriteValidator private sealed class Stage { /** Showing the library's AiTutorView. [outputContext] carries a prior error to feed back to the AI. */ @@ -77,7 +80,7 @@ private fun AiAssistFlow( outputContext = tutorStage?.outputContext, onClipboardPaste = { pastedText -> val result = try { - val sprite = XstreamSerializer.getInstance().getSpriteFromXmlStringOrThrow(pastedText) + val sprite = XstreamSerializer.getInstance().getSpriteFromXmlString(pastedText) if (sprite == null) { AiTutorSpriteValidator.Result.Invalid("The pasted text is not a valid Pocket Code sprite.") } else { diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt similarity index 96% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt index 0dc009a6fdf..26b83e2707b 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiAssistOverlayCallbacks.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt @@ -21,7 +21,7 @@ * along with this program. If not, see . */ -package org.catrobat.catroid.ui.aiassist +package org.catrobat.catroid.ui.aiassist.overlay /** * Callbacks from the AI Assist overlay back to the host activity. diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt similarity index 98% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt index 49d36178fa8..1b5622b6d39 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorSpriteValidator.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt @@ -21,7 +21,7 @@ * along with this program. If not, see . */ -package org.catrobat.catroid.ui.aiassist +package org.catrobat.catroid.ui.aiassist.validation import android.content.Context import android.util.Log From 7cc2003e216005b4a260100478ee0790572b5df6 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Tue, 16 Jun 2026 22:35:30 +0700 Subject: [PATCH 32/41] Refactor AI Tutor diff screen UI and modularize diffing logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `AiTutorDiffScreen` to a dedicated `.diff` subpackage and decompose logic into specialized helper files - Implement a GitHub-style diff layout using status stripes, leading icons, and row tinting for better change visualization - Add `BrickDiffDialog` to allow detailed inspection of "Before" and "After" states for individual bricks - Introduce `DiffToken` logic to highlight specific changed values within brick subtitles using `AnnotatedString` and bold styling - Improve script header rendering by resolving real string resources via `BrickBaseType` instead of class name humanization - Update `brickSignature` to include variable and list selections, ensuring data-only changes are correctly identified as MODIFIED - Extract diffing algorithms to `SpriteDiffer.kt` and styling constants to `DiffStatusStyle.kt` for better maintainability - Replace the dual-column comparison with a more natural single-column flow for MODIFIED rows, showing a clear "Old → New" value transition --- .../aiassist/{ => diff}/AiTutorDiffScreen.kt | 429 ++++++++---------- .../ui/aiassist/diff/BrickDiffDialog.kt | 168 +++++++ .../ui/aiassist/diff/BrickTextFormatter.kt | 162 +++++++ .../catroid/ui/aiassist/diff/DiffModel.kt | 35 ++ .../ui/aiassist/diff/DiffStatusStyle.kt | 57 +++ .../catroid/ui/aiassist/diff/SpriteDiffer.kt | 168 +++++++ 6 files changed, 784 insertions(+), 235 deletions(-) rename catroid/src/main/java/org/catrobat/catroid/ui/aiassist/{ => diff}/AiTutorDiffScreen.kt (54%) create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt create mode 100644 catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt similarity index 54% rename from catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt rename to catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt index 8438eff62b5..ff4e1d64ee4 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/AiTutorDiffScreen.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt @@ -21,17 +21,21 @@ * along with this program. If not, see . */ -package org.catrobat.catroid.ui.aiassist +package org.catrobat.catroid.ui.aiassist.diff import android.content.Context -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -45,18 +49,23 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -65,18 +74,8 @@ import androidx.compose.ui.unit.sp import org.catrobat.catroid.R import org.catrobat.catroid.content.Sprite import org.catrobat.catroid.content.bricks.Brick -import org.catrobat.catroid.content.bricks.FormulaBrick -import org.catrobat.catroid.content.bricks.ScriptBrick import org.catrobat.catroid.io.XstreamSerializer -enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } - -private data class DiffRow(val old: Brick?, val new: Brick?, val status: DiffStatus) - -object AiTutorDiffScreen { - const val TAG = "AiTutorDiffScreen" -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun AiTutorDiffScreen( @@ -100,6 +99,8 @@ fun AiTutorDiffScreen( if (newSprite == null) emptyList() else buildDiffRows(currentSprite, newSprite, context) } + var selected by remember { mutableStateOf(null) } + val added = rows.count { it.status == DiffStatus.ADDED } val removed = rows.count { it.status == DiffStatus.REMOVED } val modified = rows.count { it.status == DiffStatus.MODIFIED } @@ -108,7 +109,13 @@ fun AiTutorDiffScreen( containerColor = background, topBar = { CenterAlignedTopAppBar( - title = { Text("Review AI changes", color = white, fontWeight = FontWeight.SemiBold) }, + title = { + Text( + "Review AI changes", + color = white, + fontWeight = FontWeight.SemiBold + ) + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = barColor, titleContentColor = white @@ -161,21 +168,25 @@ fun AiTutorDiffScreen( .fillMaxSize() .padding(padding) .padding(horizontal = 12.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp), + contentPadding = PaddingValues(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { item { SummaryAndLegend(added, removed, modified, white, accent) } - item { ColumnHeader(white) } + item { Spacer(Modifier.height(0.dp)) } itemsIndexed(rows) { _, row -> if (isScriptHeaderRow(row)) { - ScriptHeaderRow(row, accent) + ScriptHeaderRow(row, context, accent) } else { - BrickDiffRow(row, context, white, accent) + BrickDiffRow(row, context, white, accent) { selected = row } } } } } } + + selected?.let { row -> + BrickDiffDialog(row, context, white, accent, actionColor) { selected = null } + } } @Composable @@ -229,32 +240,9 @@ private fun LegendChip(label: String, color: Color, textColor: Color) { } @Composable -private fun ColumnHeader(white: Color) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) - ) { - Text( - "Before", - color = white.copy(alpha = 0.6f), - fontSize = 12.sp, - modifier = Modifier.weight(1f) - ) - Spacer(Modifier.width(8.dp)) - Text( - "After", - color = white.copy(alpha = 0.6f), - fontSize = 12.sp, - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -private fun ScriptHeaderRow(row: DiffRow, accent: Color) { +private fun ScriptHeaderRow(row: DiffRow, context: Context, accent: Color) { val brick = row.new ?: row.old - val title = brick?.let { humanizeBrickName(it.javaClass.simpleName) } ?: "Script" + val title = brick?.let { scriptHeaderTitle(it, context) } ?: "Script" val tint = statusColor(row.status) Box( modifier = Modifier @@ -270,43 +258,191 @@ private fun ScriptHeaderRow(row: DiffRow, accent: Color) { ) .padding(horizontal = 12.dp, vertical = 8.dp) ) { - Text(title, color = accent, fontWeight = FontWeight.Bold, fontSize = 14.sp) + Row(verticalAlignment = Alignment.CenterVertically) { + statusIcon(row.status)?.let { iconRes -> + Icon( + painter = painterResource(iconRes), + contentDescription = statusLabel(row.status), + tint = tint ?: accent, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + } + Text(title, color = accent, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } } } @Composable -private fun BrickDiffRow(row: DiffRow, context: Context, white: Color, accent: Color) { - val tint = statusColor(row.status) +private fun BrickDiffRow( + row: DiffRow, + context: Context, + white: Color, + accent: Color, + onClick: () -> Unit +) { + when (row.status) { + DiffStatus.UNCHANGED -> UnchangedDiffRow(row, context, white, accent, onClick) + DiffStatus.MODIFIED -> statusColor(row.status)?.let { + ModifiedDiffRow(row, context, it, white, accent, onClick) + } + // ADDED / REMOVED: a single full-width block, like a natural script line. + else -> statusColor(row.status)?.let { + SingleDiffRow(row, context, it, white, accent, onClick) + } + } +} + +/** + * GitHub-style row: a 10% status tint over the page + a solid left stripe + a leading status icon, + * shared by every changed row. + */ +@Composable +private fun StatusContainer( + tint: Color, + iconRes: Int?, + iconDesc: String?, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) - .background(tint?.copy(alpha = 0.28f) ?: Color.Transparent) - .padding(horizontal = 8.dp, vertical = 8.dp), + .clickable(onClick = onClick) + .background(tint.copy(alpha = ROW_TINT_ALPHA)) // 10% status tint, GitHub diff style + .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically ) { - BrickCell(row.old, context, white, accent, Modifier.weight(1f)) + // Solid status stripe, flush to the rounded-left edge. + Box( + modifier = Modifier + .width(STRIPE_WIDTH) + .fillMaxHeight() + .background(tint) + ) + Spacer(Modifier.width(8.dp)) + if (iconRes != null) { + Box(modifier = Modifier.size(18.dp), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(iconRes), + contentDescription = iconDesc, + tint = tint, + modifier = Modifier.size(16.dp) + ) + } + Spacer(Modifier.width(6.dp)) + } + content() Spacer(Modifier.width(8.dp)) - BrickCell(row.new, context, white, accent, Modifier.weight(1f)) } } @Composable -private fun BrickCell( - brick: Brick?, +private fun SingleDiffRow( + row: DiffRow, context: Context, + tint: Color, white: Color, accent: Color, - modifier: Modifier + onClick: () -> Unit ) { - if (brick == null) { - Text("—", color = white.copy(alpha = 0.35f), modifier = modifier) - return + val brick = row.new ?: row.old ?: return + StatusContainer(tint, statusIcon(row.status), statusLabel(row.status), onClick) { + BrickContent(brick, context, white, accent, Modifier.weight(1f)) } - Column(modifier = modifier) { +} + +@Composable +private fun ModifiedDiffRow( + row: DiffRow, + context: Context, + tint: Color, + white: Color, + accent: Color, + onClick: () -> Unit +) { + val newBrick = row.new ?: return + StatusContainer(tint, statusIcon(row.status), statusLabel(row.status), onClick) { + Column(modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp)) { + Text( + text = humanizeBrickName(newBrick.javaClass.simpleName), + color = white, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val newSubtitle = brickSubtitle(newBrick, context) + if (newSubtitle != null) { + // Old → New values flow naturally (weight fill = false) for breathing room. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = (row.old?.let { brickSubtitle(it, context) }).orEmpty(), + color = white.copy(alpha = 0.45f), // faded old value + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = white.copy(alpha = 0.5f), + modifier = Modifier + .padding(horizontal = 8.dp) + .size(14.dp) + ) + Text( + text = changedSubtitleAnnotated(row.old, newBrick, context), + color = accent, // crisp new value, changed token bold + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } + } + } + } +} + +@Composable +private fun UnchangedDiffRow( + row: DiffRow, + context: Context, + white: Color, + accent: Color, + onClick: () -> Unit +) { + val brick = row.new ?: row.old ?: return + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(start = CONTENT_INDENT, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BrickContent(brick, context, white, accent, Modifier.weight(1f)) + } +} + +/** Label + optional value subtitle, the standard light-text Pocket Code cell. */ +@Composable +private fun BrickContent( + brick: Brick, + context: Context, + labelColor: Color, + valueColor: Color, + modifier: Modifier +) { + Column(modifier = modifier.padding(vertical = 8.dp)) { Text( text = humanizeBrickName(brick.javaClass.simpleName), - color = white, + color = labelColor, fontWeight = FontWeight.Medium, fontSize = 14.sp, maxLines = 2, @@ -316,7 +452,7 @@ private fun BrickCell( if (subtitle != null) { Text( text = subtitle, - color = accent, + color = valueColor, fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis @@ -324,180 +460,3 @@ private fun BrickCell( } } } - -@Composable -private fun statusColor(status: DiffStatus): Color? = when (status) { - DiffStatus.ADDED -> colorResource(R.color.brick_color_green) - DiffStatus.REMOVED -> colorResource(R.color.brick_color_red) - DiffStatus.MODIFIED -> colorResource(R.color.brick_color_yellow) - DiffStatus.UNCHANGED -> null -} - -private fun isScriptHeaderRow(row: DiffRow): Boolean = - (row.new ?: row.old) is ScriptBrick - -/** - * Flattens both sprites to a single ordered brick list each (script heads, nested bricks, else/end - * markers — exactly what the editor shows), aligns them with an LCS so unchanged bricks line up on the - * same row, then merges a removed-then-added pair of the same brick type into a single MODIFIED row. - */ -private fun buildDiffRows(oldSprite: Sprite, newSprite: Sprite, context: Context): List { - val oldFlat = flatten(oldSprite) - val newFlat = flatten(newSprite) - val signaturesOld = oldFlat.map { brickSignature(it, context) } - val signaturesNew = newFlat.map { brickSignature(it, context) } - - val n = oldFlat.size - val m = newFlat.size - val lcs = Array(n + 1) { IntArray(m + 1) } - for (i in n - 1 downTo 0) { - for (j in m - 1 downTo 0) { - lcs[i][j] = if (signaturesOld[i] == signaturesNew[j]) { - lcs[i + 1][j + 1] + 1 - } else { - maxOf(lcs[i + 1][j], lcs[i][j + 1]) - } - } - } - - val aligned = mutableListOf() - var i = 0 - var j = 0 - while (i < n && j < m) { - when { - signaturesOld[i] == signaturesNew[j] -> { - aligned.add(DiffRow(oldFlat[i], newFlat[j], DiffStatus.UNCHANGED)); i++; j++ - } - - lcs[i + 1][j] >= lcs[i][j + 1] -> { - aligned.add(DiffRow(oldFlat[i], null, DiffStatus.REMOVED)); i++ - } - - else -> { - aligned.add(DiffRow(null, newFlat[j], DiffStatus.ADDED)); j++ - } - } - } - while (i < n) aligned.add(DiffRow(oldFlat[i++], null, DiffStatus.REMOVED)) - while (j < m) aligned.add(DiffRow(null, newFlat[j++], DiffStatus.ADDED)) - - return reconcileChangeBlocks(aligned) -} - -/** - * Pairs in-place edits as MODIFIED. The LCS emits all removes of a change region before all adds, so a - * removed brick and its matching added brick (same class, changed value) are usually not adjacent. We - * therefore reconcile per **change block** — a maximal run of consecutive non-UNCHANGED rows (UNCHANGED - * rows are anchors that also keep script boundaries intact) — pairing each removed brick with the first - * unused added brick of the same class. - */ -private fun reconcileChangeBlocks(rows: List): List { - val out = mutableListOf() - var k = 0 - while (k < rows.size) { - if (rows[k].status == DiffStatus.UNCHANGED) { - out.add(rows[k]) - k++ - continue - } - val block = mutableListOf() - while (k < rows.size && rows[k].status != DiffStatus.UNCHANGED) { - block.add(rows[k]) - k++ - } - out.addAll(reconcileBlock(block)) - } - return out -} - -private fun reconcileBlock(block: List): List { - val removed = block.filter { it.status == DiffStatus.REMOVED } - val added = block.filter { it.status == DiffStatus.ADDED } - val usedAdded = BooleanArray(added.size) - val result = mutableListOf() - - // Removed bricks first (in original order): MODIFIED if a same-class added brick is still available. - for (r in removed) { - val matchIdx = added.indices.firstOrNull { a -> - !usedAdded[a] && added[a].new?.javaClass == r.old?.javaClass - } - if (matchIdx != null) { - usedAdded[matchIdx] = true - result.add(DiffRow(r.old, added[matchIdx].new, DiffStatus.MODIFIED)) - } else { - result.add(r) - } - } - // Leftover purely-added bricks, in original order. - for (a in added.indices) { - if (!usedAdded[a]) { - result.add(added[a]) - } - } - return result -} - -private fun flatten(sprite: Sprite): List { - val list = mutableListOf() - for (script in sprite.scriptList) { - try { - script.addToFlatList(list) - } catch (e: Exception) { - Log.e( - AiTutorDiffScreen.TAG, - "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${e.message}", - e - ) - // Defensive: a malformed script shouldn't crash the preview. - } - } - return list -} - -private fun brickSignature(brick: Brick, context: Context): String { - val sb = StringBuilder(brick.javaClass.simpleName) - if (brick is FormulaBrick) { - val map = brick.formulaMap - for (field in map.keys.sortedBy { it.toString() }) { - sb.append('|').append(field.toString()).append('=') - .append(formulaText(brick, field, context)) - } - } - return sb.toString() -} - -private fun brickSubtitle(brick: Brick, context: Context): String? { - if (brick !is FormulaBrick) return null - val map = brick.formulaMap - val parts = map.keys.sortedBy { it.toString() }.mapNotNull { field -> - val value = formulaText(brick, field, context) - if (value.isBlank()) null else "${humanizeField(field.toString())}: $value" - } - return if (parts.isEmpty()) null else parts.joinToString(", ") -} - -private fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = - try { - brick.formulaMap[field]?.getTrimmedFormulaString(context).orEmpty() - } catch (e: Exception) { - Log.e( - AiTutorDiffScreen.TAG, - "Error getting formula text for ${brick.javaClass.simpleName} field $field", - e - ) - "" - } - -private fun humanizeBrickName(simpleName: String): String { - val base = simpleName.removeSuffix("Brick") - if (base.isEmpty()) return simpleName - return base - .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") - .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") - .trim() -} - -private fun humanizeField(fieldName: String): String = - fieldName.split('_').joinToString(" ") { part -> - part.lowercase().replaceFirstChar { it.uppercase() } - } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt new file mode 100644 index 00000000000..d88418b60a0 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt @@ -0,0 +1,168 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import org.catrobat.catroid.R +import org.catrobat.catroid.content.bricks.Brick + +@Composable +internal fun BrickDiffDialog( + row: DiffRow, + context: Context, + white: Color, + accent: Color, + actionColor: Color, + onDismiss: () -> Unit +) { + val brick = row.new ?: row.old ?: return + val tint = statusColor(row.status) + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(12.dp), + color = colorResource(R.color.button_background) + ) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(20.dp)) { + Text( + text = humanizeBrickName(brick.javaClass.simpleName), + color = white, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Text( + text = statusLabel(row.status), + color = tint ?: accent, + fontWeight = FontWeight.Medium, + fontSize = 13.sp + ) + Spacer(Modifier.height(16.dp)) + + DialogSection( + "Before", + row.old, + null, + "Not in the original", + context, + white, + accent + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = white.copy(alpha = 0.5f), + modifier = Modifier + .size(18.dp) + .rotate(90f) + ) + } + DialogSection("After", row.new, row.old, "Removed", context, white, accent) + + Spacer(Modifier.height(20.dp)) + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = actionColor, + contentColor = white + ) + ) { Text("Close", fontWeight = FontWeight.Bold) } + } + } + } +} + +@Composable +private fun DialogSection( + label: String, + brick: Brick?, + oldForDiff: Brick?, + emptyText: String, + context: Context, + white: Color, + accent: Color +) { + Text(label, color = white.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(4.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(colorResource(R.color.app_background)) + .padding(12.dp) + ) { + if (brick == null) { + Text(emptyText, color = white.copy(alpha = 0.5f), fontSize = 14.sp) + } else { + Text( + text = humanizeBrickName(brick.javaClass.simpleName), + color = white, + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + for (token in brickValueTokens(brick, context, oldForDiff)) { + Text( + text = token.text, + color = accent, + fontWeight = if (token.changed) FontWeight.Bold else FontWeight.Normal, + fontSize = 13.sp, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt new file mode 100644 index 00000000000..318c8273c39 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt @@ -0,0 +1,162 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import android.util.Log +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.BrickBaseType +import org.catrobat.catroid.content.bricks.FormulaBrick +import org.catrobat.catroid.content.bricks.UserDataBrick +import org.catrobat.catroid.content.bricks.UserListBrick +import org.catrobat.catroid.content.bricks.UserVariableBrickInterface + +/** Names of the variables/lists a brick selects (stored outside the formula map). */ +internal fun dataNames(brick: Brick): List { + val names = mutableListOf() + (brick as? UserVariableBrickInterface)?.userVariable?.name?.let(names::add) + (brick as? UserListBrick)?.userList?.name?.let(names::add) + (brick as? UserDataBrick)?.userDataMap?.values?.forEach { it?.name?.let(names::add) } + return names.filter { it.isNotBlank() } +} + +/** + * The displayable value chunks for a brick: the selected variable/list name(s) and the formula + * field values. When [oldBrick] is given, each token is flagged [DiffToken.changed] if it differs. + * A plain "set/change a variable" brick (one data name + one formula field) is merged into a single + * ": " token so the name shows instead of the misleading "Variable" field label. + */ +internal fun brickValueTokens(brick: Brick, context: Context, oldBrick: Brick?): List { + val names = dataNames(brick) + val oldNames = oldBrick?.let { dataNames(it) } ?: emptyList() + val oldFormula = oldBrick as? FormulaBrick + val fields = + (brick as? FormulaBrick)?.formulaMap?.keys?.sortedBy { it.toString() } ?: emptyList() + + if (names.size == 1 && fields.size == 1 && brick is FormulaBrick) { + val field = fields.first() + val value = formulaText(brick, field, context) + val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } + ?.let { formulaText(it, field, context) } + val changed = + oldBrick != null && (names.first() != oldNames.firstOrNull() || value != oldValue) + return listOf(DiffToken("${names.first()}: $value", changed)) + } + + val tokens = mutableListOf() + names.forEachIndexed { index, name -> + tokens.add(DiffToken(name, oldBrick != null && oldNames.getOrNull(index) != name)) + } + if (brick is FormulaBrick) { + for (field in fields) { + val value = formulaText(brick, field, context) + if (value.isBlank()) continue + val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } + ?.let { formulaText(it, field, context) } + tokens.add( + DiffToken( + "${humanizeField(field.toString())}: $value", + oldBrick != null && oldValue != value + ) + ) + } + } + return tokens +} + +internal fun brickSubtitle(brick: Brick, context: Context): String? { + val tokens = brickValueTokens(brick, context, null) + return if (tokens.isEmpty()) null else tokens.joinToString(", ") { it.text } +} + +/** + * The new brick's value subtitle as an [AnnotatedString], with each [DiffToken] that changed from + * [oldBrick] rendered in bold (the highlighted token). + */ +internal fun changedSubtitleAnnotated( + oldBrick: Brick?, + newBrick: Brick, + context: Context +): AnnotatedString = buildAnnotatedString { + brickValueTokens(newBrick, context, oldBrick).forEachIndexed { index, token -> + if (index > 0) append(", ") + if (token.changed) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(token.text) } + } else { + append(token.text) + } + } +} + +internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = + try { + brick.formulaMap[field]?.getTrimmedFormulaString(context).orEmpty() + } catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error getting formula text for ${brick.javaClass.simpleName} field $field", + e + ) + "" + } + +internal fun humanizeBrickName(simpleName: String): String { + val base = simpleName.removeSuffix("Brick") + if (base.isEmpty()) return simpleName + return base + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") + .trim() +} + +/** + * The real editor label for a script-header brick (e.g. "When scene starts", "When tapped"). + * A brick's layout resource name matches its label string resource name, so we resolve the layout + * via [BrickBaseType.getViewResource] and look up the same-named string. Falls back to the + * humanized class name for anything unrecognized. + */ +internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { + val layoutId = (brick as? BrickBaseType)?.getViewResource() + ?: return humanizeBrickName(brick.javaClass.simpleName) + val entryName = context.resources.getResourceEntryName(layoutId) + val stringId = context.resources.getIdentifier(entryName, "string", context.packageName) + if (stringId != 0) context.getString(stringId) else humanizeBrickName(brick.javaClass.simpleName) +} catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error resolving script header title for ${brick.javaClass.simpleName}", + e + ) + humanizeBrickName(brick.javaClass.simpleName) +} + +private fun humanizeField(fieldName: String): String = + fieldName.split('_').joinToString(" ") { part -> + part.lowercase().replaceFirstChar { it.uppercase() } + } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt new file mode 100644 index 00000000000..45fb1c2afc0 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt @@ -0,0 +1,35 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import org.catrobat.catroid.content.bricks.Brick + +enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } + +internal data class DiffRow(val old: Brick?, val new: Brick?, val status: DiffStatus) + +/** A single value chunk shown for a brick (e.g. "playerY: 0"), flagged if it changed vs the old brick. */ +internal data class DiffToken(val text: String, val changed: Boolean) + +internal const val DIFF_TAG = "AiTutorDiffScreen" diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt new file mode 100644 index 00000000000..33dc17d5ee5 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt @@ -0,0 +1,57 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import org.catrobat.catroid.R + +internal val STRIPE_WIDTH = 4.dp +internal const val ROW_TINT_ALPHA = 0.28f + +internal val CONTENT_INDENT = 12.dp + +@Composable +internal fun statusColor(status: DiffStatus): Color? = when (status) { + DiffStatus.ADDED -> colorResource(R.color.brick_color_green) + DiffStatus.REMOVED -> colorResource(R.color.brick_color_red) + DiffStatus.MODIFIED -> colorResource(R.color.brick_color_yellow) + DiffStatus.UNCHANGED -> null +} + +internal fun statusIcon(status: DiffStatus): Int? = when (status) { + DiffStatus.ADDED -> R.drawable.ic_plus + DiffStatus.REMOVED -> R.drawable.ic_minus + DiffStatus.MODIFIED -> R.drawable.ic_edit_pencil + DiffStatus.UNCHANGED -> null +} + +internal fun statusLabel(status: DiffStatus): String = when (status) { + DiffStatus.ADDED -> "Added" + DiffStatus.REMOVED -> "Removed" + DiffStatus.MODIFIED -> "Modified" + DiffStatus.UNCHANGED -> "Unchanged" +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt new file mode 100644 index 00000000000..1510bb775de --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt @@ -0,0 +1,168 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import android.util.Log +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.FormulaBrick +import org.catrobat.catroid.content.bricks.ScriptBrick + +internal fun isScriptHeaderRow(row: DiffRow): Boolean = + (row.new ?: row.old) is ScriptBrick + +/** + * Flattens both sprites to a single ordered brick list each (script heads, nested bricks, else/end + * markers — exactly what the editor shows), aligns them with an LCS so unchanged bricks line up on the + * same row, then merges a removed-then-added pair of the same brick type into a single MODIFIED row. + */ +internal fun buildDiffRows(oldSprite: Sprite, newSprite: Sprite, context: Context): List { + val oldFlat = flatten(oldSprite) + val newFlat = flatten(newSprite) + val signaturesOld = oldFlat.map { brickSignature(it, context) } + val signaturesNew = newFlat.map { brickSignature(it, context) } + + val n = oldFlat.size + val m = newFlat.size + val lcs = Array(n + 1) { IntArray(m + 1) } + for (i in n - 1 downTo 0) { + for (j in m - 1 downTo 0) { + lcs[i][j] = if (signaturesOld[i] == signaturesNew[j]) { + lcs[i + 1][j + 1] + 1 + } else { + maxOf(lcs[i + 1][j], lcs[i][j + 1]) + } + } + } + + val aligned = mutableListOf() + var i = 0 + var j = 0 + while (i < n && j < m) { + when { + signaturesOld[i] == signaturesNew[j] -> { + aligned.add(DiffRow(oldFlat[i], newFlat[j], DiffStatus.UNCHANGED)); i++; j++ + } + + lcs[i + 1][j] >= lcs[i][j + 1] -> { + aligned.add(DiffRow(oldFlat[i], null, DiffStatus.REMOVED)); i++ + } + + else -> { + aligned.add(DiffRow(null, newFlat[j], DiffStatus.ADDED)); j++ + } + } + } + while (i < n) aligned.add(DiffRow(oldFlat[i++], null, DiffStatus.REMOVED)) + while (j < m) aligned.add(DiffRow(null, newFlat[j++], DiffStatus.ADDED)) + + return reconcileChangeBlocks(aligned) +} + +/** + * Pairs in-place edits as MODIFIED. The LCS emits all removes of a change region before all adds, so a + * removed brick and its matching added brick (same class, changed value) are usually not adjacent. We + * therefore reconcile per **change block** — a maximal run of consecutive non-UNCHANGED rows (UNCHANGED + * rows are anchors that also keep script boundaries intact) — pairing each removed brick with the first + * unused added brick of the same class. + */ +private fun reconcileChangeBlocks(rows: List): List { + val out = mutableListOf() + var k = 0 + while (k < rows.size) { + if (rows[k].status == DiffStatus.UNCHANGED) { + out.add(rows[k]) + k++ + continue + } + val block = mutableListOf() + while (k < rows.size && rows[k].status != DiffStatus.UNCHANGED) { + block.add(rows[k]) + k++ + } + out.addAll(reconcileBlock(block)) + } + return out +} + +private fun reconcileBlock(block: List): List { + val removed = block.filter { it.status == DiffStatus.REMOVED } + val added = block.filter { it.status == DiffStatus.ADDED } + val usedAdded = BooleanArray(added.size) + val result = mutableListOf() + + // Removed bricks first (in original order): MODIFIED if a same-class added brick is still available. + for (r in removed) { + val matchIdx = added.indices.firstOrNull { a -> + !usedAdded[a] && added[a].new?.javaClass == r.old?.javaClass + } + if (matchIdx != null) { + usedAdded[matchIdx] = true + result.add(DiffRow(r.old, added[matchIdx].new, DiffStatus.MODIFIED)) + } else { + result.add(r) + } + } + // Leftover purely-added bricks, in original order. + for (a in added.indices) { + if (!usedAdded[a]) { + result.add(added[a]) + } + } + return result +} + +private fun flatten(sprite: Sprite): List { + val list = mutableListOf() + for (script in sprite.scriptList) { + try { + script.addToFlatList(list) + } catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${e.message}", + e + ) + // Defensive: a malformed script shouldn't crash the preview. + } + } + return list +} + +private fun brickSignature(brick: Brick, context: Context): String { + val sb = StringBuilder(brick.javaClass.simpleName) + // Include the selected variable/list names so changing only the data selection is a real change. + for (name in dataNames(brick)) { + sb.append("|data=").append(name) + } + if (brick is FormulaBrick) { + val map = brick.formulaMap + for (field in map.keys.sortedBy { it.toString() }) { + sb.append('|').append(field.toString()).append('=') + .append(formulaText(brick, field, context)) + } + } + return sb.toString() +} From e3e952d27a26255d82067f94e59cecd312d64489 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 17 Jun 2026 02:29:57 +0700 Subject: [PATCH 33/41] Add Compose UI tooling dependencies for preview --- catroid/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/catroid/build.gradle b/catroid/build.gradle index e4d7a5b8212..a31440b6df8 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -482,6 +482,8 @@ dependencies { implementation "androidx.compose.runtime:runtime:1.7.5" implementation "androidx.compose.foundation:foundation:1.7.5" implementation "androidx.compose.material3:material3:1.3.1" + implementation "androidx.compose.ui:ui-tooling-preview:1.7.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.7.5" androidTestImplementation('tools.fastlane:screengrab:2.1.1') { // https://issuetracker.google.com/issues/123060356 From c90c2da11b4444355b016f50044d1c80cb473ddc Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 17 Jun 2026 02:30:50 +0700 Subject: [PATCH 34/41] Enhance AiTutorDiffScreen UI and improve handling of long formula changes - Implement a responsive `ModifiedValues` composable that switches from a horizontal to a vertical stacked layout for long text changes - Use `TextMeasurer` and `onSizeChanged` to dynamically calculate text width and determine the optimal layout for modified brick values - Replace the static legend `Row` with a `FlowRow` to better handle screen width constraints - Update `LegendChip` to include status-specific icons and improved styling for the unchanged state - Add visual borders and refine padding for unchanged brick rows to improve visual distinction - Add vertical spacing and a `Spacer` before script headers for better grouping in the diff list - Introduce Compose previews for "Changes" and "No changes" states using sample sprite data --- .../ui/aiassist/diff/AiTutorDiffScreen.kt | 250 ++++++++++++++---- 1 file changed, 205 insertions(+), 45 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt index ff4e1d64ee4..06764fa8aad 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -56,24 +57,41 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.catrobat.catroid.R +import org.catrobat.catroid.content.Script import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.StartScript +import org.catrobat.catroid.content.WhenScript import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.ChangeXByNBrick +import org.catrobat.catroid.content.bricks.HideBrick +import org.catrobat.catroid.content.bricks.SetXBrick +import org.catrobat.catroid.content.bricks.SetYBrick +import org.catrobat.catroid.content.bricks.WaitBrick +import org.catrobat.catroid.formulaeditor.Formula import org.catrobat.catroid.io.XstreamSerializer @OptIn(ExperimentalMaterial3Api::class) @@ -169,12 +187,12 @@ fun AiTutorDiffScreen( .padding(padding) .padding(horizontal = 12.dp), contentPadding = PaddingValues(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { SummaryAndLegend(added, removed, modified, white, accent) } - item { Spacer(Modifier.height(0.dp)) } itemsIndexed(rows) { _, row -> if (isScriptHeaderRow(row)) { + Spacer(Modifier.height(8.dp)) ScriptHeaderRow(row, context, accent) } else { BrickDiffRow(row, context, white, accent) { selected = row } @@ -217,25 +235,51 @@ private fun SummaryAndLegend( fontSize = 15.sp ) Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - LegendChip("Added", colorResource(R.color.brick_color_green), white) - LegendChip("Removed", colorResource(R.color.brick_color_red), white) - LegendChip("Modified", colorResource(R.color.brick_color_yellow), white) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LegendChip(DiffStatus.ADDED, white) + LegendChip(DiffStatus.REMOVED, white) + LegendChip(DiffStatus.MODIFIED, white) + LegendChip(DiffStatus.UNCHANGED, white) } } } @Composable -private fun LegendChip(label: String, color: Color, textColor: Color) { +private fun LegendChip(status: DiffStatus, textColor: Color) { + val color = statusColor(status) + val iconRes = statusIcon(status) Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier - .size(12.dp) - .clip(RoundedCornerShape(3.dp)) - .background(color) - ) + .size(16.dp) + .clip(RoundedCornerShape(4.dp)) + .then( + if (color != null) { + Modifier.background(color) + } else { + Modifier.border( + 1.dp, + colorResource(R.color.button_background), + RoundedCornerShape(4.dp) + ) + } + ), + contentAlignment = Alignment.Center + ) { + if (iconRes != null) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, // the label text already names the status + tint = textColor, + modifier = Modifier.size(11.dp) + ) + } + } Spacer(Modifier.width(4.dp)) - Text(label, color = textColor, fontSize = 12.sp) + Text(statusLabel(status), color = textColor, fontSize = 12.sp) } } @@ -286,7 +330,7 @@ private fun BrickDiffRow( DiffStatus.MODIFIED -> statusColor(row.status)?.let { ModifiedDiffRow(row, context, it, white, accent, onClick) } - // ADDED / REMOVED: a single full-width block, like a natural script line. + // ADDED / REMOVED else -> statusColor(row.status)?.let { SingleDiffRow(row, context, it, white, accent, onClick) } @@ -364,9 +408,11 @@ private fun ModifiedDiffRow( ) { val newBrick = row.new ?: return StatusContainer(tint, statusIcon(row.status), statusLabel(row.status), onClick) { - Column(modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp)) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + ) { Text( text = humanizeBrickName(newBrick.javaClass.simpleName), color = white, @@ -377,33 +423,100 @@ private fun ModifiedDiffRow( ) val newSubtitle = brickSubtitle(newBrick, context) if (newSubtitle != null) { - // Old → New values flow naturally (weight fill = false) for breathing room. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = (row.old?.let { brickSubtitle(it, context) }).orEmpty(), - color = white.copy(alpha = 0.45f), // faded old value - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - Icon( - painter = painterResource(R.drawable.ic_arrow_forward_vector), - contentDescription = null, - tint = white.copy(alpha = 0.5f), - modifier = Modifier - .padding(horizontal = 8.dp) - .size(14.dp) - ) - Text( - text = changedSubtitleAnnotated(row.old, newBrick, context), - color = accent, // crisp new value, changed token bold - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - } + ModifiedValues( + oldText = row.old?.let { brickSubtitle(it, context) }.orEmpty(), + newText = changedSubtitleAnnotated(row.old, newBrick, context), + oldColor = white.copy(alpha = 0.45f), // faded old value + newColor = accent, // crisp new value, changed token bold + arrowColor = white.copy(alpha = 0.5f) + ) + } + } + } +} + +@Composable +private fun ModifiedValues( + oldText: String, + newText: AnnotatedString, + oldColor: Color, + newColor: Color, + arrowColor: Color +) { + val measurer = rememberTextMeasurer() + val style = TextStyle(fontSize = 12.sp) + val density = LocalDensity.current + val arrowSpace = with(density) { (14.dp + 16.dp).roundToPx() } // icon + 8.dp padding each side + + val oldWidth = + remember(oldText) { measurer.measure(AnnotatedString(oldText), style).size.width } + val newWidth = remember(newText) { measurer.measure(newText, style).size.width } + + var rowWidth by remember { mutableIntStateOf(0) } + val half = (rowWidth - arrowSpace) / 2 + val sideBySide = rowWidth == 0 || (oldWidth <= half && newWidth <= half) + + Box( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { rowWidth = it.width }) { + if (sideBySide) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = oldText, + color = oldColor, + fontSize = 12.sp, + maxLines = 1, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = arrowColor, + modifier = Modifier + .padding(horizontal = 8.dp) + .size(14.dp) + ) + Text( + text = newText, + color = newColor, + fontSize = 12.sp, + maxLines = 1, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f) + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = oldText, + color = oldColor, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = arrowColor, + modifier = Modifier + .padding(vertical = 8.dp) + .size(14.dp) + .rotate(90f) // → becomes ↓ when stacked + ) + Text( + text = newText, + color = newColor, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) } } } @@ -422,15 +535,15 @@ private fun UnchangedDiffRow( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) + .border(1.dp, colorResource(R.color.button_background), RoundedCornerShape(6.dp)) .clickable(onClick = onClick) - .padding(start = CONTENT_INDENT, top = 4.dp, bottom = 4.dp), + .padding(horizontal = CONTENT_INDENT, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { BrickContent(brick, context, white, accent, Modifier.weight(1f)) } } -/** Label + optional value subtitle, the standard light-text Pocket Code cell. */ @Composable private fun BrickContent( brick: Brick, @@ -460,3 +573,50 @@ private fun BrickContent( } } } + +private fun spriteOf(name: String, vararg scripts: Script): Sprite = Sprite(name).apply { + scripts.forEach { addScript(it) } +} + +private fun startScript(vararg bricks: Brick): Script = + StartScript().apply { bricks.forEach { addBrick(it) } } + +private fun whenScript(vararg bricks: Brick): Script = + WhenScript().apply { bricks.forEach { addBrick(it) } } + +@Preview(name = "Changes", showBackground = true) +@Composable +private fun AiTutorDiffScreenPreview() { + val longOld = Formula("playerStartingHorizontalPositionBeforeOffset") + val longNew = Formula("playerComputedHorizontalPositionAfterApplyingOffsetAndClamp") + val current = spriteOf( + "current", + // SetX 0→100 (short modified), Wait unchanged, SetY 10→200 (short modified), ChangeX removed. + startScript(SetXBrick(0), WaitBrick(1000), SetYBrick(10), ChangeXByNBrick(10)), + // SetX long→long (long modified, stacks), Hide unchanged, SetY 50 added. + whenScript(SetXBrick(longOld), HideBrick()) + ) + val proposed = spriteOf( + "proposed", + startScript(SetXBrick(100), WaitBrick(1000), SetYBrick(200)), + whenScript(SetXBrick(longNew), HideBrick(), SetYBrick(50)) + ) + AiTutorDiffScreen( + currentSprite = current, + newSpriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(proposed), + onAccept = {}, + onReject = {} + ) +} + +@Preview(name = "No changes", showBackground = true) +@Composable +private fun AiTutorDiffScreenNoChangesPreview() { + val sprite = spriteOf("sprite", startScript(SetXBrick(100), WaitBrick(1000))) + AiTutorDiffScreen( + currentSprite = sprite, + newSpriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite), + onAccept = {}, + onReject = {} + ) +} From 9df9503538f9cc85f931ae208e10a92de3247cca Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 17 Jun 2026 03:23:33 +0700 Subject: [PATCH 35/41] Enhance AiTutorDiffScreen with script header interaction and improved parameter naming - Add `onClick` support to `ScriptHeaderRow` to allow selecting script headers in the diff view - Refactor `AiTutorDiffScreen` and its sub-composables to use named arguments for improved readability - Update `ScriptHeaderRow` to include a `clickable` modifier and standardized styling - Remove unused `context` parameter from the `BrickDiffDialog` call - Standardize parameter usage across `StatusContainer`, `BrickContent`, and `SummaryAndLegend` components --- .../ui/aiassist/diff/AiTutorDiffScreen.kt | 96 +++++++++++++++---- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt index 06764fa8aad..89878856aae 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt @@ -189,13 +189,32 @@ fun AiTutorDiffScreen( contentPadding = PaddingValues(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - item { SummaryAndLegend(added, removed, modified, white, accent) } + item { + SummaryAndLegend( + added = added, + removed = removed, + modified = modified, + white = white, + accent = accent + ) + } itemsIndexed(rows) { _, row -> if (isScriptHeaderRow(row)) { - Spacer(Modifier.height(8.dp)) - ScriptHeaderRow(row, context, accent) + Spacer(modifier = Modifier.height(8.dp)) + ScriptHeaderRow( + row = row, + context = context, + accent = accent, + onClick = { selected = row } + ) } else { - BrickDiffRow(row, context, white, accent) { selected = row } + BrickDiffRow( + row = row, + context = context, + white = white, + accent = accent, + onClick = { selected = row } + ) } } } @@ -203,7 +222,13 @@ fun AiTutorDiffScreen( } selected?.let { row -> - BrickDiffDialog(row, context, white, accent, actionColor) { selected = null } + BrickDiffDialog( + row = row, + white = white, + accent = accent, + actionColor = actionColor, + onDismiss = { selected = null } + ) } } @@ -284,20 +309,21 @@ private fun LegendChip(status: DiffStatus, textColor: Color) { } @Composable -private fun ScriptHeaderRow(row: DiffRow, context: Context, accent: Color) { +private fun ScriptHeaderRow(row: DiffRow, context: Context, accent: Color, onClick: () -> Unit) { val brick = row.new ?: row.old val title = brick?.let { scriptHeaderTitle(it, context) } ?: "Script" val tint = statusColor(row.status) Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(colorResource(R.color.button_background)) + .clip(RoundedCornerShape(size = 6.dp)) + .clickable(onClick = onClick) + .background(colorResource(id = R.color.button_background)) .then( if (tint != null) Modifier.border( 2.dp, tint, - RoundedCornerShape(6.dp) + RoundedCornerShape(size = 6.dp) ) else Modifier ) .padding(horizontal = 12.dp, vertical = 8.dp) @@ -326,21 +352,37 @@ private fun BrickDiffRow( onClick: () -> Unit ) { when (row.status) { - DiffStatus.UNCHANGED -> UnchangedDiffRow(row, context, white, accent, onClick) + DiffStatus.UNCHANGED -> UnchangedDiffRow( + row = row, + context = context, + white = white, + accent = accent, + onClick = onClick + ) DiffStatus.MODIFIED -> statusColor(row.status)?.let { - ModifiedDiffRow(row, context, it, white, accent, onClick) + ModifiedDiffRow( + row = row, + context = context, + tint = it, + white = white, + accent = accent, + onClick = onClick + ) } // ADDED / REMOVED else -> statusColor(row.status)?.let { - SingleDiffRow(row, context, it, white, accent, onClick) + SingleDiffRow( + row = row, + context = context, + tint = it, + white = white, + accent = accent, + onClick = onClick + ) } } } -/** - * GitHub-style row: a 10% status tint over the page + a solid left stripe + a leading status icon, - * shared by every changed row. - */ @Composable private fun StatusContainer( tint: Color, @@ -392,8 +434,19 @@ private fun SingleDiffRow( onClick: () -> Unit ) { val brick = row.new ?: row.old ?: return - StatusContainer(tint, statusIcon(row.status), statusLabel(row.status), onClick) { - BrickContent(brick, context, white, accent, Modifier.weight(1f)) + StatusContainer( + tint = tint, + iconRes = statusIcon(row.status), + iconDesc = statusLabel(row.status), + onClick = onClick + ) { + BrickContent( + brick = brick, + context = context, + labelColor = white, + valueColor = accent, + modifier = Modifier.weight(1f) + ) } } @@ -407,7 +460,12 @@ private fun ModifiedDiffRow( onClick: () -> Unit ) { val newBrick = row.new ?: return - StatusContainer(tint, statusIcon(row.status), statusLabel(row.status), onClick) { + StatusContainer( + tint = tint, + iconRes = statusIcon(row.status), + iconDesc = statusLabel(row.status), + onClick = onClick + ) { Column( modifier = Modifier .weight(1f) From f6764e511aa35a58feecb376b0d373640b963390 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 17 Jun 2026 03:24:01 +0700 Subject: [PATCH 36/41] Refactor BrickDiffDialog to display native brick prototype views - Replace manual brick field rendering in `BrickDiffDialog` with an `AndroidView` that leverages existing `getPrototypeView` logic for a more accurate representation - Simplify `DialogSection` by removing redundant parameters such as `oldForDiff`, `accent`, and `context` - Add error handling and logging for prototype view generation to provide a fallback `TextView` if the view cannot be created - Refine UI layout and spacing by adjusting modifiers, padding, and alignment within the dialog components - Remove the dependency on `brickValueTokens` within the dialog, favoring the native brick view instead --- .../ui/aiassist/diff/BrickDiffDialog.kt | 91 +++++++++---------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt index d88418b60a0..f9b11025753 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt @@ -23,8 +23,8 @@ package org.catrobat.catroid.ui.aiassist.diff -import android.content.Context -import androidx.compose.foundation.background +import android.util.Log +import android.widget.TextView import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -41,7 +41,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource @@ -49,6 +48,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import org.catrobat.catroid.R import org.catrobat.catroid.content.bricks.Brick @@ -56,7 +56,6 @@ import org.catrobat.catroid.content.bricks.Brick @Composable internal fun BrickDiffDialog( row: DiffRow, - context: Context, white: Color, accent: Color, actionColor: Color, @@ -69,9 +68,11 @@ internal fun BrickDiffDialog( shape = RoundedCornerShape(12.dp), color = colorResource(R.color.button_background) ) { - Column(modifier = Modifier - .fillMaxWidth() - .padding(20.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { Text( text = humanizeBrickName(brick.javaClass.simpleName), color = white, @@ -87,13 +88,10 @@ internal fun BrickDiffDialog( Spacer(Modifier.height(16.dp)) DialogSection( - "Before", - row.old, - null, - "Not in the original", - context, - white, - accent + label = "Before", + brick = row.old, + emptyText = "Not in the original", + white = white ) Box( modifier = Modifier @@ -110,9 +108,14 @@ internal fun BrickDiffDialog( .rotate(90f) ) } - DialogSection("After", row.new, row.old, "Removed", context, white, accent) + DialogSection( + label = "After", + brick = row.new, + emptyText = "Removed", + white = white + ) - Spacer(Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(20.dp)) Button( onClick = onDismiss, modifier = Modifier.fillMaxWidth(), @@ -120,7 +123,7 @@ internal fun BrickDiffDialog( containerColor = actionColor, contentColor = white ) - ) { Text("Close", fontWeight = FontWeight.Bold) } + ) { Text(text = "Close", fontWeight = FontWeight.Bold) } } } } @@ -130,39 +133,35 @@ internal fun BrickDiffDialog( private fun DialogSection( label: String, brick: Brick?, - oldForDiff: Brick?, emptyText: String, - context: Context, - white: Color, - accent: Color + white: Color ) { Text(label, color = white.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.height(4.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colorResource(R.color.app_background)) - .padding(12.dp) - ) { - if (brick == null) { - Text(emptyText, color = white.copy(alpha = 0.5f), fontSize = 14.sp) - } else { - Text( - text = humanizeBrickName(brick.javaClass.simpleName), - color = white, - fontWeight = FontWeight.Medium, - fontSize = 15.sp - ) - for (token in brickValueTokens(brick, context, oldForDiff)) { - Text( - text = token.text, - color = accent, - fontWeight = if (token.changed) FontWeight.Bold else FontWeight.Normal, - fontSize = 13.sp, - modifier = Modifier.padding(top = 2.dp) - ) + if (brick == null) { + Text( + text = emptyText, + color = white.copy(alpha = 0.5f), + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + try { + brick.getPrototypeView(context) + } catch (e: Exception) { + Log.e( + "BrickDiffDialog", + "Couldn't create prototype view for brick ${brick.javaClass.simpleName}", + e + ) + TextView(context).apply { + text = humanizeBrickName(brick.javaClass.simpleName) + } + } } - } + ) } } From 274b171d65473f417d4630af6af40a8008865f8e Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Wed, 17 Jun 2026 14:17:56 +0700 Subject: [PATCH 37/41] Add ic_minus and ic_edit_pencil vector drawables --- .../src/main/res/drawable/ic_edit_pencil.xml | 33 +++++++++++++++++++ catroid/src/main/res/drawable/ic_minus.xml | 32 ++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 catroid/src/main/res/drawable/ic_edit_pencil.xml create mode 100644 catroid/src/main/res/drawable/ic_minus.xml diff --git a/catroid/src/main/res/drawable/ic_edit_pencil.xml b/catroid/src/main/res/drawable/ic_edit_pencil.xml new file mode 100644 index 00000000000..c568816360d --- /dev/null +++ b/catroid/src/main/res/drawable/ic_edit_pencil.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/catroid/src/main/res/drawable/ic_minus.xml b/catroid/src/main/res/drawable/ic_minus.xml new file mode 100644 index 00000000000..81b1651183a --- /dev/null +++ b/catroid/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,32 @@ + + + + + From 7ac1809505527dbc3635fa44f2fab958d1ee9afd Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Thu, 18 Jun 2026 18:26:33 +0700 Subject: [PATCH 38/41] Remove manual undo button visibility restoration from ScriptFragment - Remove logic in `onResume` that checks for the existence of the undo XML file to update the undo menu item visibility in `SpriteActivity` --- .../catroid/ui/recyclerview/fragment/ScriptFragment.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java index 381fe6da570..2bb20239138 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java @@ -407,15 +407,6 @@ public void onResume() { scrollToFocusItem(); SnackbarUtil.showHintSnackbar(getActivity(), R.string.hint_scripts); - - // Restore undo button visibility if a snapshot still exists (e.g., after returning from play). - Project resumeProject = ProjectManager.getInstance().getCurrentProject(); - if (resumeProject != null && getActivity() != null) { - File undoFile = new File(resumeProject.getDirectory(), UNDO_CODE_XML_FILE_NAME); - if (undoFile.exists()) { - ((SpriteActivity) getActivity()).setUndoMenuItemVisibility(true); - } - } } @Override From 8764772067f8e02515034c2b1d512cc533a4e824 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 29 Jun 2026 00:31:57 +0700 Subject: [PATCH 39/41] Restrict technical error details in AiTutorErrorDialog to debug builds - Wrap the technical reason display logic in `AiTutorErrorDialog` with a `BuildConfig.DEBUG` check to prevent showing internal error details in production environments. --- .../catroid/ui/aiassist/error/AiTutorErrorDialog.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt index a6d4e8041ba..a98edce3092 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.catrobat.catroid.BuildConfig import org.catrobat.catroid.R @Composable @@ -68,8 +69,10 @@ fun AiTutorErrorDialog( "A brick in the AI's response is missing or malformed, so it can't be added to " + "your project. You can ask the AI to fix it and paste the corrected sprite again." ) - Spacer(Modifier.height(12.dp)) - Text("Details: $technicalReason", color = errorColor) + if (BuildConfig.DEBUG) { + Spacer(Modifier.height(12.dp)) + Text("Details: $technicalReason", color = errorColor) + } } }, confirmButton = { From bc1d67c13bd09778caab994ee588e324f2b980d9 Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 29 Jun 2026 14:22:25 +0700 Subject: [PATCH 40/41] Refactor and document AI Assist diffing and formatting logic - Improve readability in `SpriteDiffer` by renaming LCS variables and adding comments explaining the alignment algorithm - Remove redundant error logging and defensive `try-catch` block from `SpriteDiffer.flatten` - Reorganize `BrickTextFormatter` into logical sections (Titles, Value Text, Helpers) for better maintainability - Add KDoc documentation to internal utility functions to clarify their purpose within the diffing process --- .../ui/aiassist/diff/BrickTextFormatter.kt | 126 ++++++++++-------- .../catroid/ui/aiassist/diff/SpriteDiffer.kt | 72 +++++----- 2 files changed, 109 insertions(+), 89 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt index 318c8273c39..bf89deba2fc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt @@ -37,13 +37,64 @@ import org.catrobat.catroid.content.bricks.UserDataBrick import org.catrobat.catroid.content.bricks.UserListBrick import org.catrobat.catroid.content.bricks.UserVariableBrickInterface -/** Names of the variables/lists a brick selects (stored outside the formula map). */ -internal fun dataNames(brick: Brick): List { - val names = mutableListOf() - (brick as? UserVariableBrickInterface)?.userVariable?.name?.let(names::add) - (brick as? UserListBrick)?.userList?.name?.let(names::add) - (brick as? UserDataBrick)?.userDataMap?.values?.forEach { it?.name?.let(names::add) } - return names.filter { it.isNotBlank() } +// ── Brick titles ── + +/** Human-readable title from the class name, e.g. "SetXBrick" -> "Set X". */ +internal fun humanizeBrickName(simpleName: String): String { + val base = simpleName.removeSuffix("Brick") + if (base.isEmpty()) return simpleName + return base + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") + .trim() +} + +/** + * The editor label for a script-header brick (e.g. "When scene starts", "When tapped"). + * A brick's layout resource name matches its label string resource name, so we resolve the layout + * via [BrickBaseType.getViewResource] and look up the same-named string. Falls back to the + * humanized class name for anything unrecognized. + */ +internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { + val layoutId = (brick as? BrickBaseType)?.getViewResource() + ?: return humanizeBrickName(brick.javaClass.simpleName) + val entryName = context.resources.getResourceEntryName(layoutId) + val stringId = context.resources.getIdentifier(entryName, "string", context.packageName) + if (stringId != 0) context.getString(stringId) else humanizeBrickName(brick.javaClass.simpleName) +} catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error resolving script header title for ${brick.javaClass.simpleName}", + e + ) + humanizeBrickName(brick.javaClass.simpleName) +} + +// ── Brick value text ── + +/** The brick's value chunks joined into one plain-text subtitle, or null when it has no values. */ +internal fun brickSubtitle(brick: Brick, context: Context): String? { + val tokens = brickValueTokens(brick, context, null) + return if (tokens.isEmpty()) null else tokens.joinToString(", ") { it.text } +} + +/** + * The new brick's value subtitle as an [AnnotatedString], with each [DiffToken] that changed from + * [oldBrick] rendered in bold (the highlighted token). + */ +internal fun changedSubtitleAnnotated( + oldBrick: Brick?, + newBrick: Brick, + context: Context +): AnnotatedString = buildAnnotatedString { + brickValueTokens(newBrick, context, oldBrick).forEachIndexed { index, token -> + if (index > 0) append(", ") + if (token.changed) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(token.text) } + } else { + append(token.text) + } + } } /** @@ -90,30 +141,18 @@ internal fun brickValueTokens(brick: Brick, context: Context, oldBrick: Brick?): return tokens } -internal fun brickSubtitle(brick: Brick, context: Context): String? { - val tokens = brickValueTokens(brick, context, null) - return if (tokens.isEmpty()) null else tokens.joinToString(", ") { it.text } -} +// ── Helpers ── -/** - * The new brick's value subtitle as an [AnnotatedString], with each [DiffToken] that changed from - * [oldBrick] rendered in bold (the highlighted token). - */ -internal fun changedSubtitleAnnotated( - oldBrick: Brick?, - newBrick: Brick, - context: Context -): AnnotatedString = buildAnnotatedString { - brickValueTokens(newBrick, context, oldBrick).forEachIndexed { index, token -> - if (index > 0) append(", ") - if (token.changed) { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(token.text) } - } else { - append(token.text) - } - } +/** Names of the variables/lists a brick selects (stored outside the formula map). */ +internal fun dataNames(brick: Brick): List { + val names = mutableListOf() + (brick as? UserVariableBrickInterface)?.userVariable?.name?.let(names::add) + (brick as? UserListBrick)?.userList?.name?.let(names::add) + (brick as? UserDataBrick)?.userDataMap?.values?.forEach { it?.name?.let(names::add) } + return names.filter { it.isNotBlank() } } +/** The editor's trimmed formula string for [field], or "" if it cannot be read. */ internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = try { brick.formulaMap[field]?.getTrimmedFormulaString(context).orEmpty() @@ -126,36 +165,7 @@ internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context "" } -internal fun humanizeBrickName(simpleName: String): String { - val base = simpleName.removeSuffix("Brick") - if (base.isEmpty()) return simpleName - return base - .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") - .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") - .trim() -} - -/** - * The real editor label for a script-header brick (e.g. "When scene starts", "When tapped"). - * A brick's layout resource name matches its label string resource name, so we resolve the layout - * via [BrickBaseType.getViewResource] and look up the same-named string. Falls back to the - * humanized class name for anything unrecognized. - */ -internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { - val layoutId = (brick as? BrickBaseType)?.getViewResource() - ?: return humanizeBrickName(brick.javaClass.simpleName) - val entryName = context.resources.getResourceEntryName(layoutId) - val stringId = context.resources.getIdentifier(entryName, "string", context.packageName) - if (stringId != 0) context.getString(stringId) else humanizeBrickName(brick.javaClass.simpleName) -} catch (e: Exception) { - Log.e( - DIFF_TAG, - "Error resolving script header title for ${brick.javaClass.simpleName}", - e - ) - humanizeBrickName(brick.javaClass.simpleName) -} - +/** Turns a formula-field enum name into a readable label, e.g. "X_POSITION" -> "X Position". */ private fun humanizeField(fieldName: String): String = fieldName.split('_').joinToString(" ") { part -> part.lowercase().replaceFirstChar { it.uppercase() } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt index 1510bb775de..f5c97233059 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt @@ -24,7 +24,6 @@ package org.catrobat.catroid.ui.aiassist.diff import android.content.Context -import android.util.Log import org.catrobat.catroid.content.Sprite import org.catrobat.catroid.content.bricks.Brick import org.catrobat.catroid.content.bricks.FormulaBrick @@ -44,39 +43,51 @@ internal fun buildDiffRows(oldSprite: Sprite, newSprite: Sprite, context: Contex val signaturesOld = oldFlat.map { brickSignature(it, context) } val signaturesNew = newFlat.map { brickSignature(it, context) } - val n = oldFlat.size - val m = newFlat.size - val lcs = Array(n + 1) { IntArray(m + 1) } - for (i in n - 1 downTo 0) { - for (j in m - 1 downTo 0) { - lcs[i][j] = if (signaturesOld[i] == signaturesNew[j]) { - lcs[i + 1][j + 1] + 1 - } else { - maxOf(lcs[i + 1][j], lcs[i][j + 1]) - } + // lcsLengths[oldIndex][newIndex] = length of the longest common run of signatures starting at + // those positions. Filled bottom-up so the forward walk below can read matches ahead of it. + val oldCount = oldFlat.size + val newCount = newFlat.size + val lcsLengths = Array(oldCount + 1) { IntArray(newCount + 1) } + for (oldIndex in oldCount - 1 downTo 0) { + for (newIndex in newCount - 1 downTo 0) { + lcsLengths[oldIndex][newIndex] = + if (signaturesOld[oldIndex] == signaturesNew[newIndex]) { + lcsLengths[oldIndex + 1][newIndex + 1] + 1 + } else { + maxOf(lcsLengths[oldIndex + 1][newIndex], lcsLengths[oldIndex][newIndex + 1]) + } } } + // Walk both lists in order: matching signatures pair up as UNCHANGED; otherwise advance the side + // whose skip keeps the most matches, emitting REMOVED (old) or ADDED (new). val aligned = mutableListOf() - var i = 0 - var j = 0 - while (i < n && j < m) { + var oldIndex = 0 + var newIndex = 0 + while (oldIndex < oldCount && newIndex < newCount) { + val droppingOldKeepsMoreMatches = + lcsLengths[oldIndex + 1][newIndex] >= lcsLengths[oldIndex][newIndex + 1] when { - signaturesOld[i] == signaturesNew[j] -> { - aligned.add(DiffRow(oldFlat[i], newFlat[j], DiffStatus.UNCHANGED)); i++; j++ + signaturesOld[oldIndex] == signaturesNew[newIndex] -> { + aligned.add(DiffRow(oldFlat[oldIndex], newFlat[newIndex], DiffStatus.UNCHANGED)) + oldIndex++ + newIndex++ } - lcs[i + 1][j] >= lcs[i][j + 1] -> { - aligned.add(DiffRow(oldFlat[i], null, DiffStatus.REMOVED)); i++ + droppingOldKeepsMoreMatches -> { + aligned.add(DiffRow(oldFlat[oldIndex], null, DiffStatus.REMOVED)) + oldIndex++ } else -> { - aligned.add(DiffRow(null, newFlat[j], DiffStatus.ADDED)); j++ + aligned.add(DiffRow(null, newFlat[newIndex], DiffStatus.ADDED)) + newIndex++ } } } - while (i < n) aligned.add(DiffRow(oldFlat[i++], null, DiffStatus.REMOVED)) - while (j < m) aligned.add(DiffRow(null, newFlat[j++], DiffStatus.ADDED)) + // Leftover tail on either side: all removals or all additions. + while (oldIndex < oldCount) aligned.add(DiffRow(oldFlat[oldIndex++], null, DiffStatus.REMOVED)) + while (newIndex < newCount) aligned.add(DiffRow(null, newFlat[newIndex++], DiffStatus.ADDED)) return reconcileChangeBlocks(aligned) } @@ -107,6 +118,10 @@ private fun reconcileChangeBlocks(rows: List): List { return out } +/** + * Within one change block, pairs each removed brick with the first unused added brick of the same + * class into a MODIFIED row (in original order); unmatched removes and the leftover adds stay as-is. + */ private fun reconcileBlock(block: List): List { val removed = block.filter { it.status == DiffStatus.REMOVED } val added = block.filter { it.status == DiffStatus.ADDED } @@ -137,20 +152,15 @@ private fun reconcileBlock(block: List): List { private fun flatten(sprite: Sprite): List { val list = mutableListOf() for (script in sprite.scriptList) { - try { - script.addToFlatList(list) - } catch (e: Exception) { - Log.e( - DIFF_TAG, - "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${e.message}", - e - ) - // Defensive: a malformed script shouldn't crash the preview. - } + script.addToFlatList(list) } return list } +/** + * A brick's equality key for the LCS: its class name plus the selected data names and each formula + * field's text, so two bricks compare equal only when they would look identical in the editor. + */ private fun brickSignature(brick: Brick, context: Context): String { val sb = StringBuilder(brick.javaClass.simpleName) // Include the selected variable/list names so changing only the data selection is a real change. From 2857d60280fec3e955fcca439f2be77b38fbadbd Mon Sep 17 00:00:00 2001 From: Muhammad Haris Sabil Al Karim Date: Mon, 29 Jun 2026 17:03:03 +0700 Subject: [PATCH 41/41] Refactor brick representation in AI Tutor diffs to match editor phrases - Replace manual subtitle generation with a view-traversal approach to reconstruct the full brick editor phrase in its natural UI order - Update `DiffToken` to distinguish between static labels and dynamic values (formulas and spinner selections) - Implement specialized styling in the diff screen to highlight modified dynamic values with bold and underline attributes - Enhance data extraction using reflection to automatically include names from any `Nameable` fields, such as looks, sounds, or scenes - Update `BrickDiffDialog` to display the full reconstructed editor phrase as the dialog title - Add a fallback layout inflation mechanism to `BrickTextFormatter` to ensure phrases can be generated even if full view population fails --- .../ui/aiassist/diff/AiTutorDiffScreen.kt | 79 +++--- .../ui/aiassist/diff/BrickDiffDialog.kt | 4 +- .../ui/aiassist/diff/BrickTextFormatter.kt | 250 ++++++++++++------ .../catroid/ui/aiassist/diff/DiffModel.kt | 11 +- 4 files changed, 232 insertions(+), 112 deletions(-) diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt index 89878856aae..4f55ee2b464 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt @@ -69,14 +69,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -359,6 +364,7 @@ private fun BrickDiffRow( accent = accent, onClick = onClick ) + DiffStatus.MODIFIED -> statusColor(row.status)?.let { ModifiedDiffRow( row = row, @@ -460,6 +466,7 @@ private fun ModifiedDiffRow( onClick: () -> Unit ) { val newBrick = row.new ?: return + val inspectionMode = LocalInspectionMode.current StatusContainer( tint = tint, iconRes = statusIcon(row.status), @@ -471,28 +478,47 @@ private fun ModifiedDiffRow( .weight(1f) .padding(vertical = 8.dp) ) { - Text( - text = humanizeBrickName(newBrick.javaClass.simpleName), - color = white, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis + ModifiedValues( + oldText = row.old?.let { brickEditorLabel(it, context, inspectionMode) }.orEmpty(), + newText = phraseAnnotated( + brickPhraseTokens(newBrick, context, row.old, inspectionMode), + staticColor = white, + dynamicColor = accent + ), + oldColor = white.copy(alpha = 0.45f), + newColor = accent, + arrowColor = white.copy(alpha = 0.5f) ) - val newSubtitle = brickSubtitle(newBrick, context) - if (newSubtitle != null) { - ModifiedValues( - oldText = row.old?.let { brickSubtitle(it, context) }.orEmpty(), - newText = changedSubtitleAnnotated(row.old, newBrick, context), - oldColor = white.copy(alpha = 0.45f), // faded old value - newColor = accent, // crisp new value, changed token bold - arrowColor = white.copy(alpha = 0.5f) - ) - } } } } +/** + * Renders a brick's [brickPhraseTokens] as an [AnnotatedString]: static words in [staticColor], each + * dynamic chunk (value / spinner selection) in [dynamicColor] — medium weight normally, bold + underlined + * when it changed from the old brick — so the data reads like the editor's input fields. + */ +private fun phraseAnnotated( + tokens: List, + staticColor: Color, + dynamicColor: Color +): AnnotatedString = buildAnnotatedString { + tokens.forEachIndexed { index, token -> + if (index > 0) append(" ") + val style = when { + !token.dynamic -> SpanStyle(color = staticColor) + token.changed -> SpanStyle( + color = dynamicColor, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline + ) + + else -> SpanStyle(color = dynamicColor, fontWeight = FontWeight.Medium) + } + withStyle(style) { append(token.text) } + } +} + @Composable private fun ModifiedValues( oldText: String, @@ -610,25 +636,18 @@ private fun BrickContent( valueColor: Color, modifier: Modifier ) { + val inspectionMode = LocalInspectionMode.current Column(modifier = modifier.padding(vertical = 8.dp)) { Text( - text = humanizeBrickName(brick.javaClass.simpleName), - color = labelColor, - fontWeight = FontWeight.Medium, + text = phraseAnnotated( + brickPhraseTokens(brick, context, null, inspectionMode), + staticColor = labelColor, + dynamicColor = valueColor + ), fontSize = 14.sp, maxLines = 2, overflow = TextOverflow.Ellipsis ) - val subtitle = brickSubtitle(brick, context) - if (subtitle != null) { - Text( - text = subtitle, - color = valueColor, - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } } } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt index f9b11025753..98e2e54d8af 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -63,6 +64,7 @@ internal fun BrickDiffDialog( ) { val brick = row.new ?: row.old ?: return val tint = statusColor(row.status) + val context = LocalContext.current Dialog(onDismissRequest = onDismiss) { Surface( shape = RoundedCornerShape(12.dp), @@ -74,7 +76,7 @@ internal fun BrickDiffDialog( .padding(20.dp) ) { Text( - text = humanizeBrickName(brick.javaClass.simpleName), + text = brickEditorLabel(brick, context), color = white, fontWeight = FontWeight.Bold, fontSize = 18.sp diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt index bf89deba2fc..22a7452001f 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt @@ -25,30 +25,22 @@ package org.catrobat.catroid.ui.aiassist.diff import android.content.Context import android.util.Log -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Spinner +import android.widget.TextView +import org.catrobat.catroid.common.Nameable import org.catrobat.catroid.content.bricks.Brick import org.catrobat.catroid.content.bricks.BrickBaseType import org.catrobat.catroid.content.bricks.FormulaBrick import org.catrobat.catroid.content.bricks.UserDataBrick import org.catrobat.catroid.content.bricks.UserListBrick import org.catrobat.catroid.content.bricks.UserVariableBrickInterface +import java.lang.reflect.Modifier // ── Brick titles ── -/** Human-readable title from the class name, e.g. "SetXBrick" -> "Set X". */ -internal fun humanizeBrickName(simpleName: String): String { - val base = simpleName.removeSuffix("Brick") - if (base.isEmpty()) return simpleName - return base - .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") - .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") - .trim() -} - /** * The editor label for a script-header brick (e.g. "When scene starts", "When tapped"). * A brick's layout resource name matches its label string resource name, so we resolve the layout @@ -57,7 +49,7 @@ internal fun humanizeBrickName(simpleName: String): String { */ internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { val layoutId = (brick as? BrickBaseType)?.getViewResource() - ?: return humanizeBrickName(brick.javaClass.simpleName) + ?: throw IllegalArgumentException("Brick ${brick.javaClass.simpleName} is not a BrickBaseType") val entryName = context.resources.getResourceEntryName(layoutId) val stringId = context.resources.getIdentifier(entryName, "string", context.packageName) if (stringId != 0) context.getString(stringId) else humanizeBrickName(brick.javaClass.simpleName) @@ -70,88 +62,194 @@ internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { humanizeBrickName(brick.javaClass.simpleName) } -// ── Brick value text ── - -/** The brick's value chunks joined into one plain-text subtitle, or null when it has no values. */ -internal fun brickSubtitle(brick: Brick, context: Context): String? { - val tokens = brickValueTokens(brick, context, null) - return if (tokens.isEmpty()) null else tokens.joinToString(", ") { it.text } -} - /** - * The new brick's value subtitle as an [AnnotatedString], with each [DiffToken] that changed from - * [oldBrick] rendered in bold (the highlighted token). + * The full editor phrase for a brick as ordered [DiffToken]s: the static label words plus, inline at + * their real position, each formula field's value and each spinner selection's name. When [oldBrick] is + * given, every value/name token is flagged [DiffToken.changed] if it differs (for old→new diffing) and + * carries `dynamic = true` so the UI can style it like an input field. + * + * Static words (including runtime-only labels such as Wait's "seconds") come from the inflated + * [Brick.getView]; values come from the model ([formulaText]) and selections from [dataNames], so they + * stay correct even when the brick belongs to a parsed (non-current) sprite and even when the raw-layout + * fallback is used because [Brick.getView] failed. + * + * In [inspectionMode] (a Compose `@Preview`/layoutlib render) no real view is inflated at all — the + * preview renderer cannot load the themed font assets, so inflation crashes. There we derive the phrase + * from the model only via [modelOnlyPhraseTokens]; the same model-only path is the deep fallback when + * inflation fails on a real device. */ -internal fun changedSubtitleAnnotated( +internal fun brickPhraseTokens( + brick: Brick, + context: Context, oldBrick: Brick?, - newBrick: Brick, - context: Context -): AnnotatedString = buildAnnotatedString { - brickValueTokens(newBrick, context, oldBrick).forEachIndexed { index, token -> - if (index > 0) append(", ") - if (token.changed) { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(token.text) } - } else { - append(token.text) - } - } -} + inspectionMode: Boolean = false +): List { + if (inspectionMode) return modelOnlyPhraseTokens(brick, context, oldBrick) -/** - * The displayable value chunks for a brick: the selected variable/list name(s) and the formula - * field values. When [oldBrick] is given, each token is flagged [DiffToken.changed] if it differs. - * A plain "set/change a variable" brick (one data name + one formula field) is merged into a single - * ": " token so the name shows instead of the misleading "Variable" field label. - */ -internal fun brickValueTokens(brick: Brick, context: Context, oldBrick: Brick?): List { + val formulaBrick = brick as? FormulaBrick + val fieldIds = formulaBrick?.brickFieldToTextViewIdMap?.values?.toSet() ?: emptySet() val names = dataNames(brick) val oldNames = oldBrick?.let { dataNames(it) } ?: emptyList() val oldFormula = oldBrick as? FormulaBrick - val fields = - (brick as? FormulaBrick)?.formulaMap?.keys?.sortedBy { it.toString() } ?: emptyList() - - if (names.size == 1 && fields.size == 1 && brick is FormulaBrick) { - val field = fields.first() - val value = formulaText(brick, field, context) - val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } - ?.let { formulaText(it, field, context) } - val changed = - oldBrick != null && (names.first() != oldNames.firstOrNull() || value != oldValue) - return listOf(DiffToken("${names.first()}: $value", changed)) - } + + val root = try { + brick.getView(context) + } catch (e: Exception) { + Log.e(DIFF_TAG, "Error inflating view for ${brick.javaClass.simpleName}", e) + rawLayout(brick, context) + } ?: return modelOnlyPhraseTokens(brick, context, oldBrick) val tokens = mutableListOf() - names.forEachIndexed { index, name -> - tokens.add(DiffToken(name, oldBrick != null && oldNames.getOrNull(index) != name)) + var spinnerIndex = 0 + fun walk(node: View) { + if (node.visibility != View.VISIBLE) return + when { + // A spinner shows a selection; take its name from the model, never the (sprite-dependent) view. + node is Spinner -> { + addNameToken(tokens, names, oldNames, oldBrick, spinnerIndex) + spinnerIndex++ + } + + node is TextView && node.id != View.NO_ID && node.id in fieldIds -> { + val field = formulaBrick?.getBrickFieldFromTextViewId(node.id) ?: return + val value = formulaText(formulaBrick, field, context).trim() + if (value.isBlank()) return + val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } + ?.let { formulaText(it, field, context).trim() } + tokens.add(DiffToken(value, oldBrick != null && value != oldValue, dynamic = true)) + } + + node is TextView -> node.text?.toString()?.trim()?.takeIf { it.isNotBlank() } + ?.let { tokens.add(DiffToken(it, changed = false, dynamic = false)) } + + node is ViewGroup -> for (i in 0 until node.childCount) walk(node.getChildAt(i)) + } } - if (brick is FormulaBrick) { - for (field in fields) { - val value = formulaText(brick, field, context) - if (value.isBlank()) continue + walk(root) + // Selections whose widget isn't a Spinner (rare) are never dropped: append them after the walk. + while (spinnerIndex < names.size) { + addNameToken(tokens, names, oldNames, oldBrick, spinnerIndex) + spinnerIndex++ + } + return tokens +} + +/** Adds the [index]-th selected name (if any) as a dynamic token, flagged changed vs [oldBrick]. */ +private fun addNameToken( + tokens: MutableList, + names: List, + oldNames: List, + oldBrick: Brick?, + index: Int +) { + val name = names.getOrNull(index) ?: return + tokens.add( + DiffToken( + name, + oldBrick != null && oldNames.getOrNull(index) != name, + dynamic = true + ) + ) +} + +/** + * The brick phrase derived from the model alone, never inflating a view: a humanized class-name label + * followed by each formula field's value and each selected name. Used in Compose preview/inspection mode + * (where view inflation crashes on missing font assets) and as the deep fallback when inflation fails. + * Less precise than the inflated phrase (static words come from the class name, not the real layout), but + * never crashes and stays correct for parsed sprites. + */ +private fun modelOnlyPhraseTokens(brick: Brick, context: Context, oldBrick: Brick?): List { + val tokens = mutableListOf( + DiffToken(humanizeBrickName(brick.javaClass.simpleName), changed = false, dynamic = false) + ) + val formulaBrick = brick as? FormulaBrick + val oldFormula = oldBrick as? FormulaBrick + formulaBrick?.formulaMap?.forEach { (field, _) -> + val value = formulaText(formulaBrick, field, context).trim() + if (value.isNotBlank()) { val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } - ?.let { formulaText(it, field, context) } - tokens.add( - DiffToken( - "${humanizeField(field.toString())}: $value", - oldBrick != null && oldValue != value - ) - ) + ?.let { formulaText(it, field, context).trim() } + tokens.add(DiffToken(value, oldBrick != null && value != oldValue, dynamic = true)) } } + val names = dataNames(brick) + val oldNames = oldBrick?.let { dataNames(it) } ?: emptyList() + names.indices.forEach { addNameToken(tokens, names, oldNames, oldBrick, it) } return tokens } +/** Inflates only the brick's layout (no [Brick.getView] population) as a fallback view source. */ +private fun rawLayout(brick: Brick, context: Context): View? = try { + (brick as? BrickBaseType)?.getViewResource() + ?.let { LayoutInflater.from(context).inflate(it, null, false) } +} catch (e: Exception) { + Log.e(DIFF_TAG, "Error inflating layout for ${brick.javaClass.simpleName}", e) + null +} + +/** The brick's editor phrase as a plain string, e.g. "Place at x: 0 y: 500". */ +internal fun brickEditorLabel(brick: Brick, context: Context, inspectionMode: Boolean = false): String { + val label = brickPhraseTokens(brick, context, null, inspectionMode) + .joinToString(" ") { it.text }.trim() + return label.ifBlank { humanizeBrickName(brick.javaClass.simpleName) } +} + +/** Human-readable title from the class name, e.g. "SetXBrick" -> "Set X". */ +internal fun humanizeBrickName(simpleName: String): String { + val base = simpleName.removeSuffix("Brick") + if (base.isEmpty()) return simpleName + return base + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") + .trim() +} + // ── Helpers ── -/** Names of the variables/lists a brick selects (stored outside the formula map). */ +/** + * Names of the variables/lists/looks/sounds a brick selects (stored outside the formula map). The + * explicit cases cover the variable/list/userdata bricks; the reflective sweep adds any other + * [Nameable] selection (a look, sound, scene, instrument, …) held in a spinner-backed brick field, so + * those bricks surface their value too. A [LinkedHashSet] de-duplicates the userVariable/userList + * overlap between the two passes. + */ internal fun dataNames(brick: Brick): List { - val names = mutableListOf() + val names = LinkedHashSet() (brick as? UserVariableBrickInterface)?.userVariable?.name?.let(names::add) (brick as? UserListBrick)?.userList?.name?.let(names::add) (brick as? UserDataBrick)?.userDataMap?.values?.forEach { it?.name?.let(names::add) } + nameableFieldNames(brick).forEach(names::add) return names.filter { it.isNotBlank() } } +/** + * The names of every non-static instance field value that is a [Nameable], walking up the brick's + * class hierarchy. Matched by type (not field name) so it is ProGuard-safe; per-field reads are + * guarded so an inaccessible field never breaks the whole sweep. + */ +private fun nameableFieldNames(brick: Brick): List { + val names = mutableListOf() + var cls: Class<*>? = brick.javaClass + while (cls != null && cls != Any::class.java) { + for (field in cls.declaredFields) { + if (Modifier.isStatic(field.modifiers)) continue + try { + field.isAccessible = true + (field.get(brick) as? Nameable)?.name?.let(names::add) + } catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error reading field ${field.name} of ${brick.javaClass.simpleName}", + e + ) + } + } + cls = cls.superclass + } + return names +} + /** The editor's trimmed formula string for [field], or "" if it cannot be read. */ internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = try { @@ -164,9 +262,3 @@ internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context ) "" } - -/** Turns a formula-field enum name into a readable label, e.g. "X_POSITION" -> "X Position". */ -private fun humanizeField(fieldName: String): String = - fieldName.split('_').joinToString(" ") { part -> - part.lowercase().replaceFirstChar { it.uppercase() } - } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt index 45fb1c2afc0..5eaacabb221 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt @@ -29,7 +29,14 @@ enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } internal data class DiffRow(val old: Brick?, val new: Brick?, val status: DiffStatus) -/** A single value chunk shown for a brick (e.g. "playerY: 0"), flagged if it changed vs the old brick. */ -internal data class DiffToken(val text: String, val changed: Boolean) +/** + * A single chunk of a brick's editor phrase. [dynamic] marks a value/spinner-selection chunk (styled + * like an input field) as opposed to a static label word; [changed] flags it differing from the old brick. + */ +internal data class DiffToken( + val text: String, + val changed: Boolean, + val dynamic: Boolean = false +) internal const val DIFF_TAG = "AiTutorDiffScreen"