Skip to content

Commit 84ac800

Browse files
author
Lalit Sharma
committed
feat: implement first-run onboarding walkthrough and Help & FAQ screen; update app state and tests
1 parent 6fe622c commit 84ac800

12 files changed

Lines changed: 643 additions & 14 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.31] — 2026-02-26
9+
10+
### Added
11+
- Added first-run onboarding walkthrough for new users with step-by-step guidance across `Landing`, `Timer`, and `Settings`.
12+
- Added persisted onboarding completion state in app preferences with an explicit `Skip` option.
13+
- Added an in-app `Help & FAQ` screen under `Settings` with concise FAQ/troubleshooting content and deep links to full documentation.
14+
- Added regression tests for onboarding walkthrough configuration and Help content/doc-link validation.
15+
16+
### Changed
17+
- Updated `RootNavigator` to wire onboarding overlay behavior and Help route deep-link path (`eclipsetimer://settings/help`).
18+
- Updated `documents/planning/tech-debt.md` to mark `ADD-11` and `ADD-13` as completed and remove them from pending execution order.
19+
- Bumped `apps/mobile` version to `1.1.31`.
20+
21+
### Tests
22+
- Verified mobile checks pass: `pnpm -C apps/mobile typecheck`, `pnpm -C apps/mobile lint`, and `pnpm -C apps/mobile test`.
23+
824
## [1.1.30] — 2026-02-26
925

1026
### Changed

