polished and finalised onboarding flow

This commit is contained in:
Chris Sanden
2026-05-05 17:36:34 +02:00
parent 2bb2ac63a0
commit 9bb3bb1163
10 changed files with 310 additions and 57 deletions

View File

@@ -1,3 +1,4 @@
import { getSetupStatus } from "@/lib/setupStatus";
import { supabase } from "@/lib/supabase";
import { Session } from "@supabase/supabase-js";
import * as Notifications from 'expo-notifications';
@@ -32,6 +33,8 @@ function UseNotificationObserver() {
export default function TabLayout() {
const [session, SetSession] = useState<Session | null>(null)
const [loading, SetLoading] = useState(true);
const [setupChecked, setSetupChecked] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
UseNotificationObserver();
@@ -51,7 +54,29 @@ export default function TabLayout() {
return () => sub.subscription.unsubscribe();
}, []);
if (loading) {
useEffect(() => {
const checkSetupStatus = async () => {
if (!session?.user.id) {
setNeedsSetup(false);
setSetupChecked(true);
return;
}
try {
const setupStatus = await getSetupStatus(session.user.id);
setNeedsSetup(!setupStatus.isSetupComplete);
} catch {
setNeedsSetup(true);
} finally {
setSetupChecked(true);
}
};
setSetupChecked(false);
void checkSetupStatus();
}, [session?.user.id]);
if (loading || !setupChecked) {
return null;
}
@@ -59,6 +84,10 @@ export default function TabLayout() {
return <Redirect href="/login" />;
}
if (needsSetup) {
return <Redirect href="/setup" />;
}
return (
<Tabs
screenOptions={{
@@ -68,4 +97,4 @@ export default function TabLayout() {
<Tabs.Screen name="subjects" options={{title: "Subjects"}} />
</Tabs>
);
}
}

View File

@@ -7,11 +7,12 @@ import { formatDate, formatDateTime } from '@/lib/date';
import { RegisterForLocalNotificationsAsync } from '@/lib/notifications';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { getSetupStatus } from '@/lib/setupStatus';
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
import { supabase } from "@/lib/supabase";
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect } from "expo-router";
import { Redirect, router, Stack, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from 'react';
import {
Alert,
@@ -164,6 +165,7 @@ export default function HomeScreen() {
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
const [completingTaskId, setCompletingTaskId] = useState<string | null>(null);
const [subjectCount, setSubjectCount] = useState(0);
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const loadActiveSprint = useCallback(async () => {
const storedSprint = await GetActiveSession();
@@ -479,6 +481,25 @@ export default function HomeScreen() {
}
}, [session]);
useEffect(() => {
const loadSetupGate = async () => {
if (!session?.user.id) {
setNeedsSetup(false);
return;
}
try {
const setupStatus = await getSetupStatus(session.user.id);
setNeedsSetup(!setupStatus.isSetupComplete);
} catch {
setNeedsSetup(true);
}
};
setNeedsSetup(null);
void loadSetupGate();
}, [session?.user.id]);
useFocusEffect(
useCallback(() => {
void loadActiveSprint();
@@ -611,6 +632,14 @@ export default function HomeScreen() {
);
}, []);
if (session && needsSetup === null) {
return null;
}
if (needsSetup) {
return <Redirect href="/setup" />;
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen

View File

@@ -1,9 +1,10 @@
import { SUBJECT_COLORS } from '@/lib/subjectColors';
import { getSetupStatus } from '@/lib/setupStatus';
import { supabase } from '@/lib/supabase';
import { Subject } from '@/lib/types';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Session } from '@supabase/supabase-js';
import { router, Stack, useFocusEffect } from 'expo-router';
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, Text, View } from 'react-native';
@@ -36,6 +37,7 @@ export default function Subjects() {
const [subjects, SetSubjects] = useState<Subject[]>([]);
const [session, SetSession] = useState<Session | null>(null);
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
@@ -51,6 +53,25 @@ export default function Subjects() {
return () => sub.subscription.unsubscribe();
}, []);
useEffect(() => {
const loadSetupGate = async () => {
if (!session?.user.id) {
setNeedsSetup(false);
return;
}
try {
const setupStatus = await getSetupStatus(session.user.id);
setNeedsSetup(!setupStatus.isSetupComplete);
} catch {
setNeedsSetup(true);
}
};
setNeedsSetup(null);
void loadSetupGate();
}, [session?.user.id]);
const GetSubjects = useCallback(async () => {
if (!session?.user.id) return;
@@ -76,6 +97,14 @@ export default function Subjects() {
}, [GetSubjects, session])
);
if (session && needsSetup === null) {
return null;
}
if (needsSetup) {
return <Redirect href="/setup" />;
}
return (
<View className="flex-1 bg-app-bg">
<Stack.Screen

View File

@@ -1,3 +1,4 @@
import { getSetupStatus } from "@/lib/setupStatus";
import { supabase } from "@/lib/supabase";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
@@ -49,7 +50,7 @@ export default function Login() {
SetIsLoading(true);
const { error } = await supabase.auth.signInWithPassword({
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
@@ -61,7 +62,17 @@ export default function Login() {
return;
}
router.replace("/");
if (!data.user?.id) {
Alert.alert("Login failed, missing user session after sign-in");
return;
}
try {
const setupStatus = await getSetupStatus(data.user.id);
router.replace(setupStatus.isSetupComplete ? "/" : "/setup");
} catch {
router.replace("/setup");
}
}
const inputClassName =

View File

@@ -1,9 +1,16 @@
import { GetActiveSession, type ActiveSession } from '@/lib/asyncStorage';
import {
GetActiveSession,
GetSetupSprintDemoUsed,
SaveSetupSprintDemoUsed,
type ActiveSession,
} from '@/lib/asyncStorage';
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
import { getSetupStatus, type SetupStepKey } from '@/lib/setupStatus';
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
import { supabase } from '@/lib/supabase';
import { Session } from '@supabase/supabase-js';
import { Redirect, Stack, router, useFocusEffect, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, Text, View } from 'react-native';
type SetupState = {
@@ -40,8 +47,6 @@ const SETUP_STEPS = [
},
] as const;
type SetupStepKey = (typeof SETUP_STEPS)[number]['key'];
export default function SetupScreen() {
const {
subjectId: subjectIdParam,
@@ -89,37 +94,10 @@ export default function SetupScreen() {
return;
}
const [storedActiveSession, subjectResult, assignmentResult, taskResult, focusSessionResult] =
await Promise.all([
GetActiveSession(),
supabase
.from('subjects')
.select('sId')
.eq('uId', session.user.id)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('assignments')
.select('aId')
.eq('uId', session.user.id)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('tasks')
.select('tId')
.eq('uId', session.user.id)
.order('lastChanged', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('sprint_sessions')
.select('sessionId', { count: 'exact', head: true })
.eq('userId', session.user.id)
.eq('sessionType', 'focus')
.eq('status', 'completed'),
]);
const [storedActiveSession, status] = await Promise.all([
GetActiveSession(),
getSetupStatus(session.user.id),
]);
if (storedActiveSession && storedActiveSession.endTime <= Date.now()) {
await finalizeStoredSession('expired', storedActiveSession);
@@ -129,10 +107,10 @@ export default function SetupScreen() {
}
setSetupState({
subjectId: subjectIdParam ?? subjectResult.data?.sId ?? null,
assignmentId: assignmentIdParam ?? assignmentResult.data?.aId ?? null,
taskId: taskIdParam ?? taskResult.data?.tId ?? null,
completedFocusSessions: focusSessionResult.count ?? 0,
subjectId: subjectIdParam ?? status.subjectId,
assignmentId: assignmentIdParam ?? status.assignmentId,
taskId: taskIdParam ?? status.taskId,
completedFocusSessions: status.completedFocusSessions,
});
}, [assignmentIdParam, session?.user.id, subjectIdParam, taskIdParam]);
@@ -142,7 +120,7 @@ export default function SetupScreen() {
}, [loadSetupState])
);
const currentStep = useMemo<SetupStepKey>(() => {
const currentStep: SetupStepKey = (() => {
if (!setupState.subjectId) {
return 'subject';
}
@@ -156,7 +134,7 @@ export default function SetupScreen() {
}
return 'sprint';
}, [setupState]);
})();
const isSetupComplete =
setupState.taskId !== null && setupState.completedFocusSessions > 0;
@@ -223,14 +201,28 @@ export default function SetupScreen() {
setActiveSession(null);
}
const shouldUseDemoSprint = session?.user.id
? !(await GetSetupSprintDemoUsed(session.user.id))
: false;
if (shouldUseDemoSprint && session?.user.id) {
await SaveSetupSprintDemoUsed(session.user.id);
}
router.push({
pathname: '/task/timer',
params: {
tId: setupState.taskId,
durationSeconds: '5',
},
params: shouldUseDemoSprint
? {
tId: setupState.taskId,
durationSeconds: '5',
onboardingDemo: 'true',
}
: {
tId: setupState.taskId,
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
},
});
}, [currentStep, isSetupComplete, setupState]);
}, [currentStep, isSetupComplete, session?.user.id, setupState]);
const primaryLabel = isSetupComplete
? 'Go to dashboard'

View File

@@ -139,14 +139,16 @@ export default function TimerScreen() {
const cancelHoldIdRef = React.useRef(0);
const cancelHoldStartedAtRef = React.useRef(0);
const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId, chooseDuration } = useLocalSearchParams<{
const { tId, sessionType: sessionTypeParam, durationMinutes, durationSeconds, returnTaskId, chooseDuration, onboardingDemo } = useLocalSearchParams<{
tId?: string;
sessionType?: SessionType;
durationMinutes?: string;
durationSeconds?: string;
returnTaskId?: string;
chooseDuration?: string;
onboardingDemo?: string;
}>();
const isOnboardingDemo = onboardingDemo === 'true';
const timerOverlayHeight = Math.max(containerHeight, 1);
const timerOverlayOffscreenY = timerOverlayHeight + 1000;
const selectedSessionType: SessionType = sessionTypeParam ?? 'focus';
@@ -484,13 +486,18 @@ export default function TimerScreen() {
setIsRunning(false);
resetSessionValues();
await finalizeSprintSession('completed', completedSession);
if (isOnboardingDemo && completedSessionType === 'focus') {
router.replace('/');
return;
}
setPostSessionPrompt({
completedSessionType,
returnTaskId: completedReturnTaskId,
nextBreakType,
});
await finalizeSprintSession('completed', completedSession);
})();
});
});
@@ -502,6 +509,7 @@ export default function TimerScreen() {
currentSessionType,
finalizeSprintSession,
focusModeAnimation,
isOnboardingDemo,
resetSessionValues,
syncStudyCycleAfterCompletion,
taskDetailsAnimation,

View File

@@ -1,4 +1,5 @@
import { defaultStyles } from '@/constants/defaultStyles';
import { SaveSetupSprintDemoUsed } from '@/lib/asyncStorage';
import { CheckAssignmentCompletion } from '@/lib/progress';
import { supabase } from '@/lib/supabase';
import type { Task } from '@/lib/types';
@@ -125,9 +126,14 @@ export default function UpsertTask() {
SetIsSaving(false);
if (!isEditMode && isSetupFlow && result.data?.tId) {
await SaveSetupSprintDemoUsed(data.user.id);
router.replace({
pathname: '/task/timer',
params: { tId: result.data.tId },
params: {
tId: result.data.tId,
durationSeconds: '5',
onboardingDemo: 'true',
},
});
return;
}