diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 860f188a0ecc..c34e4f64531c 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -4099,7 +4099,7 @@ }, { "kind": "ui-call", - "line": 269, + "line": 270, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Enter the setup code from openclaw qr.", "surface": "android", @@ -4107,7 +4107,7 @@ }, { "kind": "ui-call", - "line": 270, + "line": 271, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Setup code was not accepted. Generate a fresh code with openclaw qr.", "surface": "android", @@ -4115,7 +4115,7 @@ }, { "kind": "ui-call", - "line": 272, + "line": 273, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "That QR code is not an OpenClaw setup QR. Generate a fresh code with openclaw qr, then try again.", "surface": "android", @@ -4123,7 +4123,7 @@ }, { "kind": "ui-call", - "line": 274, + "line": 275, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "That looks like a setup code. Go back and choose Setup Gateway, then Use setup code.", "surface": "android", @@ -4131,7 +4131,7 @@ }, { "kind": "ui-call", - "line": 276, + "line": 277, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr.", "surface": "android", @@ -4139,7 +4139,7 @@ }, { "kind": "ui-call", - "line": 278, + "line": 279, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually.", "surface": "android", @@ -4147,7 +4147,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 281, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Could not read a QR code from that image. Choose a clearer image or enter the setup code manually.", "surface": "android", @@ -4155,7 +4155,7 @@ }, { "kind": "ui-call", - "line": 282, + "line": 283, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Could not start the camera. Choose a QR image from gallery or enter the setup code manually.", "surface": "android", @@ -4163,7 +4163,7 @@ }, { "kind": "ui-call", - "line": 804, + "line": 838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Trust", "surface": "android", @@ -4171,7 +4171,7 @@ }, { "kind": "ui-call", - "line": 809, + "line": 843, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Cancel", "surface": "android", @@ -4179,7 +4179,7 @@ }, { "kind": "ui-call", - "line": 1000, + "line": 1038, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -4187,7 +4187,7 @@ }, { "kind": "ui-call", - "line": 1001, + "line": 1039, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools.", "surface": "android", @@ -4195,7 +4195,7 @@ }, { "kind": "ui-call", - "line": 1026, + "line": 1070, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "OpenClaw logo", "surface": "android", @@ -4203,7 +4203,7 @@ }, { "kind": "ui-call", - "line": 1082, + "line": 1129, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connect to your Gateway", "surface": "android", @@ -4211,7 +4211,7 @@ }, { "kind": "ui-call", - "line": 1083, + "line": 1130, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Choose device permissions", "surface": "android", @@ -4219,7 +4219,7 @@ }, { "kind": "ui-call", - "line": 1084, + "line": 1131, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Use OpenClaw from your phone", "surface": "android", @@ -4227,7 +4227,7 @@ }, { "kind": "ui-call", - "line": 1106, + "line": 1153, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Security notice", "surface": "android", @@ -4235,7 +4235,7 @@ }, { "kind": "ui-call", - "line": 1108, + "line": 1155, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "The connected OpenClaw agent can use device capabilities you enable. Continue only if you trust the Gateway and agent you connect to.", "surface": "android", @@ -4243,7 +4243,7 @@ }, { "kind": "ui-call", - "line": 1149, + "line": 1196, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connect Gateway", "surface": "android", @@ -4251,7 +4251,7 @@ }, { "kind": "ui-call", - "line": 1150, + "line": 1197, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Scan a QR code or use the setup code from your OpenClaw Gateway.", "surface": "android", @@ -4259,7 +4259,7 @@ }, { "kind": "ui-call", - "line": 1159, + "line": 1206, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Could not open setup guide.", "surface": "android", @@ -4267,7 +4267,7 @@ }, { "kind": "ui-call", - "line": 1167, + "line": 1214, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Scan QR or setup code", "surface": "android", @@ -4275,7 +4275,7 @@ }, { "kind": "ui-call", - "line": 1173, + "line": 1220, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Set up manually", "surface": "android", @@ -4283,7 +4283,7 @@ }, { "kind": "ui-call", - "line": 1195, + "line": 1242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Before you start", "surface": "android", @@ -4291,7 +4291,7 @@ }, { "kind": "ui-call", - "line": 1201, + "line": 1248, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Access to the Gateway device", "surface": "android", @@ -4299,7 +4299,7 @@ }, { "kind": "ui-call", - "line": 1202, + "line": 1249, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Have a terminal open on the device running OpenClaw.", "surface": "android", @@ -4307,7 +4307,7 @@ }, { "kind": "ui-call", - "line": 1205, + "line": 1252, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Phone can reach the Gateway", "surface": "android", @@ -4315,7 +4315,7 @@ }, { "kind": "ui-call", - "line": 1206, + "line": 1253, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Use the same network, or a secure remote Gateway URL.", "surface": "android", @@ -4323,7 +4323,7 @@ }, { "kind": "ui-call", - "line": 1212, + "line": 1259, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Android setup guide", "surface": "android", @@ -4331,7 +4331,7 @@ }, { "kind": "ui-call", - "line": 1255, + "line": 1302, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Setup Gateway", "surface": "android", @@ -4339,7 +4339,7 @@ }, { "kind": "ui-call", - "line": 1260, + "line": 1307, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Step 1", "surface": "android", @@ -4347,7 +4347,7 @@ }, { "kind": "ui-call", - "line": 1261, + "line": 1308, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Start your Gateway.", "surface": "android", @@ -4355,7 +4355,7 @@ }, { "kind": "ui-named-argument", - "line": 1262, + "line": 1309, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw gateway", "surface": "android", @@ -4363,7 +4363,7 @@ }, { "kind": "ui-call", - "line": 1266, + "line": 1313, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Step 2", "surface": "android", @@ -4371,7 +4371,7 @@ }, { "kind": "ui-call", - "line": 1267, + "line": 1314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Generate a QR code.", "surface": "android", @@ -4379,7 +4379,7 @@ }, { "kind": "ui-named-argument", - "line": 1268, + "line": 1315, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw qr", "surface": "android", @@ -4387,7 +4387,7 @@ }, { "kind": "ui-call", - "line": 1291, + "line": 1338, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Choose from gallery", "surface": "android", @@ -4395,7 +4395,7 @@ }, { "kind": "ui-call", - "line": 1345, + "line": 1393, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "QR code not accepted", "surface": "android", @@ -4403,7 +4403,7 @@ }, { "kind": "ui-call", - "line": 1360, + "line": 1412, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Choose another image", "surface": "android", @@ -4411,7 +4411,7 @@ }, { "kind": "ui-call", - "line": 1413, + "line": 1465, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Scan QR code", "surface": "android", @@ -4419,7 +4419,7 @@ }, { "kind": "ui-call", - "line": 1415, + "line": 1467, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Open the camera and frame the code from openclaw qr.", "surface": "android", @@ -4427,7 +4427,7 @@ }, { "kind": "ui-call", - "line": 1449, + "line": 1501, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Align the QR code inside the square.", "surface": "android", @@ -4435,7 +4435,7 @@ }, { "kind": "ui-call", - "line": 1467, + "line": 1519, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Camera access is needed to scan the setup QR.", "surface": "android", @@ -4443,7 +4443,7 @@ }, { "kind": "ui-call", - "line": 1472, + "line": 1524, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Allow camera", "surface": "android", @@ -4451,7 +4451,7 @@ }, { "kind": "ui-call", - "line": 1493, + "line": 1545, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Close scanner", "surface": "android", @@ -4459,7 +4459,7 @@ }, { "kind": "ui-call", - "line": 1655, + "line": 1708, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Enter setup code", "surface": "android", @@ -4467,7 +4467,7 @@ }, { "kind": "ui-call", - "line": 1656, + "line": 1714, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Setup code", "surface": "android", @@ -4475,7 +4475,7 @@ }, { "kind": "ui-call", - "line": 1660, + "line": 1718, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Paste setup code", "surface": "android", @@ -4483,7 +4483,7 @@ }, { "kind": "ui-call", - "line": 1668, + "line": 1726, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Use setup code", "surface": "android", @@ -4491,7 +4491,7 @@ }, { "kind": "ui-call", - "line": 1707, + "line": 1766, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Manual setup", "surface": "android", @@ -4499,7 +4499,7 @@ }, { "kind": "ui-call", - "line": 1710, + "line": 1769, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway URL", "surface": "android", @@ -4507,7 +4507,7 @@ }, { "kind": "ui-call", - "line": 1719, + "line": 1778, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Host", "surface": "android", @@ -4515,7 +4515,7 @@ }, { "kind": "ui-call", - "line": 1720, + "line": 1779, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Port", "surface": "android", @@ -4523,7 +4523,7 @@ }, { "kind": "ui-call", - "line": 1725, + "line": 1784, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Use the Gateway computer's LAN address or secure remote hostname.", "surface": "android", @@ -4531,7 +4531,7 @@ }, { "kind": "ui-call", - "line": 1732, + "line": 1791, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Token", "surface": "android", @@ -4539,7 +4539,7 @@ }, { "kind": "ui-call", - "line": 1733, + "line": 1792, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Paste token", "surface": "android", @@ -4547,7 +4547,7 @@ }, { "kind": "ui-call", - "line": 1735, + "line": 1794, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Paste a shared Gateway token or operator-issued token.", "surface": "android", @@ -4555,7 +4555,7 @@ }, { "kind": "ui-call", - "line": 1742, + "line": 1801, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Password", "surface": "android", @@ -4563,7 +4563,7 @@ }, { "kind": "ui-call", - "line": 1743, + "line": 1802, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Password optional", "surface": "android", @@ -4571,7 +4571,7 @@ }, { "kind": "ui-call", - "line": 1747, + "line": 1806, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connection security", "surface": "android", @@ -4579,7 +4579,7 @@ }, { "kind": "ui-call", - "line": 1769, + "line": 1828, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Unencrypted", "surface": "android", @@ -4587,7 +4587,7 @@ }, { "kind": "ui-call", - "line": 1775, + "line": 1834, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Secure (TLS)", "surface": "android", @@ -4595,7 +4595,7 @@ }, { "kind": "ui-call", - "line": 1793, + "line": 1857, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Could not test connection", "surface": "android", @@ -4603,7 +4603,7 @@ }, { "kind": "ui-call", - "line": 1798, + "line": 1862, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Test connection", "surface": "android", @@ -4611,7 +4611,7 @@ }, { "kind": "ui-call", - "line": 1885, + "line": 1949, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Setup code was not accepted", "surface": "android", @@ -4619,7 +4619,7 @@ }, { "kind": "ui-call", - "line": 1891, + "line": 1955, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Your phone is paired with ${recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName)}. Continue to finish node access.", "surface": "android", @@ -4627,7 +4627,7 @@ }, { "kind": "ui-call", - "line": 1943, + "line": 2007, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway paired", "surface": "android", @@ -4635,7 +4635,7 @@ }, { "kind": "ui-call", - "line": 1944, + "line": 2008, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Pair Gateway", "surface": "android", @@ -4643,7 +4643,7 @@ }, { "kind": "ui-call", - "line": 1990, + "line": 2060, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "View details", "surface": "android", @@ -4651,7 +4651,7 @@ }, { "kind": "ui-call", - "line": 2023, + "line": 2093, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connection details", "surface": "android", @@ -4659,7 +4659,7 @@ }, { "kind": "ui-call", - "line": 2035, + "line": 2105, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Copy", "surface": "android", @@ -4667,7 +4667,7 @@ }, { "kind": "ui-call", - "line": 2040, + "line": 2110, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Close", "surface": "android", @@ -4675,7 +4675,7 @@ }, { "kind": "ui-call", - "line": 2052, + "line": 2122, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Details copied", "surface": "android", @@ -4683,7 +4683,7 @@ }, { "kind": "ui-call", - "line": 2137, + "line": 2167, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Approve node access", "surface": "android", @@ -4691,7 +4691,7 @@ }, { "kind": "ui-call", - "line": 2144, + "line": 2174, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway pairing is complete. Approve this phone as a node so OpenClaw can use the device capabilities you enable.", "surface": "android", @@ -4699,7 +4699,7 @@ }, { "kind": "ui-call", - "line": 2152, + "line": 2182, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "On the Gateway computer, run:", "surface": "android", @@ -4707,7 +4707,7 @@ }, { "kind": "ui-call", - "line": 2161, + "line": 2191, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Use the requestId from the pending command in the approve command.", "surface": "android", @@ -4715,7 +4715,7 @@ }, { "kind": "ui-call", - "line": 2172, + "line": 2202, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "I have approved", "surface": "android", @@ -4723,7 +4723,7 @@ }, { "kind": "ui-call", - "line": 2173, + "line": 2203, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Checking approval…", "surface": "android", @@ -4731,7 +4731,7 @@ }, { "kind": "ui-call", - "line": 2185, + "line": 2215, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Still waiting for approval", "surface": "android", @@ -4739,7 +4739,7 @@ }, { "kind": "ui-call", - "line": 2188, + "line": 2218, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Run the approve command on the Gateway computer, then check again.", "surface": "android", @@ -4747,7 +4747,7 @@ }, { "kind": "ui-call", - "line": 2195, + "line": 2225, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "OK", "surface": "android", @@ -4755,7 +4755,7 @@ }, { "kind": "ui-call", - "line": 2345, + "line": 2375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Copy approval command", "surface": "android", @@ -4763,7 +4763,7 @@ }, { "kind": "ui-call", - "line": 2371, + "line": 2406, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings.", "surface": "android", @@ -4771,7 +4771,7 @@ }, { "kind": "ui-call", - "line": 2410, + "line": 2445, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Back", "surface": "android", @@ -4779,7 +4779,7 @@ }, { "kind": "ui-call", - "line": 2461, + "line": 2496, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Permissions", "surface": "android", @@ -4787,7 +4787,7 @@ }, { "kind": "ui-call", - "line": 2522, + "line": 2557, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connected", "surface": "android", @@ -4795,7 +4795,7 @@ }, { "kind": "ui-call", - "line": 2523, + "line": 2558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Your Gateway is ready.", "surface": "android", @@ -4803,7 +4803,7 @@ }, { "kind": "ui-call", - "line": 2527, + "line": 2562, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Approve this phone on the gateway.\nThen retry the connection.", "surface": "android", @@ -4811,7 +4811,7 @@ }, { "kind": "ui-call", - "line": 2530, + "line": 2565, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Node Approval Pending", "surface": "android", @@ -4819,7 +4819,7 @@ }, { "kind": "ui-call", - "line": 2531, + "line": 2566, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.", "surface": "android", @@ -4827,7 +4827,7 @@ }, { "kind": "ui-call", - "line": 2534, + "line": 2569, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Pairing Gateway", "surface": "android", @@ -4835,7 +4835,7 @@ }, { "kind": "ui-call", - "line": 2535, + "line": 2570, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Approval is in progress.\nOpenClaw will reconnect automatically.", "surface": "android", @@ -4843,7 +4843,7 @@ }, { "kind": "ui-call", - "line": 2538, + "line": 2573, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connecting Gateway", "surface": "android", @@ -4851,7 +4851,7 @@ }, { "kind": "ui-call", - "line": 2539, + "line": 2574, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "OpenClaw is checking gateway and node access.", "surface": "android", @@ -4859,7 +4859,7 @@ }, { "kind": "ui-call", - "line": 2542, + "line": 2577, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Still connecting", "surface": "android", @@ -4867,7 +4867,7 @@ }, { "kind": "ui-call", - "line": 2543, + "line": 2578, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "This is taking longer than expected.\nCheck that the Gateway is running and reachable.", "surface": "android", @@ -4875,7 +4875,7 @@ }, { "kind": "ui-call", - "line": 2546, + "line": 2581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Connection issue", "surface": "android", @@ -4883,7 +4883,7 @@ }, { "kind": "ui-call", - "line": 2547, + "line": 2582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "We could not reach your Gateway.\nLet's fix this.", "surface": "android", @@ -4891,7 +4891,7 @@ }, { "kind": "ui-call", - "line": 2555, + "line": 2590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Continue", "surface": "android", @@ -4899,7 +4899,7 @@ }, { "kind": "ui-call", - "line": 2556, + "line": 2591, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Retry connection", "surface": "android", @@ -4907,7 +4907,7 @@ }, { "kind": "ui-call", - "line": 2557, + "line": 2592, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Go back", "surface": "android", @@ -4915,7 +4915,7 @@ }, { "kind": "ui-call", - "line": 2653, + "line": 2688, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway received this phone", "surface": "android", @@ -4923,7 +4923,7 @@ }, { "kind": "ui-call", - "line": 2654, + "line": 2689, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Waiting for device approval", "surface": "android", @@ -4931,7 +4931,7 @@ }, { "kind": "ui-call", - "line": 2655, + "line": 2690, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Retrying automatically", "surface": "android", @@ -4939,7 +4939,7 @@ }, { "kind": "ui-call", - "line": 2659, + "line": 2694, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway needs device approval", "surface": "android", @@ -4947,7 +4947,7 @@ }, { "kind": "ui-call", - "line": 2660, + "line": 2695, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Run the approval command on the Gateway", "surface": "android", @@ -4955,7 +4955,7 @@ }, { "kind": "ui-call", - "line": 2676, + "line": 2711, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Opening Gateway connection", "surface": "android", @@ -4963,7 +4963,7 @@ }, { "kind": "ui-call", - "line": 2685, + "line": 2720, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Checking pairing access", "surface": "android", @@ -4971,7 +4971,7 @@ }, { "kind": "ui-call", - "line": 2693, + "line": 2728, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Checking node access", "surface": "android", @@ -4979,7 +4979,7 @@ }, { "kind": "ui-call", - "line": 2762, + "line": 2797, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Ready for chat and voice", "surface": "android", @@ -4987,7 +4987,7 @@ }, { "kind": "ui-call", - "line": 2764, + "line": 2799, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway paired. Waiting for node capability approval.", "surface": "android", @@ -4995,7 +4995,7 @@ }, { "kind": "ui-call", - "line": 2767, + "line": 2802, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway approval is pending. Run this on the gateway host:", "surface": "android", @@ -5003,7 +5003,7 @@ }, { "kind": "ui-call", - "line": 2768, + "line": 2803, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry.", "surface": "android", @@ -5011,7 +5011,7 @@ }, { "kind": "ui-call", - "line": 2776, + "line": 2811, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway paired. Checking node capability approval.", "surface": "android", @@ -5019,7 +5019,7 @@ }, { "kind": "ui-call", - "line": 2778, + "line": 2813, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway paired. Waiting for operator access.", "surface": "android", @@ -5027,7 +5027,7 @@ }, { "kind": "ui-call", - "line": 2780, + "line": 2815, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway approval is in progress. OpenClaw will retry automatically.", "surface": "android", @@ -5035,7 +5035,7 @@ }, { "kind": "ui-call", - "line": 2782, + "line": 2817, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway unreachable", "surface": "android", @@ -5043,7 +5043,7 @@ }, { "kind": "ui-call", - "line": 2788, + "line": 2823, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "The code may have expired or been generated for another Gateway.", "surface": "android", @@ -5051,7 +5051,7 @@ }, { "kind": "ui-call", - "line": 2792, + "line": 2827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway password is required. Enter it again or edit this connection.", "surface": "android", @@ -5059,7 +5059,7 @@ }, { "kind": "ui-call", - "line": 2793, + "line": 2828, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway password is invalid. Re-enter it or reset this gateway connection.", "surface": "android", @@ -5067,7 +5067,7 @@ }, { "kind": "ui-call", - "line": 2794, + "line": 2829, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway token is required. Enter it again or edit this connection.", "surface": "android", @@ -5075,7 +5075,7 @@ }, { "kind": "ui-call", - "line": 2797, + "line": 2832, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway requires this device identity. Re-authenticate or reset this gateway connection.", "surface": "android", @@ -5083,7 +5083,7 @@ }, { "kind": "ui-call", - "line": 2800, + "line": 2835, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Saved authentication is invalid. Re-authenticate or reset this gateway connection.", "surface": "android", @@ -5091,7 +5091,7 @@ }, { "kind": "ui-call", - "line": 2801, + "line": 2836, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway authentication is not configured. Edit this connection and try again.", "surface": "android", @@ -5099,7 +5099,7 @@ }, { "kind": "ui-call", - "line": 2802, + "line": 2837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway authentication needs review. Check gateway settings, then retry.", "surface": "android", @@ -5107,7 +5107,7 @@ }, { "kind": "ui-call", - "line": 2803, + "line": 2838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Gateway authentication needs attention.", "surface": "android", @@ -5115,7 +5115,7 @@ }, { "kind": "ui-call", - "line": 2814, + "line": 2849, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "This app is older than the Gateway. Update OpenClaw on this device, then retry.", "surface": "android", @@ -5123,7 +5123,7 @@ }, { "kind": "ui-call", - "line": 2816, + "line": 2851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry.", "surface": "android", @@ -5131,7 +5131,7 @@ }, { "kind": "ui-call", - "line": 2817, + "line": 2852, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry.", "surface": "android", @@ -5139,7 +5139,7 @@ }, { "kind": "ui-call", - "line": 2819, + "line": 2854, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "$summary $details", "surface": "android", @@ -5147,7 +5147,7 @@ }, { "kind": "conditional-branch", - "line": 2854, + "line": 2889, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw devices approve $requestId", "surface": "android", @@ -5155,7 +5155,7 @@ }, { "kind": "conditional-branch", - "line": 2856, + "line": 2891, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw devices list", "surface": "android", @@ -5163,7 +5163,7 @@ }, { "kind": "conditional-branch", - "line": 2863, + "line": 2898, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw nodes approve $requestId", "surface": "android", @@ -5171,7 +5171,7 @@ }, { "kind": "conditional-branch", - "line": 2865, + "line": 2900, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "openclaw nodes approve REQUEST_ID", "surface": "android", @@ -5179,7 +5179,7 @@ }, { "kind": "ui-call", - "line": 2950, + "line": 2985, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Approval command copied", "surface": "android", @@ -5187,7 +5187,7 @@ }, { "kind": "ui-call", - "line": 2959, + "line": 2994, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Command copied", "surface": "android", @@ -5195,7 +5195,7 @@ }, { "kind": "ui-call", - "line": 3026, + "line": 3061, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Enabled", "surface": "android", @@ -5203,7 +5203,7 @@ }, { "kind": "ui-call", - "line": 3027, + "line": 3062, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Off", "surface": "android", @@ -5211,7 +5211,7 @@ }, { "kind": "ui-call", - "line": 3028, + "line": 3063, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Not allowed", "surface": "android", @@ -5219,7 +5219,7 @@ }, { "kind": "ui-call", - "line": 3036, + "line": 3071, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Granted", "surface": "android", @@ -5227,7 +5227,7 @@ }, { "kind": "ui-call", - "line": 3036, + "line": 3071, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Not granted", "surface": "android", @@ -5235,7 +5235,7 @@ }, { "kind": "ui-call", - "line": 3162, + "line": 3197, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Transcribe voice prompts", "surface": "android", @@ -5243,7 +5243,7 @@ }, { "kind": "ui-call", - "line": 3162, + "line": 3197, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Voice", "surface": "android", @@ -5251,7 +5251,7 @@ }, { "kind": "ui-call", - "line": 3167, + "line": 3202, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Camera", "surface": "android", @@ -5259,7 +5259,7 @@ }, { "kind": "ui-call", - "line": 3168, + "line": 3203, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Capture photos and clips from this phone", "surface": "android", @@ -5267,7 +5267,7 @@ }, { "kind": "ui-call", - "line": 3177, + "line": 3212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Location", "surface": "android", @@ -5275,7 +5275,7 @@ }, { "kind": "ui-call", - "line": 3177, + "line": 3212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Read this phone's location", "surface": "android", @@ -5283,7 +5283,7 @@ }, { "kind": "ui-call", - "line": 3181, + "line": 3216, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Photos", "surface": "android", @@ -5291,7 +5291,7 @@ }, { "kind": "ui-call", - "line": 3181, + "line": 3216, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Read recent photos and media", "surface": "android", @@ -5299,7 +5299,7 @@ }, { "kind": "ui-call", - "line": 3187, + "line": 3222, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Contacts", "surface": "android", @@ -5307,7 +5307,7 @@ }, { "kind": "ui-call", - "line": 3187, + "line": 3222, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Find people and contact details", "surface": "android", @@ -5315,7 +5315,7 @@ }, { "kind": "ui-call", - "line": 3190, + "line": 3225, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Calendar", "surface": "android", @@ -5323,7 +5323,7 @@ }, { "kind": "ui-call", - "line": 3190, + "line": 3225, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Read and update events", "surface": "android", @@ -5331,7 +5331,7 @@ }, { "kind": "ui-call", - "line": 3193, + "line": 3228, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Notifications", "surface": "android", @@ -5339,7 +5339,7 @@ }, { "kind": "ui-call", - "line": 3193, + "line": 3228, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Show OpenClaw alerts", "surface": "android", @@ -5347,7 +5347,7 @@ }, { "kind": "ui-call", - "line": 3196, + "line": 3231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Notification listener", "surface": "android", @@ -5355,7 +5355,7 @@ }, { "kind": "ui-call", - "line": 3196, + "line": 3231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Read selected app notifications", "surface": "android", @@ -5363,7 +5363,7 @@ }, { "kind": "ui-call", - "line": 3200, + "line": 3235, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Motion", "surface": "android", @@ -5371,7 +5371,7 @@ }, { "kind": "ui-call", - "line": 3200, + "line": 3235, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Share steps and activity", "surface": "android", @@ -5379,7 +5379,7 @@ }, { "kind": "ui-call", - "line": 3207, + "line": 3242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Device access; Gateway opt-in still required", "surface": "android", @@ -5387,7 +5387,7 @@ }, { "kind": "ui-call", - "line": 3207, + "line": 3242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "SMS", "surface": "android", @@ -5395,7 +5395,7 @@ }, { "kind": "ui-call", - "line": 3214, + "line": 3249, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Call Log", "surface": "android", @@ -5403,7 +5403,7 @@ }, { "kind": "ui-call", - "line": 3214, + "line": 3249, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt", "source": "Show recent call history", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index d9c95dffa987..b48c2d8642a4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -22,6 +22,7 @@ import ai.openclaw.app.ui.design.ClawScaffold import ai.openclaw.app.ui.design.ClawSecondaryButton import ai.openclaw.app.ui.design.ClawTextField import ai.openclaw.app.ui.design.ClawTheme +import ai.openclaw.app.ui.design.MascotMood import ai.openclaw.app.ui.design.OpenClawMascot import android.Manifest import android.content.ClipData @@ -284,6 +285,34 @@ internal fun OnboardingErrorCode.nativeTextOrNull(): NativeText? { } } +/** Visible errors outrank step defaults; connected recovery is the only pre-handoff success surface. */ +internal fun onboardingMascotMood( + step: OnboardingStep, + recoveryState: GatewayRecoveryUiState? = null, + setupErrorCode: OnboardingErrorCode = OnboardingErrorCode.None, + setupScanErrorCode: OnboardingErrorCode = OnboardingErrorCode.None, +): MascotMood { + if ( + setupErrorCode != OnboardingErrorCode.None || + setupScanErrorCode != OnboardingErrorCode.None || + recoveryState == GatewayRecoveryUiState.Failed + ) { + return MascotMood.Sad + } + return when (step) { + OnboardingStep.Recovery -> + if (recoveryState == GatewayRecoveryUiState.Connected) MascotMood.Celebrating else MascotMood.Working + OnboardingStep.NodeApproval -> MascotMood.Thinking + OnboardingStep.Permissions -> MascotMood.Curious + OnboardingStep.Welcome, + OnboardingStep.Gateway, + OnboardingStep.SetupCode, + OnboardingStep.EnterSetupCode, + OnboardingStep.Manual, + -> MascotMood.Idle + } +} + private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L private const val GATEWAY_CONNECT_TIMEOUT_MS = 20_000L private const val NODE_APPROVAL_REFRESH_OBSERVE_TIMEOUT_MS = 750L @@ -762,6 +791,11 @@ fun OnboardingFlow( setupScanErrorCode.nativeTextOrNull()?.let { message -> SetupScanErrorDialog( message = message.resolveNativeTextResource(), + mascotMood = + onboardingMascotMood( + step = step, + setupScanErrorCode = setupScanErrorCode, + ), onDismiss = { setupScanErrorCode = OnboardingErrorCode.None }, onChooseAnotherImage = { setupScanErrorCode = OnboardingErrorCode.None @@ -816,6 +850,7 @@ fun OnboardingFlow( OnboardingStep.Welcome -> WelcomeScreen( modifier = modifier, + mascotMood = onboardingMascotMood(step = step), onConnect = { step = OnboardingStep.Gateway }, ) OnboardingStep.Gateway -> @@ -879,6 +914,7 @@ fun OnboardingFlow( modifier = modifier, setupCode = setupCode, error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(), + mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode), onBack = ::goBack, onSetupCodeChange = { setupCode = it @@ -895,6 +931,7 @@ fun OnboardingFlow( token = token, password = password, error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(), + mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode), onBack = ::goBack, onManualHostChange = { manualHost = it @@ -990,6 +1027,7 @@ fun OnboardingFlow( @Composable private fun WelcomeScreen( + mascotMood: MascotMood, onConnect: () -> Unit, modifier: Modifier = Modifier, ) { @@ -999,7 +1037,7 @@ private fun WelcomeScreen( OnboardingIntroHero( title = nativeString("Welcome to OpenClaw"), subtitle = nativeString("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools."), - mark = { WelcomeLogo() }, + mark = { WelcomeLogo(mood = mascotMood, announceLogo = true) }, ) Spacer(modifier = Modifier.height(24.dp)) WelcomeChecklist() @@ -1014,7 +1052,12 @@ private fun WelcomeScreen( } @Composable -private fun WelcomeLogo() { +private fun WelcomeLogo( + mood: MascotMood, + // Only the welcome hero announces the logo; status/error reuses are + // decorative and must stay silent for TalkBack. + announceLogo: Boolean = false, +) { Surface( modifier = Modifier.size(OnboardingHeroMarkSize), shape = CircleShape, @@ -1023,7 +1066,11 @@ private fun WelcomeLogo() { border = BorderStroke(1.dp, ClawTheme.colors.border), ) { Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) { - OpenClawMascot(contentDescription = nativeString("OpenClaw logo"), modifier = Modifier.fillMaxSize()) + OpenClawMascot( + contentDescription = if (announceLogo) nativeString("OpenClaw logo") else null, + modifier = Modifier.fillMaxSize(), + mood = mood, + ) } } } @@ -1307,6 +1354,7 @@ private fun SetupCodeInstructionsScreen( @Composable private fun SetupScanErrorDialog( message: String, + mascotMood: MascotMood, onDismiss: () -> Unit, onChooseAnotherImage: () -> Unit, onEnterSetupCode: () -> Unit, @@ -1349,6 +1397,10 @@ private fun SetupScanErrorDialog( ) } + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + WelcomeLogo(mood = mascotMood) + } + Text( text = message, style = ClawTheme.type.body, @@ -1644,6 +1696,7 @@ private fun analyzeSetupQrFrame( private fun SetupCodeEntryScreen( setupCode: String, error: String?, + mascotMood: MascotMood, onBack: () -> Unit, onSetupCodeChange: (String) -> Unit, onUseSetupCode: () -> Unit, @@ -1653,6 +1706,11 @@ private fun SetupCodeEntryScreen( Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) { Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { OnboardingHeader(title = nativeText("Enter setup code"), onBack = onBack) + if (error != null) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + WelcomeLogo(mood = mascotMood) + } + } LabeledField(label = nativeString("Setup code")) { ClawTextField( value = setupCode, @@ -1679,6 +1737,7 @@ private fun ManualGatewaySetupScreen( token: String, password: String, error: String?, + mascotMood: MascotMood, onBack: () -> Unit, onManualHostChange: (String) -> Unit, onManualPortChange: (String) -> Unit, @@ -1789,6 +1848,11 @@ private fun ManualGatewaySetupScreen( } } error?.let { message -> + item { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + WelcomeLogo(mood = mascotMood) + } + } item { InlineError(title = nativeString("Could not test connection"), body = message) } @@ -1956,7 +2020,13 @@ private fun GatewayRecoveryScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - GatewayRecoveryIcon(state = recoveryState) + WelcomeLogo( + mood = + onboardingMascotMood( + step = OnboardingStep.Recovery, + recoveryState = recoveryState, + ), + ) Spacer(modifier = Modifier.height(13.dp)) Text(text = recoveryTitle.resolveNativeTextResource(), style = ClawTheme.type.display, color = ClawTheme.colors.text, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(8.dp)) @@ -2052,46 +2122,6 @@ private fun copyGatewayDiagnostic( Toast.makeText(context, nativeString("Details copied"), Toast.LENGTH_SHORT).show() } -@Composable -private fun GatewayRecoveryIcon(state: GatewayRecoveryUiState) { - val icon = - when (state) { - GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.Security - GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering - GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering - GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering - GatewayRecoveryUiState.TakingLonger -> Icons.Default.WifiTethering - GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline - } - val tint = - when (state) { - GatewayRecoveryUiState.Connected -> ClawTheme.colors.success - GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning - GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning - GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text - GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text - GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warning - GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning - } - Surface( - modifier = Modifier.size(62.dp), - shape = CircleShape, - color = - when (state) { - GatewayRecoveryUiState.Connected -> ClawTheme.colors.successSoft - GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warningSoft - GatewayRecoveryUiState.Failed -> ClawTheme.colors.warningSoft - else -> ClawTheme.colors.surfaceRaised - }, - contentColor = tint, - ) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(36.dp), tint = tint) - } - } -} - @Composable private fun NodeApprovalScreen( approval: GatewayNodeCapabilityApproval, @@ -2131,7 +2161,7 @@ private fun NodeApprovalScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - GatewayRecoveryIcon(state = GatewayRecoveryUiState.NodeCapabilityApprovalPending) + WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.NodeApproval)) Spacer(modifier = Modifier.height(13.dp)) Text( text = nativeString("Approve node access"), @@ -2366,6 +2396,11 @@ private fun PermissionSetupScreen( item { PermissionTopBar(onBack = onBack) } + item { + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) { + WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.Permissions)) + } + } item { Text( text = nativeString("Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings."), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotAnimator.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotAnimator.kt new file mode 100644 index 000000000000..f1d6435c1b93 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotAnimator.kt @@ -0,0 +1,515 @@ +package ai.openclaw.app.ui.design + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +private const val NONZERO_SEED: ULong = 0x9E37_79B9_7F4A_7C15uL +private const val XORSHIFT_MULTIPLIER: ULong = 2_685_821_657_736_338_717uL +private const val TAU = PI * 2.0 +private const val BLINK_DURATION = 0.16 +private const val CATCH_DURATION = 0.8 + +private enum class Gesture { + Wave, + Hop, + Celebrate, + Sigh, + Yawn, + ClawSnap, + DonHardHat, + WipeBrow, +} + +private fun clamp( + value: Double, + min: Double = 0.0, + max: Double = 1.0, +): Double = value.coerceIn(min, max) + +private fun cyclePhase( + time: Double, + period: Double, +): Double { + val normalized = (time / period) % 1.0 + return if (normalized < 0.0) normalized + 1.0 else normalized +} + +private fun easeInOut(value: Double): Double { + val t = clamp(value) + return t * t * (3.0 - 2.0 * t) +} + +private fun bell(value: Double): Double { + val t = clamp(value) + return easeInOut(if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0) +} + +private fun plateau( + value: Double, + attack: Double, + release: Double, +): Double { + val t = clamp(value) + if (t < attack) return easeInOut(t / attack) + if (t > release) return easeInOut((1.0 - t) / (1.0 - release)) + return 1.0 +} + +private fun gestureDuration(gesture: Gesture): Double = + when (gesture) { + Gesture.Wave -> 1.5 + Gesture.Hop -> 0.7 + Gesture.Celebrate -> 2.4 + Gesture.Sigh -> 1.8 + Gesture.Yawn, + Gesture.WipeBrow, + -> 2.0 + Gesture.ClawSnap -> 0.6 + Gesture.DonHardHat -> 1.0 + } + +private class SeededGenerator( + seed: ULong, +) { + private var state = if (seed == 0uL) NONZERO_SEED else seed + + fun next(): ULong { + state = state xor (state shr 12) + state = state xor (state shl 25) + state = state xor (state shr 27) + return state * XORSHIFT_MULTIPLIER + } + + fun unit(): Double = (next() shr 11).toDouble() / 9_007_199_254_740_992.0 +} + +/** Pure deterministic mood loops plus randomized blink, gaze, claw-snap, and mood-beat schedules. */ +class MascotAnimator( + seed: ULong = System.nanoTime().toULong(), +) { + private val rng = SeededGenerator(seed) + private var currentMood = MascotMood.Idle + private var startTime: Double? = null + private var lastPoseTime = 0.0 + private var activeGesture: Gesture? = null + private var activeGestureStart = 0.0 + private var pendingGesture: Gesture? = null + private var pendingGestureAt = 0.0 + private var nextBlinkAt = 0.0 + private var pendingDoubleBlink = false + private val blinkStarts = mutableListOf() + private var nextGlanceAt = 0.0 + private var gazeHoldUntil = 0.0 + private var gazeTarget = MascotGaze() + private var currentGaze = MascotGaze() + private var nextClawSnapAt = 0.0 + private var nextMoodBeatAt = 0.0 + private var teaseActive = false + private var teaseChangedAt = 0.0 + private var catchStartedAt: Double? = null + + fun setMood( + mood: MascotMood, + timeSeconds: Double, + ) { + if (mood == currentMood) return + currentMood = mood + // Do not let a queued hello or old mood gesture leak into the new body language. + pendingGesture = null + activeGesture = null + rescheduleMoodBeat(timeSeconds) + entranceGesture(mood)?.let { startGesture(it, timeSeconds) } + } + + fun setTease( + active: Boolean, + timeSeconds: Double, + ) { + teaseActive = active + teaseChangedAt = timeSeconds + } + + fun playCatch(timeSeconds: Double) { + catchStartedAt = timeSeconds + } + + fun poseAt(timeSeconds: Double): MascotPose { + if (startTime == null) begin(timeSeconds) + val dt = clamp(timeSeconds - lastPoseTime, 0.0, 0.1) + lastPoseTime = timeSeconds + advanceSchedules(timeSeconds) + + // TS and Swift phase ambient loops from their host clocks. startTime only + // gates first-run schedules; raw time here preserves cross-platform parity. + val pose = basePose(currentMood, timeSeconds) + applyGaze(pose, currentMood, timeSeconds, dt) + applyBlinks(pose, timeSeconds) + + activeGesture?.let { gesture -> + val progress = (timeSeconds - activeGestureStart) / gestureDuration(gesture) + if (progress >= 1.0) { + activeGesture = null + } else { + applyGesture(gesture, pose, progress) + } + } + + if (teaseActive && timeSeconds >= teaseChangedAt) { + pose.mouthRound = max(pose.mouthRound, 0.5) + pose.gaze = MascotGaze(x = 0.0, y = 0.6) + } + catchStartedAt?.let { startedAt -> + val progress = (timeSeconds - startedAt) / CATCH_DURATION + if (progress >= 1.0) { + catchStartedAt = null + } else if (progress >= 0.0) { + val flash = bell(progress) + applyGesture(Gesture.ClawSnap, pose, clamp(progress / 0.75)) + pose.happyEyes = max(pose.happyEyes, 0.9 * flash) + pose.mouthCurve = max(pose.mouthCurve, 0.7 * flash) + pose.blush = max(pose.blush, 0.65 * flash) + } + } + + return pose.clamp() + } + + private fun begin(timeSeconds: Double) { + startTime = timeSeconds + lastPoseTime = timeSeconds + nextBlinkAt = timeSeconds + random(0.8, 2.4) + nextGlanceAt = timeSeconds + random(1.5, 4.0) + nextClawSnapAt = timeSeconds + random(2.0, 5.0) + rescheduleMoodBeat(timeSeconds) + if (currentMood == MascotMood.Idle || currentMood == MascotMood.Curious || currentMood == MascotMood.Happy) { + pendingGesture = Gesture.Wave + pendingGestureAt = timeSeconds + 0.9 + } + } + + private fun advanceSchedules(timeSeconds: Double) { + if (timeSeconds >= nextBlinkAt) { + blinkStarts.add(timeSeconds) + if (pendingDoubleBlink) { + pendingDoubleBlink = false + nextBlinkAt = timeSeconds + blinkInterval() + } else if (random(0.0, 1.0) < 0.14) { + pendingDoubleBlink = true + nextBlinkAt = timeSeconds + 0.34 + } else { + nextBlinkAt = timeSeconds + blinkInterval() + } + } + blinkStarts.removeAll { start -> timeSeconds - start > BLINK_DURATION } + + if (timeSeconds >= nextGlanceAt) { + gazeTarget = randomGlanceTarget() + gazeHoldUntil = timeSeconds + random(0.7, 1.9) + nextGlanceAt = gazeHoldUntil + glanceInterval() + } else if (timeSeconds >= gazeHoldUntil) { + gazeTarget = MascotGaze() + } + + if (timeSeconds >= nextClawSnapAt) { + if (activeGesture == null && currentMood != MascotMood.Sad && currentMood != MascotMood.Working) { + startGesture(Gesture.ClawSnap, timeSeconds) + } + nextClawSnapAt = timeSeconds + random(4.0, 9.0) + } + + if (timeSeconds >= nextMoodBeatAt) { + if (activeGesture == null) { + when (currentMood) { + MascotMood.Sad -> startGesture(Gesture.Sigh, timeSeconds) + MascotMood.Sleepy -> startGesture(Gesture.Yawn, timeSeconds) + MascotMood.Working -> startGesture(Gesture.WipeBrow, timeSeconds) + else -> Unit + } + } + rescheduleMoodBeat(timeSeconds) + } + + val pending = pendingGesture + if (pending != null && timeSeconds >= pendingGestureAt && activeGesture == null) { + pendingGesture = null + startGesture(pending, timeSeconds) + } + } + + private fun basePose( + mood: MascotMood, + timeSeconds: Double, + ): MascotPose { + val pose = MascotPose() + when (mood) { + MascotMood.Idle -> { + pose.floatOffset = -4.8 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0))) + pose.antennaDegrees = -3.0 * sin(TAU * cyclePhase(timeSeconds, 2.0)) + } + MascotMood.Curious -> { + pose.floatOffset = -4.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.4))) + pose.antennaDegrees = -4.0 * sin(TAU * cyclePhase(timeSeconds, 1.7)) + pose.bodyTilt = 1.6 * sin(TAU * cyclePhase(timeSeconds, 5.2)) + } + MascotMood.Thinking -> { + pose.floatOffset = -3.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.0))) + pose.antennaDegrees = -5.0 * sin(TAU * cyclePhase(timeSeconds, 1.3)) + pose.bodyTilt = 2.0 * sin(TAU * cyclePhase(timeSeconds, 6.0)) + pose.eyeGlowAlpha = 0.9 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 0.8)) + } + MascotMood.Working -> { + val phase = cyclePhase(timeSeconds, 0.95) + pose.rightClawDegrees = + when { + phase < 0.05 -> -6.0 + phase < 0.6 -> -6.0 - 28.0 * easeInOut((phase - 0.05) / 0.55) + phase < 0.72 -> { + val strike = clamp((phase - 0.6) / 0.12) + -34.0 + 46.0 * strike * strike + } + else -> 12.0 - 18.0 * easeInOut((phase - 0.72) / 0.28) + } + pose.leftClawDegrees = 4.0 + 2.0 * sin(TAU * phase) + val impact = bell(clamp((phase - 0.72) / 0.14)) + pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.8))) + 0.8 * impact + pose.bodyStretch = 1.0 - 0.03 * impact + pose.bodyTilt = 2.2 + 0.6 * sin(TAU * cyclePhase(timeSeconds, 5.0)) + if (phase >= 0.72) { + val recoil = clamp((phase - 0.72) / 0.28) + pose.antennaDegrees = 6.0 * (1.0 - recoil) * sin(recoil * 3.0 * PI) + } + pose.leftEyeOpenness = 0.85 + pose.rightEyeOpenness = 0.85 + pose.mouthCurve = 0.18 + pose.hardHat = 1.0 + pose.effect = MascotEffect.Sparks + val strikePhase = (phase - 0.72) % 1.0 + pose.effectPhase = if (strikePhase < 0.0) strikePhase + 1.0 else strikePhase + } + MascotMood.Happy -> { + pose.floatOffset = -6.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.0))) + pose.antennaDegrees = -4.5 * sin(TAU * cyclePhase(timeSeconds, 1.6)) + pose.mouthCurve = 0.55 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 3.0)) + pose.happyEyes = 0.35 + } + MascotMood.Celebrating -> { + val hop = abs(sin(TAU * cyclePhase(timeSeconds, 1.6))) + pose.floatOffset = -9.0 * hop + pose.bodyStretch = 1.0 + 0.03 * hop + pose.antennaDegrees = -6.0 * sin(TAU * cyclePhase(timeSeconds, 0.8)) + val clawWave = sin(TAU * cyclePhase(timeSeconds, 0.9)) + pose.leftClawDegrees = 20.0 + 8.0 * clawWave + pose.rightClawDegrees = -20.0 + 8.0 * clawWave + pose.mouthCurve = 0.9 + pose.mouthOpen = 0.35 + pose.happyEyes = 0.7 + pose.glowScale = 1.1 + pose.effect = MascotEffect.Sparkles + pose.effectPhase = cyclePhase(timeSeconds, 2.2) + } + MascotMood.Sad -> { + pose.floatOffset = -2.4 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.5))) + pose.antennaDegrees = -1.5 * sin(TAU * cyclePhase(timeSeconds, 3.0)) + pose.antennaDroop = 0.75 + pose.mouthCurve = -0.55 + pose.eyeGlowAlpha = 0.6 + } + MascotMood.Sleepy -> { + pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 6.0))) + pose.antennaDroop = 0.35 + pose.leftEyeOpenness = 0.22 + 0.08 * sin(TAU * cyclePhase(timeSeconds, 3.0)) + pose.rightEyeOpenness = pose.leftEyeOpenness + pose.eyeGlowAlpha = 0.5 + pose.mouthRound = 0.15 + pose.bodyTilt = 2.5 * sin(TAU * cyclePhase(timeSeconds, 6.0)) + pose.effect = MascotEffect.Zzz + pose.effectPhase = cyclePhase(timeSeconds, 3.0) + } + MascotMood.Attentive -> { + pose.floatOffset = -3.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0))) + pose.antennaDegrees = -2.5 * sin(TAU * cyclePhase(timeSeconds, 2.0)) + pose.mouthCurve = 0.25 + } + } + return pose + } + + private fun applyGaze( + pose: MascotPose, + mood: MascotMood, + timeSeconds: Double, + dt: Double, + ) { + val target = + when (mood) { + MascotMood.Thinking -> MascotGaze(x = 0.4 * sin(TAU * cyclePhase(timeSeconds, 3.8)), y = -0.55) + MascotMood.Working -> + MascotGaze( + x = 0.55 + 0.04 * sin(TAU * cyclePhase(timeSeconds, 4.6)), + y = 0.45 + 0.02 * cos(TAU * cyclePhase(timeSeconds, 3.9)), + ) + MascotMood.Attentive -> MascotGaze(x = gazeTarget.x * 0.5, y = 0.35) + MascotMood.Sad -> MascotGaze(x = gazeTarget.x * 0.3, y = 0.5) + MascotMood.Sleepy -> MascotGaze(x = 0.0, y = 0.4) + MascotMood.Idle, + MascotMood.Curious, + MascotMood.Happy, + MascotMood.Celebrating, + -> gazeTarget + } + val blend = 1.0 - exp(-dt * 9.0) + currentGaze = + MascotGaze( + x = currentGaze.x + (target.x - currentGaze.x) * blend, + y = currentGaze.y + (target.y - currentGaze.y) * blend, + ) + pose.gaze = currentGaze + } + + private fun applyBlinks( + pose: MascotPose, + timeSeconds: Double, + ) { + if (pose.happyEyes >= 0.6) return + for (start in blinkStarts) { + val progress = (timeSeconds - start) / BLINK_DURATION + if (progress < 0.0 || progress > 1.0) continue + val closure = bell(progress) + pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - closure) + pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - closure) + pose.eyeGlowAlpha *= max(0.3, 1.0 - closure) + } + } + + private fun applyGesture( + gesture: Gesture, + pose: MascotPose, + progress: Double, + ) { + val p = clamp(progress) + when (gesture) { + Gesture.Wave -> { + val raised = plateau(p, 0.18, 0.82) + pose.rightClawDegrees += raised * (-28.0 + 9.0 * sin(p * 6.0 * PI)) + pose.bodyTilt += -2.0 * raised + pose.mouthCurve = max(pose.mouthCurve, 0.5 * raised) + } + Gesture.Hop -> { + val air = bell(clamp((p - 0.2) / 0.6)) + pose.floatOffset += -9.0 * air + pose.bodyStretch += + 0.045 * air - + 0.1 * bell(clamp(p / 0.2)) - + 0.06 * bell(clamp((p - 0.82) / 0.18)) + pose.mouthCurve = max(pose.mouthCurve, 0.4 * air) + } + Gesture.Celebrate -> { + val envelope = plateau(p, 0.12, 0.88) + val hops = abs(sin(p * 4.0 * PI)) + pose.floatOffset += -11.0 * hops * envelope + pose.bodyStretch += 0.035 * hops * envelope + pose.leftClawDegrees += 38.0 * envelope + pose.rightClawDegrees += -38.0 * envelope + pose.happyEyes = max(pose.happyEyes, envelope) + pose.mouthCurve = max(pose.mouthCurve, envelope) + pose.mouthOpen = max(pose.mouthOpen, 0.6 * bell(p)) + pose.antennaDroop = 0.0 + pose.glowScale = max(pose.glowScale, 1.0 + 0.2 * envelope) + pose.effect = MascotEffect.Sparkles + pose.effectPhase = p + } + Gesture.Sigh -> { + val rise = easeInOut(clamp(p / 0.3)) + val fall = easeInOut(clamp((p - 0.3) / 0.45)) + pose.bodyStretch += 0.025 * rise - 0.08 * fall * (1.0 - clamp((p - 0.85) / 0.15)) + pose.gaze = MascotGaze(x = pose.gaze.x, y = 0.5 * fall) + pose.antennaDroop = min(1.0, pose.antennaDroop + 0.15 * fall) + } + Gesture.Yawn -> { + val openness = plateau(p, 0.3, 0.75) + pose.mouthRound = max(pose.mouthRound, 0.9 * openness) + pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - 0.9 * openness) + pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - 0.9 * openness) + pose.bodyStretch += 0.03 * openness + pose.bodyTilt += -2.0 * openness + } + Gesture.ClawSnap -> { + pose.leftClawDegrees += -8.0 * bell(clamp(p / 0.7)) + pose.rightClawDegrees += -8.0 * bell(clamp((p - 0.25) / 0.7)) + } + Gesture.DonHardHat -> { + val drop = easeInOut(clamp(p / 0.55)) + pose.hardHat = min(pose.hardHat, drop) + if (p < 0.55) pose.gaze = MascotGaze(x = 0.0, y = -0.9 * (1.0 - p)) + pose.bodyStretch -= 0.04 * bell(clamp((p - 0.5) / 0.2)) + val ready = bell(clamp((p - 0.7) / 0.3)) + pose.leftClawDegrees += -8.0 * ready + pose.rightClawDegrees += 8.0 * ready + } + Gesture.WipeBrow -> { + val envelope = plateau(p, 0.2, 0.8) + pose.leftClawDegrees *= 1.0 - envelope + pose.rightClawDegrees *= 1.0 - envelope + pose.leftClawDegrees += 38.0 * envelope * (0.9 + 0.1 * sin(p * 5.0 * PI)) + pose.bodyTilt *= 1.0 - envelope + pose.bodyStretch += 0.02 * envelope + pose.happyEyes = max(pose.happyEyes, 0.7 * envelope) + pose.mouthCurve = max(pose.mouthCurve, 0.5 * envelope) + pose.gaze = MascotGaze(x = pose.gaze.x * (1.0 - envelope), y = pose.gaze.y * (1.0 - envelope)) + pose.effect = MascotEffect.Sweat + pose.effectPhase = p + } + } + } + + private fun entranceGesture(mood: MascotMood): Gesture? = + when (mood) { + MascotMood.Happy -> Gesture.Hop + MascotMood.Celebrating -> Gesture.Celebrate + MascotMood.Sad -> Gesture.Sigh + MascotMood.Sleepy -> Gesture.Yawn + MascotMood.Working -> Gesture.DonHardHat + MascotMood.Idle, + MascotMood.Curious, + MascotMood.Thinking, + MascotMood.Attentive, + -> null + } + + private fun startGesture( + gesture: Gesture, + timeSeconds: Double, + ) { + activeGesture = gesture + activeGestureStart = timeSeconds + } + + private fun random( + min: Double, + max: Double, + ): Double = min + (max - min) * rng.unit() + + private fun blinkInterval(): Double = if (currentMood == MascotMood.Attentive) random(1.8, 4.0) else random(2.2, 5.5) + + private fun glanceInterval(): Double = + when (currentMood) { + MascotMood.Curious -> random(1.6, 4.0) + MascotMood.Thinking -> random(1.2, 3.0) + else -> random(3.0, 8.0) + } + + private fun randomGlanceTarget(): MascotGaze { + val magnitude = random(0.5, 1.0) + val angle = random(0.0, TAU) + return MascotGaze(x = cos(angle) * magnitude, y = sin(angle) * magnitude * 0.6) + } + + private fun rescheduleMoodBeat(timeSeconds: Double) { + nextMoodBeatAt = timeSeconds + random(6.0, 12.0) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotPose.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotPose.kt new file mode 100644 index 000000000000..7d20f203bb12 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotPose.kt @@ -0,0 +1,127 @@ +package ai.openclaw.app.ui.design + +enum class MascotMood { + Idle, + Curious, + Thinking, + Working, + Happy, + Celebrating, + Sad, + Sleepy, + Attentive, +} + +enum class MascotEffect { + None, + Sparkles, + Zzz, + Sparks, + Sweat, +} + +data class MascotGaze( + val x: Double = 0.0, + val y: Double = 0.0, +) + +data class MascotPose( + var floatOffset: Double = 0.0, + var antennaDegrees: Double = 0.0, + var antennaDroop: Double = 0.0, + var leftClawDegrees: Double = 0.0, + var rightClawDegrees: Double = 0.0, + var eyeGlowAlpha: Double = 1.0, + var glowScale: Double = 1.0, + var leftEyeOpenness: Double = 1.0, + var rightEyeOpenness: Double = 1.0, + var happyEyes: Double = 0.0, + var gaze: MascotGaze = MascotGaze(), + var mouthCurve: Double = 0.0, + var mouthOpen: Double = 0.0, + var mouthRound: Double = 0.0, + var blush: Double = 0.0, + var hardHat: Double = 0.0, + var bodyTilt: Double = 0.0, + var bodyStretch: Double = 1.0, + var effect: MascotEffect = MascotEffect.None, + var effectPhase: Double = 0.0, +) { + /** Keeps every channel inside the drawable 120x120 art-space bounds. */ + fun clamp(): MascotPose { + floatOffset = floatOffset.coerceIn(-12.0, 2.0) + antennaDegrees = antennaDegrees.coerceIn(-14.0, 14.0) + antennaDroop = antennaDroop.coerceIn(0.0, 1.0) + leftClawDegrees = leftClawDegrees.coerceIn(-45.0, 45.0) + rightClawDegrees = rightClawDegrees.coerceIn(-45.0, 45.0) + eyeGlowAlpha = eyeGlowAlpha.coerceIn(0.0, 1.0) + glowScale = glowScale.coerceIn(0.5, 1.6) + leftEyeOpenness = leftEyeOpenness.coerceIn(0.0, 1.0) + rightEyeOpenness = rightEyeOpenness.coerceIn(0.0, 1.0) + happyEyes = happyEyes.coerceIn(0.0, 1.0) + gaze = MascotGaze(gaze.x.coerceIn(-1.2, 1.2), gaze.y.coerceIn(-1.2, 1.2)) + mouthCurve = mouthCurve.coerceIn(-1.0, 1.0) + mouthOpen = mouthOpen.coerceIn(0.0, 1.0) + mouthRound = mouthRound.coerceIn(0.0, 1.0) + blush = blush.coerceIn(0.0, 1.0) + hardHat = hardHat.coerceIn(0.0, 1.0) + bodyTilt = bodyTilt.coerceIn(-8.0, 8.0) + bodyStretch = bodyStretch.coerceIn(0.86, 1.05) + return this + } + + companion object { + /** Motionless mood expression used when Android animations are disabled. */ + fun staticPose(mood: MascotMood): MascotPose = + MascotPose().apply { + when (mood) { + MascotMood.Idle, + MascotMood.Curious, + MascotMood.Attentive, + -> Unit + MascotMood.Thinking -> gaze = MascotGaze(x = 0.3, y = -0.5) + MascotMood.Working -> { + hardHat = 1.0 + rightClawDegrees = -28.0 + gaze = MascotGaze(x = 0.4, y = 0.35) + mouthCurve = 0.15 + bodyTilt = 2.0 + } + MascotMood.Happy -> { + mouthCurve = 0.6 + happyEyes = 0.4 + } + MascotMood.Celebrating -> { + mouthCurve = 0.9 + mouthOpen = 0.4 + happyEyes = 0.8 + leftClawDegrees = 30.0 + rightClawDegrees = -30.0 + } + MascotMood.Sad -> { + antennaDroop = 0.75 + mouthCurve = -0.55 + eyeGlowAlpha = 0.6 + gaze = MascotGaze(x = 0.0, y = 0.5) + } + MascotMood.Sleepy -> { + leftEyeOpenness = 0.25 + rightEyeOpenness = 0.25 + eyeGlowAlpha = 0.5 + antennaDroop = 0.35 + } + } + } + } +} + +fun staticPose(mood: MascotMood): MascotPose = MascotPose.staticPose(mood) + +/** + * Tinted silhouettes stay on the ambient idle loop: mood faces cannot read in + * monochrome, and tiny tinted toolbar marks must not hammer or celebrate. + */ +internal fun effectiveMascotMood( + mood: MascotMood, + tinted: Boolean, +): MascotMood = if (tinted) MascotMood.Idle else mood diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/OpenClawMascot.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/OpenClawMascot.kt index e239146e70d8..4155dcf76278 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/OpenClawMascot.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/OpenClawMascot.kt @@ -1,25 +1,24 @@ package ai.openclaw.app.ui.design import android.provider.Settings -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.InfiniteRepeatableSpec -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.keyframes -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.withTransform @@ -29,6 +28,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin // Canonical 120x120 mascot geometry from ui/public/favicon.svg; parts stay // separate paths so claws, antennae, and eyes can animate independently. @@ -44,40 +47,56 @@ private val RightClawPath = PathParser().parsePathString("M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z").toPath() private val LeftAntennaPath = PathParser().parsePathString("M45 15 Q35 5 30 8").toPath() private val RightAntennaPath = PathParser().parsePathString("M75 15 Q85 5 90 8").toPath() +private val HardHatDomePath = + PathParser().parsePathString("M45 15 C47 7 54 3 60 3 C66 3 73 7 75 15 L45 15 Z").toPath() private val CoralBright = Color(0xFFFF4D4D) private val CoralDark = Color(0xFF991B1B) private val EyeDark = Color(0xFF050810) private val EyeGlow = Color(0xFF00E5CC) +private val Blush = Color(0xFFFF9EAE) +private val HatAmber = Color(0xFFF2A833) +private val HatLight = Color(0xFFFFD659) +private val HatOutline = Color(0xB8B8731F) +private val SweatBlue = Color(0xFF80D4FF) -// Claws hinge on their body-facing edge, antennae rotate around their own center. private val LeftClawPivot = Offset(26f, 53f) private val RightClawPivot = Offset(94f, 53f) private val LeftAntennaPivot = Offset(37.5f, 11f) private val RightAntennaPivot = Offset(82.5f, 11f) +private val LeftEyeCenter = Offset(45f, 35f) +private val RightEyeCenter = Offset(75f, 35f) -private val EaseInOut = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) - -private class MascotPose( - val floatOffset: State, - val antennaDegrees: State, - val leftClawDegrees: State, - val rightClawDegrees: State, - val eyeGlowAlpha: State, -) - -/** - * Animated OpenClaw mascot mirroring the openclaw.ai hero mark: body float, - * antenna wiggle, eye blink, and staggered claw snaps. With [tint] the mascot - * renders as a single-color silhouette (replacement for tinted [Icon] usage). - */ +/** Animated 120x120 OpenClaw mascot. [tint] keeps the single-color icon rendering path. */ @Composable fun OpenClawMascot( modifier: Modifier = Modifier, tint: Color? = null, contentDescription: String? = null, + mood: MascotMood = MascotMood.Idle, ) { - val pose = rememberMascotPose() + val context = LocalContext.current + val animationsEnabled = + remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f + } + val animator = remember { MascotAnimator() } + var pose by remember { mutableStateOf(staticPose(mood)) } + + LaunchedEffect(animationsEnabled, mood, tint) { + if (!animationsEnabled) { + pose = staticPose(effectiveMascotMood(mood = mood, tinted = tint != null)) + return@LaunchedEffect + } + while (true) { + withFrameNanos { frameTimeNanos -> + val timeSeconds = frameTimeNanos / 1_000_000_000.0 + animator.setMood(effectiveMascotMood(mood = mood, tinted = tint != null), timeSeconds) + pose = animator.poseAt(timeSeconds) + } + } + } + val semantics = if (contentDescription == null) { Modifier @@ -88,143 +107,313 @@ fun OpenClawMascot( } } Canvas(modifier = modifier.then(semantics)) { - val scale = size.minDimension / 120f + val artScale = size.minDimension / 120f withTransform({ - scale(scale, scale, pivot = Offset.Zero) - translate(top = pose.floatOffset.value) + scale(artScale, artScale, pivot = Offset.Zero) + translate(top = pose.floatOffset.toFloat()) }) { drawMascot(pose, tint) } } } -@Composable -private fun rememberMascotPose(): MascotPose { - val context = LocalContext.current - // Compose infinite transitions ignore the system animator scale; honor the - // OS "remove animations" setting explicitly with a static pose. - val animationsEnabled = - remember(context) { - Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f - } - if (!animationsEnabled) { - return remember { - val zero = mutableFloatStateOf(0f) - MascotPose(zero, zero, zero, zero, mutableFloatStateOf(1f)) - } - } - - val transition = rememberInfiniteTransition(label = "openclawMascot") - val floatOffset = - transition.animateFloat( - initialValue = 0f, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - keyframes { - durationMillis = 4000 - 0f at 0 using EaseInOut - -5f at 2000 using EaseInOut - }, - ), - label = "float", - ) - val antennaDegrees = - transition.animateFloat( - initialValue = 0f, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - keyframes { - durationMillis = 2000 - 0f at 0 using EaseInOut - -3f at 500 using EaseInOut - 3f at 1500 using EaseInOut - }, - ), - label = "antenna", - ) - val leftClawDegrees = - transition.animateFloat( - initialValue = 0f, - targetValue = 0f, - animationSpec = infiniteRepeatable(clawSnapKeyframes()), - label = "clawLeft", - ) - val rightClawDegrees = - transition.animateFloat( - initialValue = 0f, - targetValue = 0f, - animationSpec = - InfiniteRepeatableSpec( - clawSnapKeyframes(), - RepeatMode.Restart, - initialStartOffset = StartOffset(200), - ), - label = "clawRight", - ) - val eyeGlowAlpha = - transition.animateFloat( - initialValue = 1f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - keyframes { - durationMillis = 3000 - 1f at 0 using EaseInOut - 1f at 2700 using EaseInOut - 0.3f at 2850 using EaseInOut - }, - ), - label = "blink", - ) - return remember(transition) { - MascotPose(floatOffset, antennaDegrees, leftClawDegrees, rightClawDegrees, eyeGlowAlpha) - } -} - -private fun clawSnapKeyframes() = - keyframes { - durationMillis = 4000 - 0f at 0 using EaseInOut - 0f at 3400 using EaseInOut - -8f at 3600 using EaseInOut - 0f at 3800 using EaseInOut - } - private fun DrawScope.drawMascot( pose: MascotPose, tint: Color?, ) { - val bodyBrush = - if (tint == null) { - Brush.linearGradient( - colors = listOf(CoralBright, CoralDark), - start = Offset.Zero, - end = Offset(120f, 120f), - ) - } else { - SolidColor(tint) + val stretchY = pose.bodyStretch.toFloat() + val stretchX = (1.0 + (1.0 - pose.bodyStretch) * 0.5).coerceIn(0.97, 1.03).toFloat() + withTransform({ + if (stretchY != 1f) scale(stretchX, stretchY, pivot = Offset(60f, 110f)) + if (pose.bodyTilt != 0.0) rotate(pose.bodyTilt.toFloat(), pivot = Offset(60f, 60f)) + }) { + val bodyBrush = + tint?.let(::SolidColor) + ?: Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(15f, 10f), + end = Offset(105f, 110f), + ) + val leftClawBrush = + tint?.let(::SolidColor) + ?: Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(3.125f, 43.67f), + end = Offset(26.197f, 65.451f), + ) + val rightClawBrush = + tint?.let(::SolidColor) + ?: Brush.linearGradient( + colors = listOf(CoralBright, CoralDark), + start = Offset(93.803f, 43.67f), + end = Offset(116.875f, 65.451f), + ) + + drawPath(BodyPath, bodyBrush) + withTransform({ rotate(pose.leftClawDegrees.toFloat(), pivot = LeftClawPivot) }) { + drawPath(LeftClawPath, leftClawBrush) + } + withTransform({ rotate(pose.rightClawDegrees.toFloat(), pivot = RightClawPivot) }) { + drawPath(RightClawPath, rightClawBrush) + } + + val antennaColor = tint ?: CoralBright + val antennaStroke = Stroke(width = 2f, cap = StrokeCap.Round) + val wiggle = (pose.antennaDegrees * (1.0 - pose.antennaDroop)).toFloat() + withTransform({ rotate((-pose.antennaDroop * 40.0).toFloat(), pivot = Offset(45f, 15f)) }) { + withTransform({ rotate(wiggle, pivot = LeftAntennaPivot) }) { + drawPath(LeftAntennaPath, antennaColor, style = antennaStroke) + } + } + withTransform({ rotate((pose.antennaDroop * 40.0).toFloat(), pivot = Offset(75f, 15f)) }) { + withTransform({ rotate(wiggle, pivot = RightAntennaPivot) }) { + drawPath(RightAntennaPath, antennaColor, style = antennaStroke) + } + } + + drawHardHat(pose.hardHat.toFloat(), tint) + if (tint == null) { + drawBlush(pose) + drawEye(LeftEyeCenter, pose.leftEyeOpenness, pose) + drawEye(RightEyeCenter, pose.rightEyeOpenness, pose) + drawMouth(pose) + drawEffect(pose) } - // Same paint order as favicon.svg: body, claws, antennae, eyes. - drawPath(BodyPath, bodyBrush) - withTransform({ rotate(pose.leftClawDegrees.value, pivot = LeftClawPivot) }) { - drawPath(LeftClawPath, bodyBrush) } - withTransform({ rotate(pose.rightClawDegrees.value, pivot = RightClawPivot) }) { - drawPath(RightClawPath, bodyBrush) - } - val antennaColor = tint ?: CoralBright - val antennaStroke = Stroke(width = 3f, cap = StrokeCap.Round) - withTransform({ rotate(pose.antennaDegrees.value, pivot = LeftAntennaPivot) }) { - drawPath(LeftAntennaPath, antennaColor, style = antennaStroke) - } - withTransform({ rotate(pose.antennaDegrees.value, pivot = RightAntennaPivot) }) { - drawPath(RightAntennaPath, antennaColor, style = antennaStroke) - } - drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(45f, 35f)) - drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(75f, 35f)) - val glowColor = tint ?: EyeGlow - drawCircle(glowColor, radius = 2.5f, center = Offset(46f, 34f), alpha = pose.eyeGlowAlpha.value) - drawCircle(glowColor, radius = 2.5f, center = Offset(76f, 34f), alpha = pose.eyeGlowAlpha.value) +} + +private fun DrawScope.drawEye( + center: Offset, + openness: Double, + pose: MascotPose, +) { + val shifted = + Offset( + x = center.x + (pose.gaze.x * 2.0).toFloat(), + y = center.y + (pose.gaze.y * 1.5).toFloat(), + ) + if (pose.happyEyes < 1.0) { + val height = max(1.2, 12.0 * openness * (1.0 - 0.6 * pose.happyEyes)).toFloat() + val eyeCenterY = shifted.y - 6f + (12f - height) * 0.65f + height / 2f + drawOval( + color = EyeDark, + topLeft = Offset(shifted.x - 6f, eyeCenterY - height / 2f), + size = Size(12f, height), + alpha = (1.0 - pose.happyEyes).toFloat(), + ) + } + if (pose.happyEyes > 0.0) { + val arc = + Path().apply { + moveTo(shifted.x - 6f, shifted.y + 2f) + quadraticTo(shifted.x, shifted.y - 5.5f, shifted.x + 6f, shifted.y + 2f) + } + drawPath( + path = arc, + color = EyeDark, + alpha = pose.happyEyes.toFloat(), + style = Stroke(width = 2.6f, cap = StrokeCap.Round), + ) + } + + val glowVisibility = pose.eyeGlowAlpha * openness * (1.0 - pose.happyEyes) + if (glowVisibility <= 0.01) return + val glowRadius = (2.0 * pose.glowScale).toFloat() + val glowCenter = + Offset( + x = shifted.x + 1f + (pose.gaze.x * 1.2).toFloat(), + y = shifted.y - 1f + (pose.gaze.y * 0.9).toFloat(), + ) + drawCircle(EyeGlow, radius = glowRadius, center = glowCenter, alpha = glowVisibility.toFloat()) +} + +private fun DrawScope.drawMouth(pose: MascotPose) { + when { + pose.mouthRound > 0.05 -> { + val radiusX = (1.0 + 3.2 * pose.mouthRound).toFloat() + val radiusY = (1.0 + 4.2 * pose.mouthRound).toFloat() + drawOval( + color = EyeDark, + topLeft = Offset(60f - radiusX, 51f - radiusY), + size = Size(radiusX * 2f, radiusY * 2f), + ) + } + pose.mouthOpen > 0.05 -> { + val grin = + Path().apply { + moveTo(52.5f, 48.5f) + quadraticTo(60f, (48.5 + 14.0 * pose.mouthOpen).toFloat(), 67.5f, 48.5f) + close() + } + drawPath(grin, EyeDark) + } + kotlin.math.abs(pose.mouthCurve) > 0.05 -> { + val curve = + Path().apply { + moveTo(52.5f, 49f) + quadraticTo(60f, (49.0 + 8.0 * pose.mouthCurve).toFloat(), 67.5f, 49f) + } + drawPath(curve, EyeDark, style = Stroke(width = 2.2f, cap = StrokeCap.Round)) + } + } +} + +private fun DrawScope.drawBlush(pose: MascotPose) { + if (pose.blush <= 0.02) return + val alpha = (pose.blush * 0.55).toFloat() + drawOval(Blush, topLeft = Offset(32.5f, 42.5f), size = Size(9f, 5f), alpha = alpha) + drawOval(Blush, topLeft = Offset(78.5f, 42.5f), size = Size(9f, 5f), alpha = alpha) +} + +private fun DrawScope.drawHardHat( + amount: Float, + tint: Color?, +) { + if (amount <= 0.01f) return + withTransform({ + translate(top = -14f * (1f - amount)) + rotate(-5f, pivot = Offset(60f, 15f)) + }) { + val fill = + tint?.let(::SolidColor) + ?: Brush.verticalGradient(colors = listOf(HatLight, HatAmber), startY = 3f, endY = 16f) + drawPath(HardHatDomePath, fill, alpha = amount) + if (tint == null) { + drawPath(HardHatDomePath, HatOutline, alpha = amount, style = Stroke(width = 0.8f)) + } + drawRoundRect( + brush = tint?.let(::SolidColor) ?: SolidColor(HatAmber), + topLeft = Offset(41f, 14f), + size = Size(38f, 5f), + cornerRadius = CornerRadius(2f, 2f), + alpha = amount, + ) + if (tint == null) { + drawRoundRect( + color = HatOutline, + topLeft = Offset(41f, 14f), + size = Size(38f, 5f), + cornerRadius = CornerRadius(2f, 2f), + alpha = amount, + style = Stroke(width = 0.8f), + ) + } + } +} + +private fun DrawScope.drawEffect(pose: MascotPose) { + when (pose.effect) { + MascotEffect.None -> Unit + MascotEffect.Sparkles -> { + repeat(6) { index -> + val phase = (pose.effectPhase + index * 0.37) % 1.0 + val alpha = effectBell(phase) + if (alpha > 0.05) { + val angle = PI + PI * (index + 0.5) / 6.0 + val center = + Offset( + x = (60.0 + cos(angle) * (50.0 + index % 3 * 4.0)).toFloat(), + y = (55.0 + sin(angle) * (40.0 + (index * 5) % 3 * 4.0)).toFloat(), + ) + drawPath( + sparklePath(center, (2.5 + 2.0 * alpha).toFloat()), + if (index % 2 == 0) EyeGlow else CoralBright, + alpha = alpha.toFloat(), + ) + } + } + } + MascotEffect.Zzz -> { + repeat(3) { index -> + val phase = (pose.effectPhase + index * 0.33) % 1.0 + val alpha = if (phase < 0.2) phase / 0.2 else 1.0 - (phase - 0.2) / 0.8 + if (alpha > 0.05) { + drawZ( + position = + Offset( + x = (86.0 + 14.0 * phase + 2.0 * sin(phase * 4.0 * PI)).toFloat(), + y = (24.0 - 20.0 * phase).toFloat(), + ), + size = (6.0 + 4.0 * phase).toFloat(), + alpha = alpha.toFloat(), + ) + } + } + } + MascotEffect.Sparks -> { + repeat(5) { index -> + val rawPhase = pose.effectPhase - index * 0.025 + if (rawPhase >= 0.0 && rawPhase < 0.45) { + val alpha = if (rawPhase < 0.08) rawPhase / 0.08 else 1.0 - (rawPhase - 0.08) / 0.37 + val angle = Math.toRadians(-160.0 + index * 35.0) + val radius = 5.0 + 12.0 * rawPhase / 0.45 + val particleSize = (2.2 + index % 3 * 0.8).toFloat() + val center = + Offset( + x = (106.0 + cos(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(), + y = (66.0 + sin(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(), + ) + drawPath( + sparklePath(center, particleSize), + if (index % 2 == 0) EyeGlow else HatAmber, + alpha = alpha.toFloat(), + ) + } + } + } + MascotEffect.Sweat -> { + val alpha = effectBell(pose.effectPhase) + if (alpha > 0.02) { + val center = Offset(42f, (24.0 + 7.0 * pose.effectPhase).toFloat()) + val drop = + Path().apply { + moveTo(center.x, center.y - 3f) + cubicTo(center.x - 4f, center.y + 1f, center.x - 2f, center.y + 3f, center.x, center.y + 3f) + cubicTo(center.x + 2f, center.y + 3f, center.x + 4f, center.y + 1f, center.x, center.y - 3f) + close() + } + drawPath(drop, SweatBlue, alpha = alpha.toFloat()) + } + } + } +} + +private fun sparklePath( + center: Offset, + size: Float, +): Path = + Path().apply { + moveTo(center.x, center.y - size) + listOf(1f to 0f, 0f to 1f, -1f to 0f, 0f to -1f).forEach { (dx, dy) -> + quadraticTo(center.x, center.y, center.x + size * dx, center.y + size * dy) + } + close() + } + +private fun DrawScope.drawZ( + position: Offset, + size: Float, + alpha: Float, +) { + val width = size * 0.62f + val height = size * 0.78f + val path = + Path().apply { + moveTo(position.x - width / 2f, position.y - height / 2f) + lineTo(position.x + width / 2f, position.y - height / 2f) + lineTo(position.x - width / 2f, position.y + height / 2f) + lineTo(position.x + width / 2f, position.y + height / 2f) + } + drawPath( + path = path, + color = EyeGlow, + alpha = alpha * 0.9f, + style = Stroke(width = max(1.2f, size * 0.16f), cap = StrokeCap.Round, join = StrokeJoin.Round), + ) +} + +private fun effectBell(value: Double): Double { + val t = value.coerceIn(0.0, 1.0) + val edge = if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0 + return edge * edge * (3.0 - 2.0 * edge) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt index 06f60af60d49..dfaf6e2a1c7f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt @@ -6,6 +6,7 @@ import ai.openclaw.app.LocationMode import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.i18n.nativeText import ai.openclaw.app.i18n.resolveNativeText +import ai.openclaw.app.ui.design.MascotMood import android.Manifest import androidx.compose.runtime.saveable.SaverScope import kotlinx.coroutines.CompletableDeferred @@ -19,6 +20,43 @@ import org.junit.Test import java.util.Base64 class OnboardingFlowLogicTest { + @Test + fun mascotMoodTracksVisibleOnboardingState() { + assertEquals(MascotMood.Idle, onboardingMascotMood(OnboardingStep.Welcome)) + assertEquals(MascotMood.Curious, onboardingMascotMood(OnboardingStep.Permissions)) + assertEquals(MascotMood.Thinking, onboardingMascotMood(OnboardingStep.NodeApproval)) + assertEquals( + MascotMood.Working, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Finishing), + ) + assertEquals( + MascotMood.Working, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.TakingLonger), + ) + assertEquals( + MascotMood.Celebrating, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Connected), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Failed), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood( + step = OnboardingStep.EnterSetupCode, + setupErrorCode = OnboardingErrorCode.SetupCodeRejected, + ), + ) + assertEquals( + MascotMood.Sad, + onboardingMascotMood( + step = OnboardingStep.SetupCode, + setupScanErrorCode = OnboardingErrorCode.InvalidSetupQr, + ), + ) + } + @Test fun onboardingBackDestinationsMatchTheVisibleFlow() { assertEquals(null, onboardingBackDestination(OnboardingStep.Welcome)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt new file mode 100644 index 000000000000..3e402392f27b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt @@ -0,0 +1,194 @@ +package ai.openclaw.app.ui.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MascotAnimatorTest { + @Test + fun allMoodChannelsStayInsideBoundsForThirtySeconds() { + MascotMood.entries.forEachIndexed { index, mood -> + val animator = MascotAnimator(seed = (index + 1).toULong()) + animator.setMood(mood, 0.0) + + repeat(3_001) { frame -> + val pose = animator.poseAt(frame / 100.0) + assertInBounds(pose, mood, frame) + } + } + } + + @Test + fun sameSeedProducesIdenticalPoses() { + val first = MascotAnimator(seed = 0xCAFE_BABEuL) + val second = MascotAnimator(seed = 0xCAFE_BABEuL) + first.setMood(MascotMood.Curious, 0.0) + second.setMood(MascotMood.Curious, 0.0) + + repeat(1_500) { frame -> + val time = frame * 0.023 + if (frame == 400) { + first.setMood(MascotMood.Thinking, time) + second.setMood(MascotMood.Thinking, time) + } + if (frame == 900) { + first.setMood(MascotMood.Happy, time) + second.setMood(MascotMood.Happy, time) + } + assertEquals(first.poseAt(time), second.poseAt(time)) + } + } + + @Test + fun workingCycleSeatsHatSwingsClawAndShowsWorkEffects() { + val animator = MascotAnimator(seed = 7uL) + animator.setMood(MascotMood.Working, 0.0) + var minRightClaw = Double.POSITIVE_INFINITY + var maxRightClaw = Double.NEGATIVE_INFINITY + var seatedHat = false + var sawSparks = false + var sawSweat = false + + repeat(2_001) { frame -> + val time = frame / 100.0 + val pose = animator.poseAt(time) + minRightClaw = minOf(minRightClaw, pose.rightClawDegrees) + maxRightClaw = maxOf(maxRightClaw, pose.rightClawDegrees) + seatedHat = seatedHat || (time >= 1.0 && pose.hardHat >= 0.99) + sawSparks = sawSparks || pose.effect == MascotEffect.Sparks + sawSweat = sawSweat || pose.effect == MascotEffect.Sweat + } + + assertTrue("hard hat never seated", seatedHat) + assertTrue("hammer swing was ${maxRightClaw - minRightClaw}°", maxRightClaw - minRightClaw > 25.0) + assertTrue("impact sparks never appeared", sawSparks) + assertTrue("wipe-brow sweat never appeared", sawSweat) + } + + @Test + fun moodChangeCancelsQueuedAndActiveGestures() { + val animator = MascotAnimator(seed = 11uL) + animator.poseAt(0.0) + animator.poseAt(0.95) + animator.setMood(MascotMood.Thinking, 0.95) + + val afterWaveCancellation = animator.poseAt(1.0) + assertEquals(0.0, afterWaveCancellation.rightClawDegrees, 0.000_001) + + animator.setMood(MascotMood.Working, 1.1) + assertTrue(animator.poseAt(1.3).hardHat < 1.0) + animator.setMood(MascotMood.Sad, 1.3) + + val afterHatCancellation = animator.poseAt(1.31) + assertEquals(0.0, afterHatCancellation.hardHat, 0.000_001) + } + + @Test + fun staticPoseSignaturesMatchMoodContract() { + assertEquals(MascotPose(), staticPose(MascotMood.Idle)) + assertEquals(MascotGaze(x = 0.3, y = -0.5), staticPose(MascotMood.Thinking).gaze) + + val working = staticPose(MascotMood.Working) + assertEquals(1.0, working.hardHat, 0.0) + assertEquals(-28.0, working.rightClawDegrees, 0.0) + + val celebrating = staticPose(MascotMood.Celebrating) + assertEquals(0.8, celebrating.happyEyes, 0.0) + assertEquals(30.0, celebrating.leftClawDegrees, 0.0) + assertEquals(-30.0, celebrating.rightClawDegrees, 0.0) + + val sad = staticPose(MascotMood.Sad) + assertEquals(0.75, sad.antennaDroop, 0.0) + assertEquals(-0.55, sad.mouthCurve, 0.0) + + val sleepy = staticPose(MascotMood.Sleepy) + assertEquals(0.25, sleepy.leftEyeOpenness, 0.0) + assertEquals(0.5, sleepy.eyeGlowAlpha, 0.0) + } + + @Test + fun clampCoversEveryBoundedChannel() { + val pose = + MascotPose( + floatOffset = 100.0, + antennaDegrees = -100.0, + antennaDroop = 2.0, + leftClawDegrees = -100.0, + rightClawDegrees = 100.0, + eyeGlowAlpha = -1.0, + glowScale = 9.0, + leftEyeOpenness = -1.0, + rightEyeOpenness = 2.0, + happyEyes = 2.0, + gaze = MascotGaze(x = -9.0, y = 9.0), + mouthCurve = -9.0, + mouthOpen = 9.0, + mouthRound = -9.0, + blush = 9.0, + hardHat = -9.0, + bodyTilt = 90.0, + bodyStretch = 9.0, + effect = MascotEffect.Sweat, + effectPhase = 0.75, + ).clamp() + + assertEquals(2.0, pose.floatOffset, 0.0) + assertEquals(-14.0, pose.antennaDegrees, 0.0) + assertEquals(1.0, pose.antennaDroop, 0.0) + assertEquals(-45.0, pose.leftClawDegrees, 0.0) + assertEquals(45.0, pose.rightClawDegrees, 0.0) + assertEquals(0.0, pose.eyeGlowAlpha, 0.0) + assertEquals(1.6, pose.glowScale, 0.0) + assertEquals(0.0, pose.leftEyeOpenness, 0.0) + assertEquals(1.0, pose.rightEyeOpenness, 0.0) + assertEquals(1.0, pose.happyEyes, 0.0) + assertEquals(MascotGaze(x = -1.2, y = 1.2), pose.gaze) + assertEquals(-1.0, pose.mouthCurve, 0.0) + assertEquals(1.0, pose.mouthOpen, 0.0) + assertEquals(0.0, pose.mouthRound, 0.0) + assertEquals(1.0, pose.blush, 0.0) + assertEquals(0.0, pose.hardHat, 0.0) + assertEquals(8.0, pose.bodyTilt, 0.0) + assertEquals(1.05, pose.bodyStretch, 0.0) + assertEquals(MascotEffect.Sweat, pose.effect) + assertEquals(0.75, pose.effectPhase, 0.0) + } + + private fun assertInBounds( + pose: MascotPose, + mood: MascotMood, + frame: Int, + ) { + val location = "$mood frame $frame" + assertTrue(location, pose.floatOffset in -12.0..2.0) + assertTrue(location, pose.antennaDegrees in -14.0..14.0) + assertTrue(location, pose.antennaDroop in 0.0..1.0) + assertTrue(location, pose.leftClawDegrees in -45.0..45.0) + assertTrue(location, pose.rightClawDegrees in -45.0..45.0) + assertTrue(location, pose.eyeGlowAlpha in 0.0..1.0) + assertTrue(location, pose.glowScale in 0.5..1.6) + assertTrue(location, pose.leftEyeOpenness in 0.0..1.0) + assertTrue(location, pose.rightEyeOpenness in 0.0..1.0) + assertTrue(location, pose.happyEyes in 0.0..1.0) + assertTrue(location, pose.gaze.x in -1.2..1.2) + assertTrue(location, pose.gaze.y in -1.2..1.2) + assertTrue(location, pose.mouthCurve in -1.0..1.0) + assertTrue(location, pose.mouthOpen in 0.0..1.0) + assertTrue(location, pose.mouthRound in 0.0..1.0) + assertTrue(location, pose.blush in 0.0..1.0) + assertTrue(location, pose.hardHat in 0.0..1.0) + assertTrue(location, pose.bodyTilt in -8.0..8.0) + assertTrue(location, pose.bodyStretch in 0.86..1.05) + assertTrue(location, pose.effectPhase in 0.0..1.0) + } +} + +class EffectiveMascotMoodTest { + @Test + fun `tinted mascots stay ambient regardless of requested mood`() { + for (mood in MascotMood.entries) { + assertEquals(MascotMood.Idle, effectiveMascotMood(mood = mood, tinted = true)) + assertEquals(mood, effectiveMascotMood(mood = mood, tinted = false)) + } + } +}