apps/mobile/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eclipse-timer/mobile",
3-
"version": "1.1.30",
3+
"version": "1.1.31",
44
"private": true,
55
"main": "index.js",
66
"scripts": {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useMemo } from "react";
2+
import { Pressable, StyleSheet, Text, View } from "react-native";
3+
4+
import { useAppTheme } from "../theme/useAppTheme";
5+
import {
6+
type OnboardingRouteName,
7+
type OnboardingWalkthroughStep,
8+
onboardingRouteLabel,
9+
} from "./onboardingWalkthrough";
10+
11+
type FirstRunOnboardingOverlayProps = {
12+
visible: boolean;
13+
step: OnboardingWalkthroughStep | null;
14+
stepIndex: number;
15+
stepCount: number;
16+
isStepRouteActive: boolean;
17+
onGoToStepRoute: (route: OnboardingRouteName) => void;
18+
onNext: () => void;
19+
onSkip: () => void;
20+
};
21+
22+
export default function FirstRunOnboardingOverlay({
23+
visible,
24+
step,
25+
stepIndex,
26+
stepCount,
27+
isStepRouteActive,
28+
onGoToStepRoute,
29+
onNext,
30+
onSkip,
31+
}: FirstRunOnboardingOverlayProps) {
32+
const { colors } = useAppTheme();
33+
const styles = useMemo(() => createStyles(colors), [colors]);
34+
35+
if (!visible || !step) return null;
36+
37+
const isLastStep = stepIndex >= stepCount - 1;
38+
const primaryLabel = isStepRouteActive
39+
? isLastStep
40+
? "Finish"
41+
: "Next Tip"
42+
: `Go to ${onboardingRouteLabel(step.route)}`;
43+
44+
return (
45+
<View style={styles.overlay}>
46+
<View style={styles.backdrop} />
47+
<View style={styles.card}>
48+
<View style={styles.headerRow}>
49+
<Text style={styles.progressLabel}>
50+
Step {stepIndex + 1} of {stepCount}
51+
</Text>
52+
<Pressable style={styles.skipButton} onPress={onSkip} accessibilityRole="button">
53+
<Text style={styles.skipLabel}>Skip</Text>
54+
</Pressable>
55+
</View>
56+
57+
<Text style={styles.title}>{step.title}</Text>
58+
<Text style={styles.description}>{step.description}</Text>
59+
<Text style={styles.highlight}>Highlight: {step.highlightLabel}</Text>
60+
61+
<Pressable
62+
style={styles.primaryButton}
63+
onPress={() => {
64+
if (isStepRouteActive) {
65+
onNext();
66+
return;
67+
}
68+
onGoToStepRoute(step.route);
69+
}}
70+
accessibilityRole="button"
71+
accessibilityLabel={primaryLabel}
72+
>
73+
<Text style={styles.primaryLabel}>{primaryLabel}</Text>
74+
</Pressable>
75+
</View>
76+
</View>
77+
);
78+
}
79+
80+
function createStyles(colors: ReturnType<typeof useAppTheme>["colors"]) {
81+
return StyleSheet.create({
82+
overlay: {
83+
...StyleSheet.absoluteFillObject,
84+
zIndex: 40,
85+
justifyContent: "flex-end",
86+
paddingHorizontal: 12,
87+
paddingBottom: 14,
88+
},
89+
backdrop: {
90+
...StyleSheet.absoluteFillObject,
91+
backgroundColor: colors.overlay,
92+
},
93+
card: {
94+
borderRadius: 14,
95+
borderWidth: 1,
96+
borderColor: colors.borderStrong,
97+
backgroundColor: colors.surface,
98+
paddingVertical: 14,
99+
paddingHorizontal: 12,
100+
gap: 8,
101+
},
102+
headerRow: {
103+
flexDirection: "row",
104+
alignItems: "center",
105+
justifyContent: "space-between",
106+
gap: 8,
107+
},
108+
progressLabel: {
109+
color: colors.textSecondary,
110+
fontSize: 12,
111+
fontWeight: "700",
112+
textTransform: "uppercase",
113+
letterSpacing: 0.5,
114+
},
115+
skipButton: {
116+
borderRadius: 999,
117+
borderWidth: 1,
118+
borderColor: colors.inputBorder,
119+
backgroundColor: colors.surfaceMuted,
120+
paddingVertical: 7,
121+
paddingHorizontal: 10,
122+
},
123+
skipLabel: {
124+
color: colors.textSecondary,
125+
fontSize: 12,
126+
fontWeight: "700",
127+
},
128+
title: {
129+
color: colors.textPrimary,
130+
fontSize: 17,
131+
fontWeight: "800",
132+
},
133+
description: {
134+
color: colors.textMuted,
135+
fontSize: 13,
136+
lineHeight: 19,
137+
},
138+
highlight: {
139+
color: colors.textSecondary,
140+
fontSize: 12,
141+
fontWeight: "700",
142+
},
143+
primaryButton: {
144+
marginTop: 4,
145+
borderRadius: 10,
146+
backgroundColor: colors.primary,
147+
alignItems: "center",
148+
justifyContent: "center",
149+
paddingVertical: 11,
150+
},
151+
primaryLabel: {
152+
color: colors.primaryText,
153+
fontSize: 14,
154+
fontWeight: "800",
155+
textTransform: "uppercase",
156+
},
157+
});
158+
}

apps/mobile/src/navigation/RootNavigator.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useLandingScroll } from "../hooks/useLandingScroll";
4444
import { useNotificationScheduler } from "../hooks/useNotificationScheduler";
4545
import { useTimerState } from "../hooks/useTimerState";
4646
import EclipsePreviewScreen, { type PreviewPayload } from "../screens/EclipsePreviewScreen";
47+
import HelpScreen from "../screens/HelpScreen";
4748
import LandingScreen from "../screens/LandingScreen";
4849
import LocationSettingsScreen from "../screens/LocationSettingsScreen";
4950
import NotificationSettingsScreen from "../screens/NotificationSettingsScreen";
@@ -56,6 +57,8 @@ import { type FavoriteLocation, useAppState } from "../state/appState";
5657
import { useAppTheme } from "../theme/useAppTheme";
5758
import { localYmdNow } from "../utils/date";
5859
import { kindCodeForRecord, kindLabelFromCode } from "../utils/eclipse";
60+
import FirstRunOnboardingOverlay from "./FirstRunOnboardingOverlay";
61+
import { ONBOARDING_WALKTHROUGH_STEPS, type OnboardingRouteName } from "./onboardingWalkthrough";
5962
import SideMenu, { type MenuRouteName } from "./SideMenu";
6063

