From f006ffd0c16f5b77c1ca1ed60d8c6d6539407674 Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Fri, 18 Sep 2026 23:29:52 +1000 Subject: [PATCH 1/6] feat(onboarding): add analytics events for the voice coach Co-Authored-By: Claude Fable 5.1 --- .../src/shared/constants/posthog-events.ts | 13 +++++- .../src/shared/services/analytics.ts | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/blotztask-mobile/src/shared/constants/posthog-events.ts b/blotztask-mobile/src/shared/constants/posthog-events.ts index 40a173bb7..d146aca32 100644 --- a/blotztask-mobile/src/shared/constants/posthog-events.ts +++ b/blotztask-mobile/src/shared/constants/posthog-events.ts @@ -28,6 +28,12 @@ export const EVENTS = { ONBOARDING_STARTED: "onboarding_started", ONBOARDING_STEP_VIEWED: "onboarding_step_viewed", ONBOARDING_COMPLETED: "onboarding_completed", + ONBOARDING_VOICE_COACH_SHOWN: "onboarding_voice_coach_shown", + ONBOARDING_VOICE_COACH_TAPPED: "onboarding_voice_coach_tapped", + ONBOARDING_VOICE_COACH_DISMISSED: "onboarding_voice_coach_dismissed", + ONBOARDING_VOICE_MIC_PRESSED: "onboarding_voice_mic_pressed", + ONBOARDING_VOICE_TASK_GENERATED: "onboarding_voice_task_generated", + ONBOARDING_VOICE_TASK_CREATED: "onboarding_voice_task_created", } as const; export const SCREEN_NAMES = { @@ -64,8 +70,11 @@ export type LoginErrorCode = | (typeof WebAuthErrorCodes)[keyof typeof WebAuthErrorCodes] | "NoTokensReturned"; -/** How a task was created. `manual` = task form, `ai` = AI generation sheet. */ -export type TaskSource = "manual" | "ai"; +/** + * How a task was created. `manual` = task form, `ai` = AI generation sheet, + * `onboarding_ai` = the AI sheet when opened from the post-onboarding voice coach. + */ +export type TaskSource = "manual" | "ai" | "onboarding_ai"; export type AiTaskOutcome = "accepted" | "rejected" | "abandoned"; export type AiTaskInputMode = "voice" | "text"; diff --git a/blotztask-mobile/src/shared/services/analytics.ts b/blotztask-mobile/src/shared/services/analytics.ts index 0a2febdb2..e4f33b554 100644 --- a/blotztask-mobile/src/shared/services/analytics.ts +++ b/blotztask-mobile/src/shared/services/analytics.ts @@ -125,6 +125,46 @@ export const analytics = { }); }, + /** Fires when the voice coach (the spotlight on the AI button) appears after onboarding. */ + trackOnboardingVoiceCoachShown() { + posthog.capture(EVENTS.ONBOARDING_VOICE_COACH_SHOWN); + }, + + /** Fires when the user taps the spotlighted AI button and the sheet opens. */ + trackOnboardingVoiceCoachTapped() { + posthog.capture(EVENTS.ONBOARDING_VOICE_COACH_TAPPED); + }, + + /** Fires when the user closes the voice coach without opening the AI sheet. */ + trackOnboardingVoiceCoachDismissed() { + posthog.capture(EVENTS.ONBOARDING_VOICE_COACH_DISMISSED); + }, + + /** Fires on every mic press in the AI sheet opened from the voice coach. `attempt` starts at 1. */ + trackOnboardingVoiceMicPressed(params: { attempt: number }) { + posthog.capture(EVENTS.ONBOARDING_VOICE_MIC_PRESSED, { + attempt: params.attempt, + }); + }, + + /** + * Fires when a turn in the coached sheet ends with at least one draft. + * `taskCount` counts every draft shown (tasks, recurring tasks and notes). + */ + trackOnboardingVoiceTaskGenerated(params: { inputMode: AiTaskInputMode; taskCount: number }) { + posthog.capture(EVENTS.ONBOARDING_VOICE_TASK_GENERATED, { + input_mode: params.inputMode, + task_count: params.taskCount, + }); + }, + + /** Fires after the drafts from the coached sheet are saved. The coach's success event. */ + trackOnboardingVoiceTaskCreated(params: { taskCount: number }) { + posthog.capture(EVENTS.ONBOARDING_VOICE_TASK_CREATED, { + task_count: params.taskCount, + }); + }, + /** * We treat a user as "active" if they stay on the app for more than 5 seconds. * Fires once per calendar day. Used to calculate Daily Active Users (DAU) and retention. From 865fcbad99b4559e9004770bf0f50399ad0382cc Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Fri, 18 Sep 2026 23:42:04 +1000 Subject: [PATCH 2/6] feat(onboarding): coach new users to the real AI sheet after onboarding When onboarding ends, the Today page dims and only the AI button stays lit, with a line asking the user to say one thing to do today. Tapping it opens the existing AI sheet with first-run copy; tapping anywhere else closes the coach. Tasks saved from that sheet report source onboarding_ai. Co-Authored-By: Claude Fable 5.1 --- .../src/app/(protected)/(tabs)/_layout.tsx | 15 +++ .../component/voice-hint-text.tsx | 15 ++- .../screens/ai-task-sheet-screen.tsx | 41 +++++- .../components/voice-coach-overlay.tsx | 127 ++++++++++++++++++ .../onboarding/hooks/useVoiceCoachStore.ts | 21 +++ .../onboarding/screens/onboarding-screen.tsx | 4 + .../src/i18n/locales/en/onboarding.json | 7 + .../src/i18n/locales/zh/onboarding.json | 7 + 8 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx create mode 100644 blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts diff --git a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx index 46c36438b..9e1b9a553 100644 --- a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx +++ b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { Tabs, router } from "expo-router"; import { Pressable, View, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -5,6 +6,8 @@ import { ASSETS } from "@/shared/constants/assets"; import { BottomNavImage } from "@/shared/components/bottom-nav-image"; import { GradientCircle } from "@/shared/components/gradient-circle"; import { theme } from "@/shared/constants/theme"; +import { VoiceCoachOverlay } from "@/feature/onboarding/components/voice-coach-overlay"; +import { useVoiceCoachStore } from "@/feature/onboarding/hooks/useVoiceCoachStore"; function UnfocusedTabIcon({ children }: { children: React.ReactNode }) { return ( @@ -58,6 +61,15 @@ function getTabIcon(routeKey: string, focused: boolean) { export default function ProtectedTabsLayout() { const insets = useSafeAreaInsets(); + const aiButtonRef = useRef(null); + const setButtonFrame = useVoiceCoachStore((state) => state.setButtonFrame); + + // The voice coach puts its spotlight exactly where this button is drawn. + const reportAiButtonFrame = () => { + aiButtonRef.current?.measureInWindow((x, y, width, height) => { + setButtonFrame({ x, y, width, height }); + }); + }; return ( @@ -96,6 +108,8 @@ export default function ProtectedTabsLayout() { options={{ tabBarButton: () => ( router.push("/ai-task-sheet")} > @@ -130,6 +144,7 @@ export default function ProtectedTabsLayout() { }} /> + ); } diff --git a/blotztask-mobile/src/feature/ai-task-generate/component/voice-hint-text.tsx b/blotztask-mobile/src/feature/ai-task-generate/component/voice-hint-text.tsx index acd66995b..2af7c4ff4 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/component/voice-hint-text.tsx +++ b/blotztask-mobile/src/feature/ai-task-generate/component/voice-hint-text.tsx @@ -2,9 +2,16 @@ import React, { useState, useEffect, useRef } from "react"; import { View, Text } from "react-native"; import { useTranslation } from "react-i18next"; -export const VoiceHintText = () => { +type Props = { + /** Replaces the small "try saying" line. */ + label?: string; + /** Replaces the typed-out example. */ + hint?: string; +}; + +export const VoiceHintText = ({ label, hint }: Props) => { const { t } = useTranslation("aiTaskGenerate"); - const hintText = t("voiceHint.hintText"); + const hintText = hint ?? t("voiceHint.hintText"); const [displayedHint, setDisplayedHint] = useState(""); const indexRef = useRef(0); @@ -23,7 +30,9 @@ export const VoiceHintText = () => { return ( - {t("voiceHint.trySaying")} + + {label ?? t("voiceHint.trySaying")} + {displayedHint} diff --git a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx index 6e422a965..4151fe5b7 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx +++ b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx @@ -22,7 +22,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { LinearGradient } from "expo-linear-gradient"; import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; -import { router } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import * as Haptics from "expo-haptics"; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; import { useTranslation } from "react-i18next"; @@ -52,6 +52,12 @@ const HOLD_HINT_AUTO_HIDE_MS = 2500; export default function AiTaskSheetScreen() { // --- Hooks --- const { t } = useTranslation("aiTaskGenerate"); + const { t: tOnboarding } = useTranslation("onboarding"); + // Set when the sheet is opened from the post-onboarding voice coach. + const { source } = useLocalSearchParams<{ source?: string }>(); + const isFromOnboarding = source === "onboarding"; + const taskSource = isFromOnboarding ? "onboarding_ai" : "ai"; + const micPressCount = useRef(0); const { height } = useWindowDimensions(); const { bottom } = useSafeAreaInsets(); const [isAiGenerating, setIsAiGenerating] = useState(false); @@ -166,6 +172,21 @@ export default function AiTaskSheetScreen() { const hasContent = streamedTasks.length > 0 || streamedRecurringTasks.length > 0 || streamedNotes.length > 0; + // A finished turn appends to `turns`; report the ones that produced something. + const lastTurn = turns.at(-1); + useEffect(() => { + if (!isFromOnboarding || !lastTurn) return; + const taskCount = + lastTurn.generated_tasks.length + + lastTurn.generated_recurring_tasks.length + + lastTurn.generated_notes.length; + if (taskCount === 0) return; + analytics.trackOnboardingVoiceTaskGenerated({ + inputMode: lastTurn.input_mode, + taskCount, + }); + }, [isFromOnboarding, lastTurn]); + // --- Handlers --- const handleDismiss = () => { // Skip analytics only for passive open-and-close sessions with no submitted AI request. @@ -200,7 +221,7 @@ export default function AiTaskSheetScreen() { const taskId = await addTaskAsync(convertAiTaskToTaskUpsertDTO(task)); analytics.trackTaskCreated({ taskId, - source: "ai", + source: taskSource, isRecurring: false, hasDeadline: false, }); @@ -209,7 +230,7 @@ export default function AiTaskSheetScreen() { const { recurringTaskId } = await createRecurringTaskAsync(mapRecurringToCreateDTO(task)); analytics.trackTaskCreated({ taskId: recurringTaskId, - source: "ai", + source: taskSource, isRecurring: true, hasDeadline: false, }); @@ -222,6 +243,11 @@ export default function AiTaskSheetScreen() { if (allSucceeded) { displayNotes.forEach(() => analytics.trackNoteCreated({ source: "ai" })); analytics.trackAiTaskGenerationSession({ outcome: "accepted", turns }); + if (isFromOnboarding) { + analytics.trackOnboardingVoiceTaskCreated({ + taskCount: displayTasks.length + displayRecurringTasks.length + displayNotes.length, + }); + } router.back(); // Delay the toast slightly to ensure it appears after the sheet has fully closed requestIdleCallback(() => Toast.show({ type: "success", text1: t("success.taskAdded") })); @@ -230,6 +256,10 @@ export default function AiTaskSheetScreen() { }; const handleMicPressIn = () => { + if (isFromOnboarding) { + micPressCount.current += 1; + analytics.trackOnboardingVoiceMicPressed({ attempt: micPressCount.current }); + } void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); heldLongEnough.current = false; hideHoldHintLater.cancel(); @@ -299,7 +329,10 @@ export default function AiTaskSheetScreen() { {/* Hint text (no results) */} {!hasContent && ( - + )} diff --git a/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx new file mode 100644 index 000000000..a42244560 --- /dev/null +++ b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx @@ -0,0 +1,127 @@ +import { useEffect } from "react"; +import { Pressable, StyleSheet, Text, View, useWindowDimensions } from "react-native"; +import Animated, { FadeIn, FadeOut, useReducedMotion } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; +import { router } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { ASSETS } from "@/shared/constants/assets"; +import { GradientCircle } from "@/shared/components/gradient-circle"; +import { analytics } from "@/shared/services/analytics"; +import { useVoiceCoachStore } from "../hooks/useVoiceCoachStore"; + +const BUTTON_SIZE = 58; +const RING_SIZE = 84; + +const pulse = { + from: { transform: [{ scale: 0.8 }], opacity: 0.7 }, + to: { transform: [{ scale: 1.6 }], opacity: 0 }, +}; + +/** + * Shown once, right after onboarding: dims the app and leaves only the AI button lit, so the + * user's first voice task goes through the real AI sheet rather than a copy of it. + */ +export function VoiceCoachOverlay() { + const { t } = useTranslation("onboarding"); + const { height: windowHeight } = useWindowDimensions(); + const { top } = useSafeAreaInsets(); + const reducedMotion = useReducedMotion(); + const isVisible = useVoiceCoachStore((state) => state.isVisible); + const buttonFrame = useVoiceCoachStore((state) => state.buttonFrame); + const hide = useVoiceCoachStore((state) => state.hide); + + const isShowing = isVisible && buttonFrame !== null; + + useEffect(() => { + if (isShowing) analytics.trackOnboardingVoiceCoachShown(); + }, [isShowing]); + + if (!isShowing) return null; + + const centerX = buttonFrame.x + buttonFrame.width / 2; + const centerY = buttonFrame.y + buttonFrame.height / 2; + + const handleDismiss = () => { + analytics.trackOnboardingVoiceCoachDismissed(); + hide(); + }; + + const handleOpenSheet = () => { + analytics.trackOnboardingVoiceCoachTapped(); + hide(); + router.push({ pathname: "/ai-task-sheet", params: { source: "onboarding" } }); + }; + + return ( + + + + + {t("voice-coach.dismiss")} + + + + + {t("voice-coach.title")} + + + {t("voice-coach.subtitle")} + + + + + {!reducedMotion && ( + + )} + + + + + + + + ); +} diff --git a/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts b/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts new file mode 100644 index 000000000..ac80081a7 --- /dev/null +++ b/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; + +type ButtonFrame = { x: number; y: number; width: number; height: number }; + +interface VoiceCoachState { + /** In memory on purpose: the coach belongs to the session that just finished onboarding. */ + isVisible: boolean; + /** Window frame of the real AI tab button, so the spotlight sits exactly on it. */ + buttonFrame: ButtonFrame | null; + show: () => void; + hide: () => void; + setButtonFrame: (frame: ButtonFrame) => void; +} + +export const useVoiceCoachStore = create((set) => ({ + isVisible: false, + buttonFrame: null, + show: () => set({ isVisible: true }), + hide: () => set({ isVisible: false }), + setButtonFrame: (buttonFrame) => set({ buttonFrame }), +})); diff --git a/blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx b/blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx index 7db5330c8..be83a219d 100644 --- a/blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx +++ b/blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx @@ -4,6 +4,7 @@ import { OnboardingBreakdownSection } from "@/feature/onboarding/components/onbo import { OnboardingInviteSection } from "@/feature/onboarding/components/onboarding-invite-section"; import { OnboardingNoteSection } from "@/feature/onboarding/components/onboarding-note-section"; import { REDEEM_REFERRAL_CODE_MUTATION_KEY } from "@/feature/referral/hooks/useRedeemReferralCode"; +import { useVoiceCoachStore } from "@/feature/onboarding/hooks/useVoiceCoachStore"; import { useWhatsNewSeen } from "@/feature/whats-new/hooks/useWhatsNewSeen"; import { IntroCarousel, type CarouselExitOutcome } from "@/shared/components/intro-carousel"; import type { OnboardingSection } from "@/shared/constants/posthog-events"; @@ -24,6 +25,7 @@ const SECTIONS = [ export default function OnboardingScreen() { const { setUserOnboarded } = useUserProfileMutation(); const { markAsSeen } = useWhatsNewSeen(); + const showVoiceCoach = useVoiceCoachStore((state) => state.show); const { t } = useTranslation("onboarding"); useLanguageInit(); @@ -43,6 +45,8 @@ export default function OnboardingScreen() { analytics.trackOnboardingCompleted({ outcome, exit_section }); await setUserOnboarded(true); await markAsSeen(); + // Skipped or completed, the first thing in the app is a nudge to try voice for real. + showVoiceCoach(); router.replace("/(protected)/(tabs)"); }; diff --git a/blotztask-mobile/src/i18n/locales/en/onboarding.json b/blotztask-mobile/src/i18n/locales/en/onboarding.json index e6a3dc0a6..c005c4e96 100644 --- a/blotztask-mobile/src/i18n/locales/en/onboarding.json +++ b/blotztask-mobile/src/i18n/locales/en/onboarding.json @@ -23,5 +23,12 @@ "notes": { "title": "Catch ideas \nbefore they fade", "subtitle": "Save your thoughts in Notes, Spin the gacha, \nand AI picks one task for you to do today." + }, + "voice-coach": { + "title": "Tap here and say one thing \nyou need to do today", + "subtitle": "AI turns it into a task.", + "dismiss": "Not now", + "sheetLabel": "Try it now", + "sheetHint": "Hold, and say one thing you need to do today" } } diff --git a/blotztask-mobile/src/i18n/locales/zh/onboarding.json b/blotztask-mobile/src/i18n/locales/zh/onboarding.json index 65aac189e..118a89cc9 100644 --- a/blotztask-mobile/src/i18n/locales/zh/onboarding.json +++ b/blotztask-mobile/src/i18n/locales/zh/onboarding.json @@ -23,5 +23,12 @@ "notes": { "title": "捕捉转瞬即逝的灵感", "subtitle": "将想法存入 Notes,抽个扭蛋,\n由 AI 幫您决定今天要做哪件事。" + }, + "voice-coach": { + "title": "点这里,说一件今天要做的事", + "subtitle": "AI 帮你变成任务。", + "dismiss": "以后再说", + "sheetLabel": "现在试试", + "sheetHint": "按住,说一件今天要做的事" } } From c14df20efe4e379f82f4cac136c43ad9382882ca Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Fri, 18 Sep 2026 23:58:22 +1000 Subject: [PATCH 3/6] feat(onboarding): cuter voice coach copy, new AI sheet example, steadier spotlight - Coach and first-run sheet copy now speak as the AI helper - The AI sheet's example is a weekly task instead of the dated election line - The spotlight is placed relative to its own measured origin, so a platform offset in window coordinates cannot move it off the button Co-Authored-By: Claude Fable 5.1 --- .../components/voice-coach-overlay.tsx | 156 ++++++++++-------- .../src/i18n/locales/en/ai-task-generate.json | 2 +- .../src/i18n/locales/en/onboarding.json | 8 +- .../src/i18n/locales/zh/ai-task-generate.json | 2 +- .../src/i18n/locales/zh/onboarding.json | 8 +- 5 files changed, 96 insertions(+), 80 deletions(-) diff --git a/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx index a42244560..93fd4c442 100644 --- a/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx +++ b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx @@ -1,6 +1,6 @@ -import { useEffect } from "react"; -import { Pressable, StyleSheet, Text, View, useWindowDimensions } from "react-native"; -import Animated, { FadeIn, FadeOut, useReducedMotion } from "react-native-reanimated"; +import { useEffect, useRef, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import Animated, { FadeIn, useReducedMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; import { router } from "expo-router"; @@ -24,23 +24,41 @@ const pulse = { */ export function VoiceCoachOverlay() { const { t } = useTranslation("onboarding"); - const { height: windowHeight } = useWindowDimensions(); const { top } = useSafeAreaInsets(); const reducedMotion = useReducedMotion(); const isVisible = useVoiceCoachStore((state) => state.isVisible); const buttonFrame = useVoiceCoachStore((state) => state.buttonFrame); const hide = useVoiceCoachStore((state) => state.hide); - const isShowing = isVisible && buttonFrame !== null; + // The button frame is in window coordinates. Measuring this layer the same way and + // subtracting keeps the spotlight on the button even where the two origins differ + // (Android can offset window coordinates by the status bar). + const layerRef = useRef(null); + const [layer, setLayer] = useState<{ x: number; y: number; height: number } | null>(null); + const measureLayer = () => { + layerRef.current?.measureInWindow((x, y, _width, height) => setLayer({ x, y, height })); + }; + + const isShowing = isVisible && buttonFrame !== null && layer !== null; useEffect(() => { if (isShowing) analytics.trackOnboardingVoiceCoachShown(); }, [isShowing]); - if (!isShowing) return null; + // The measuring layer stays mounted (and untouchable) so the coach can fade in and out inside it. + if (!isShowing) { + return ( + + ); + } - const centerX = buttonFrame.x + buttonFrame.width / 2; - const centerY = buttonFrame.y + buttonFrame.height / 2; + const centerX = buttonFrame.x - layer.x + buttonFrame.width / 2; + const centerY = buttonFrame.y - layer.y + buttonFrame.height / 2; const handleDismiss = () => { analytics.trackOnboardingVoiceCoachDismissed(); @@ -54,74 +72,72 @@ export function VoiceCoachOverlay() { }; return ( - - - - - {t("voice-coach.dismiss")} - + + + - - - {t("voice-coach.title")} - - - {t("voice-coach.subtitle")} + + {t("voice-coach.dismiss")} - - - {!reducedMotion && ( - + + {t("voice-coach.title")} + + + {t("voice-coach.subtitle")} + + + + + {!reducedMotion && ( + + )} + + - )} - - - - - - - + > + + + + + + ); } diff --git a/blotztask-mobile/src/i18n/locales/en/ai-task-generate.json b/blotztask-mobile/src/i18n/locales/en/ai-task-generate.json index 2bce6368a..f11cf2e84 100644 --- a/blotztask-mobile/src/i18n/locales/en/ai-task-generate.json +++ b/blotztask-mobile/src/i18n/locales/en/ai-task-generate.json @@ -39,7 +39,7 @@ }, "voiceHint": { "trySaying": "Try speaking or typing", - "hintText": "\"Attend the U.S. presidential election tomorrow at 8 AM\"" + "hintText": "\"Call mum every Sunday at 8pm\"" }, "voiceListening": { "title": "Listening ...", diff --git a/blotztask-mobile/src/i18n/locales/en/onboarding.json b/blotztask-mobile/src/i18n/locales/en/onboarding.json index c005c4e96..66f13e22a 100644 --- a/blotztask-mobile/src/i18n/locales/en/onboarding.json +++ b/blotztask-mobile/src/i18n/locales/en/onboarding.json @@ -25,10 +25,10 @@ "subtitle": "Save your thoughts in Notes, Spin the gacha, \nand AI picks one task for you to do today." }, "voice-coach": { - "title": "Tap here and say one thing \nyou need to do today", - "subtitle": "AI turns it into a task.", + "title": "Hi! I'm your AI helper", + "subtitle": "Tap me and tell me what's on today.", "dismiss": "Not now", - "sheetLabel": "Try it now", - "sheetHint": "Hold, and say one thing you need to do today" + "sheetLabel": "Your first task", + "sheetHint": "Hold the button and tell me.\nI'll write it down!" } } diff --git a/blotztask-mobile/src/i18n/locales/zh/ai-task-generate.json b/blotztask-mobile/src/i18n/locales/zh/ai-task-generate.json index 5afa49eab..bcbab987c 100644 --- a/blotztask-mobile/src/i18n/locales/zh/ai-task-generate.json +++ b/blotztask-mobile/src/i18n/locales/zh/ai-task-generate.json @@ -39,7 +39,7 @@ }, "voiceHint": { "trySaying": "试试说话或输入", - "hintText": "\"明天早上 8 点参加美国总统选举\"" + "hintText": "\"每周日晚上八点给妈妈打电话\"" }, "voiceListening": { "title": "聆听中 ...", diff --git a/blotztask-mobile/src/i18n/locales/zh/onboarding.json b/blotztask-mobile/src/i18n/locales/zh/onboarding.json index 118a89cc9..4395b42eb 100644 --- a/blotztask-mobile/src/i18n/locales/zh/onboarding.json +++ b/blotztask-mobile/src/i18n/locales/zh/onboarding.json @@ -25,10 +25,10 @@ "subtitle": "将想法存入 Notes,抽个扭蛋,\n由 AI 幫您决定今天要做哪件事。" }, "voice-coach": { - "title": "点这里,说一件今天要做的事", - "subtitle": "AI 帮你变成任务。", + "title": "嗨,我是你的 AI 小帮手!", + "subtitle": "点我一下,说说今天要做什么吧。", "dismiss": "以后再说", - "sheetLabel": "现在试试", - "sheetHint": "按住,说一件今天要做的事" + "sheetLabel": "你的第一条任务", + "sheetHint": "按住按钮告诉我,\n我帮你记下来!" } } From 8519fa0d3081c2b192797021cda87264b4442a5c Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Sat, 19 Sep 2026 00:20:50 +1000 Subject: [PATCH 4/6] fix(ai-task): closing the sheet mid-generation no longer shows an error Closing the AI sheet stops the SignalR connection, which rejects a request still in flight. That was handled as a failure: an error toast on the Today page and an ai_task_generation_failed event. Cards stream in before the turn finishes, so users close the sheet inside that window all the time. Requests cancelled by closing are now ignored; real failures still report. Co-Authored-By: Claude Fable 5.1 --- .../feature/ai-task-generate/hooks/useAiTaskGenerator.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts index fc74e7471..05c2d04b5 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts +++ b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts @@ -40,6 +40,9 @@ export function useAiTaskGenerator({ const [turns, setTurns] = useState([]); const requestStartedAtRef = useRef(null); const pendingInputModeRef = useRef(null); + // Closing the sheet stops the connection, which rejects any request still in flight. + // That is the user leaving, not a failure, so it must not toast or count as one. + const isClosedRef = useRef(false); const submitAudioForTranscription = async (uri: string): Promise => { if (!connection || connection.state !== signalR.HubConnectionState.Connected) { @@ -57,6 +60,7 @@ export function useAiTaskGenerator({ try { await signalRService.invoke(connection, "TranscribeAudio", base64); } catch (error) { + if (isClosedRef.current) return; console.error("TranscribeAudio invocation failed:", error); setIsAiGenerating(false); const startedAt = requestStartedAtRef.current; @@ -85,6 +89,7 @@ export function useAiTaskGenerator({ try { await signalRService.invoke(connection, "SendMessage", text); } catch (error) { + if (isClosedRef.current) return; console.error("SendMessage invocation failed:", error); setIsAiGenerating(false); const startedAt = requestStartedAtRef.current; @@ -215,6 +220,7 @@ export function useAiTaskGenerator({ }; useEffect(() => { + isClosedRef.current = false; let conn: signalR.HubConnection | null = null; signalRService @@ -245,6 +251,7 @@ export function useAiTaskGenerator({ .catch((error) => console.error("Error connecting to SignalR:", error)); return () => { + isClosedRef.current = true; if (conn) { conn.off("ReceiveGenerationResult", generationCompleteHandler); conn.off("ReceiveGenerationError", generationErrorHandler); From 9137a150ccec9f7f8777dcddfea0eb3b0ff5bf09 Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Sat, 19 Sep 2026 00:24:08 +1000 Subject: [PATCH 5/6] revert: leave the AI sheet dismissal fix to #547 Reverts 8519fa0d. The same bug is already fixed by the approved, not-yet-merged PR #547 (same file, same approach), so keeping it here would only conflict with it. Co-Authored-By: Claude Fable 5.1 --- .../feature/ai-task-generate/hooks/useAiTaskGenerator.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts index 05c2d04b5..fc74e7471 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts +++ b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts @@ -40,9 +40,6 @@ export function useAiTaskGenerator({ const [turns, setTurns] = useState([]); const requestStartedAtRef = useRef(null); const pendingInputModeRef = useRef(null); - // Closing the sheet stops the connection, which rejects any request still in flight. - // That is the user leaving, not a failure, so it must not toast or count as one. - const isClosedRef = useRef(false); const submitAudioForTranscription = async (uri: string): Promise => { if (!connection || connection.state !== signalR.HubConnectionState.Connected) { @@ -60,7 +57,6 @@ export function useAiTaskGenerator({ try { await signalRService.invoke(connection, "TranscribeAudio", base64); } catch (error) { - if (isClosedRef.current) return; console.error("TranscribeAudio invocation failed:", error); setIsAiGenerating(false); const startedAt = requestStartedAtRef.current; @@ -89,7 +85,6 @@ export function useAiTaskGenerator({ try { await signalRService.invoke(connection, "SendMessage", text); } catch (error) { - if (isClosedRef.current) return; console.error("SendMessage invocation failed:", error); setIsAiGenerating(false); const startedAt = requestStartedAtRef.current; @@ -220,7 +215,6 @@ export function useAiTaskGenerator({ }; useEffect(() => { - isClosedRef.current = false; let conn: signalR.HubConnection | null = null; signalRService @@ -251,7 +245,6 @@ export function useAiTaskGenerator({ .catch((error) => console.error("Error connecting to SignalR:", error)); return () => { - isClosedRef.current = true; if (conn) { conn.off("ReceiveGenerationResult", generationCompleteHandler); conn.off("ReceiveGenerationError", generationErrorHandler); From 85f792be7d10c3f340e8bc2a1417df968acc45e7 Mon Sep 17 00:00:00 2001 From: sol-wizard Date: Sat, 19 Sep 2026 00:46:00 +1000 Subject: [PATCH 6/6] refactor(onboarding): tidy the voice coach wiring - One AiTabButtonIcon drawn by both the tab bar and the coach, so the lit copy cannot drift from the real button - The button frame is local state in the tabs layout, passed as a prop; the store now only holds visibility - AI_SHEET_SOURCE replaces the "onboarding" string shared by two files No behaviour change. Co-Authored-By: Claude Fable 5.1 --- .../src/app/(protected)/(tabs)/_layout.tsx | 24 ++++++++---------- .../models/ai-sheet-source.ts | 7 ++++++ .../screens/ai-task-sheet-screen.tsx | 5 ++-- .../components/voice-coach-overlay.tsx | 25 +++++++++++-------- .../onboarding/hooks/useVoiceCoachStore.ts | 7 ------ .../shared/components/ai-tab-button-icon.tsx | 13 ++++++++++ 6 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 blotztask-mobile/src/feature/ai-task-generate/models/ai-sheet-source.ts create mode 100644 blotztask-mobile/src/shared/components/ai-tab-button-icon.tsx diff --git a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx index 9e1b9a553..aa11784dc 100644 --- a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx +++ b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx @@ -1,13 +1,15 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { Tabs, router } from "expo-router"; import { Pressable, View, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ASSETS } from "@/shared/constants/assets"; import { BottomNavImage } from "@/shared/components/bottom-nav-image"; -import { GradientCircle } from "@/shared/components/gradient-circle"; +import { AiTabButtonIcon } from "@/shared/components/ai-tab-button-icon"; import { theme } from "@/shared/constants/theme"; -import { VoiceCoachOverlay } from "@/feature/onboarding/components/voice-coach-overlay"; -import { useVoiceCoachStore } from "@/feature/onboarding/hooks/useVoiceCoachStore"; +import { + VoiceCoachOverlay, + type ButtonFrame, +} from "@/feature/onboarding/components/voice-coach-overlay"; function UnfocusedTabIcon({ children }: { children: React.ReactNode }) { return ( @@ -62,12 +64,12 @@ function getTabIcon(routeKey: string, focused: boolean) { export default function ProtectedTabsLayout() { const insets = useSafeAreaInsets(); const aiButtonRef = useRef(null); - const setButtonFrame = useVoiceCoachStore((state) => state.setButtonFrame); + const [aiButtonFrame, setAiButtonFrame] = useState(null); // The voice coach puts its spotlight exactly where this button is drawn. const reportAiButtonFrame = () => { aiButtonRef.current?.measureInWindow((x, y, width, height) => { - setButtonFrame({ x, y, width, height }); + setAiButtonFrame({ x, y, width, height }); }); }; @@ -113,13 +115,7 @@ export default function ProtectedTabsLayout() { className="flex-1 items-center justify-center" onPress={() => router.push("/ai-task-sheet")} > - - - + ), }} @@ -144,7 +140,7 @@ export default function ProtectedTabsLayout() { }} /> - + ); } diff --git a/blotztask-mobile/src/feature/ai-task-generate/models/ai-sheet-source.ts b/blotztask-mobile/src/feature/ai-task-generate/models/ai-sheet-source.ts new file mode 100644 index 000000000..774434e8d --- /dev/null +++ b/blotztask-mobile/src/feature/ai-task-generate/models/ai-sheet-source.ts @@ -0,0 +1,7 @@ +/** Where the AI sheet was opened from, passed as the `source` route param. */ +export const AI_SHEET_SOURCE = { + /** The voice coach shown right after onboarding. */ + ONBOARDING: "onboarding", +} as const; + +export type AiSheetSource = (typeof AI_SHEET_SOURCE)[keyof typeof AI_SHEET_SOURCE]; diff --git a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx index 4151fe5b7..7e782041b 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx +++ b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx @@ -32,6 +32,7 @@ import { ListeningIndicator } from "../component/listening-indicator"; import { HoldToTalkPill } from "../component/hold-to-talk-pill"; import { useAiTaskGenerator } from "../hooks/useAiTaskGenerator"; import { useVoiceRecorder } from "../hooks/useVoiceRecorder"; +import { AI_SHEET_SOURCE, type AiSheetSource } from "../models/ai-sheet-source"; import { useAllLabels } from "@/shared/hooks/useAllLabels"; import { mapExtractedTaskDTOToAiTaskDTO } from "../utils/map-extracted-to-task-dto"; import { convertAiTaskToTaskUpsertDTO } from "../utils/map-aitask-to-addtaskitem-dto"; @@ -54,8 +55,8 @@ export default function AiTaskSheetScreen() { const { t } = useTranslation("aiTaskGenerate"); const { t: tOnboarding } = useTranslation("onboarding"); // Set when the sheet is opened from the post-onboarding voice coach. - const { source } = useLocalSearchParams<{ source?: string }>(); - const isFromOnboarding = source === "onboarding"; + const { source } = useLocalSearchParams<{ source?: AiSheetSource }>(); + const isFromOnboarding = source === AI_SHEET_SOURCE.ONBOARDING; const taskSource = isFromOnboarding ? "onboarding_ai" : "ai"; const micPressCount = useRef(0); const { height } = useWindowDimensions(); diff --git a/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx index 93fd4c442..1fea5dd70 100644 --- a/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx +++ b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx @@ -5,14 +5,20 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import MaterialCommunityIcons from "@react-native-vector-icons/material-design-icons/static"; import { router } from "expo-router"; import { useTranslation } from "react-i18next"; -import { ASSETS } from "@/shared/constants/assets"; -import { GradientCircle } from "@/shared/components/gradient-circle"; +import { AI_TAB_BUTTON_SIZE, AiTabButtonIcon } from "@/shared/components/ai-tab-button-icon"; +import { AI_SHEET_SOURCE } from "@/feature/ai-task-generate/models/ai-sheet-source"; import { analytics } from "@/shared/services/analytics"; import { useVoiceCoachStore } from "../hooks/useVoiceCoachStore"; -const BUTTON_SIZE = 58; const RING_SIZE = 84; +export type ButtonFrame = { x: number; y: number; width: number; height: number }; + +type Props = { + /** Window frame of the real AI tab button, so the spotlight sits exactly on it. */ + buttonFrame: ButtonFrame | null; +}; + const pulse = { from: { transform: [{ scale: 0.8 }], opacity: 0.7 }, to: { transform: [{ scale: 1.6 }], opacity: 0 }, @@ -22,12 +28,11 @@ const pulse = { * Shown once, right after onboarding: dims the app and leaves only the AI button lit, so the * user's first voice task goes through the real AI sheet rather than a copy of it. */ -export function VoiceCoachOverlay() { +export function VoiceCoachOverlay({ buttonFrame }: Props) { const { t } = useTranslation("onboarding"); const { top } = useSafeAreaInsets(); const reducedMotion = useReducedMotion(); const isVisible = useVoiceCoachStore((state) => state.isVisible); - const buttonFrame = useVoiceCoachStore((state) => state.buttonFrame); const hide = useVoiceCoachStore((state) => state.hide); // The button frame is in window coordinates. Measuring this layer the same way and @@ -68,7 +73,7 @@ export function VoiceCoachOverlay() { const handleOpenSheet = () => { analytics.trackOnboardingVoiceCoachTapped(); hide(); - router.push({ pathname: "/ai-task-sheet", params: { source: "onboarding" } }); + router.push({ pathname: "/ai-task-sheet", params: { source: AI_SHEET_SOURCE.ONBOARDING } }); }; return ( @@ -129,13 +134,11 @@ export function VoiceCoachOverlay() { hitSlop={12} style={{ position: "absolute", - left: centerX - BUTTON_SIZE / 2, - top: centerY - BUTTON_SIZE / 2, + left: centerX - AI_TAB_BUTTON_SIZE / 2, + top: centerY - AI_TAB_BUTTON_SIZE / 2, }} > - - - + diff --git a/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts b/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts index ac80081a7..d607bdd6e 100644 --- a/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts +++ b/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts @@ -1,21 +1,14 @@ import { create } from "zustand"; -type ButtonFrame = { x: number; y: number; width: number; height: number }; - interface VoiceCoachState { /** In memory on purpose: the coach belongs to the session that just finished onboarding. */ isVisible: boolean; - /** Window frame of the real AI tab button, so the spotlight sits exactly on it. */ - buttonFrame: ButtonFrame | null; show: () => void; hide: () => void; - setButtonFrame: (frame: ButtonFrame) => void; } export const useVoiceCoachStore = create((set) => ({ isVisible: false, - buttonFrame: null, show: () => set({ isVisible: true }), hide: () => set({ isVisible: false }), - setButtonFrame: (buttonFrame) => set({ buttonFrame }), })); diff --git a/blotztask-mobile/src/shared/components/ai-tab-button-icon.tsx b/blotztask-mobile/src/shared/components/ai-tab-button-icon.tsx new file mode 100644 index 000000000..11b08b9cc --- /dev/null +++ b/blotztask-mobile/src/shared/components/ai-tab-button-icon.tsx @@ -0,0 +1,13 @@ +import { ASSETS } from "@/shared/constants/assets"; +import { GradientCircle } from "@/shared/components/gradient-circle"; + +export const AI_TAB_BUTTON_SIZE = 58; + +/** The AI button as drawn in the tab bar. The voice coach draws the same one on top of it. */ +export function AiTabButtonIcon() { + return ( + + + + ); +}