Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions blotztask-mobile/src/app/(protected)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -58,6 +63,15 @@ function getTabIcon(routeKey: string, focused: boolean) {

export default function ProtectedTabsLayout() {
const insets = useSafeAreaInsets();
const aiButtonRef = useRef<View>(null);
const [aiButtonFrame, setAiButtonFrame] = useState<ButtonFrame | null>(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 (
<View className="flex-1 bg-background">
Expand Down Expand Up @@ -96,16 +110,12 @@ export default function ProtectedTabsLayout() {
options={{
tabBarButton: () => (
<Pressable
ref={aiButtonRef}
onLayout={reportAiButtonFrame}
className="flex-1 items-center justify-center"
onPress={() => router.push("/ai-task-sheet")}
>
<GradientCircle size={58}>
<ASSETS.whiteBun
width={28}
height={28}
style={{ position: "absolute" } as const}
/>
</GradientCircle>
<AiTabButtonIcon />
</Pressable>
),
}}
Expand All @@ -130,6 +140,7 @@ export default function ProtectedTabsLayout() {
}}
/>
</Tabs>
<VoiceCoachOverlay buttonFrame={aiButtonFrame} />
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -23,7 +30,9 @@ export const VoiceHintText = () => {

return (
<View className="flex-1 w-full items-center justify-center px-8">
<Text className="text-white/60 font-baloo text-base mb-2">{t("voiceHint.trySaying")}</Text>
<Text className="text-white/60 font-baloo text-base mb-2">
{label ?? t("voiceHint.trySaying")}
</Text>
<View style={{ minHeight: 72 }} className="w-full items-center">
<Text className="text-white font-balooBold text-2xl text-center">{displayedHint}</Text>
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -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];
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});
Expand All @@ -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,
});
Expand All @@ -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") }));
Expand All @@ -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();
Expand Down Expand Up @@ -299,7 +330,10 @@ export default function AiTaskSheetScreen() {
{/* Hint text (no results) */}
{!hasContent && (
<View className={`flex-1 w-full ${isKeyboardVisible ? "opacity-0" : "opacity-100"}`}>
<VoiceHintText />
<VoiceHintText
label={isFromOnboarding ? tOnboarding("voice-coach.sheetLabel") : undefined}
hint={isFromOnboarding ? tOnboarding("voice-coach.sheetHint") : undefined}
/>
</View>
)}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<View>(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 (
<View
ref={layerRef}
onLayout={measureLayer}
pointerEvents="none"
style={StyleSheet.absoluteFill}
/>
);
}

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 (
<View ref={layerRef} onLayout={measureLayer} style={StyleSheet.absoluteFill}>
<Animated.View entering={FadeIn.duration(250)} style={StyleSheet.absoluteFill}>
<Pressable
onPress={handleDismiss}
accessibilityRole="button"
accessibilityLabel={t("voice-coach.dismiss")}
style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(0,0,0,0.65)" }]}
/>

<Text
className="absolute right-6 font-baloo text-lg text-white/70"
style={{ top: top + 12 }}
pointerEvents="none"
>
{t("voice-coach.dismiss")}
</Text>

<View
pointerEvents="none"
className="absolute left-6 right-6 items-center"
style={{ bottom: layer.height - centerY + RING_SIZE / 2 + 8 }}
>
<Text className="font-balooBold text-3xl text-white text-center">
{t("voice-coach.title")}
</Text>
<Text className="font-baloo text-base text-white/70 text-center mt-2">
{t("voice-coach.subtitle")}
</Text>
<MaterialCommunityIcons name="chevron-down" size={36} color="white" />
</View>

{!reducedMotion && (
<Animated.View
pointerEvents="none"
style={{
position: "absolute",
left: centerX - RING_SIZE / 2,
top: centerY - RING_SIZE / 2,
width: RING_SIZE,
height: RING_SIZE,
borderRadius: RING_SIZE / 2,
backgroundColor: "white",
animationName: pulse,
animationDuration: "1400ms",
animationIterationCount: "infinite",
animationTimingFunction: "ease-out",
}}
/>
)}

<Pressable
onPress={handleOpenSheet}
accessibilityRole="button"
accessibilityLabel={t("voice-coach.title")}
hitSlop={12}
style={{
position: "absolute",
left: centerX - AI_TAB_BUTTON_SIZE / 2,
top: centerY - AI_TAB_BUTTON_SIZE / 2,
}}
>
<AiTabButtonIcon />
</Pressable>
</Animated.View>
</View>
);
}
Original file line number Diff line number Diff line change
@@ -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<VoiceCoachState>((set) => ({
isVisible: false,
show: () => set({ isVisible: true }),
hide: () => set({ isVisible: false }),
}));
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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";
Expand All @@ -24,6 +25,7 @@
export default function OnboardingScreen() {
const { setUserOnboarded } = useUserProfileMutation();
const { markAsSeen } = useWhatsNewSeen();
const showVoiceCoach = useVoiceCoachStore((state) => state.show);
const { t } = useTranslation("onboarding");
useLanguageInit();

Expand All @@ -39,10 +41,12 @@
const isRedeemingReferralCode =
useIsMutating({ mutationKey: REDEEM_REFERRAL_CODE_MUTATION_KEY }) > 0;

const handleFinish = async (outcome: CarouselExitOutcome, exit_section: OnboardingSection) => {

Check warning on line 44 in blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx

View workflow job for this annotation

GitHub Actions / build-and-test-mobile-frontend

Identifier 'exit_section' is not in camel case
analytics.trackOnboardingCompleted({ outcome, exit_section });

Check warning on line 45 in blotztask-mobile/src/feature/onboarding/screens/onboarding-screen.tsx

View workflow job for this annotation

GitHub Actions / build-and-test-mobile-frontend

Identifier 'exit_section' is not in camel case
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)");
};

Expand Down
2 changes: 1 addition & 1 deletion blotztask-mobile/src/i18n/locales/en/ai-task-generate.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...",
Expand Down
Loading
Loading