6164
enableScreens();
@@ -65,6 +68,7 @@ type RootStackParamList = {
6568
Timer: undefined;
6669
Preview: { payload: PreviewPayload };
6770
Settings: undefined;
71+
Help: undefined;
6872
ThemeSettings: undefined;
6973
NotificationSettings: undefined;
7074
LocationSettings: undefined;
@@ -77,6 +81,7 @@ const linking: LinkingOptions<RootStackParamList> = {
7781
Landing: "landing",
7882
Timer: "timer",
7983
Settings: "settings",
84+
Help: "settings/help",
8085
ThemeSettings: "settings/theme",
8186
NotificationSettings: "notifications",
8287
LocationSettings: "locations",
@@ -168,6 +173,7 @@ function toMenuRouteName(route: keyof RootStackParamList): MenuRouteName | null
168173
if (route === "Landing" || route === "Timer") return route;
169174
if (
170175
route === "Settings" ||
176+
route === "Help" ||
171177
route === "ThemeSettings" ||
172178
route === "NotificationSettings" ||
173179
route === "LocationSettings"
@@ -468,13 +474,18 @@ function SettingsRoute({ navigation, onOpenMenu }: SettingsRouteProps) {
468474
return (
469475
<SettingsScreen
470476
onOpenMenu={onOpenMenu}
477+
onOpenHelp={() => navigation.navigate("Help")}
471478
onOpenThemeSettings={() => navigation.navigate("ThemeSettings")}
472479
onOpenNotificationSettings={() => navigation.navigate("NotificationSettings")}
473480
onOpenLocationSettings={() => navigation.navigate("LocationSettings")}
474481
/>
475482
);
476483
}
477484

485+
function HelpRoute({ onOpenMenu }: RouteWithMenuProps) {
486+
return <HelpScreen onOpenMenu={onOpenMenu} />;
487+
}
488+
478489
function ThemeSettingsRoute({ onOpenMenu }: ThemeSettingsRouteProps) {
479490
const { state, actions } = useAppState();
480491

@@ -517,12 +528,13 @@ function LocationSettingsRoute({ onOpenMenu }: RouteWithMenuProps) {
517528
}
518529

519530
export default function RootNavigator() {
520-
const { state: appState, actions } = useAppState();
531+
const { state: appState, hasHydratedPreferences, actions } = useAppState();
521532
const { colors, resolvedTheme } = useAppTheme();
522533
const navigationRef = useNavigationContainerRef<RootStackParamList>();
523534
const pendingFeaturedDeepLinkActionRef = useRef<FeaturedEclipseDeepLinkAction | null>(null);
524535
const [catalog, setCatalog] = useState<EclipseRecord[] | null>(null);
525536
const [isMenuOpen, setIsMenuOpen] = useState(false);
537+
const [onboardingStepIndex, setOnboardingStepIndex] = useState(0);
526538
const [currentRouteName, setCurrentRouteName] = useState<keyof RootStackParamList>("Landing");
527539
const navigationTheme = useMemo<NavigationTheme>(() => {
528540
const base = resolvedTheme === "light" ? NavigationLightTheme : NavigationDarkTheme;
@@ -665,6 +677,48 @@ export default function RootNavigator() {
665677
);
666678

667679
const activeMenuRoute = useMemo(() => toMenuRouteName(currentRouteName), [currentRouteName]);
680+
const onboardingStepCount = ONBOARDING_WALKTHROUGH_STEPS.length;
681+
const onboardingStep = ONBOARDING_WALKTHROUGH_STEPS[onboardingStepIndex] ?? null;
682+
const isFirstRunOnboardingVisible =
683+
hasHydratedPreferences && !appState.hasCompletedOnboarding && onboardingStepCount > 0;
684+
const isOnboardingStepRouteActive = onboardingStep
685+
? currentRouteName === onboardingStep.route
686+
: false;
687+
688+
const completeOnboarding = useCallback(() => {
689+
closeMenu();
690+
setOnboardingStepIndex(0);
691+
actions.setOnboardingCompleted(true);
692+
}, [actions, closeMenu]);
693+
694+
const goToOnboardingStepRoute = useCallback(
695+
(route: OnboardingRouteName) => {
696+
closeMenu();
697+
if (!navigationRef.isReady()) return;
698+
const currentRoute = navigationRef.getCurrentRoute()?.name;
699+
if (currentRoute === route) return;
700+
navigationRef.navigate(route);
701+
},
702+
[closeMenu, navigationRef],
703+
);
704+
705+
const goToNextOnboardingStep = useCallback(() => {
706+
if (!onboardingStepCount) {
707+
completeOnboarding();
708+
return;
709+
}
710+
if (onboardingStepIndex >= onboardingStepCount - 1) {
711+
completeOnboarding();
712+
return;
713+
}
714+
715+
const nextStepIndex = onboardingStepIndex + 1;
716+
setOnboardingStepIndex(nextStepIndex);
717+
718+
const nextStep = ONBOARDING_WALKTHROUGH_STEPS[nextStepIndex];
719+
if (!nextStep) return;
720+
goToOnboardingStepRoute(nextStep.route);
721+
}, [completeOnboarding, goToOnboardingStepRoute, onboardingStepCount, onboardingStepIndex]);
668722

669723
useEffect(() => {
670724
if (!isMenuOpen) return;
@@ -675,6 +729,21 @@ export default function RootNavigator() {
675729
return () => subscription.remove();
676730
}, [closeMenu, isMenuOpen]);
677731

732+
useEffect(() => {
733+
if (!isFirstRunOnboardingVisible) {
734+
setOnboardingStepIndex(0);
735+
return;
736+
}
737+
if (onboardingStepIndex >= onboardingStepCount) {
738+
setOnboardingStepIndex(Math.max(0, onboardingStepCount - 1));
739+
}
740+
}, [isFirstRunOnboardingVisible, onboardingStepCount, onboardingStepIndex]);
741+
742+
useEffect(() => {
743+
if (!isFirstRunOnboardingVisible || !isMenuOpen) return;
744+
closeMenu();
745+
}, [closeMenu, isFirstRunOnboardingVisible, isMenuOpen]);
746+
678747
useEffect(() => {
679748
let didCancel = false;
680749

@@ -737,6 +806,7 @@ export default function RootNavigator() {
737806
<Stack.Screen name="Settings">
738807
{(props) => <SettingsRoute {...props} onOpenMenu={openMenu} />}
739808
</Stack.Screen>
809+
<Stack.Screen name="Help">{() => <HelpRoute onOpenMenu={openMenu} />}</Stack.Screen>
740810
<Stack.Screen name="ThemeSettings">
741811
{(props) => <ThemeSettingsRoute {...props} onOpenMenu={openMenu} />}
742812
</Stack.Screen>
@@ -753,6 +823,16 @@ export default function RootNavigator() {
753823
onClose={closeMenu}
754824
onNavigate={onNavigateFromMenu}
755825
/>
826+
<FirstRunOnboardingOverlay
827+
visible={isFirstRunOnboardingVisible}
828+
step={onboardingStep}
829+
stepIndex={onboardingStepIndex}
830+
stepCount={onboardingStepCount}
831+
isStepRouteActive={isOnboardingStepRouteActive}
832+
onGoToStepRoute={goToOnboardingStepRoute}
833+
onNext={goToNextOnboardingStep}
834+
onSkip={completeOnboarding}
835+
/>
756836
</View>
757837
</NavigationContainer>
758838
);

0 commit comments

Comments
 (0)