From 7496f4d988abcc713db815fa4ee2daecb4763c92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 14:04:40 -0700 Subject: [PATCH] feat(android): mascot mood engine and onboarding moods (#109311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the deterministic mascot animator to Android: full pose channels (face, gaze, droop, hard hat, effects), seeded mood loops including the hard-hat working hammer cycle, entrance gestures, and static poses when system animations are off. Onboarding heroes now feel the flow — working while the gateway connects, celebrating once paired, thinking during node approval, curious on permissions, sad on visible errors. Tinted silhouette marks keep their ambient idle loop and stay silent for TalkBack outside the welcome hero. --- apps/.i18n/native-source.json | 328 +++++------ .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 125 +++-- .../openclaw/app/ui/design/MascotAnimator.kt | 515 ++++++++++++++++++ .../ai/openclaw/app/ui/design/MascotPose.kt | 127 +++++ .../openclaw/app/ui/design/OpenClawMascot.kt | 495 +++++++++++------ .../app/ui/OnboardingFlowLogicTest.kt | 38 ++ .../app/ui/design/MascotAnimatorTest.kt | 194 +++++++ 7 files changed, 1460 insertions(+), 362 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotAnimator.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/design/MascotPose.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/ui/design/MascotAnimatorTest.kt 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)) + } + } +}