diff --git a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx index 46c36438b..aa11784dc 100644 --- a/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx +++ b/blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx @@ -1,10 +1,15 @@ +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, + type ButtonFrame, +} from "@/feature/onboarding/components/voice-coach-overlay"; function UnfocusedTabIcon({ children }: { children: React.ReactNode }) { return ( @@ -58,6 +63,15 @@ function getTabIcon(routeKey: string, focused: boolean) { export default function ProtectedTabsLayout() { const insets = useSafeAreaInsets(); + const aiButtonRef = useRef(null); + 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) => { + setAiButtonFrame({ x, y, width, height }); + }); + }; return ( @@ -96,16 +110,12 @@ export default function ProtectedTabsLayout() { options={{ tabBarButton: () => ( router.push("/ai-task-sheet")} > - - - + ), }} @@ -130,6 +140,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/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 6e422a965..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 @@ -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"; @@ -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"; @@ -52,6 +53,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?: AiSheetSource }>(); + const isFromOnboarding = source === AI_SHEET_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 +173,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 +222,7 @@ export default function AiTaskSheetScreen() { const taskId = await addTaskAsync(convertAiTaskToTaskUpsertDTO(task)); analytics.trackTaskCreated({ taskId, - source: "ai", + source: taskSource, isRecurring: false, hasDeadline: false, }); @@ -209,7 +231,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 +244,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 +257,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 +330,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..1fea5dd70 --- /dev/null +++ b/blotztask-mobile/src/feature/onboarding/components/voice-coach-overlay.tsx @@ -0,0 +1,146 @@ +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"; +import { useTranslation } from "react-i18next"; +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 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 }, +}; + +/** + * 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({ buttonFrame }: Props) { + const { t } = useTranslation("onboarding"); + const { top } = useSafeAreaInsets(); + const reducedMotion = useReducedMotion(); + const isVisible = useVoiceCoachStore((state) => state.isVisible); + const hide = useVoiceCoachStore((state) => state.hide); + + // 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]); + + // The measuring layer stays mounted (and untouchable) so the coach can fade in and out inside it. + if (!isShowing) { + return ( + + ); + } + + const centerX = buttonFrame.x - layer.x + buttonFrame.width / 2; + const centerY = buttonFrame.y - layer.y + buttonFrame.height / 2; + + const handleDismiss = () => { + analytics.trackOnboardingVoiceCoachDismissed(); + hide(); + }; + + const handleOpenSheet = () => { + analytics.trackOnboardingVoiceCoachTapped(); + hide(); + router.push({ pathname: "/ai-task-sheet", params: { source: AI_SHEET_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..d607bdd6e --- /dev/null +++ b/blotztask-mobile/src/feature/onboarding/hooks/useVoiceCoachStore.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; + +interface VoiceCoachState { + /** In memory on purpose: the coach belongs to the session that just finished onboarding. */ + isVisible: boolean; + show: () => void; + hide: () => void; +} + +export const useVoiceCoachStore = create((set) => ({ + isVisible: false, + show: () => set({ isVisible: true }), + hide: () => set({ isVisible: false }), +})); 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/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 e6a3dc0a6..66f13e22a 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": "Hi! I'm your AI helper", + "subtitle": "Tap me and tell me what's on today.", + "dismiss": "Not now", + "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 65aac189e..4395b42eb 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": "嗨,我是你的 AI 小帮手!", + "subtitle": "点我一下,说说今天要做什么吧。", + "dismiss": "以后再说", + "sheetLabel": "你的第一条任务", + "sheetHint": "按住按钮告诉我,\n我帮你记下来!" } } 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 ( + + + + ); +} 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.