diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml
index 439fe10d..60a2af57 100644
--- a/app/.maestro/crisis-button-reachability.yaml
+++ b/app/.maestro/crisis-button-reachability.yaml
@@ -69,9 +69,121 @@ name: "Crisis button reaches CrisisResources from every tab"
id: "nav-back-button"
# ── Profile tab ──────────────────────────────────────────────────
+# FEAT-212: Profile migrated from a local currentScreen state machine to a nested
+# React Navigation stack (ProfileStackNavigator), and CollapsibleCrisisButton was
+# re-hosted from inside ProfileScreen to the navigator wrapper (sibling above the
+# stack). The overlay must therefore stay reachable not just on the Profile tab
+# landing (menu route) but on EVERY pushed subscreen — the precise regression this
+# migration risks. We traverse each depth-1 route + one depth-2 route and assert
+# crisis-profile → crisis-resources-screen from each.
+#
+# Pops use two distinct testIDs:
+# - nav-back-button → pops the root CrisisResources modal (INFRA-185)
+# - profile-back-button → pops a Profile-stack route (FEAT-212 header back)
+#
+# NOTE: the Privacy → CloudBackup depth-2 route is gated by the cloud_sync feature
+# flag (dark by default), so its entry row is not present in this default-build
+# run; that route's overlay hosting is pinned by ProfileStackNavigator.test.tsx.
+# Legal → LegalDocument (ungated) provides the on-device depth-2 assertion here.
+
+# menu route (Profile tab landing)
- tapOn:
id: "tab-profile"
- tapOn:
id: "crisis-profile"
- assertVisible:
id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+
+# depth-1: Notifications & Display
+- scrollUntilVisible:
+ element:
+ id: "profile-card-appsettings"
+ direction: DOWN
+- tapOn:
+ id: "profile-card-appsettings"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+- tapOn:
+ id: "profile-back-button"
+
+# depth-1: Privacy & Data
+- scrollUntilVisible:
+ element:
+ id: "profile-card-privacy"
+ direction: DOWN
+- tapOn:
+ id: "profile-card-privacy"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+- tapOn:
+ id: "profile-back-button"
+
+# depth-1: Account
+- scrollUntilVisible:
+ element:
+ id: "profile-card-account"
+ direction: DOWN
+- tapOn:
+ id: "profile-card-account"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+- tapOn:
+ id: "profile-back-button"
+
+# depth-1: About Stoic Mindfulness
+- scrollUntilVisible:
+ element:
+ id: "profile-card-stoic"
+ direction: DOWN
+- tapOn:
+ id: "profile-card-stoic"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+- tapOn:
+ id: "profile-back-button"
+
+# depth-1: Legal Documents
+- scrollUntilVisible:
+ element:
+ id: "profile-card-legal"
+ direction: DOWN
+- tapOn:
+ id: "profile-card-legal"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+
+# depth-2: Legal Documents → an individual document (real push, depth 2)
+- tapOn:
+ id: "profile-legal-doc-privacy-policy"
+- tapOn:
+ id: "crisis-profile"
+- assertVisible:
+ id: "crisis-resources-screen"
+- tapOn:
+ id: "nav-back-button"
+- tapOn:
+ id: "profile-back-button"
+- tapOn:
+ id: "profile-back-button"
diff --git a/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx b/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx
index 69d7a852..1da6f630 100644
--- a/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx
+++ b/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx
@@ -5,7 +5,10 @@
* (previously plain Text with no role) gain `accessibilityRole="header"` /
* `accessibilityLevel={2}` so screen readers expose the article's structure under
* the screen's level-1 heading. Verifies all four section headers carry the role
- * and level, and that the close affordance is properly labelled.
+ * and level.
+ *
+ * FEAT-212: the back affordance is now the native stack header (ProfileStackNavigator),
+ * so the in-content close-button label test was removed.
*/
import React from 'react';
import { render } from '@testing-library/react-native';
@@ -14,7 +17,7 @@ import AboutStoicMindfulnessScreen from '@/features/profile/screens/AboutStoicMi
describe('AboutStoicMindfulnessScreen — accessibility (FEAT-211)', () => {
it('exposes all four section titles as level-2 headers', () => {
- const { getAllByRole } = render();
+ const { getAllByRole } = render();
const headers = getAllByRole('header');
expect(headers).toHaveLength(4);
@@ -24,7 +27,7 @@ describe('AboutStoicMindfulnessScreen — accessibility (FEAT-211)', () => {
});
it('labels each section header for screen readers', () => {
- const { getByText } = render();
+ const { getByText } = render();
['What is Stoic Mindfulness?', 'The Five Principles', 'Developmental Stages', 'Philosophical Foundations'].forEach((title) => {
const node = getByText(title);
@@ -32,12 +35,4 @@ describe('AboutStoicMindfulnessScreen — accessibility (FEAT-211)', () => {
expect(node.props.accessibilityLevel).toBe(2);
});
});
-
- it('gives the close button an accessible label and hint', () => {
- const { getByLabelText } = render();
-
- const closeButton = getByLabelText('Close About Stoic Mindfulness');
- expect(closeButton.props.accessibilityRole).toBe('button');
- expect(closeButton.props.accessibilityHint).toBe('Returns to profile menu');
- });
});
diff --git a/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx b/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx
index a9e38ba7..88fdc4be 100644
--- a/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx
+++ b/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx
@@ -6,18 +6,21 @@
* order, the four developmental stages with their timeframes, and the three dated
* philosopher attributions. Content is a protected therapeutic path — these
* assertions guard against accidental edits during the structural move (and the
- * later FEAT-76 content enhancement). Also verifies the close affordance calls back.
+ * later FEAT-76 content enhancement).
+ *
+ * FEAT-212: the screen is now a route; its title ("About Stoic Mindfulness") and
+ * back affordance are supplied by the native stack header (ProfileStackNavigator),
+ * not an in-content SubMenuHeader — so the close-button test moved to the navigator.
*/
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render } from '@testing-library/react-native';
import AboutStoicMindfulnessScreen from '@/features/profile/screens/AboutStoicMindfulnessScreen';
describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
- it('renders the title and all four section headers', () => {
- const { getByText } = render();
+ it('renders all four section headers', () => {
+ const { getByText } = render();
- expect(getByText('About Stoic Mindfulness')).toBeTruthy();
expect(getByText('What is Stoic Mindfulness?')).toBeTruthy();
expect(getByText('The Five Principles')).toBeTruthy();
expect(getByText('Developmental Stages')).toBeTruthy();
@@ -25,7 +28,7 @@ describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
});
it('renders the five principles in canonical order', () => {
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('1. Aware Presence')).toBeTruthy();
expect(getByText('2. Radical Acceptance')).toBeTruthy();
@@ -35,7 +38,7 @@ describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
});
it('renders the four developmental stages with exact timeframes', () => {
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('Fragmented (1-6 months)')).toBeTruthy();
expect(getByText('Effortful (6-18 months)')).toBeTruthy();
@@ -44,18 +47,10 @@ describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
});
it('renders the three philosopher attributions', () => {
- const { getByText } = render();
+ const { getByText } = render();
expect(getByText('Marcus Aurelius')).toBeTruthy();
expect(getByText('Epictetus')).toBeTruthy();
expect(getByText('Seneca')).toBeTruthy();
});
-
- it('calls onReturn when the close button is pressed', () => {
- const onReturn = jest.fn();
- const { getByLabelText } = render();
-
- fireEvent.press(getByLabelText('Close About Stoic Mindfulness'));
- expect(onReturn).toHaveBeenCalledTimes(1);
- });
});
diff --git a/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx b/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx
index 7e01e62d..69f2926b 100644
--- a/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx
+++ b/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx
@@ -8,28 +8,26 @@
* INFRA-199: the row now resolves the flag via the runtime `useFeatureFlag`
* hook (from @/core/analytics) rather than the sync `isFeatureEnabled`, so the
* control point here is the mocked hook. The dark-ship assertions are unchanged.
+ *
+ * FEAT-212: Cloud Backup is now a pushed route on ProfileStackNavigator rather
+ * than an in-component sub-screen, so pressing the row calls
+ * navigation.navigate('CloudBackup') instead of swapping rendered content.
*/
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
-// useFocusEffect needs a NavigationContainer in the real impl; no-op it.
+const mockNavigate = jest.fn();
+
+// PrivacyDataScreen uses useFocusEffect (no-op it) and useNavigation (spy the
+// navigate so we can assert the row pushes the CloudBackup route).
jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn(),
+ useNavigation: () => ({ navigate: mockNavigate }),
}));
// Control the flag per test (the runtime hook is the gating point post-INFRA-199).
const mockUseFeatureFlag = jest.fn();
-// Stub the heavy child screen so navigation is observable without rendering
-// the full cloud-backup UI (and its useCloudSync hook chain).
-jest.mock('@/features/profile/screens/CloudBackupScreen', () => {
- const { Text } = require('react-native');
- return {
- __esModule: true,
- default: () => Cloud Backup Screen,
- };
-});
-
// Consent store: provide the slice PrivacyDataScreen destructures.
jest.mock('@/core/stores/consentStore', () => ({
useConsentStore: () => ({
@@ -66,21 +64,21 @@ describe('PrivacyDataScreen — Manage Cloud Backup row (MAINT-173)', () => {
it('hides the row when cloud_sync is OFF (dark ship)', async () => {
mockUseFeatureFlag.mockReturnValue(false);
- const { queryByText } = render();
+ const { queryByText } = render();
// Wait for the post-load form to render.
await waitFor(() => expect(queryByText('Settings Backup')).toBeTruthy());
expect(queryByText('Manage Cloud Backup')).toBeNull();
});
- it('shows the row, with a11y role/label, and navigates when cloud_sync is ON', async () => {
+ it('shows the row, with a11y role/label, and pushes the CloudBackup route when cloud_sync is ON', async () => {
mockUseFeatureFlag.mockImplementation((name) => name === 'cloud_sync');
- const { findByRole, getByTestId } = render();
+ const { findByRole } = render();
const row = await findByRole('button', { name: 'Manage Cloud Backup' });
expect(row).toBeTruthy();
fireEvent.press(row);
- expect(getByTestId('cloud-backup-screen')).toBeTruthy();
+ expect(mockNavigate).toHaveBeenCalledWith('CloudBackup');
});
});
diff --git a/app/__tests__/unit/profile-stack-navigator.test.tsx b/app/__tests__/unit/profile-stack-navigator.test.tsx
new file mode 100644
index 00000000..258bb665
--- /dev/null
+++ b/app/__tests__/unit/profile-stack-navigator.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * profile-stack-navigator.test.tsx — FEAT-212 crisis-overlay re-host (audit §5.1).
+ *
+ * FEAT-212 moved CollapsibleCrisisButton from inside ProfileScreen up to the
+ * ProfileStackNavigator wrapper (sibling above ). This spec pins
+ * the frozen crisis contract at the jest layer by capturing the props the navigator
+ * passes to the overlay:
+ * - CB-7: testID="crisis-profile" (the safety e2e + reachability target)
+ * - CB-6: mode="standard" (full opacity — never downgraded to immersive)
+ * - CB-4: position="right"
+ * - CB-1: onNavigate routes to the ROOT CrisisResources modal, not the local
+ * Profile stack — the failure mode where the dial path silently no-ops.
+ *
+ * The navigator machinery and screen modules are stubbed: this test is about WHAT
+ * the navigator wires into the overlay. On-device reachability across every route
+ * (depth-1 + depth-2) is the Maestro crisis-button-reachability flow.
+ */
+import React from 'react';
+import { render } from '@testing-library/react-native';
+
+// Root navigation spy — the navigator must resolve THIS (root) nav for onNavigate.
+const mockNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+// Stub the stack so just renders its children and screens are
+// inert — keeps the overlay (the sibling we care about) rendering without a
+// NavigationContainer (unsupported in this jest env).
+jest.mock('@react-navigation/stack', () => {
+ const ReactLib = require('react');
+ return {
+ createStackNavigator: () => ({
+ Navigator: ({ children }: { children: React.ReactNode }) =>
+ ReactLib.createElement(ReactLib.Fragment, null, children),
+ Screen: () => null,
+ }),
+ };
+});
+jest.mock('@react-navigation/elements', () => ({ HeaderBackButton: () => null }));
+
+// Capture the props the navigator passes to the crisis overlay.
+const mockCrisisProps = jest.fn();
+jest.mock('@/features/crisis/components/CollapsibleCrisisButton', () => {
+ const ReactLib = require('react');
+ const { Text } = require('react-native');
+ return {
+ __esModule: true,
+ CollapsibleCrisisButton: (props: Record) => {
+ mockCrisisProps(props);
+ return ReactLib.createElement(Text, { testID: props.testID as string }, 'crisis');
+ },
+ };
+});
+
+// Screen modules are imported at the navigator's module top — stub them so their
+// heavy transitive deps (e.g. react-native-markdown-display ESM) never load.
+jest.mock('@/features/profile/screens/ProfileScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/AccountSettingsScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/PrivacyDataScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/AppSettingsScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/AboutStoicMindfulnessScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/AboutBeingScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/LegalDocumentsListScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/LegalDocumentScreen', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/features/profile/screens/CloudBackupScreen', () => ({ __esModule: true, default: () => null }));
+
+import ProfileStackNavigator from '@/features/profile/ProfileStackNavigator';
+
+beforeEach(() => {
+ mockNavigate.mockClear();
+ mockCrisisProps.mockClear();
+});
+
+describe('ProfileStackNavigator — crisis overlay re-host (FEAT-212)', () => {
+ it('hosts the crisis overlay as a sibling of the stack (testID="crisis-profile")', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('crisis-profile')).toBeTruthy();
+ });
+
+ it('passes the frozen crisis props: standard mode, right position, pinned testID', () => {
+ render();
+ expect(mockCrisisProps).toHaveBeenCalledTimes(1);
+ const props = mockCrisisProps.mock.calls[0][0];
+ expect(props.mode).toBe('standard'); // CB-6: never immersive
+ expect(props.position).toBe('right'); // CB-4
+ expect(props.testID).toBe('crisis-profile'); // CB-7
+ });
+
+ it('CB-1: onNavigate routes to the ROOT CrisisResources modal', () => {
+ render();
+ const props = mockCrisisProps.mock.calls[0][0];
+ (props.onNavigate as () => void)();
+ expect(mockNavigate).toHaveBeenCalledWith('CrisisResources');
+ });
+});
diff --git a/app/src/core/navigation/CleanTabNavigator.tsx b/app/src/core/navigation/CleanTabNavigator.tsx
index ca9766c4..d00fd882 100644
--- a/app/src/core/navigation/CleanTabNavigator.tsx
+++ b/app/src/core/navigation/CleanTabNavigator.tsx
@@ -16,7 +16,7 @@ import { View, Text } from 'react-native';
import Svg, { Path, Circle, Rect, ClipPath, Defs, G } from 'react-native-svg';
import { colorSystem, spacing, typography } from '@/core/theme';
import CleanHomeScreen from '@/features/home/screens/CleanHomeScreen';
-import ProfileScreen from '@/features/profile/screens/ProfileScreen';
+import ProfileStackNavigator from '@/features/profile/ProfileStackNavigator';
import InsightsScreen from '@/features/insights/screens/InsightsScreen';
import LearnScreen from '@/features/learn/screens/LearnScreen';
import BrainIcon from '@/core/components/shared/BrainIcon';
@@ -190,10 +190,12 @@ const CleanTabNavigator: React.FC = () => {
(
— never inside a screen — so it overlays
+ * every Profile route (menu through depth-2) and stays <3 taps / <3s / <200ms
+ * from 988. Mirrors the proven MorningFlowNavigator wrapper pattern. Two pins
+ * that silently break the dial path if changed:
+ * 1. onNavigate MUST use the ROOT navigation (CrisisResources is a root-stack
+ * modal; the local Profile-stack nav has no such route).
+ * 2. mode="standard" (full opacity) — NOT "immersive"; eager import only;
+ * testID="crisis-profile"; position="right". The safety e2e targets these.
+ */
+import React from 'react';
+import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack';
+import { useNavigation } from '@react-navigation/native';
+import { HeaderBackButton } from '@react-navigation/elements';
+import { CollapsibleCrisisButton } from '@/features/crisis/components/CollapsibleCrisisButton';
+import { colorSystem, typography } from '@/core/theme';
+import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator';
+import { getLegalDocument, type LegalDocumentType } from './content/legalDocuments';
+import ProfileScreen from './screens/ProfileScreen';
+import AccountSettingsScreen from './screens/AccountSettingsScreen';
+import PrivacyDataScreen from './screens/PrivacyDataScreen';
+import AppSettingsScreen from './screens/AppSettingsScreen';
+import AboutStoicMindfulnessScreen from './screens/AboutStoicMindfulnessScreen';
+import AboutBeingScreen from './screens/AboutBeingScreen';
+import LegalDocumentsListScreen from './screens/LegalDocumentsListScreen';
+import LegalDocumentScreen from './screens/LegalDocumentScreen';
+import CloudBackupScreen from './screens/CloudBackupScreen';
+
+export type ProfileStackParamList = {
+ ProfileMenu: undefined;
+ Account: undefined;
+ Privacy: undefined;
+ AppSettings: undefined;
+ StoicMindfulness: undefined;
+ About: undefined;
+ Legal: undefined;
+ CloudBackup: undefined;
+ LegalDocument: { documentType: LegalDocumentType };
+};
+
+const Stack = createStackNavigator();
+
+const ProfileStackNavigator: React.FC = () => {
+ // CB-1: CrisisResources is a ROOT-stack modal. Resolve the root navigator
+ // explicitly (mirrors MorningFlowNavigator L67/L283) — do NOT rely on the
+ // local Profile-stack nav, which has no CrisisResources route.
+ const rootNavigation = useNavigation>();
+
+ return (
+ <>
+ (
+
+ ),
+ }}
+ >
+ {/* Menu route keeps its own SafeAreaView + in-content "Your Profile"
+ header, so the tab landing is visually unchanged. */}
+
+
+
+
+
+
+
+
+ ({
+ title: getLegalDocument(route.params.documentType)?.title ?? 'Legal Document',
+ })}
+ />
+
+
+ {/* CRISIS OVERLAY (CB-2/3/4/5/6/7): sibling ABOVE the navigator → renders
+ on every Profile route. Frozen props — change only with crisis sign-off. */}
+ rootNavigation.navigate('CrisisResources')}
+ testID="crisis-profile"
+ position="right"
+ />
+ >
+ );
+};
+
+export default ProfileStackNavigator;
diff --git a/app/src/features/profile/index.ts b/app/src/features/profile/index.ts
index 5b1b9828..d22a2c15 100644
--- a/app/src/features/profile/index.ts
+++ b/app/src/features/profile/index.ts
@@ -4,6 +4,7 @@
* User profile, account settings, and app configuration
*/
+export { default as ProfileStackNavigator, type ProfileStackParamList } from './ProfileStackNavigator';
export { default as ProfileScreen } from './screens/ProfileScreen';
export { default as AccountSettingsScreen } from './screens/AccountSettingsScreen';
export { default as AppSettingsScreen } from './screens/AppSettingsScreen';
diff --git a/app/src/features/profile/screens/AboutBeingScreen.tsx b/app/src/features/profile/screens/AboutBeingScreen.tsx
new file mode 100644
index 00000000..e460f895
--- /dev/null
+++ b/app/src/features/profile/screens/AboutBeingScreen.tsx
@@ -0,0 +1,66 @@
+/**
+ * About Being. Screen (placeholder)
+ *
+ * Extracted from ProfileScreen's former inline `renderPlaceholder('About Being.')`
+ * during the FEAT-212 nav migration. The "About Being." menu card stays gated
+ * behind `ABOUT_BEING_CONTENT_READY` (FEAT-209 H2) — this route only renders once
+ * that content ships. Kept as a real route so the gated card's navigation target
+ * is valid; the native stack header supplies the back chevron.
+ */
+import React from 'react';
+import { View, Text, StyleSheet, ScrollView } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
+
+const AboutBeingScreen: React.FC = () => (
+
+
+ Our mission and the science of mindfulness
+
+
+ This feature is coming soon. We're working hard to bring you the best experience.
+
+
+
+
+);
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colorSystem.base.white,
+ },
+ scrollContainer: {
+ flex: 1,
+ },
+ scrollContent: {
+ padding: spacing[24],
+ paddingBottom: spacing[32],
+ },
+ subtitle: {
+ fontSize: typography.bodyLarge.size,
+ fontWeight: typography.fontWeight.regular,
+ color: colorSystem.gray[600],
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: spacing[24],
+ },
+ placeholderContent: {
+ backgroundColor: colorSystem.gray[100],
+ borderRadius: borderRadius.large,
+ padding: spacing[32],
+ marginVertical: spacing[32],
+ minHeight: 200,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ placeholderText: {
+ fontSize: typography.bodyRegular.size,
+ fontWeight: typography.fontWeight.regular,
+ color: colorSystem.gray[500],
+ textAlign: 'center',
+ lineHeight: 24,
+ },
+});
+
+export default AboutBeingScreen;
diff --git a/app/src/features/profile/screens/AboutStoicMindfulnessScreen.tsx b/app/src/features/profile/screens/AboutStoicMindfulnessScreen.tsx
index 9a9adc56..b0479b74 100644
--- a/app/src/features/profile/screens/AboutStoicMindfulnessScreen.tsx
+++ b/app/src/features/profile/screens/AboutStoicMindfulnessScreen.tsx
@@ -14,15 +14,11 @@ import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
-import SubMenuHeader from '../components/SubMenuHeader';
-interface AboutStoicMindfulnessScreenProps {
- onReturn: () => void;
-}
-
-const AboutStoicMindfulnessScreen: React.FC = ({ onReturn }) => (
+// FEAT-212: rendered as a route on ProfileStackNavigator; the native stack header
+// supplies the back chevron (SubMenuHeader's ✕ removed).
+const AboutStoicMindfulnessScreen: React.FC = () => (
-
void;
-}
+// FEAT-212: rendered as a route on ProfileStackNavigator; the native stack header
+// supplies the back chevron (SubMenuHeader's ✕ removed).
const DATA_RIGHTS_TITLE = 'Your data rights';
const DATA_RIGHTS_DESCRIPTION =
'Data export and account deletion are coming soon. You have the right to access and delete your personal wellness data.';
-const AccountSettingsScreen: React.FC = ({ onReturn }) => {
+const AccountSettingsScreen: React.FC = () => {
// MVP: Use dev mode utilities for user information
// V2 (FEAT-16): Replace with actual auth service
const userEmail = getCurrentUserEmail();
@@ -48,7 +46,6 @@ const AccountSettingsScreen: React.FC = ({ onReturn
return (
-
{/* Account Information Section */}
diff --git a/app/src/features/profile/screens/AppSettingsScreen.tsx b/app/src/features/profile/screens/AppSettingsScreen.tsx
index 3a48d7bb..89f6918b 100644
--- a/app/src/features/profile/screens/AppSettingsScreen.tsx
+++ b/app/src/features/profile/screens/AppSettingsScreen.tsx
@@ -30,13 +30,10 @@ import { useFocusEffect } from '@react-navigation/native';
import { useSettingsStore } from '@/core/stores/settingsStore';
import { useAnalytics } from '@/core/analytics';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
-import SubMenuHeader from '../components/SubMenuHeader';
-interface AppSettingsScreenProps {
- onReturn: () => void;
-}
-
-const AppSettingsScreen: React.FC = ({ onReturn }) => {
+// FEAT-212: rendered as a route on ProfileStackNavigator; the native stack header
+// supplies the back chevron (SubMenuHeader's ✕ removed).
+const AppSettingsScreen: React.FC = () => {
const settingsStore = useSettingsStore();
const { trackScreenView, trackSettingsOpened } = useAnalytics();
const [isSaving, setIsSaving] = useState(false);
@@ -138,7 +135,6 @@ const AppSettingsScreen: React.FC = ({ onReturn }) => {
return (
-
{/* Notifications Section */}
diff --git a/app/src/features/profile/screens/CloudBackupScreen.tsx b/app/src/features/profile/screens/CloudBackupScreen.tsx
index 1bad002b..5ade07b7 100644
--- a/app/src/features/profile/screens/CloudBackupScreen.tsx
+++ b/app/src/features/profile/screens/CloudBackupScreen.tsx
@@ -13,16 +13,13 @@
import React from 'react';
import { ScrollView, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import SubMenuHeader from '../components/SubMenuHeader';
import CloudBackupSettings from '@/core/components/settings/CloudBackupSettings';
import { useFeatureFlag } from '@/core/analytics';
import { colorSystem, spacing } from '@/core/theme';
-interface CloudBackupScreenProps {
- onReturn: () => void;
-}
-
-const CloudBackupScreen: React.FC = ({ onReturn }) => {
+// FEAT-212: rendered as a route (Privacy → CloudBackup) on ProfileStackNavigator;
+// the native stack header supplies the back chevron (SubMenuHeader's ✕ removed).
+const CloudBackupScreen: React.FC = () => {
// Belt-and-suspenders: never render when the feature flag is off, even if
// reached by some path other than the (already flag-gated) entry row.
const cloudSyncAvailable = useFeatureFlag('cloud_sync');
@@ -32,7 +29,6 @@ const CloudBackupScreen: React.FC = ({ onReturn }) => {
return (
-
diff --git a/app/src/features/profile/screens/LegalDocumentScreen.tsx b/app/src/features/profile/screens/LegalDocumentScreen.tsx
index ca40c97c..21c00066 100644
--- a/app/src/features/profile/screens/LegalDocumentScreen.tsx
+++ b/app/src/features/profile/screens/LegalDocumentScreen.tsx
@@ -14,36 +14,32 @@
*/
import React from 'react';
-import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
+import { Text, StyleSheet, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRoute, RouteProp } from '@react-navigation/native';
import Markdown from 'react-native-markdown-display';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
-import { LegalDocument } from '../content/legalDocuments';
+import { getLegalDocument } from '../content/legalDocuments';
+import type { ProfileStackParamList } from '../ProfileStackNavigator';
-interface LegalDocumentScreenProps {
- document: LegalDocument;
- onReturn: () => void;
-}
+// FEAT-212: route on ProfileStackNavigator. The document is resolved from the
+// serializable `documentType` route param; the native stack header supplies the
+// back chevron and the document title (set in the navigator), so the former
+// custom in-content "← Back" header is removed.
+const LegalDocumentScreen: React.FC = () => {
+ const route = useRoute>();
+ const document = getLegalDocument(route.params.documentType);
+
+ if (!document) {
+ return (
+
+ Document not found.
+
+ );
+ }
-const LegalDocumentScreen: React.FC = ({
- document,
- onReturn,
-}) => {
return (
-
-
- ← Back
-
- Legal Documents
-
-
-
void;
-}
-
-const LegalDocumentsListScreen: React.FC = ({
- onReturn,
-}) => {
- const [selectedDocument, setSelectedDocument] = useState(null);
+import { legalDocumentsList } from '../content/legalDocuments';
+import type { ProfileStackParamList } from '../ProfileStackNavigator';
- if (selectedDocument) {
- return (
- setSelectedDocument(null)}
- />
- );
- }
+// FEAT-212: rendered as a route on ProfileStackNavigator. Selecting a document is
+// now a pushed route (Legal → LegalDocument) carrying a serializable documentType,
+// not an in-component state machine; the native stack header supplies the back chevron.
+const LegalDocumentsListScreen: React.FC = () => {
+ const navigation = useNavigation>();
return (
-
= ({
setSelectedDocument(doc)}
+ onPress={() => navigation.navigate('LegalDocument', { documentType: doc.id })}
+ testID={`profile-legal-doc-${doc.id}`}
accessibilityRole="button"
accessibilityLabel={`View ${doc.title}`}
accessibilityHint={doc.description}
diff --git a/app/src/features/profile/screens/PrivacyDataScreen.tsx b/app/src/features/profile/screens/PrivacyDataScreen.tsx
index e2832eff..c3825bb3 100644
--- a/app/src/features/profile/screens/PrivacyDataScreen.tsx
+++ b/app/src/features/profile/screens/PrivacyDataScreen.tsx
@@ -26,16 +26,12 @@ import {
} from 'react-native';
import { Ionicons } from '@react-native-vector-icons/ionicons';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useFocusEffect } from '@react-navigation/native';
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
+import { StackNavigationProp } from '@react-navigation/stack';
import { useConsentStore } from '@/core/stores/consentStore';
import { useAnalytics, useFeatureFlag } from '@/core/analytics';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
-import SubMenuHeader from '../components/SubMenuHeader';
-import CloudBackupScreen from './CloudBackupScreen';
-
-interface PrivacyDataScreenProps {
- onReturn: () => void;
-}
+import type { ProfileStackParamList } from '../ProfileStackNavigator';
/**
* Storage Location Row Component
@@ -155,13 +151,15 @@ const storageRowStyles = StyleSheet.create({
},
});
-const PrivacyDataScreen: React.FC = ({ onReturn }) => {
+const PrivacyDataScreen: React.FC = () => {
+ // FEAT-212: rendered as a route on ProfileStackNavigator. The cloud-backup
+ // sub-screen is now a pushed route (Privacy → CloudBackup), not an in-component
+ // state machine; the native stack header supplies the back chevron.
+ const navigation = useNavigation>();
const { loadConsent, currentConsent, updateConsent, setUniversalOptOut } = useConsentStore();
const { trackScreenView, trackSettingsOpened, trackConsentChanged } = useAnalytics();
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
- // Sub-screen navigation for the (flag-gated) comprehensive cloud-backup UI.
- const [showCloudBackup, setShowCloudBackup] = useState(false);
// Runtime flag (INFRA-199): gates UI visibility of the cloud-backup entry.
// PostHog promotes post-consent; build-time default is the fail-safe floor.
const cloudSyncAvailable = useFeatureFlag('cloud_sync');
@@ -224,11 +222,6 @@ const PrivacyDataScreen: React.FC = ({ onReturn }) => {
}
};
- // Flag-gated cloud-backup sub-screen
- if (showCloudBackup) {
- return setShowCloudBackup(false)} />;
- }
-
// Render loading state
if (isLoading) {
return (
@@ -243,7 +236,6 @@ const PrivacyDataScreen: React.FC = ({ onReturn }) => {
return (
-
{/* Universal Opt-Out Section (INFRA-151) */}
@@ -340,7 +332,8 @@ const PrivacyDataScreen: React.FC = ({ onReturn }) => {
{cloudSyncAvailable && (
setShowCloudBackup(true)}
+ onPress={() => navigation.navigate('CloudBackup')}
+ testID="profile-cloud-backup"
accessibilityRole="button"
accessibilityLabel="Manage Cloud Backup"
accessibilityHint="Opens cloud backup status, manual backup, and restore controls"
diff --git a/app/src/features/profile/screens/ProfileScreen.tsx b/app/src/features/profile/screens/ProfileScreen.tsx
index c40da331..453b7026 100644
--- a/app/src/features/profile/screens/ProfileScreen.tsx
+++ b/app/src/features/profile/screens/ProfileScreen.tsx
@@ -11,29 +11,29 @@ import {
StyleSheet,
ScrollView,
Pressable,
- Linking,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useNavigation, useFocusEffect } from '@react-navigation/native';
+import { useNavigation, useFocusEffect, CompositeNavigationProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
+// FEAT-212: subscreens are now routes on ProfileStackNavigator. This component is
+// the "ProfileMenu" route — it only renders the menu and navigates to the others.
// OnboardingScreen no longer embedded - navigation to LegalGate handles full flow
-import AppSettingsScreen from './AppSettingsScreen';
-import PrivacyDataScreen from './PrivacyDataScreen';
-import AccountSettingsScreen from './AccountSettingsScreen';
-import LegalDocumentsListScreen from './LegalDocumentsListScreen';
-import AboutStoicMindfulnessScreen from './AboutStoicMindfulnessScreen';
import { RootStackParamList } from '@/core/navigation/CleanRootNavigator';
+import type { ProfileStackParamList } from '../ProfileStackNavigator';
import { useSubscriptionStore } from '@/core/stores/subscriptionStore';
import { isDevMode } from '@/core/constants/devMode';
-import { CollapsibleCrisisButton } from '@/features/crisis/components/CollapsibleCrisisButton';
import { MaterialDesignIcons } from '@react-native-vector-icons/material-design-icons';
import ThresholdEducationModal from '@/core/components/ThresholdEducationModal';
import { useAssessmentStore } from '@/features/assessment/stores/assessmentStore';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
import { useAnalytics } from '@/core/analytics';
-import SubMenuHeader from '../components/SubMenuHeader';
-type ProfileScreenNavigationProp = StackNavigationProp;
+// Navigates within the Profile stack (Privacy, Account, …) AND up to root-stack
+// routes (Subscription, LegalGate, AssessmentFlow, CrisisResources).
+type ProfileScreenNavigationProp = CompositeNavigationProp<
+ StackNavigationProp,
+ StackNavigationProp
+>;
type AssessmentType = 'phq9' | 'gad7';
@@ -43,15 +43,12 @@ interface AssessmentMetadata {
status: 'recent' | 'due' | 'recommended' | 'never';
}
-type Screen = 'menu' | 'account' | 'privacy' | 'appSettings' | 'about' | 'stoicMindfulness' | 'legal';
-
// FEAT-209 H2: gate the "About Being." card until real content exists, so we
// stop shipping a "coming soon" placeholder as a first-class menu affordance.
// Build-time constant (not a feature flag) — flip to true when content lands.
const ABOUT_BEING_CONTENT_READY = false;
const ProfileScreen: React.FC = () => {
- const [currentScreen, setCurrentScreen] = useState('menu');
const navigation = useNavigation();
const subscriptionStore = useSubscriptionStore();
const [showEducationModal, setShowEducationModal] = useState(false);
@@ -75,10 +72,6 @@ const ProfileScreen: React.FC = () => {
navigation.navigate('LegalGate');
};
- const handleReturnToMenu = () => {
- setCurrentScreen('menu');
- };
-
const handleSubscriptionPress = () => {
navigation.navigate('Subscription');
};
@@ -323,7 +316,8 @@ const ProfileScreen: React.FC = () => {
setCurrentScreen('appSettings')}
+ onPress={() => navigation.navigate('AppSettings')}
+ testID="profile-card-appsettings"
accessibilityRole="button"
accessibilityLabel="Notifications & Display"
accessibilityHint="Configure notifications and accessibility preferences"
@@ -337,7 +331,8 @@ const ProfileScreen: React.FC = () => {
setCurrentScreen('privacy')}
+ onPress={() => navigation.navigate('Privacy')}
+ testID="profile-card-privacy"
accessibilityRole="button"
accessibilityLabel="Privacy and Data"
accessibilityHint="Control your data, export information, and manage privacy settings"
@@ -351,7 +346,8 @@ const ProfileScreen: React.FC = () => {
setCurrentScreen('account')}
+ onPress={() => navigation.navigate('Account')}
+ testID="profile-card-account"
accessibilityRole="button"
accessibilityLabel="Account"
accessibilityHint="Manage your account details and preferences"
@@ -376,7 +372,8 @@ const ProfileScreen: React.FC = () => {
setCurrentScreen('stoicMindfulness')}
+ onPress={() => navigation.navigate('StoicMindfulness')}
+ testID="profile-card-stoic"
accessibilityRole="button"
accessibilityLabel="About Stoic Mindfulness"
accessibilityHint="Explore the 5 core principles and developmental stages"
@@ -392,7 +389,8 @@ const ProfileScreen: React.FC = () => {
{ABOUT_BEING_CONTENT_READY && (
setCurrentScreen('about')}
+ onPress={() => navigation.navigate('About')}
+ testID="profile-card-about-being"
accessibilityRole="button"
accessibilityLabel="About Being"
accessibilityHint="Learn about our mission and how Being supports your mental wellbeing"
@@ -407,7 +405,8 @@ const ProfileScreen: React.FC = () => {
setCurrentScreen('legal')}
+ onPress={() => navigation.navigate('Legal')}
+ testID="profile-card-legal"
accessibilityRole="button"
accessibilityLabel="Legal Documents"
accessibilityHint="View Privacy Policy, Terms of Service, and Medical Disclaimer"
@@ -440,70 +439,10 @@ const ProfileScreen: React.FC = () => {
);
- const renderPlaceholder = (title: string, description: string) => (
-
-
-
- {description}
-
-
-
- This feature is coming soon. We're working hard to bring you the best experience.
-
-
-
-
- );
-
- // Render different screens based on state with crisis button overlay
- const renderContent = () => {
- if (currentScreen === 'menu') return renderMenu();
-
- if (currentScreen === 'account') {
- return ;
- }
-
- if (currentScreen === 'privacy') {
- return ;
- }
-
- if (currentScreen === 'appSettings') {
- return ;
- }
-
- if (currentScreen === 'about') {
- return renderPlaceholder(
- 'About Being.',
- 'Our mission and the science of mindfulness'
- );
- }
-
- if (currentScreen === 'stoicMindfulness') {
- return ;
- }
-
- if (currentScreen === 'legal') {
- return ;
- }
-
- return null;
- };
-
- return (
- <>
- {renderContent()}
- {/* Crisis Button Overlay - accessible across all profile screens */}
- navigation.navigate('CrisisResources')}
- testID="crisis-profile"
- position="right"
- />
- >
- );
+ // FEAT-212: this component is the ProfileMenu route. The crisis overlay is now
+ // hosted by ProfileStackNavigator (sibling above the stack), so it is no longer
+ // rendered here — it covers every Profile route including this one.
+ return renderMenu();
};
const styles = StyleSheet.create({
@@ -536,9 +475,6 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: 24,
},
- subtitleSpacing: {
- marginBottom: spacing[24],
- },
section: {
marginBottom: spacing[32],
},
@@ -600,22 +536,6 @@ const styles = StyleSheet.create({
fontWeight: typography.fontWeight.medium,
color: colorSystem.base.midnightBlue,
},
- placeholderContent: {
- backgroundColor: colorSystem.gray[100],
- borderRadius: borderRadius.large,
- padding: spacing[32],
- marginVertical: spacing[32],
- minHeight: 200,
- justifyContent: 'center',
- alignItems: 'center',
- },
- placeholderText: {
- fontSize: typography.bodyRegular.size,
- fontWeight: typography.fontWeight.regular,
- color: colorSystem.gray[500],
- textAlign: 'center',
- lineHeight: 24,
- },
primaryButton: {
backgroundColor: colorSystem.base.midnightBlue,
paddingVertical: spacing[16],
diff --git a/app/src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx b/app/src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx
index 07969d90..c19ccc6f 100644
--- a/app/src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx
+++ b/app/src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx
@@ -48,13 +48,9 @@ jest.mock('@/core/stores/subscriptionStore', () => ({
}),
}));
-// Sub-screens are imported at module top but only rendered for non-menu states.
-// Stub them so the menu test doesn't pull their heavy transitive deps
-// (e.g. react-native-markdown-display, which ships untransformed ESM).
-jest.mock('../AppSettingsScreen', () => () => null);
-jest.mock('../PrivacyDataScreen', () => () => null);
-jest.mock('../AccountSettingsScreen', () => () => null);
-jest.mock('../LegalDocumentsListScreen', () => () => null);
+// FEAT-212: ProfileScreen is now the "ProfileMenu" route component — the
+// subscreens are sibling routes on ProfileStackNavigator and are no longer
+// imported here, so no stubbing of their heavy transitive deps is needed.
// Stand-in modal that surfaces its `visible` prop so we can assert the inline
// ⓘ trigger opens the scoring education (AS-5) without depending on RN Modal.
@@ -119,10 +115,9 @@ describe('ProfileScreen — FEAT-209 information architecture', () => {
});
describe('ProfileScreen — safety invariants preserved (audit §5.1)', () => {
- it('CB-7: keeps the persistent crisis button on the Profile screen', () => {
- const { getByTestId } = render();
- expect(getByTestId('crisis-profile')).toBeTruthy();
- });
+ // CB-7 (persistent crisis button) moved to ProfileStackNavigator in FEAT-212 —
+ // the overlay is now hosted above the stack so it covers every Profile route.
+ // It is pinned in ProfileStackNavigator.test.tsx, not here.
it('AS-1: PHQ-9 and GAD-7 stay separate, instrument-named, actionable entries', () => {
const { getByTestId } = render();