diff --git a/mobile-app/sapot-mobile-app/CLAUDE.md b/mobile-app/sapot-mobile-app/CLAUDE.md index 3f17c2c5..9fdac625 100644 --- a/mobile-app/sapot-mobile-app/CLAUDE.md +++ b/mobile-app/sapot-mobile-app/CLAUDE.md @@ -56,7 +56,7 @@ features// types.ts index.ts # Public API ``` -Features: `announcements`, `auth`, `call`, `chat`, `getting-started`, `gps`, `settings`, `shared`, `sync`. +Features: `announcements`, `auth`, `call`, `chat`, `getting-started`, `gps`, `help`, `settings`, `shared`, `sync`. - `app/` — Expo Router file-based routing (screens). - `features/shared/` — cross-feature services, DI containers, connection/crypto/database infrastructure. Check here first before writing a new util/service. diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/_layout.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/_layout.tsx index e133a813..2bc8ce1c 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/_layout.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/_layout.tsx @@ -1,4 +1,5 @@ import { useAuth } from "@/features/auth"; +import { HelpIconButton, useTourAnchor } from "@/features/help"; import { useConnectionService, useReducedMotion } from "@/features/shared/hooks"; import { navLog } from "@/features/shared/core/utils/logger"; import Entypo from "@expo/vector-icons/Entypo"; @@ -52,6 +53,10 @@ function TabUnderline({ focused }: { focused: boolean }) { ); } +function TourTabLabel({ children, color, focused, anchor }: { children: React.ReactNode; color: string; focused: boolean; anchor: ReturnType }) { + return {children}; +} + function IncomingCallListener() { const connectionService = useConnectionService(); const pathname = usePathname(); @@ -141,6 +146,10 @@ export default function TabLayout() { const colorScheme = useColorScheme(); const theme = useTheme(); const { isRescuer, isGuest } = useAuth(); + const chatsAnchor = useTourAnchor("chats-tab"); + const scanQrAnchor = useTourAnchor("scan-qr-tab"); + const mapAnchor = useTourAnchor("map-tab"); + const settingsAnchor = useTourAnchor("settings-tab"); useEffect(() => { navLog.info("[TabLayout] mounted"); @@ -193,7 +202,7 @@ export default function TabLayout() { name="index" options={{ title: "Chats", - tabBarLabel: "Chats", + tabBarLabel: ({ children, color, focused }) => {children}, tabBarIcon: ({ color }) => ( ), @@ -220,7 +229,7 @@ export default function TabLayout() { name="map" options={{ title: "Live Map", - tabBarLabel: "Map", + tabBarLabel: ({ children, color, focused }) => {children}, tabBarIcon: ({ color }) => ( ), @@ -230,6 +239,7 @@ export default function TabLayout() { headerShadowVisible: false, headerStyle: { backgroundColor: "transparent" }, href: isRescuer ? undefined : null, + headerRight: () => , }} /> ), headerShown: false, - tabBarLabel: "Settings", + tabBarLabel: ({ children, color, focused }) => {children}, }} /> - + + + + ), }} @@ -318,6 +331,8 @@ export default function TabLayout() { router.back(); }} /> + + ), }} diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/_layout.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/_layout.tsx index 6382d775..717e5f06 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/_layout.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/_layout.tsx @@ -1,4 +1,5 @@ -import { AUTH_ROUTES } from "@/config/routes"; +import { AUTH_ROUTES, SETTINGS_ROUTES } from "@/config/routes"; +import { TourOverlay, TourProvider, useTourAnchor, useTourAutostart } from "@/features/help"; import { IS_DEBUG_ENABLED } from "@/config/debug"; import { useAuth } from "@/features/auth"; import { AnnouncementSeenProvider } from "@/features/announcements/context/announcement-seen-context"; @@ -24,11 +25,11 @@ import { navLog } from "@/features/shared/core/utils/logger"; import { getFocusedRouteNameFromRoute } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as Notifications from "expo-notifications"; -import { Redirect, router } from "expo-router"; +import { Redirect, router, useRouter } from "expo-router"; import { Drawer } from "expo-router/drawer"; import { useEffect, useRef } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; -import { Icon, Text, useTheme } from "react-native-paper"; +import { Icon, IconButton, Text, useTheme } from "react-native-paper"; import { PageLoader } from "@/features/shared/components/page-loader"; import { SafeAreaView } from "react-native-safe-area-context"; import "../../task/signaling-task"; @@ -55,6 +56,9 @@ const labelIcon = { function HeaderRight() { const { isPublished, isZeroconfAllowed } = useZeroconfPublished(); const { store } = useAppMode(); + const modeAnchor = useTourAnchor("mode-badge"); + const navigation = useRouter(); + const theme = useTheme(); return ( - + {store.mode.toUpperCase()} + navigation.push(SETTINGS_ROUTES.HELP_CENTER)} /> ); } +function TourAutostart() { + const { isAuthenticated, isGuest } = useAuth(); + useTourAutostart(isAuthenticated || isGuest); + return null; +} + export default function DrawerLayout() { const { isAuthenticated, loading, isGuest } = useAuth(); const theme = useTheme(); @@ -255,6 +269,8 @@ export default function DrawerLayout() { )} + + } screenOptions={{ @@ -387,6 +403,8 @@ export default function DrawerLayout() { }} /> + + diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx index 81807db8..5b23672f 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx @@ -1,4 +1,5 @@ import { APP_ROUTES } from "@/config/routes"; +import { HelpIconButton, useTourAnchor } from "@/features/help"; import { AnnouncementCard } from "@/features/announcements/components/announcement-card"; import { useAnnouncementNewCount } from "@/features/announcements/hooks/use-announcement-new-count"; import { useAnnouncements } from "@/features/announcements/hooks/use-announcements"; @@ -45,6 +46,7 @@ export default function AnnouncementsScreen() { ); const { visible, message, variant, showError, hideToast } = useToast(); const reducedMotion = useReducedMotion(); + const announcementsAnchor = useTourAnchor("announcements-drawer-item"); const markedRef = useRef(false); @@ -129,13 +131,16 @@ export default function AnnouncementsScreen() { } }} /> - + + + + {newCount > 0 && ( {newCount} new diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/__tests__/help-article.test.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/__tests__/help-article.test.tsx new file mode 100644 index 00000000..e2841296 --- /dev/null +++ b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/__tests__/help-article.test.tsx @@ -0,0 +1,11 @@ +import { render } from "@testing-library/react-native"; +import HelpArticleScreen from "../help/[id]"; +let mockParams: { id?: string } = {}; +jest.mock("expo-router", () => ({ useRouter: () => ({ push: jest.fn() }), useLocalSearchParams: () => mockParams })); +jest.mock("@/features/help", () => { const actual = jest.requireActual("@/features/help"); return { ...actual, useHelpContext: () => ({ mode: "lan", isGuest: true, isRescuer: false }) }; }); +jest.mock("react-native-paper", () => { + const actual = jest.requireActual("react-native-paper"); + const { Pressable, Text } = require("react-native"); + return { ...actual, Button: ({ children, onPress }: { children: string; onPress: () => void }) => {children} }; +}); +describe("HelpArticleScreen", () => { it("renders articles and handles unavailable routes", () => { mockParams = { id: "calls" }; const known = render(); expect(known.getByText("Calls")).toBeTruthy(); known.unmount(); mockParams = { id: "announcements" }; expect(render().getByText(/doesn't apply/i)).toBeTruthy(); }); }); diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help-center.tsx b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help-center.tsx index 5a947afc..f5a4f5d6 100644 --- a/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help-center.tsx +++ b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help-center.tsx @@ -1,10 +1,15 @@ +import { APP_ROUTES } from "@/config/routes"; +import { ArticleList, resetTourCompletion, useTour } from "@/features/help"; import { uiLog } from "@/features/shared/core/utils/logger"; +import { useRouter } from "expo-router"; import { useEffect } from "react"; import { View } from "react-native"; -import { Text, useTheme } from "react-native-paper"; +import { useTheme } from "react-native-paper"; -export default function SwitchMode() { +export default function HelpCenter() { const theme = useTheme(); + const router = useRouter(); + const { start } = useTour(); useEffect(() => { uiLog.info("[HelpCenter] mounted"); @@ -13,11 +18,12 @@ export default function SwitchMode() { }; }, []); - return ( - - - Help Center - - - ); + const handleReplayTour = async () => { + uiLog.info("[HelpCenter] tour replay requested"); + await resetTourCompletion(); + await start(); + router.push(APP_ROUTES.HOME); + }; + + return ; } diff --git a/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help/[id].tsx b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help/[id].tsx new file mode 100644 index 00000000..13da33a3 --- /dev/null +++ b/mobile-app/sapot-mobile-app/app/(drawer)/settings/support/help/[id].tsx @@ -0,0 +1,14 @@ +import { SETTINGS_ROUTES } from "@/config/routes"; +import { ArticleView, getArticle, isVisible, useHelpContext } from "@/features/help"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { View } from "react-native"; +import { Button, Text, useTheme } from "react-native-paper"; + +export default function HelpArticleScreen() { + const theme = useTheme(); const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); + const ctx = useHelpContext(); const article = getArticle(id ?? ""); + const unavailableMessage = !article ? "We couldn't find that topic." : !isVisible(article.audience, ctx) ? `This topic doesn't apply in ${ctx.mode.toUpperCase()} mode.` : null; + if (unavailableMessage) return {unavailableMessage}; + if (!article) return null; + return ; +} diff --git a/mobile-app/sapot-mobile-app/config/routes.ts b/mobile-app/sapot-mobile-app/config/routes.ts index f64dbe71..8a683e48 100644 --- a/mobile-app/sapot-mobile-app/config/routes.ts +++ b/mobile-app/sapot-mobile-app/config/routes.ts @@ -47,6 +47,7 @@ export const SETTINGS_ROUTES = { // Support ABOUT_US: "/(drawer)/settings/support/about-us", HELP_CENTER: "/(drawer)/settings/support/help-center", + HELP_ARTICLE: "/(drawer)/settings/support/help/[id]", } as const; export const APP_ROUTES = { HOME: "/(drawer)/(tabs)", diff --git a/mobile-app/sapot-mobile-app/docs/ARCHITECTURE.md b/mobile-app/sapot-mobile-app/docs/ARCHITECTURE.md index a2781d45..d61e7f1e 100644 --- a/mobile-app/sapot-mobile-app/docs/ARCHITECTURE.md +++ b/mobile-app/sapot-mobile-app/docs/ARCHITECTURE.md @@ -255,6 +255,12 @@ Four sub-domains in dependency order (bottom → top): **One-way dependency rule:** `core` ← `crypto` ← `peer` / `connection` ← domain features. A sub-domain may only import from itself and sub-domains *below* it. Domain features (`chat/`, `auth/`, etc.) depend on the engine — never the reverse. +### `features/help/` + +Offline Help Center articles and a one-time, audience-filtered spotlight tour. `TourProvider` +is mounted in `app/(drawer)/_layout.tsx` inside the authenticated app providers and wraps the +drawer, so it can read auth and app-mode state while presenting its Paper Portal overlay. + ### `features/announcements/` Server-fetched announcement board — no WatermelonDB, purely React Query. diff --git a/mobile-app/sapot-mobile-app/docs/ENV_CONFIG.md b/mobile-app/sapot-mobile-app/docs/ENV_CONFIG.md index 3990b51f..e07f2d7d 100644 --- a/mobile-app/sapot-mobile-app/docs/ENV_CONFIG.md +++ b/mobile-app/sapot-mobile-app/docs/ENV_CONFIG.md @@ -164,7 +164,7 @@ Sensitive runtime config is stored via `expo-secure-store` (not AsyncStorage). Managed in `features/shared/core/stores/secure-config.ts`: -All 18 keys are declared in that file's `KEYS` constant: +All secure-storage keys are declared in that file's `KEYS` constant: | Key | Value | |---|---| @@ -188,6 +188,7 @@ All 18 keys are declared in that file's `KEYS` constant: | `signalingSecretKey` | Secret key used for signalling-channel encryption | | `recoveryTokenHex` | Recovery session token, hex-encoded | | `guestMigrationState` | Guest→registered-account migration progress state | +| `helpTourCompleted` | Version of the in-app guide the user has completed; compared with `CURRENT_TOUR_VERSION` | >>>>>>> develop This config is also read by the background task (`task/signaling-task.ts`) on Android when the app is killed. diff --git a/mobile-app/sapot-mobile-app/docs/design-system.md b/mobile-app/sapot-mobile-app/docs/design-system.md index 99de1a0c..f5fe5a04 100644 --- a/mobile-app/sapot-mobile-app/docs/design-system.md +++ b/mobile-app/sapot-mobile-app/docs/design-system.md @@ -44,6 +44,7 @@ app/ ├─ (drawer)/ ← main app (drawer + nested tabs/stacks) │ └─ (tabs)/ │ └─ chat/[id].tsx ← dynamic chat screen + │ └─ settings/support/help/[id].tsx ← Help Center article ├─ getting-started/ └─ +not-found.tsx ``` diff --git a/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-list.test.tsx b/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-list.test.tsx new file mode 100644 index 00000000..948a2774 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-list.test.tsx @@ -0,0 +1,5 @@ +import { render } from "@testing-library/react-native"; +import { ArticleList } from "../article-list"; +jest.mock("expo-router", () => ({ useRouter: () => ({ push: jest.fn() }) })); +jest.mock("../../hooks/use-help-context", () => ({ useHelpContext: () => ({ mode: "lan", isGuest: true, isRescuer: false }) })); +describe("ArticleList", () => { it("filters unavailable article topics", () => { const screen = render(); expect(screen.getByText("Connecting & modes")).toBeTruthy(); expect(screen.queryByText("Announcements")).toBeNull(); expect(screen.queryByText("Map & location")).toBeNull(); }); }); diff --git a/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-view.test.tsx b/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-view.test.tsx new file mode 100644 index 00000000..0c5e2a32 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/__tests__/article-view.test.tsx @@ -0,0 +1,7 @@ +import { render } from "@testing-library/react-native"; +import { ArticleView } from "../article-view"; +import type { HelpArticle, HelpContext } from "../../types"; +jest.mock("expo-router", () => ({ useRouter: () => ({ push: jest.fn() }) })); +const ctx: HelpContext = { mode: "lan", isGuest: true, isRescuer: false }; +const article: HelpArticle = { title: "Test", summary: "Summary", icon: "help", category: "problems", blocks: [{ type: "paragraph", text: "Visible" }, { type: "paragraph", text: "Hidden", audience: { modes: ["server"] } }, { type: "steps", items: ["First"] }] }; +describe("ArticleView", () => { it("filters blocks and renders step numbers", () => { const screen = render(); expect(screen.getByText("Visible")).toBeTruthy(); expect(screen.queryByText("Hidden")).toBeNull(); expect(screen.getByText("1.")).toBeTruthy(); }); }); diff --git a/mobile-app/sapot-mobile-app/features/help/components/__tests__/help-icon-button.test.tsx b/mobile-app/sapot-mobile-app/features/help/components/__tests__/help-icon-button.test.tsx new file mode 100644 index 00000000..976c30f8 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/__tests__/help-icon-button.test.tsx @@ -0,0 +1,9 @@ +import { fireEvent, render } from "@testing-library/react-native"; +import { HelpIconButton } from "../help-icon-button"; +const mockPush = jest.fn(); jest.mock("expo-router", () => ({ useRouter: () => ({ push: mockPush }) })); +jest.mock("react-native-paper", () => { + const React = require("react"); + const { Pressable } = require("react-native"); + return { IconButton: ({ accessibilityLabel, onPress }: { accessibilityLabel: string; onPress: () => void }) => }; +}); +it("opens the matching article", () => { const screen = render(); fireEvent.press(screen.getByLabelText("Help")); expect(mockPush).toHaveBeenCalledWith({ pathname: "/(drawer)/settings/support/help/[id]", params: { id: "calls" } }); }); diff --git a/mobile-app/sapot-mobile-app/features/help/components/__tests__/tour-overlay.test.tsx b/mobile-app/sapot-mobile-app/features/help/components/__tests__/tour-overlay.test.tsx new file mode 100644 index 00000000..1d9f6bcd --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/__tests__/tour-overlay.test.tsx @@ -0,0 +1,47 @@ +import { render } from "@testing-library/react-native"; +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { TourOverlay } from "../tour-overlay"; + +const mockTour = { status: "running", step: { anchorId: "chats-tab", title: "Chats", body: "Body" }, stepIndex: 0, totalSteps: 3, anchorRect: { x: 20, y: 600, width: 80, height: 40 }, next: jest.fn(), skip: jest.fn(), measureActiveAnchor: jest.fn() }; +jest.mock("../../context/tour-context", () => ({ useTour: () => mockTour })); +jest.mock("@/features/shared/hooks", () => ({ useReducedMotion: () => false })); +jest.mock("../tour-step-card", () => ({ TourStepCard: () => null })); +jest.mock("react-native-paper", () => { + const React = require("react"); + return { Portal: ({ children }: { children: ReactNode }) => <>{children}, useTheme: () => ({ colors: { scrim: "rgb(0, 0, 0)", backdrop: "rgba(45, 48, 56, 0.4)", primary: "#3A7AFE" } }) }; +}); + +/** Flattened backgroundColor of every View the overlay renders. */ +function backgroundColors(screen: ReturnType): string[] { + return screen.UNSAFE_getAllByType(View) + .map((node) => (Array.isArray(node.props.style) ? Object.assign({}, ...node.props.style) : node.props.style)?.backgroundColor) + .filter((color): color is string => typeof color === "string"); +} + +/** Matches colors with no alpha channel, or an alpha of exactly 1. */ +const OPAQUE = /^(rgb\(|#(?:[0-9a-f]{3}|[0-9a-f]{6})$)|^rgba\([^)]*,\s*1(\.0+)?\s*\)$/i; + +describe("TourOverlay backdrop", () => { + it("dims the screen instead of blacking it out when an anchor is measured", () => { + const screen = render(); + const painted = backgroundColors(screen); + + expect(painted.length).toBeGreaterThan(0); + expect(painted.filter((color) => OPAQUE.test(color))).toEqual([]); + }); + + it("dims the screen instead of blacking it out when no anchor is measured", () => { + const withoutAnchor = { ...mockTour, anchorRect: undefined }; + jest.spyOn(require("../../context/tour-context"), "useTour").mockReturnValue(withoutAnchor); + + jest.useFakeTimers(); + const screen = render(); + jest.advanceTimersByTime(1000); + screen.rerender(); + const painted = backgroundColors(screen); + jest.useRealTimers(); + + expect(painted.filter((color) => OPAQUE.test(color))).toEqual([]); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/help/components/article-list.tsx b/mobile-app/sapot-mobile-app/features/help/components/article-list.tsx new file mode 100644 index 00000000..bae460a7 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/article-list.tsx @@ -0,0 +1,11 @@ +import { SETTINGS_ROUTES } from "@/config/routes"; +import { useRouter } from "expo-router"; +import { Pressable, ScrollView, View } from "react-native"; +import { Icon, Text, useTheme } from "react-native-paper"; +import { useHelpArticles } from "../hooks/use-help-articles"; + +interface ArticleListProps { onReplayTour: () => void } +export function ArticleList({ onReplayTour }: ArticleListProps) { + const theme = useTheme(); const router = useRouter(); const articles = useHelpArticles(); + return Take the tour again{articles.map(({ id, article }) => router.push({ pathname: SETTINGS_ROUTES.HELP_ARTICLE, params: { id } })} style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, paddingVertical: 14 }}>{article.title}{article.summary})}; +} diff --git a/mobile-app/sapot-mobile-app/features/help/components/article-view.tsx b/mobile-app/sapot-mobile-app/features/help/components/article-view.tsx new file mode 100644 index 00000000..aaaf4a90 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/article-view.tsx @@ -0,0 +1,13 @@ +import { ScrollView, View } from "react-native"; +import { Text, useTheme } from "react-native-paper"; +import { isVisible } from "../services/help-visibility"; +import type { HelpArticle, HelpContext } from "../types"; +import { BlockRenderer } from "./blocks/block-renderer"; + +interface ArticleViewProps { article: HelpArticle; ctx: HelpContext } + +export function ArticleView({ article, ctx }: ArticleViewProps) { + const theme = useTheme(); + const blocks = article.blocks.filter((block) => isVisible(block.audience, ctx)); + return {article.title}{article.summary}{blocks.map((block, index) => )}; +} diff --git a/mobile-app/sapot-mobile-app/features/help/components/blocks/block-renderer.tsx b/mobile-app/sapot-mobile-app/features/help/components/blocks/block-renderer.tsx new file mode 100644 index 00000000..fc2abadc --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/blocks/block-renderer.tsx @@ -0,0 +1,24 @@ +import { useRouter } from "expo-router"; +import { Image, View } from "react-native"; +import { Button, Icon, Text, useTheme } from "react-native-paper"; +import type { HelpBlock } from "../../types"; + +interface BlockRendererProps { block: HelpBlock } + +export function BlockRenderer({ block }: BlockRendererProps) { + const theme = useTheme(); + const router = useRouter(); + switch (block.type) { + case "paragraph": return {block.text}; + case "steps": return {block.items.map((item, index) => {`${index + 1}.`}{item})}; + case "bullets": return {block.items.map((item) => {`• ${item}`})}; + case "callout": { + const warning = block.tone === "warning"; + const backgroundColor = warning ? theme.colors.errorContainer : theme.colors.secondaryContainer; + const color = warning ? theme.colors.onErrorContainer : theme.colors.onSecondaryContainer; + return {block.text}; + } + case "image": return ; + case "action": return ; + } +} diff --git a/mobile-app/sapot-mobile-app/features/help/components/help-icon-button.tsx b/mobile-app/sapot-mobile-app/features/help/components/help-icon-button.tsx new file mode 100644 index 00000000..a631e1af --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/help-icon-button.tsx @@ -0,0 +1,10 @@ +import { SETTINGS_ROUTES } from "@/config/routes"; +import { useRouter } from "expo-router"; +import { IconButton } from "react-native-paper"; +import type { HelpArticleId } from "../content/registry"; + +interface HelpIconButtonProps { articleId: HelpArticleId; size?: number; color?: string } +export function HelpIconButton({ articleId, size = 22, color }: HelpIconButtonProps) { + const router = useRouter(); + return router.push({ pathname: SETTINGS_ROUTES.HELP_ARTICLE, params: { id: articleId } })} />; +} diff --git a/mobile-app/sapot-mobile-app/features/help/components/tour-overlay.tsx b/mobile-app/sapot-mobile-app/features/help/components/tour-overlay.tsx new file mode 100644 index 00000000..8a646a62 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/tour-overlay.tsx @@ -0,0 +1,23 @@ +import { useReducedMotion } from "@/features/shared/hooks"; +import { useEffect, useState } from "react"; +import { Keyboard, useWindowDimensions, View } from "react-native"; +import { Portal, useTheme } from "react-native-paper"; +import { ANCHOR_WAIT_MS } from "../constants"; +import { useTour } from "../context/tour-context"; +import { TourStepCard } from "./tour-step-card"; + +const CARD_MARGIN = 16; const HIGHLIGHT_PADDING = 6; +export function TourOverlay() { + const { status, step, stepIndex, totalSteps, anchorRect, next, skip, measureActiveAnchor } = useTour(); + const { width, height } = useWindowDimensions(); const theme = useTheme(); const reducedMotion = useReducedMotion(); const [graceElapsed, setGraceElapsed] = useState(false); + useEffect(() => { if (status !== "running") return; measureActiveAnchor(); setGraceElapsed(false); const timer = setTimeout(() => setGraceElapsed(true), ANCHOR_WAIT_MS); return () => clearTimeout(timer); }, [measureActiveAnchor, status, stepIndex]); + useEffect(() => { if (status !== "running") return; const show = Keyboard.addListener("keyboardDidShow", measureActiveAnchor); const hide = Keyboard.addListener("keyboardDidHide", measureActiveAnchor); measureActiveAnchor(); return () => { show.remove(); hide.remove(); }; }, [height, measureActiveAnchor, status, width]); + if (status !== "running" || !step || (!anchorRect && !graceElapsed)) return null; + const card = ; + // `scrim` is an opaque black token meant to be composited at a reduced opacity; `backdrop` is the + // theme's ready-made translucent dim (the one Paper's own Modal uses), so the app stays visible behind the tour. + const backdrop = theme.colors.backdrop; + if (!anchorRect) return {card}; + const top = Math.max(0, anchorRect.y - HIGHLIGHT_PADDING); const left = Math.max(0, anchorRect.x - HIGHLIGHT_PADDING); const boxWidth = anchorRect.width + HIGHLIGHT_PADDING * 2; const boxHeight = anchorRect.height + HIGHLIGHT_PADDING * 2; const placeCardBelow = height - (top + boxHeight) > height / 3; + return {card}; +} diff --git a/mobile-app/sapot-mobile-app/features/help/components/tour-step-card.tsx b/mobile-app/sapot-mobile-app/features/help/components/tour-step-card.tsx new file mode 100644 index 00000000..91ba6f15 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/components/tour-step-card.tsx @@ -0,0 +1,8 @@ +import { View } from "react-native"; +import { Button, Text, useTheme } from "react-native-paper"; + +interface TourStepCardProps { title: string; body: string; stepIndex: number; totalSteps: number; onNext: () => void; onSkip: () => void } +export function TourStepCard({ title, body, stepIndex, totalSteps, onNext, onSkip }: TourStepCardProps) { + const theme = useTheme(); const isLast = stepIndex + 1 >= totalSteps; + return {`${stepIndex + 1} of ${totalSteps}`}{title}{body}; +} diff --git a/mobile-app/sapot-mobile-app/features/help/constants.ts b/mobile-app/sapot-mobile-app/features/help/constants.ts new file mode 100644 index 00000000..d64e0740 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/constants.ts @@ -0,0 +1,5 @@ +/** Bump only for a materially changed tour that should be replayed. */ +export const CURRENT_TOUR_VERSION = 1; + +/** Time to wait for a focused screen to lay out a tour target. */ +export const ANCHOR_WAIT_MS = 500; diff --git a/mobile-app/sapot-mobile-app/features/help/content/__tests__/registry.test.ts b/mobile-app/sapot-mobile-app/features/help/content/__tests__/registry.test.ts new file mode 100644 index 00000000..ea6131b6 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/__tests__/registry.test.ts @@ -0,0 +1,9 @@ +import { HELP_ARTICLES, HELP_ARTICLE_IDS, getArticle } from "../registry"; + +describe("help article registry", () => { + it("exposes its article ids", () => expect(HELP_ARTICLE_IDS).toHaveLength(Object.keys(HELP_ARTICLES).length)); + it("returns known articles and safely ignores unknown ids", () => { + expect(getArticle("calls")?.title).toBe("Calls"); + expect(getArticle("not-real")).toBeUndefined(); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/account-and-recovery.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/account-and-recovery.ts new file mode 100644 index 00000000..1a9a43bb --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/account-and-recovery.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const accountAndRecovery: HelpArticle = { + title: "Account & recovery", + summary: "Manage your profile, password, and recovery methods.", + icon: "shield-account", + category: "account", + audience: { guest: "exclude" }, + blocks: [ + { type: "paragraph", text: "Your account keeps your identity and recovery settings available when you use SAPOT in Server mode." }, + { type: "bullets", items: ["Keep your recovery key in a safe place.", "Use Settings to update your profile and security details."] }, + { type: "callout", tone: "warning", text: "Never share your password or recovery key with another person." }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/announcements.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/announcements.ts new file mode 100644 index 00000000..d95e3839 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/announcements.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const announcements: HelpArticle = { + title: "Announcements", + summary: "Read official updates from the SAPOT server.", + icon: "bullhorn", + category: "communicating", + audience: { modes: ["server", "auto"] }, + blocks: [ + { type: "paragraph", text: "Announcements are official updates published by the SAPOT team or incident responders." }, + { type: "bullets", items: ["Use the filters to focus on high-priority updates.", "Announcements need access to the SAPOT server."] }, + { type: "action", label: "Open Announcements", route: { pathname: "/(drawer)/announcements" } }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/calls.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/calls.ts new file mode 100644 index 00000000..7bb32a26 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/calls.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const calls: HelpArticle = { + title: "Calls", + summary: "Place and answer audio and video calls with nearby people.", + icon: "phone", + category: "communicating", + blocks: [ + { type: "paragraph", text: "Calls run directly between devices when possible, so they can keep working on a local network." }, + { type: "steps", items: ["Open Chats and select a person.", "Tap the audio or video call button on their profile.", "Wait for them to accept the call."] }, + { type: "callout", tone: "info", text: "SAPOT requests microphone and camera permission before your first call." }, + { type: "bullets", items: ["Choose earpiece, speaker, or Bluetooth from the call controls.", "Turning your camera off continues the call as audio only."] }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/chat.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/chat.ts new file mode 100644 index 00000000..e573eca4 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/chat.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const chat: HelpArticle = { + title: "Chat", + summary: "Start conversations, add peers, and understand message delivery.", + icon: "message-text", + category: "communicating", + blocks: [ + { type: "paragraph", text: "Chats keep your conversations with people you have connected with." }, + { type: "steps", items: ["Open Chats.", "Choose a person, or scan their QR code to connect.", "Write a message and tap send."] }, + { type: "callout", tone: "info", text: "On a local network, SAPOT can exchange messages without internet access." }, + { type: "action", label: "Open Chats", route: { pathname: "/(drawer)/(tabs)" } }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/connecting.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/connecting.ts new file mode 100644 index 00000000..46f4b796 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/connecting.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const connecting: HelpArticle = { + title: "Connecting & modes", + summary: "Server mode, LAN mode, and what the badge in the header means.", + icon: "access-point-network", + category: "getting-connected", + blocks: [ + { type: "paragraph", text: "SAPOT works through the internet in Server mode or directly across a local network in LAN mode." }, + { type: "paragraph", text: "Your account lets messages sync and lets you receive notifications while the app is closed.", audience: { modes: ["server", "auto"], guest: "exclude" } }, + { type: "paragraph", text: "In LAN mode, messages stay on this device and reach people on the same network.", audience: { modes: ["lan"] } }, + { type: "bullets", items: ["The badge at the top of the screen shows your current mode.", "You can change mode later in Settings."] }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/map-and-location.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/map-and-location.ts new file mode 100644 index 00000000..b6f15e9f --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/map-and-location.ts @@ -0,0 +1,14 @@ +import type { HelpArticle } from "../../types"; + +export const mapAndLocation: HelpArticle = { + title: "Map & location", + summary: "View nearby responders and shared locations.", + icon: "map-marker-radius", + category: "communicating", + audience: { rescuerOnly: true }, + blocks: [ + { type: "paragraph", text: "The map shows the latest locations shared by people on the SAPOT network." }, + { type: "callout", tone: "warning", text: "Location sharing needs device location permission. You can change that permission in your device settings." }, + { type: "action", label: "Open Map", route: { pathname: "/(drawer)/(tabs)/map" } }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/articles/troubleshooting.ts b/mobile-app/sapot-mobile-app/features/help/content/articles/troubleshooting.ts new file mode 100644 index 00000000..9dad2c31 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/articles/troubleshooting.ts @@ -0,0 +1,12 @@ +import type { HelpArticle } from "../../types"; + +export const troubleshooting: HelpArticle = { + title: "Troubleshooting", + summary: "Quick checks when SAPOT cannot connect or a feature is unavailable.", + icon: "wrench", + category: "problems", + blocks: [ + { type: "steps", items: ["Check that Wi-Fi is on and you are connected to the incident network.", "Confirm the mode badge matches the network you intend to use.", "Check device permissions for the feature you are using."] }, + { type: "callout", tone: "info", text: "If the server is unavailable, LAN conversations can still work with nearby peers." }, + ], +}; diff --git a/mobile-app/sapot-mobile-app/features/help/content/registry.ts b/mobile-app/sapot-mobile-app/features/help/content/registry.ts new file mode 100644 index 00000000..f8a7b684 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/registry.ts @@ -0,0 +1,19 @@ +import type { HelpArticle } from "../types"; +import { accountAndRecovery } from "./articles/account-and-recovery"; +import { announcements } from "./articles/announcements"; +import { calls } from "./articles/calls"; +import { chat } from "./articles/chat"; +import { connecting } from "./articles/connecting"; +import { mapAndLocation } from "./articles/map-and-location"; +import { troubleshooting } from "./articles/troubleshooting"; + +export const HELP_ARTICLES = { + connecting, chat, calls, "map-and-location": mapAndLocation, announcements, + "account-and-recovery": accountAndRecovery, troubleshooting, +} as const satisfies Record; + +export type HelpArticleId = keyof typeof HELP_ARTICLES; +export const HELP_ARTICLE_IDS = Object.keys(HELP_ARTICLES) as HelpArticleId[]; +export function getArticle(id: string): HelpArticle | undefined { + return HELP_ARTICLES[id as HelpArticleId]; +} diff --git a/mobile-app/sapot-mobile-app/features/help/content/tour-steps.ts b/mobile-app/sapot-mobile-app/features/help/content/tour-steps.ts new file mode 100644 index 00000000..de005212 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/content/tour-steps.ts @@ -0,0 +1,10 @@ +import type { TourStep } from "../types"; + +export const TOUR_STEPS: readonly TourStep[] = [ + { anchorId: "mode-badge", title: "Your connection mode", body: "This badge shows whether SAPOT is using the internet or a local network." }, + { anchorId: "chats-tab", title: "Conversations live here", body: "Use Chats for every message you send or receive." }, + { anchorId: "scan-qr-tab", title: "Add someone nearby", body: "Scan another person's QR code to connect directly." }, + { anchorId: "map-tab", title: "See people on the map", body: "The map shows nearby responders and shared locations.", audience: { rescuerOnly: true } }, + { anchorId: "announcements-drawer-item", title: "Announcements", body: "Official updates from the server appear here.", audience: { modes: ["server", "auto"] } }, + { anchorId: "settings-tab", title: "Help whenever you need it", body: "Open Help Center in Settings to read the complete guide." }, +]; diff --git a/mobile-app/sapot-mobile-app/features/help/context/__tests__/tour-context.test.tsx b/mobile-app/sapot-mobile-app/features/help/context/__tests__/tour-context.test.tsx new file mode 100644 index 00000000..b5b0727f --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/context/__tests__/tour-context.test.tsx @@ -0,0 +1,6 @@ +import { act, render } from "@testing-library/react-native"; +import { Text } from "react-native"; +import { TourProvider, useTour } from "../tour-context"; +const mockClaim = jest.fn(); jest.mock("../../services/tour-persistence", () => ({ claimTourStart: () => mockClaim() })); jest.mock("../../hooks/use-help-context", () => ({ useHelpContext: () => ({ mode: "server", isGuest: false, isRescuer: true }) })); +function Probe() { const { status, stepIndex, start, next, skip } = useTour(); return <>{status}{stepIndex} void start()}>startnextskip; } +describe("TourProvider", () => { beforeEach(() => { mockClaim.mockResolvedValue(true); }); it("runs and advances after a claimed start", async () => { const screen = render(); await act(async () => { screen.getByTestId("start").props.onPress(); }); expect(screen.getByTestId("status").props.children).toBe("running"); act(() => screen.getByTestId("next").props.onPress()); expect(screen.getByTestId("index").props.children).toBe(1); }); }); diff --git a/mobile-app/sapot-mobile-app/features/help/context/tour-context.tsx b/mobile-app/sapot-mobile-app/features/help/context/tour-context.tsx new file mode 100644 index 00000000..65ce1ca5 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/context/tour-context.tsx @@ -0,0 +1,70 @@ +import { uiLog } from "@/features/shared/core/utils/logger"; +import type React from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import type { LayoutChangeEvent, View } from "react-native"; +import { TOUR_STEPS } from "../content/tour-steps"; +import { useHelpContext } from "../hooks/use-help-context"; +import { isVisible } from "../services/help-visibility"; +import { claimTourStart } from "../services/tour-persistence"; +import type { AnchorId, TourStep } from "../types"; + +export type AnchorRect = { x: number; y: number; width: number; height: number }; +export type TourStatus = "idle" | "running" | "done"; +type AnchorViews = Partial>; + +type TourContextValue = { + status: TourStatus; step: TourStep | undefined; stepIndex: number; totalSteps: number; + anchorRect: AnchorRect | undefined; start: () => Promise; next: () => void; skip: () => void; + registerAnchor: (id: AnchorId, rect: AnchorRect) => void; + registerAnchorView: (id: AnchorId, view: View | null) => void; + measureActiveAnchor: () => void; +}; +const TourContext = createContext(null); + +export function TourProvider({ children }: { children: React.ReactNode }) { + const helpContext = useHelpContext(); + const [status, setStatus] = useState("idle"); + const [stepIndex, setStepIndex] = useState(0); + const [anchors, setAnchors] = useState>>({}); + const anchorViews = useRef({}); + const steps = useMemo(() => TOUR_STEPS.filter((step) => isVisible(step.audience, helpContext)), [helpContext]); + + const registerAnchor = useCallback((id: AnchorId, rect: AnchorRect) => { + setAnchors((current) => { + const previous = current[id]; + if (previous && previous.x === rect.x && previous.y === rect.y && previous.width === rect.width && previous.height === rect.height) return current; + return { ...current, [id]: rect }; + }); + }, []); + const registerAnchorView = useCallback((id: AnchorId, view: View | null) => { anchorViews.current[id] = view; }, []); + const step = status === "running" ? steps[stepIndex] : undefined; + const measureActiveAnchor = useCallback(() => { + if (!step) return; + anchorViews.current[step.anchorId]?.measureInWindow((x, y, width, height) => { + if (width > 0 || height > 0) registerAnchor(step.anchorId, { x, y, width, height }); + }); + }, [registerAnchor, step]); + const start = useCallback(async () => { + if (!(await claimTourStart())) return; + uiLog.info("[help] tour started", { steps: steps.length }); + setStepIndex(0); setStatus("running"); + }, [steps.length]); + const finish = useCallback((reason: "completed" | "skipped") => { uiLog.info("[help] tour ended", { reason }); setStatus("done"); }, []); + const next = useCallback(() => setStepIndex((current) => { + if (current + 1 >= steps.length) { finish("completed"); return current; } + return current + 1; + }), [finish, steps.length]); + const skip = useCallback(() => finish("skipped"), [finish]); + const value = useMemo(() => ({ status, step, stepIndex, totalSteps: steps.length, anchorRect: step ? anchors[step.anchorId] : undefined, start, next, skip, registerAnchor, registerAnchorView, measureActiveAnchor }), [anchors, measureActiveAnchor, next, registerAnchor, registerAnchorView, skip, start, status, step, stepIndex, steps.length]); + return {children}; +} + +export function useTour(): TourContextValue { const value = useContext(TourContext); if (!value) throw new Error("useTour must be used within a TourProvider"); return value; } +export function useTourAnchor(id: AnchorId) { + const { registerAnchor, registerAnchorView } = useTour(); + const node = useRef(null); + const measure = useCallback(() => node.current?.measureInWindow((x, y, width, height) => { if (width > 0 || height > 0) registerAnchor(id, { x, y, width, height }); }), [id, registerAnchor]); + const ref = useCallback((view: View | null) => { node.current = view; registerAnchorView(id, view); }, [id, registerAnchorView]); + const onLayout = useCallback((_event: LayoutChangeEvent) => measure(), [measure]); + return { ref, onLayout, measure }; +} diff --git a/mobile-app/sapot-mobile-app/features/help/hooks/use-help-articles.ts b/mobile-app/sapot-mobile-app/features/help/hooks/use-help-articles.ts new file mode 100644 index 00000000..cc9e38ed --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/hooks/use-help-articles.ts @@ -0,0 +1,10 @@ +import { HELP_ARTICLES, HELP_ARTICLE_IDS, type HelpArticleId } from "../content/registry"; +import { isVisible } from "../services/help-visibility"; +import type { HelpArticle } from "../types"; +import { useHelpContext } from "./use-help-context"; + +export type VisibleArticle = { id: HelpArticleId; article: HelpArticle }; +export function useHelpArticles(): VisibleArticle[] { + const ctx = useHelpContext(); + return HELP_ARTICLE_IDS.filter((id) => isVisible(HELP_ARTICLES[id].audience, ctx)).map((id) => ({ id, article: HELP_ARTICLES[id] })); +} diff --git a/mobile-app/sapot-mobile-app/features/help/hooks/use-help-context.ts b/mobile-app/sapot-mobile-app/features/help/hooks/use-help-context.ts new file mode 100644 index 00000000..8c03351a --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/hooks/use-help-context.ts @@ -0,0 +1,9 @@ +import { useAuth } from "@/features/auth"; +import { useAppMode } from "@/features/shared/core/context"; +import type { HelpContext } from "../types"; + +export function useHelpContext(): HelpContext { + const { store } = useAppMode(); + const { isGuest, isRescuer } = useAuth(); + return { mode: store.mode, isGuest, isRescuer }; +} diff --git a/mobile-app/sapot-mobile-app/features/help/hooks/use-tour-autostart.ts b/mobile-app/sapot-mobile-app/features/help/hooks/use-tour-autostart.ts new file mode 100644 index 00000000..c3cf6012 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/hooks/use-tour-autostart.ts @@ -0,0 +1,9 @@ +import { useEffect, useRef } from "react"; +import { InteractionManager } from "react-native"; +import { useTour } from "../context/tour-context"; +import { shouldAutostartTour } from "../services/tour-persistence"; + +export function useTourAutostart(enabled: boolean): void { + const { start, status } = useTour(); const attempted = useRef(false); + useEffect(() => { if (!enabled || attempted.current || status !== "idle") return; attempted.current = true; let cancelled = false; const task = InteractionManager.runAfterInteractions(async () => { if (await shouldAutostartTour() && !cancelled) await start(); }); return () => { cancelled = true; task.cancel(); }; }, [enabled, start, status]); +} diff --git a/mobile-app/sapot-mobile-app/features/help/index.ts b/mobile-app/sapot-mobile-app/features/help/index.ts new file mode 100644 index 00000000..ad8c3d28 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/index.ts @@ -0,0 +1,14 @@ +export * from "./types"; +export * from "./constants"; +export { isVisible } from "./services/help-visibility"; +export { resetTourCompletion } from "./services/tour-persistence"; +export { HELP_ARTICLES, HELP_ARTICLE_IDS, getArticle } from "./content/registry"; +export type { HelpArticleId } from "./content/registry"; +export { ArticleList } from "./components/article-list"; +export { ArticleView } from "./components/article-view"; +export { HelpIconButton } from "./components/help-icon-button"; +export { TourOverlay } from "./components/tour-overlay"; +export { TourProvider, useTour, useTourAnchor } from "./context/tour-context"; +export { useHelpArticles } from "./hooks/use-help-articles"; +export { useHelpContext } from "./hooks/use-help-context"; +export { useTourAutostart } from "./hooks/use-tour-autostart"; diff --git a/mobile-app/sapot-mobile-app/features/help/services/__tests__/help-visibility.test.ts b/mobile-app/sapot-mobile-app/features/help/services/__tests__/help-visibility.test.ts new file mode 100644 index 00000000..1403d36b --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/services/__tests__/help-visibility.test.ts @@ -0,0 +1,17 @@ +import { isVisible } from "../help-visibility"; +import type { HelpContext } from "../../types"; + +const ctx = (overrides: Partial = {}): HelpContext => ({ + mode: "server", isGuest: false, isRescuer: false, ...overrides, +}); + +describe("isVisible", () => { + it("shows undeclared audiences", () => expect(isVisible(undefined, ctx())).toBe(true)); + it("requires all declared audience conditions", () => { + const audience = { modes: ["server"] as const, rescuerOnly: true as const, guest: "exclude" as const }; + expect(isVisible(audience, ctx())).toBe(false); + expect(isVisible(audience, ctx({ isRescuer: true }))).toBe(true); + expect(isVisible(audience, ctx({ isRescuer: true, mode: "lan" }))).toBe(false); + expect(isVisible(audience, ctx({ isRescuer: true, isGuest: true }))).toBe(false); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/help/services/__tests__/tour-persistence.test.ts b/mobile-app/sapot-mobile-app/features/help/services/__tests__/tour-persistence.test.ts new file mode 100644 index 00000000..1d6f75b2 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/services/__tests__/tour-persistence.test.ts @@ -0,0 +1,26 @@ +import { CURRENT_TOUR_VERSION } from "../../constants"; +import { claimTourStart, shouldAutostartTour } from "../tour-persistence"; +import { getHelpTourCompleted, saveHelpTourCompleted } from "@/features/shared/core/stores/secure-config"; + +jest.mock("@/features/shared/core/stores/secure-config", () => ({ + getHelpTourCompleted: jest.fn(), saveHelpTourCompleted: jest.fn(), clearHelpTourCompleted: jest.fn(), +})); +const mockGet = getHelpTourCompleted as jest.MockedFunction; +const mockSave = saveHelpTourCompleted as jest.MockedFunction; + +describe("tour persistence", () => { + beforeEach(() => jest.clearAllMocks()); + it("autostarts only for missing or older versions", async () => { + mockGet.mockResolvedValue(undefined); + await expect(shouldAutostartTour()).resolves.toBe(true); + mockGet.mockResolvedValue(CURRENT_TOUR_VERSION); + await expect(shouldAutostartTour()).resolves.toBe(false); + mockGet.mockRejectedValue(new Error("unavailable")); + await expect(shouldAutostartTour()).resolves.toBe(false); + }); + it("claims a verified version write", async () => { + mockSave.mockResolvedValue(); mockGet.mockResolvedValue(CURRENT_TOUR_VERSION); + await expect(claimTourStart()).resolves.toBe(true); + expect(mockSave).toHaveBeenCalledWith(CURRENT_TOUR_VERSION); + }); +}); diff --git a/mobile-app/sapot-mobile-app/features/help/services/help-visibility.ts b/mobile-app/sapot-mobile-app/features/help/services/help-visibility.ts new file mode 100644 index 00000000..e062fe42 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/services/help-visibility.ts @@ -0,0 +1,11 @@ +import type { HelpAudience, HelpContext } from "../types"; + +/** The single audience predicate shared by articles, blocks, and tour steps. */ +export function isVisible(audience: HelpAudience | undefined, ctx: HelpContext): boolean { + if (!audience) return true; + if (audience.modes && !audience.modes.includes(ctx.mode)) return false; + if (audience.rescuerOnly && !ctx.isRescuer) return false; + if (audience.guest === "only" && !ctx.isGuest) return false; + if (audience.guest === "exclude" && ctx.isGuest) return false; + return true; +} diff --git a/mobile-app/sapot-mobile-app/features/help/services/tour-persistence.ts b/mobile-app/sapot-mobile-app/features/help/services/tour-persistence.ts new file mode 100644 index 00000000..9a23971a --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/services/tour-persistence.ts @@ -0,0 +1,40 @@ +import { + clearHelpTourCompleted, + getHelpTourCompleted, + saveHelpTourCompleted, +} from "@/features/shared/core/stores/secure-config"; +import { uiLog } from "@/features/shared/core/utils/logger"; +import { CURRENT_TOUR_VERSION } from "../constants"; + +export async function shouldAutostartTour(): Promise { + try { + const completed = await getHelpTourCompleted(); + return completed === undefined || completed < CURRENT_TOUR_VERSION; + } catch (error) { + uiLog.error("[help] tour flag unreadable, suppressing autostart", { error }); + return false; + } +} + +export async function claimTourStart(): Promise { + try { + await saveHelpTourCompleted(CURRENT_TOUR_VERSION); + const readBack = await getHelpTourCompleted(); + if (readBack !== CURRENT_TOUR_VERSION) { + uiLog.error("[help] tour flag write not confirmed, suppressing autostart", { readBack }); + return false; + } + return true; + } catch (error) { + uiLog.error("[help] tour flag write failed, suppressing autostart", { error }); + return false; + } +} + +export async function resetTourCompletion(): Promise { + try { + await clearHelpTourCompleted(); + } catch (error) { + uiLog.warn("[help] could not clear tour flag; replaying anyway", { error }); + } +} diff --git a/mobile-app/sapot-mobile-app/features/help/types.ts b/mobile-app/sapot-mobile-app/features/help/types.ts new file mode 100644 index 00000000..eb90e3c7 --- /dev/null +++ b/mobile-app/sapot-mobile-app/features/help/types.ts @@ -0,0 +1,51 @@ +import type { ImageSourcePropType } from "react-native"; +import type { AppMode } from "@/features/shared/core/stores/app-mode-store"; + +export type AnchorId = + | "mode-badge" + | "chats-tab" + | "scan-qr-tab" + | "map-tab" + | "peer-profile-call-buttons" + | "announcements-drawer-item" + | "settings-tab"; + +export type HelpAudience = { + modes?: readonly AppMode[]; + guest?: "only" | "exclude"; + rescuerOnly?: true; +}; + +export type HelpContext = { + mode: AppMode; + isGuest: boolean; + isRescuer: boolean; +}; + +export type HelpRoute = { pathname: string; params?: Record }; + +export type HelpBlock = + | { type: "paragraph"; text: string; audience?: HelpAudience } + | { type: "steps"; items: readonly string[]; audience?: HelpAudience } + | { type: "bullets"; items: readonly string[]; audience?: HelpAudience } + | { type: "callout"; tone: "info" | "warning"; text: string; audience?: HelpAudience } + | { type: "image"; source: ImageSourcePropType; alt: string; audience?: HelpAudience } + | { type: "action"; label: string; route: HelpRoute; audience?: HelpAudience }; + +export type HelpCategory = "getting-connected" | "communicating" | "account" | "problems"; + +export type HelpArticle = { + title: string; + summary: string; + icon: string; + category: HelpCategory; + audience?: HelpAudience; + blocks: readonly HelpBlock[]; +}; + +export type TourStep = { + anchorId: AnchorId; + title: string; + body: string; + audience?: HelpAudience; +}; diff --git a/mobile-app/sapot-mobile-app/features/shared/components/custom-drawer-content.tsx b/mobile-app/sapot-mobile-app/features/shared/components/custom-drawer-content.tsx index e1d35638..dd4e898e 100644 --- a/mobile-app/sapot-mobile-app/features/shared/components/custom-drawer-content.tsx +++ b/mobile-app/sapot-mobile-app/features/shared/components/custom-drawer-content.tsx @@ -247,6 +247,15 @@ export function CustomDrawerContent(props: DrawerContentComponentProps) { )} style={{ marginHorizontal: 0, borderRadius: 0 }} /> + { + uiLog.info("drawer › help pressed"); + router.push(SETTINGS_ROUTES.HELP_CENTER); + }} + icon={({ color, size }) => } + style={{ marginHorizontal: 0, borderRadius: 0 }} + /> => { } }; +/** Stores the version of the in-app guide that the user has seen. */ +export const saveHelpTourCompleted = async (version: number): Promise => { + await setItemAsync(KEYS.HELP_TOUR_COMPLETED, String(version)); +}; + +export const getHelpTourCompleted = async (): Promise => { + const value = await getItemAsync(KEYS.HELP_TOUR_COMPLETED); + if (value === null) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +export const clearHelpTourCompleted = async (): Promise => { + await deleteItemAsync(KEYS.HELP_TOUR_COMPLETED); +}; + export const getSyncLastPulledAt = async (): Promise => { try { const val = await getItemAsync(KEYS.SYNC_LAST_PULLED_AT);