Skip to content
Draft
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
2 changes: 1 addition & 1 deletion mobile-app/sapot-mobile-app/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ features/<name>/
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.
Expand Down
37 changes: 26 additions & 11 deletions mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,6 +53,10 @@ function TabUnderline({ focused }: { focused: boolean }) {
);
}

function TourTabLabel({ children, color, focused, anchor }: { children: React.ReactNode; color: string; focused: boolean; anchor: ReturnType<typeof useTourAnchor> }) {
return <View ref={anchor.ref} onLayout={anchor.onLayout} collapsable={false} style={{ alignItems: "center", justifyContent: "center", gap: 4 }}><Text style={{ color, fontSize: 12, fontWeight: focused ? "600" : "500" }}>{children}</Text><TabUnderline focused={focused} /></View>;
}

function IncomingCallListener() {
const connectionService = useConnectionService();
const pathname = usePathname();
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -193,7 +202,7 @@ export default function TabLayout() {
name="index"
options={{
title: "Chats",
tabBarLabel: "Chats",
tabBarLabel: ({ children, color, focused }) => <TourTabLabel anchor={chatsAnchor} color={color} focused={focused}>{children}</TourTabLabel>,
tabBarIcon: ({ color }) => (
<Entypo name="chat" size={24} color={color} />
),
Expand All @@ -220,7 +229,7 @@ export default function TabLayout() {
name="map"
options={{
title: "Live Map",
tabBarLabel: "Map",
tabBarLabel: ({ children, color, focused }) => <TourTabLabel anchor={mapAnchor} color={color} focused={focused}>{children}</TourTabLabel>,
tabBarIcon: ({ color }) => (
<Feather name="map" size={24} color={color} />
),
Expand All @@ -230,6 +239,7 @@ export default function TabLayout() {
headerShadowVisible: false,
headerStyle: { backgroundColor: "transparent" },
href: isRescuer ? undefined : null,
headerRight: () => <HelpIconButton articleId="map-and-location" />,
}}
/>
<Tabs.Screen
Expand All @@ -252,7 +262,7 @@ export default function TabLayout() {
<Feather name="settings" size={24} color={color} />
),
headerShown: false,
tabBarLabel: "Settings",
tabBarLabel: ({ children, color, focused }) => <TourTabLabel anchor={settingsAnchor} color={color} focused={focused}>{children}</TourTabLabel>,
}}
/>
<Tabs.Screen
Expand Down Expand Up @@ -293,14 +303,17 @@ export default function TabLayout() {
}}
/>

<Appbar.Content
titleStyle={{
fontWeight: "bold",
color: theme.dark ? "#E6ECF5" : "#000",
fontSize: 24,
}}
title={options.title ?? "QR Code"}
/>
<View ref={scanQrAnchor.ref} onLayout={scanQrAnchor.onLayout} collapsable={false} style={{ flex: 1 }}>
<Appbar.Content
titleStyle={{
fontWeight: "bold",
color: theme.colors.onSurface,
fontSize: 24,
}}
title={options.title ?? "QR Code"}
/>
</View>
<HelpIconButton articleId="chat" />
</Appbar.Header>
),
}}
Expand All @@ -318,6 +331,8 @@ export default function TabLayout() {
router.back();
}}
/>
<Appbar.Content title="" />
<HelpIconButton articleId="calls" />
</Appbar.Header>
),
}}
Expand Down
30 changes: 24 additions & 6 deletions mobile-app/sapot-mobile-app/app/(drawer)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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 (
<View
Expand All @@ -70,6 +74,9 @@ function HeaderRight() {
isZeroconfAllowed={isZeroconfAllowed}
/>
<View
ref={modeAnchor.ref}
onLayout={modeAnchor.onLayout}
collapsable={false}
style={{
display: "flex",
flexDirection: "row",
Expand All @@ -80,26 +87,33 @@ function HeaderRight() {
alignItems: "center",
borderRadius: 20,
borderWidth: 1,
borderColor: "#103462",
borderColor: theme.colors.primary,
}}
>
<Icon source={labelIcon[store.mode]} size={12} color="#103462" />
<Icon source={labelIcon[store.mode]} size={12} color={theme.colors.primary} />
<Text
style={{
marginLeft: 2,
fontSize: 11,
lineHeight: 13,
fontWeight: "600",
color: "#103462",
color: theme.colors.primary,
}}
>
{store.mode.toUpperCase()}
</Text>
</View>
<IconButton icon="help-circle-outline" size={20} accessibilityLabel="Help" onPress={() => navigation.push(SETTINGS_ROUTES.HELP_CENTER)} />
</View>
);
}

function TourAutostart() {
const { isAuthenticated, isGuest } = useAuth();
useTourAutostart(isAuthenticated || isGuest);
return null;
}

export default function DrawerLayout() {
const { isAuthenticated, loading, isGuest } = useAuth();
const theme = useTheme();
Expand Down Expand Up @@ -255,6 +269,8 @@ export default function DrawerLayout() {
<DebugPanel />
</>
)}
<TourProvider>
<TourAutostart />
<Drawer
drawerContent={(props) => <CustomDrawerContent {...props} />}
screenOptions={{
Expand Down Expand Up @@ -387,6 +403,8 @@ export default function DrawerLayout() {
}}
/>
</Drawer>
<TourOverlay />
</TourProvider>
</CallProvider>
</AnnouncementSeenProvider>
</QueryClientProvider>
Expand Down
19 changes: 12 additions & 7 deletions mobile-app/sapot-mobile-app/app/(drawer)/announcements.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -129,13 +131,16 @@ export default function AnnouncementsScreen() {
}
}}
/>
<Appbar.Content
title="Announcements"
titleStyle={[
styles.appbarTitle,
{ color: theme.dark ? "#E6ECF5" : "#000" },
]}
/>
<View ref={announcementsAnchor.ref} onLayout={announcementsAnchor.onLayout} collapsable={false} style={{ flex: 1 }}>
<Appbar.Content
title="Announcements"
titleStyle={[
styles.appbarTitle,
{ color: theme.colors.onSurface },
]}
/>
</View>
<HelpIconButton articleId="announcements" />
{newCount > 0 && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{newCount} new</Text>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => <Pressable onPress={onPress}><Text>{children}</Text></Pressable> };
});
describe("HelpArticleScreen", () => { it("renders articles and handles unavailable routes", () => { mockParams = { id: "calls" }; const known = render(<HelpArticleScreen />); expect(known.getByText("Calls")).toBeTruthy(); known.unmount(); mockParams = { id: "announcements" }; expect(render(<HelpArticleScreen />).getByText(/doesn't apply/i)).toBeTruthy(); }); });
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -13,11 +18,12 @@ export default function SwitchMode() {
};
}, []);

return (
<View style={{ flex: 1, backgroundColor: theme.colors.secondary }}>
<View style={{ padding: 16, alignItems: "center" }}>
<Text>Help Center</Text>
</View>
</View>
);
const handleReplayTour = async () => {
uiLog.info("[HelpCenter] tour replay requested");
await resetTourCompletion();
await start();
router.push(APP_ROUTES.HOME);
};

return <View style={{ flex: 1, backgroundColor: theme.colors.secondary }}><ArticleList onReplayTour={handleReplayTour} /></View>;
}
Original file line number Diff line number Diff line change
@@ -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 <View style={{ flex: 1, padding: 24, gap: 16, justifyContent: "center", backgroundColor: theme.colors.secondary }}><Text variant="titleMedium" style={{ color: theme.colors.onSurface }}>{unavailableMessage}</Text><Button mode="contained" onPress={() => router.push(SETTINGS_ROUTES.HELP_CENTER)}>Back to Help Center</Button></View>;
if (!article) return null;
return <ArticleView article={article} ctx={ctx} />;
}
1 change: 1 addition & 0 deletions mobile-app/sapot-mobile-app/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
6 changes: 6 additions & 0 deletions mobile-app/sapot-mobile-app/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion mobile-app/sapot-mobile-app/docs/ENV_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mobile-app/sapot-mobile-app/docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<ArticleList onReplayTour={jest.fn()} />); expect(screen.getByText("Connecting & modes")).toBeTruthy(); expect(screen.queryByText("Announcements")).toBeNull(); expect(screen.queryByText("Map & location")).toBeNull(); }); });
Original file line number Diff line number Diff line change
@@ -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(<ArticleView article={article} ctx={ctx} />); expect(screen.getByText("Visible")).toBeTruthy(); expect(screen.queryByText("Hidden")).toBeNull(); expect(screen.getByText("1.")).toBeTruthy(); }); });
Original file line number Diff line number Diff line change
@@ -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 }) => <Pressable accessibilityLabel={accessibilityLabel} onPress={onPress} /> };
});
it("opens the matching article", () => { const screen = render(<HelpIconButton articleId="calls" />); fireEvent.press(screen.getByLabelText("Help")); expect(mockPush).toHaveBeenCalledWith({ pathname: "/(drawer)/settings/support/help/[id]", params: { id: "calls" } }); });
Loading
Loading