diff --git a/CLAUDE.md b/CLAUDE.md index 65b9c850..06a439a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ pnpm format:check # Check formatting without modifying pnpm test # Run all Jest tests pnpm test:watch # Run tests in watch mode pnpm test -- --coverage # Run tests with coverage report (80% minimum required) +pnpm test:ci # Run tests with coverage in CI mode (used by GitHub Actions) pnpm maestro # Run all Maestro E2E flows pnpm maestro:record # Record new Maestro flow ``` @@ -67,6 +68,59 @@ pnpm test -- -t "test name pattern" # Run tests matching pattern These checks are not optional. All six validation steps must pass before the user commits. If any check fails, fix the issues and re-run all checks before proceeding. +**Test-Driven Development (TDD) - REQUIRED:** + +You MUST use TDD for ALL code changes. This is not optional. The TDD cycle is: + +1. **RED** - Write a failing test first +2. **GREEN** - Write the minimum code to make the test pass +3. **REFACTOR** - Clean up the code while keeping tests green + +**TDD Workflow:** + +```bash +# 1. Write the test FIRST (it should fail) +pnpm test -- path/to/new-feature.test.ts +# Expected: Test fails (RED) + +# 2. Write the implementation +# ... make code changes ... + +# 3. Run the test again (it should pass) +pnpm test -- path/to/new-feature.test.ts +# Expected: Test passes (GREEN) + +# 4. Refactor if needed, keeping tests green +pnpm test -- path/to/new-feature.test.ts +# Expected: Test still passes (REFACTOR) + +# 5. Run full test suite to check for regressions +pnpm test +``` + +**What Requires Tests:** + +| Change Type | Test Requirement | +| ---------------- | -------------------------------------------------- | +| New feature | Tests for happy path, edge cases, error handling | +| Bug fix | Regression test that would have caught the bug | +| Refactoring | Existing tests must pass; add tests if gaps found | +| New component | Rendering, props, user interactions, state changes | +| New hook | Return values, state updates, side effects | +| New utility | All code paths, edge cases, error conditions | +| API integration | Mock external calls, test success/error scenarios | +| Deletion/removal | Update or remove related tests | +| UI changes | Snapshot tests or interaction tests | + +**TDD Anti-Patterns to AVOID:** + +- Writing code first and tests after (defeats the purpose) +- Writing tests that always pass (tests must fail first) +- Testing implementation details instead of behavior +- Skipping tests "because it's a small change" +- Mocking everything (test real behavior when possible) +- Writing tests without assertions + **Complete Workflow:** ```bash @@ -76,12 +130,308 @@ pnpm format && pnpm lint && pnpm typecheck && pnpm build:web && pnpm test **Important:** -- Do NOT commit or push changes - allow the user to do this manually - Do NOT skip the validation checks to save time - All validation checks must pass before changes are considered complete +- Only push after all tasks are complete and all changes are validated and committed + +**Commit Workflow (for Claude Code):** + +When completing multiple tasks in a session: + +1. **Commit after each completed task** - Create an atomic commit once a task passes all validation checks +2. **Use Conventional Commits** - Format: `(): ` (see Git Workflow section) +3. **Keep commits atomic** - One feature, fix, or change per commit +4. **Only push after ALL tasks are complete** - Do not push until the entire session's work is finished +5. **Verify before pushing** - Ensure all commits are ready and all tasks pass validation + +Example workflow for multiple tasks: + +```bash +# Task 1 complete - commit immediately +git add . && git commit -m "feat(auth): add password reset flow" + +# Task 2 complete - commit immediately +git add . && git commit -m "fix(profile): handle null avatar gracefully" + +# Task 3 complete - commit immediately +git add . && git commit -m "test(auth): add coverage for password reset" + +# ALL tasks complete - now push +git push +``` + +**Pushing Guidelines:** + +Before pushing, verify: + +1. **All tasks complete** - Every task in the session passes validation +2. **All commits ready** - Review with `git log --oneline` to confirm commit history +3. **No uncommitted changes** - Run `git status` to verify clean working tree +4. **Branch is up to date** - Pull latest if working on shared branch: `git pull --rebase` + +**When NOT to push:** + +- Tests are failing or coverage dropped below 80% +- Typecheck or lint errors exist +- Visual verification was skipped for UI changes +- You're mid-task and work is incomplete +- There are merge conflicts that need resolution + +**Handling Push Failures:** + +```bash +# If push is rejected due to remote changes: +git pull --rebase origin +# Resolve any conflicts, then: +git push + +# If rebase has conflicts: +# 1. Fix conflicts in affected files +# 2. git add +# 3. git rebase --continue +# 4. git push +``` + +**Force Push Warning:** + +- **NEVER** force push to `main` or `develop` branches +- Force push (`git push --force`) rewrites history and can break other developers' work +- Only use `--force-with-lease` on personal feature branches after rebase + +**Visual Verification (CRITICAL):** + +Before committing ANY UI or functional changes, you MUST verify them visually: + +1. **Start the web dev server**: Run `pnpm web` to launch the development server +2. **Use Chrome DevTools MCP**: Use the `chrome-devtools` MCP tools to interact with and verify the running app +3. **Test the changes**: Navigate to affected screens and verify the changes work correctly +4. **Check for regressions**: Ensure existing functionality still works as expected + +**Chrome DevTools MCP Workflow:** + +```bash +# 1. Start the dev server (in background or separate terminal) +pnpm web + +# 2. Use Chrome DevTools MCP to: +# - Navigate to pages (navigate_page) +# - Take snapshots (take_snapshot) to see page structure +# - Take screenshots (take_screenshot) to verify visual appearance +# - Click elements (click) to test interactions +# - Fill forms (fill) to test inputs +# - Check console for errors (list_console_messages) +# - Verify network requests (list_network_requests) +``` + +**Verification Checklist:** + +For every change, verify the following as applicable: + +- [ ] **Page loads without errors** - No console errors, no white screen +- [ ] **Layout renders correctly** - Elements positioned as expected, no overflow issues +- [ ] **Text is readable** - Correct fonts, sizes, colors, contrast +- [ ] **Interactive elements work** - Buttons clickable, forms submittable, links navigate +- [ ] **State updates correctly** - UI reflects data changes, loading states show/hide +- [ ] **Error states display** - Invalid inputs show errors, failed requests show messages +- [ ] **Responsive behavior** - Use `resize_page` to test different viewport sizes +- [ ] **Theme compatibility** - Test in both light and dark modes if applicable +- [ ] **Accessibility** - Elements have proper labels (check snapshot for accessible names) +- [ ] **Navigation flows** - Back/forward buttons work, deep links resolve correctly +- [ ] **Data persistence** - Changes save to Supabase, refresh preserves state +- [ ] **Loading states** - Spinners/skeletons show during async operations +- [ ] **Empty states** - UI handles zero-data scenarios gracefully +- [ ] **Edge cases** - Very long text, special characters, boundary values + +**Network & API Verification:** + +Always verify API interactions for data-related changes: + +1. **Check request payload** - Use `get_network_request` to verify correct data is sent +2. **Verify response handling** - Ensure success/error responses update UI correctly +3. **Test offline behavior** - Use `emulate` with `networkConditions: "Offline"` to test graceful degradation +4. **Check for race conditions** - Rapid clicking shouldn't cause duplicate requests +5. **Verify auth headers** - Authenticated requests include proper tokens + +```bash +# Example: Verify a form submission +1. Fill form: mcp__chrome-devtools__fill_form +2. Submit: mcp__chrome-devtools__click (submit button) +3. Check network: mcp__chrome-devtools__list_network_requests (resourceTypes: ["fetch", "xhr"]) +4. Get request details: mcp__chrome-devtools__get_network_request (reqid from step 3) +5. Verify payload and response are correct +``` + +**Console Error Severity:** + +Not all console messages are equal - know what to look for: + +| Level | Action Required | +| ---------- | ------------------------------------------------------ | +| `error` | **MUST FIX** - Indicates broken functionality | +| `warn` | **SHOULD FIX** - May indicate potential issues | +| `log/info` | Review if unexpected - May indicate debug code left in | + +Filter console messages by type: + +```bash +mcp__chrome-devtools__list_console_messages (types: ["error", "warn"]) +``` + +**Performance Testing:** + +For changes that may impact performance, use Chrome DevTools performance tracing: + +```bash +# Start a performance trace with page reload +mcp__chrome-devtools__performance_start_trace (reload: true, autoStop: true) + +# Or manually control the trace +mcp__chrome-devtools__performance_start_trace (reload: false, autoStop: false) +# ... perform actions ... +mcp__chrome-devtools__performance_stop_trace + +# Analyze specific insights +mcp__chrome-devtools__performance_analyze_insight (insightSetId: "...", insightName: "LCPBreakdown") +``` + +**Performance Checklist:** + +- [ ] **Initial load time** - Page renders meaningful content within 2-3 seconds +- [ ] **Largest Contentful Paint (LCP)** - Main content visible quickly +- [ ] **Time to Interactive (TTI)** - Page responds to input promptly +- [ ] **No layout shifts** - Content doesn't jump around during load +- [ ] **Efficient re-renders** - State changes don't cause excessive re-renders +- [ ] **Network waterfall** - No blocking requests, parallel fetches where possible +- [ ] **Bundle size** - New dependencies don't significantly increase bundle + +**When to performance test:** + +- Adding new dependencies/libraries +- Implementing list views with many items +- Adding images or media +- Creating complex animations +- Fetching large datasets + +**Form Validation Edge Cases:** + +Test forms with unexpected inputs to ensure robust validation: + +**Email Fields:** + +```bash +# Test these values with mcp__chrome-devtools__fill +- "" # Empty +- "notanemail" # Missing @ +- "@nodomain.com" # Missing local part +- "spaces in@email.com" # Spaces +- "valid@email.com" # Valid (should pass) +- "a@b.c" # Minimal valid +- "very.long.email.address.that.exceeds.normal.length@extremely-long-domain-name.com" +``` + +**Password Fields:** + +```bash +- "" # Empty +- "short" # Too short +- "nouppercaseornumbers" # Missing requirements +- "ValidP@ssw0rd!" # Valid (should pass) +- "a]P1" + "x".repeat(1000) # Extremely long +- "" # XSS attempt (should be escaped) +``` + +**Text/Name Fields:** + +```bash +- "" # Empty +- " " # Only whitespace +- "A" # Single character +- "José María" # Unicode/accents +- "李明" # Non-Latin characters +- "O'Brien-Smith" # Apostrophes and hyphens +- "" # XSS attempt +- "x".repeat(10000) # Extremely long input +``` + +**Date Fields:** + +```bash +- "" # Empty +- "2099-12-31" # Far future +- "1900-01-01" # Far past +- "2024-02-30" # Invalid date +- Today's date # Boundary +- Tomorrow # Future (may be invalid for sobriety_date) +``` + +**Form Validation Checklist:** + +- [ ] **Required fields show errors when empty** - Clear error messages appear +- [ ] **Invalid formats rejected** - Email, date, etc. validate format +- [ ] **Error messages are helpful** - Tell user what's wrong and how to fix +- [ ] **Errors clear when corrected** - Message disappears after valid input +- [ ] **Submit disabled until valid** - Or shows all errors on attempt +- [ ] **Server errors handled** - Network failures, duplicate entries, etc. +- [ ] **XSS inputs escaped** - Script tags render as text, not executed +- [ ] **SQL injection prevented** - Special characters don't break queries (Supabase RLS helps) + +**Testing Authentication Flows:** + +Since this app has auth guards, test these scenarios: + +1. **Unauthenticated state** - Verify redirect to `/login` +2. **Authenticated without profile** - Verify redirect to `/onboarding` +3. **Fully authenticated** - Verify access to `/(tabs)` screens +4. **Session expiry** - Verify graceful handling of expired tokens + +**Common Issues to Watch For:** + +| Issue | How to Detect | Chrome DevTools Tool | +| ---------------------- | -------------------------------- | --------------------------------------------------- | +| JavaScript errors | Red errors in console | `list_console_messages` | +| Failed API calls | 4xx/5xx responses | `list_network_requests`, `get_network_request` | +| Missing elements | Element not in snapshot | `take_snapshot` | +| Visual regressions | Screenshot differs from expected | `take_screenshot` | +| Broken interactions | Click does nothing | `click` + `take_snapshot` | +| Form validation issues | Submit doesn't work | `fill` + `click` | +| Performance problems | Slow load times | `performance_start_trace`, `performance_stop_trace` | +| Layout breakage | Elements overlapping/hidden | `take_screenshot` at different sizes | + +**Step-by-Step Example:** + +```text +# Example: Verifying a new button on the profile screen + +1. Start dev server: pnpm web +2. List pages: mcp__chrome-devtools__list_pages +3. Navigate: mcp__chrome-devtools__navigate_page (url: "http://localhost:8081/profile") +4. Wait for load: mcp__chrome-devtools__wait_for (text: "Profile") +5. Take snapshot: mcp__chrome-devtools__take_snapshot +6. Verify button exists in snapshot with correct uid +7. Click button: mcp__chrome-devtools__click (uid: "button-uid") +8. Take screenshot: mcp__chrome-devtools__take_screenshot +9. Check console: mcp__chrome-devtools__list_console_messages +10. Verify no errors and expected behavior occurred +``` + +**When to Skip Visual Verification:** + +Only skip visual verification for changes that have NO runtime impact: + +- Documentation-only changes (README, CLAUDE.md, comments) +- Type-only changes (interfaces, type definitions with no runtime code) +- Test-only changes (test files that don't affect production code) +- Configuration changes that don't affect the UI (ESLint rules, tsconfig) + +**For ALL other changes, visual verification is MANDATORY.** **Why this matters:** +- Static analysis (typecheck, lint) cannot catch runtime or visual bugs +- UI changes may look correct in code but render incorrectly +- User interactions may have unexpected side effects +- Console errors or network issues only appear at runtime +- This is the final safety check before committing - Prevents TypeScript errors from reaching production - Maintains consistent code style across the project - Catches potential bugs and issues early @@ -269,6 +619,28 @@ Every code change MUST include corresponding tests. This applies to: - Clean up mocks in `beforeEach()` or `afterEach()` - Avoid testing implementation details; test behavior instead +**Jest Configuration (`jest.config.js`):** + +- Test environment: `node` (not jsdom - faster for React Native) +- Coverage thresholds: 80% global (statements, branches, functions, lines) +- Test patterns: `**/__tests__/**/*.(spec|test).[jt]s?(x)` + +**Mock Strategy (`jest.setup.js`):** + +Comprehensive mocks are configured for: + +- **React Native**: Core components, Animated, Platform +- **Expo modules**: Router, Font, SplashScreen, AuthSession, WebBrowser, Linking +- **Supabase**: Auth and database client +- **Sentry**: Error tracking +- **AsyncStorage**: Secure storage +- **Firebase**: Analytics + +**Test Utilities Location:** `__tests__/test-utils.tsx` + +- Exports `renderWithProviders()` wrapper for components needing context +- Pre-configured AuthContext and ThemeContext mocks + ## Supabase Schema Overview **Core Tables:** @@ -320,6 +692,32 @@ SENTRY_PROJECT=sobriety-waypoint - `EXPO_PUBLIC_*` = Available in client-side code - Other vars = Build-time only (NOT in app code) +## Advanced Expo Configuration + +**Experimental Features Enabled (`app.config.ts`):** + +- `experiments.reactCompiler: true` - React Compiler for automatic memoization +- `experiments.typedRoutes: true` - Type-safe routing with Expo Router +- `newArchEnabled: true` - React Native New Architecture + +**EAS Update Configuration:** + +- Runtime version policy: `"appVersion"` - Updates tied to app version +- Update check on launch: Enabled +- Fallback to cache timeout: 0ms (immediate) + +**Firebase Integration:** + +Firebase configuration uses a two-tier strategy: + +1. **EAS Builds**: Uses EAS secrets (`GOOGLE_SERVICES_JSON`, `GOOGLE_SERVICES_PLIST`) +2. **Local Development**: Falls back to local files (`google-services.json`, `GoogleService-Info.plist`) + +**Build Tools:** + +- **Babel** (`babel.config.js`): Uses `babel-preset-expo` (standard Expo preset) +- **Metro** (`metro.config.js`): Wrapped with Sentry for source map uploads + ## CI/CD Pipeline **GitHub Actions:** @@ -340,18 +738,36 @@ SENTRY_PROJECT=sobriety-waypoint - Applies 2-5 relevant labels (type, area, priority) - Runs on PR/issue open, reopen, or edit +4. **Daily Codebase Review** (`.github/workflows/daily-codebase-review.yml`): + - Runs daily on schedule or manual trigger + - Comprehensive codebase health assessment + - Creates GitHub issues for findings + **EAS Build Profiles:** - `development`: Dev client for local testing - `preview`: CI builds with Release config, OTA channel `preview` - `production`: Production builds with auto version bump +**EAS CLI Requirement:** + +- Minimum version: `>= 16.27.0` +- App version source: `remote` (version managed by EAS) + **Required GitHub Secrets:** - `EXPO_PUBLIC_SUPABASE_URL` - `EXPO_PUBLIC_SUPABASE_ANON_KEY` - `EXPO_TOKEN` (from expo.dev → Access Tokens) -- `CLAUDE_CODE_OAUTH_TOKEN` (for Claude Code Review and Auto Label actions) +- `CLAUDE_CODE_OAUTH_TOKEN` (for Claude Code Review, Auto Label, and Daily Review actions) + +**Custom GitHub Action:** + +`.github/actions/setup-project/action.yml` - Reusable action for: + +- Node.js 22 setup +- pnpm installation with caching +- Dependency installation ## Security Reminders @@ -586,9 +1002,17 @@ const styles = StyleSheet.create({ | Event handlers | handle prefix | `handlePress`, `handleSubmit` | | Async functions | verb describing action | `fetchTasks`, `updateProfile` | -**TypeScript:** +**TypeScript Configuration (`tsconfig.json`):** + +Key settings for performance and developer experience: + +- `strict: true` - Full type safety +- `incremental: true` - Faster rebuilds via incremental compilation +- `skipLibCheck: true` - Skip type checking of declaration files +- `paths: { "@/*": ["./*"] }` - Path alias for clean imports + +**TypeScript Best Practices:** -- Strict mode enabled (`strict: true` in tsconfig) - Prefer explicit types over inference for public APIs - Use database types from `types/database.ts` as source of truth - Avoid `any` - use `unknown` with type guards when type is truly unknown @@ -606,11 +1030,43 @@ const styles = StyleSheet.create({ - StyleSheet.create() for component styles (no inline objects) - Extract reusable logic into custom hooks -**Git Workflow:** +**ESLint Configuration (`eslint.config.js`):** + +This project uses ESLint flat config with: + +- `eslint-config-expo/flat` as base configuration +- `eslint-config-prettier` for Prettier compatibility +- **`no-console: 'error'`** globally enforced (use `logger` from `@/lib/logger` instead) + +Files exempt from `no-console`: + +- `lib/logger.ts` - Logger implementation +- `lib/sentry.ts` - Sentry initialization +- `jest.setup.js` - Test setup + +**Prettier Configuration (`.prettierrc`):** + +| Setting | Value | Purpose | +| --------------- | ---------- | ---------------------------------- | +| `semi` | `true` | Use semicolons | +| `singleQuote` | `true` | Prefer single quotes | +| `trailingComma` | `"es5"` | Trailing commas where valid in ES5 | +| `printWidth` | `100` | Line wrap at 100 characters | +| `tabWidth` | `2` | 2-space indentation | +| `arrowParens` | `"always"` | Always wrap arrow function params | +| `endOfLine` | `"lf"` | Unix line endings | + +**Pre-commit Hooks (Husky + lint-staged):** + +Automatically runs on every commit: + +1. **TypeScript/JavaScript files** (`*.{js,jsx,ts,tsx}`): + - `prettier --write` - Auto-format + - `eslint --fix` - Fix lint issues +2. **JSON/Markdown files** (`*.{json,md}`): + - `prettier --write` - Auto-format -- Husky + lint-staged auto-format on commit -- Pre-commit checks: Prettier format + ESLint on staged TS/JS files -- Skip hooks only via `git commit -n` (not recommended) +Skip hooks (not recommended): `git commit -n` **Branch Naming (Conventional Branch):** @@ -678,9 +1134,9 @@ Common scopes for this project: Examples: ```text -feat(journey): add dual metrics display for slip-ups -fix(supabase): make client SSR-compatible for static builds -refactor(auth): simplify session refresh logic +feat(auth): add password reset flow +fix(journey): handle null avatar gracefully +refactor(tasks): optimize task loading logic test(journey): add coverage for timeline events chore(deps): bump expo-router to v6.0.15 docs(readme): update setup instructions diff --git a/__tests__/app/profile.keyboard-avoidance.test.tsx b/__tests__/app/profile.keyboard-avoidance.test.tsx index fc301e6f..301afc46 100644 --- a/__tests__/app/profile.keyboard-avoidance.test.tsx +++ b/__tests__/app/profile.keyboard-avoidance.test.tsx @@ -183,6 +183,7 @@ jest.mock('lucide-react-native', () => ({ AlertCircle: () => null, CheckCircle: () => null, Settings: () => null, + X: () => null, // Icons used by SettingsSheet LogOut: () => null, Moon: () => null, @@ -331,7 +332,7 @@ jest.mock('@/components/GlassBottomSheet', () => { }; }); -// Mock BottomSheetScrollView (required by SettingsSheet) +// Mock BottomSheetScrollView and BottomSheetTextInput (required by sheets) jest.mock('@gorhom/bottom-sheet', () => ({ BottomSheetScrollView: ({ children, ...props }: { children: React.ReactNode }) => { const React = require('react'); @@ -342,8 +343,24 @@ jest.mock('@gorhom/bottom-sheet', () => ({ children ); }, + BottomSheetTextInput: (props: Record) => { + const React = require('react'); + const { TextInput } = require('react-native'); + return React.createElement(TextInput, props); + }, })); +// Mock EnterInviteCodeSheet +jest.mock('@/components/sheets/EnterInviteCodeSheet', () => { + const React = require('react'); + const MockEnterInviteCodeSheet = React.forwardRef(() => null); + MockEnterInviteCodeSheet.displayName = 'EnterInviteCodeSheet'; + return { + __esModule: true, + default: MockEnterInviteCodeSheet, + }; +}); + // Store original Platform.OS const originalPlatformOS = Platform.OS; @@ -420,16 +437,15 @@ describe('ProfileScreen Keyboard Avoidance', () => { }); }); - it('shows invite input when enter invite code is pressed', async () => { + it('presents invite code sheet when enter invite code is pressed', async () => { render(); await waitFor(() => { fireEvent.press(screen.getByText('Enter Invite Code')); }); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); + // EnterInviteCodeSheet is mocked - the sheet is presented via ref + // This test verifies the button is clickable and doesn't throw }); }); diff --git a/__tests__/app/profile.test.tsx b/__tests__/app/profile.test.tsx index b0584afb..2b481a30 100644 --- a/__tests__/app/profile.test.tsx +++ b/__tests__/app/profile.test.tsx @@ -185,6 +185,7 @@ jest.mock('lucide-react-native', () => ({ AlertCircle: () => null, CheckCircle: () => null, Settings: () => null, + X: () => null, // Icons used by SettingsSheet LogOut: () => null, Moon: () => null, @@ -321,7 +322,7 @@ jest.mock('@/components/GlassBottomSheet', () => { }; }); -// Mock BottomSheetScrollView (required by SettingsSheet) +// Mock BottomSheetScrollView and BottomSheetTextInput (required by sheets) jest.mock('@gorhom/bottom-sheet', () => ({ BottomSheetScrollView: ({ children, ...props }: { children: React.ReactNode }) => { const React = require('react'); @@ -332,8 +333,41 @@ jest.mock('@gorhom/bottom-sheet', () => ({ children ); }, + BottomSheetTextInput: (props: Record) => { + const React = require('react'); + const { TextInput } = require('react-native'); + return React.createElement(TextInput, props); + }, })); +// Mock EnterInviteCodeSheet - functional mock that captures onSubmit prop +let capturedOnSubmit: ((code: string) => Promise) | null = null; +const mockEnterInviteCodePresent = jest.fn(); +const mockEnterInviteCodeDismiss = jest.fn(); + +jest.mock('@/components/sheets/EnterInviteCodeSheet', () => { + const React = require('react'); + const MockEnterInviteCodeSheet = React.forwardRef( + ( + props: { onSubmit: (code: string) => Promise; onClose?: () => void }, + ref: React.Ref<{ present: () => void; dismiss: () => void }> + ) => { + // Capture the onSubmit callback for testing + capturedOnSubmit = props.onSubmit; + React.useImperativeHandle(ref, () => ({ + present: mockEnterInviteCodePresent, + dismiss: mockEnterInviteCodeDismiss, + })); + return null; + } + ); + MockEnterInviteCodeSheet.displayName = 'EnterInviteCodeSheet'; + return { + __esModule: true, + default: MockEnterInviteCodeSheet, + }; +}); + // ============================================================================= // Test Suite // ============================================================================= @@ -343,6 +377,9 @@ describe('ProfileScreen', () => { jest.clearAllMocks(); mockSponsorRelationships = []; mockSponseeRelationships = []; + capturedOnSubmit = null; + mockEnterInviteCodePresent.mockClear(); + mockEnterInviteCodeDismiss.mockClear(); }); describe('User Profile Display', () => { @@ -487,73 +524,25 @@ describe('ProfileScreen', () => { }); describe('Invite Code Flow', () => { - it('shows invite code input when Enter Invite Code is pressed', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - }); - - it('shows Connect and Cancel buttons in invite input form', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByText('Connect')).toBeTruthy(); - expect(screen.getByText('Cancel')).toBeTruthy(); - }); - }); - - it('hides invite input when Cancel is pressed', async () => { + it('shows Enter Invite Code button', async () => { render(); await waitFor(() => { expect(screen.getByText('Enter Invite Code')).toBeTruthy(); }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByText('Cancel')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Cancel')); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - expect(screen.queryByPlaceholderText('Enter 8-character code')).toBeNull(); - }); }); - it('allows typing in invite code input', async () => { + it('Enter Invite Code button is clickable', async () => { render(); await waitFor(() => { expect(screen.getByText('Enter Invite Code')).toBeTruthy(); }); + // The sheet is mocked, so we just verify the button is pressable fireEvent.press(screen.getByText('Enter Invite Code')); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'ABC12345'); - - expect(input.props.value).toBe('ABC12345'); + // No error means success - the sheet's behavior is tested in EnterInviteCodeSheet.test.tsx }); }); @@ -628,20 +617,14 @@ describe('ProfileScreen', () => { }); describe('Invite Code Validation', () => { - it('requires 8 character code format', async () => { + // Note: Invite code validation tests are covered in EnterInviteCodeSheet.test.tsx + // The profile screen now uses a sheet for invite code entry + it('shows Enter Invite Code button (validation tested in sheet)', async () => { render(); await waitFor(() => { expect(screen.getByText('Enter Invite Code')).toBeTruthy(); }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - const input = screen.getByPlaceholderText('Enter 8-character code'); - expect(input).toBeTruthy(); - // Check maxLength is set (implied by placeholder text) - }); }); }); @@ -712,52 +695,14 @@ describe('ProfileScreen', () => { }); describe('Invite Code Submission', () => { - it('validates invite code length', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - - // Enter too short code - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'ABC'); - - // Try to connect - fireEvent.press(screen.getByText('Connect')); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invite code must be 8 characters'); - }); - }); - - it('does not submit when invite code is empty', async () => { + // Note: Invite code submission validation is now handled by EnterInviteCodeSheet + // These tests are covered in EnterInviteCodeSheet.test.tsx + it('(validation and submission tested in EnterInviteCodeSheet.test.tsx)', async () => { render(); await waitFor(() => { expect(screen.getByText('Enter Invite Code')).toBeTruthy(); }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByText('Connect')).toBeTruthy(); - }); - - // Leave input empty and try to connect - fireEvent.press(screen.getByText('Connect')); - - // Should not crash, just not do anything (return early) - await waitFor(() => { - expect(screen.getByText('Connect')).toBeTruthy(); - }); }); }); @@ -920,48 +865,7 @@ describe('ProfileScreen', () => { }); }); - describe('Invite Code Validation', () => { - it('shows error for short invite code', async () => { - const { Alert } = jest.requireMock('react-native'); - - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'ABC'); - - fireEvent.press(screen.getByText('Connect')); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invite code must be 8 characters'); - }); - }); - - it('allows entering 8-character invite code', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'ABCD1234'); - expect(input.props.value).toBe('ABCD1234'); - }); - }); - }); + // Note: The second 'Invite Code Validation' block tests are covered in EnterInviteCodeSheet.test.tsx describe('Your Sponsor Section', () => { it('renders Your Sponsor section title', async () => { @@ -1167,123 +1071,18 @@ describe('ProfileScreen', () => { }); }); + // Note: Enter Invite Code Inline tests are now covered in EnterInviteCodeSheet.test.tsx + // The profile screen now uses EnterInviteCodeSheet for invite code entry describe('Enter Invite Code Inline', () => { - it('shows invite input when Enter Invite Code is pressed', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - // Should show the input placeholder - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - }); - - it('shows Connect button when input is visible', async () => { - render(); - - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - await waitFor(() => { - // Check for the Connect button - expect(screen.getByText('Connect')).toBeTruthy(); - }); - }); - - it('shows Cancel button when input is visible', async () => { - render(); - - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - await waitFor(() => { - expect(screen.getByText('Cancel')).toBeTruthy(); - }); - }); - - it('allows entering invite code in input', async () => { - render(); - - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'TESTCODE'); - - expect(input.props.value).toBe('TESTCODE'); - }); - - it('hides input when Cancel is pressed', async () => { + it('(inline input behavior now tested in EnterInviteCodeSheet.test.tsx)', async () => { render(); - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Cancel')); - - await waitFor(() => { - // Input should be gone, button should be back - expect(screen.queryByPlaceholderText('Enter 8-character code')).toBeNull(); expect(screen.getByText('Enter Invite Code')).toBeTruthy(); }); }); }); - describe('Invite Code Validation', () => { - it('shows error for short invite code', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'SHORT'); - - // Get the Connect buttons (there may be multiple) and press the last one - const buttons = screen.getAllByText('Connect'); - fireEvent.press(buttons[buttons.length - 1]); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invite code must be 8 characters'); - }); - }); - }); - - describe('Invite Code Invalid', () => { - it('shows error for invalid invite code', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - fireEvent.press(screen.getByText('Enter Invite Code')); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'BADCODE1'); - - const buttons = screen.getAllByText('Connect'); - fireEvent.press(buttons[buttons.length - 1]); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invalid or expired invite code'); - }); - }); - }); + // Note: Invite Code Validation and Invalid tests are now covered in EnterInviteCodeSheet.test.tsx describe('Disconnect Sponsor', () => { beforeEach(() => { @@ -1551,75 +1350,75 @@ describe('ProfileScreen', () => { }); }); + // Note: Join with Invite Code - Success Flow tests are now covered in EnterInviteCodeSheet.test.tsx + // The joinWithInviteCode function is called from EnterInviteCodeSheet's onSubmit callback describe('Join with Invite Code - Success Flow', () => { + it('(success flow tested in EnterInviteCodeSheet.test.tsx)', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + }); + }); + }); + + // Note: Join with Invite Code - Error Cases tests are now covered in EnterInviteCodeSheet.test.tsx + // Error messages from joinWithInviteCode are thrown and displayed by the sheet + describe('Join with Invite Code - Error Cases', () => { + it('(error cases tested in EnterInviteCodeSheet.test.tsx)', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + }); + }); + }); + + // Note: More complex flows like sponsor profile fetch error, disconnect relationship, + // edit sobriety date, and slip-up modal submission are tested via integration tests. + + describe('Generate Invite Code Error', () => { beforeEach(() => { mockSponsorRelationships = []; mockSponseeRelationships = []; - // Set up successful invite code flow const { supabase } = jest.requireMock('@/lib/supabase'); supabase.from.mockImplementation((table: string) => { if (table === 'invite_codes') { return { - insert: jest.fn().mockResolvedValue({ error: null }), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: null }), - }), - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'TESTCODE', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() + 86400000).toISOString(), // Tomorrow - used_by: null, - }, - error: null, - }), + insert: jest.fn().mockResolvedValue({ error: new Error('Database error') }), + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponsorRelationships, + error: null, + }), + }; + } + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponseeRelationships, + error: null, + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; }), - }), + })), }; } - if (table === 'profiles') { + if (table === 'tasks') { return { select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'sponsor-123', display_name: 'Jane S.' }, - error: null, - }), - }), - }), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: null }), - }), - }; - } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), - }), - }), - }), - }), - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - if (table === 'notifications') { - return { - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - if (table === 'tasks') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + in: jest.fn().mockResolvedValue({ data: [], error: null }), }), }), }; @@ -1631,93 +1430,99 @@ describe('ProfileScreen', () => { }); }); - it('successfully connects with valid invite code', async () => { + it('shows error alert when invite code generation fails', async () => { const { Alert } = jest.requireMock('react-native'); render(); await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - // Show invite input - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + expect(screen.getByText('Generate Invite Code')).toBeTruthy(); }); - // Enter valid invite code - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'TESTCODE'); - - // Press Connect - fireEvent.press(screen.getByText('Connect')); + fireEvent.press(screen.getByText('Generate Invite Code')); - // Should show success alert await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Success', - expect.stringContaining('Connected with Jane S.') - ); + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to generate invite code'); }); }); }); - describe('Join with Invite Code - Error Cases', () => { + describe('sponsee task statistics', () => { beforeEach(() => { + jest.clearAllMocks(); + + // Set up sponsee relationships with task stats + mockSponseeRelationships = [ + { + id: 'rel-1', + sponsor_id: 'user-123', + sponsee_id: 'sponsee-1', + status: 'active', + sponsee: { + id: 'sponsee-1', + display_name: 'Jane S.', + sobriety_date: '2024-06-01', + }, + }, + ]; mockSponsorRelationships = []; - mockSponseeRelationships = []; - }); - it('shows error for expired invite code', async () => { + // Mock supabase to return tasks for the sponsees const { supabase } = jest.requireMock('@/lib/supabase'); - const { Alert } = jest.requireMock('react-native'); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { + if (table === 'sponsor_sponsee_relationships') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'EXPIRED1', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() - 86400000).toISOString(), // Yesterday - used_by: null, - }, - error: null, - }), + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponseeRelationships, + error: null, + }), + }; + } + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponsorRelationships, + error: null, + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; }), - }), + })), }; } - if (table === 'profiles') { + if (table === 'tasks') { return { select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'sponsor-123', display_name: 'Jane S.' }, + in: jest.fn().mockResolvedValue({ + data: [ + { id: 'task-1', sponsee_id: 'sponsee-1', status: 'assigned' }, + { id: 'task-2', sponsee_id: 'sponsee-1', status: 'completed' }, + { id: 'task-3', sponsee_id: 'sponsee-1', status: 'completed' }, + ], error: null, }), }), }), }; } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ data: mockSponsorRelationships, error: null }), - }), - }), - }; - } - if (table === 'tasks') { + if (table === 'invite_codes') { return { + insert: jest.fn().mockResolvedValue({ error: null }), select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + gt: jest.fn().mockReturnValue({ + is: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), }), }), }; @@ -1727,79 +1532,123 @@ describe('ProfileScreen', () => { eq: jest.fn().mockReturnThis(), }; }); + }); + it('displays sponsee with task statistics', async () => { render(); await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + expect(screen.getByText('Jane S.')).toBeTruthy(); }); - fireEvent.press(screen.getByText('Enter Invite Code')); - + // Should show task progress (2 completed out of 3 total) await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + expect(screen.getByText(/2\/3/)).toBeTruthy(); + }); + }); + }); + + describe('error handling for relationships fetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSponsorRelationships = []; + mockSponseeRelationships = []; + + // Mock supabase to throw an error + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation(() => { + throw new Error('Database connection failed'); }); + }); + + it('handles error during relationships fetch gracefully', async () => { + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'EXPIRED1'); - fireEvent.press(screen.getByText('Connect')); + render(); + // Should still render without crashing + // Name may appear in multiple places (profile header and edit sheet) await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'This invite code has expired'); + const nameElements = screen.getAllByText('John D.'); + expect(nameElements.length).toBeGreaterThan(0); }); + + consoleSpy.mockRestore(); }); + }); - it('shows error for already used invite code', async () => { - const { supabase } = jest.requireMock('@/lib/supabase'); - const { Alert } = jest.requireMock('react-native'); + // Note: Join with Invite Code - Already Connected, Relationship Creation Error, Network Error + // tests are now covered in EnterInviteCodeSheet.test.tsx + // Error messages from joinWithInviteCode are thrown and displayed by the sheet + + describe('Disconnect Sponsee Flow', () => { + beforeEach(() => { + mockSponsorRelationships = []; + mockSponseeRelationships = [ + { + id: 'rel-2', + sponsor_id: 'user-123', + sponsee_id: 'sponsee-456', + status: 'active', + connected_at: '2024-02-01T00:00:00Z', + sponsee: { + id: 'sponsee-456', + display_name: 'Jane D.', + sobriety_date: '2024-01-01', + }, + }, + ]; + const { supabase } = jest.requireMock('@/lib/supabase'); supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { + if (table === 'sponsor_sponsee_relationships') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'USEDCODE', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() + 86400000).toISOString(), - used_by: 'other-user', - }, - error: null, - }), + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponsorRelationships, + error: null, + }), + }; + } + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponseeRelationships, + error: null, + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; }), + })), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), }), }; } - if (table === 'profiles') { + if (table === 'notifications') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'sponsor-123', display_name: 'Jane S.' }, - error: null, - }), - }), - }), + insert: jest.fn().mockResolvedValue({ error: null }), }; } - if (table === 'sponsor_sponsee_relationships') { + if (table === 'tasks') { return { select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ data: mockSponsorRelationships, error: null }), + in: jest.fn().mockResolvedValue({ data: [], error: null }), }), }), }; } - if (table === 'tasks') { + if (table === 'invite_codes') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + insert: jest.fn().mockResolvedValue({ error: null }), }; } return { @@ -1807,79 +1656,120 @@ describe('ProfileScreen', () => { eq: jest.fn().mockReturnThis(), }; }); + }); + it('shows disconnect confirmation dialog for sponsee', async () => { + const { Alert } = jest.requireMock('react-native'); render(); await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + expect(screen.getByText('Jane D.')).toBeTruthy(); }); - fireEvent.press(screen.getByText('Enter Invite Code')); + fireEvent.press(screen.getByText('Disconnect')); await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + expect(Alert.alert).toHaveBeenCalledWith( + 'Confirm Disconnection', + expect.stringContaining('Jane D.'), + expect.any(Array) + ); }); + }); + + it('successfully disconnects sponsee when confirmed', async () => { + const { Alert } = jest.requireMock('react-native'); + // Mock Alert.alert to auto-confirm + Alert.alert.mockImplementation( + (_title: string, _message: string, buttons: { text: string; onPress?: () => void }[]) => { + const disconnectButton = buttons?.find((b) => b.text === 'Disconnect'); + if (disconnectButton?.onPress) { + disconnectButton.onPress(); + } + } + ); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'USEDCODE'); - fireEvent.press(screen.getByText('Connect')); + render(); await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'This invite code has already been used'); + expect(screen.getByText('Jane D.')).toBeTruthy(); }); - }); - it('shows error when trying to connect to yourself', async () => { - const { supabase } = jest.requireMock('@/lib/supabase'); - const { Alert } = jest.requireMock('react-native'); + fireEvent.press(screen.getByText('Disconnect')); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith('Success', 'Successfully disconnected'); + }); + }); + }); + + describe('Disconnect Sponsor Flow', () => { + beforeEach(() => { + mockSponseeRelationships = []; + mockSponsorRelationships = [ + { + id: 'rel-1', + sponsor_id: 'sponsor-123', + sponsee_id: 'user-123', + status: 'active', + connected_at: '2024-01-15T00:00:00Z', + sponsor: { + id: 'sponsor-123', + display_name: 'Bob S.', + sobriety_date: '2020-01-01', + }, + }, + ]; + + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'sponsor_sponsee_relationships') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'SELFCODE', - sponsor_id: 'user-123', // Same as current user - expires_at: new Date(Date.now() + 86400000).toISOString(), - used_by: null, - }, - error: null, - }), + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponsorRelationships, + error: null, + }), + }; + } + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponseeRelationships, + error: null, + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; }), + })), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), }), }; } - if (table === 'profiles') { + if (table === 'notifications') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'user-123', display_name: 'John D.' }, - error: null, - }), - }), - }), + insert: jest.fn().mockResolvedValue({ error: null }), }; } - if (table === 'sponsor_sponsee_relationships') { + if (table === 'tasks') { return { select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ data: mockSponsorRelationships, error: null }), + in: jest.fn().mockResolvedValue({ data: [], error: null }), }), }), }; } - if (table === 'tasks') { + if (table === 'invite_codes') { return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + insert: jest.fn().mockResolvedValue({ error: null }), }; } return { @@ -1887,47 +1777,48 @@ describe('ProfileScreen', () => { eq: jest.fn().mockReturnThis(), }; }); + }); + it('shows disconnect confirmation for sponsor relationship', async () => { + const { Alert } = jest.requireMock('react-native'); render(); await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); - - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + expect(screen.getByText('Bob S.')).toBeTruthy(); }); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'SELFCODE'); - fireEvent.press(screen.getByText('Connect')); + fireEvent.press(screen.getByText('Disconnect')); await waitFor(() => { expect(Alert.alert).toHaveBeenCalledWith( - 'Error', - 'You cannot connect to yourself as a sponsor' + 'Confirm Disconnection', + expect.stringContaining('Bob S.'), + expect.any(Array) ); }); }); }); - // Note: More complex flows like sponsor profile fetch error, disconnect relationship, - // edit sobriety date, and slip-up modal submission are tested via integration tests. - - describe('Generate Invite Code Error', () => { + describe('Disconnect - Error Handling', () => { beforeEach(() => { - mockSponsorRelationships = []; mockSponseeRelationships = []; + mockSponsorRelationships = [ + { + id: 'rel-1', + sponsor_id: 'sponsor-123', + sponsee_id: 'user-123', + status: 'active', + connected_at: '2024-01-15T00:00:00Z', + sponsor: { + id: 'sponsor-123', + display_name: 'Bob S.', + sobriety_date: '2020-01-01', + }, + }, + ]; const { supabase } = jest.requireMock('@/lib/supabase'); supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { - return { - insert: jest.fn().mockResolvedValue({ error: new Error('Database error') }), - }; - } if (table === 'sponsor_sponsee_relationships') { return { select: jest.fn().mockImplementation(() => ({ @@ -1953,6 +1844,9 @@ describe('ProfileScreen', () => { }; }), })), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: { message: 'Disconnect failed' } }), + }), }; } if (table === 'tasks') { @@ -1964,6 +1858,11 @@ describe('ProfileScreen', () => { }), }; } + if (table === 'invite_codes') { + return { + insert: jest.fn().mockResolvedValue({ error: null }), + }; + } return { select: jest.fn().mockReturnThis(), eq: jest.fn().mockReturnThis(), @@ -1971,61 +1870,68 @@ describe('ProfileScreen', () => { }); }); - it('shows error alert when invite code generation fails', async () => { + it('shows error when disconnect fails', async () => { const { Alert } = jest.requireMock('react-native'); + // Mock Alert.alert to auto-confirm disconnect + Alert.alert.mockImplementation( + (_title: string, _message: string, buttons: { text: string; onPress?: () => void }[]) => { + const disconnectButton = buttons?.find((b) => b.text === 'Disconnect'); + if (disconnectButton?.onPress) { + disconnectButton.onPress(); + } + } + ); + render(); await waitFor(() => { - expect(screen.getByText('Generate Invite Code')).toBeTruthy(); + expect(screen.getByText('Bob S.')).toBeTruthy(); }); - fireEvent.press(screen.getByText('Generate Invite Code')); + fireEvent.press(screen.getByText('Disconnect')); await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to generate invite code'); + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to disconnect.'); }); }); }); - describe('sponsee task statistics', () => { + describe('Connect to Another Sponsor', () => { beforeEach(() => { - jest.clearAllMocks(); - - // Set up sponsee relationships with task stats - mockSponseeRelationships = [ + mockSponseeRelationships = []; + mockSponsorRelationships = [ { id: 'rel-1', - sponsor_id: 'user-123', - sponsee_id: 'sponsee-1', + sponsor_id: 'sponsor-123', + sponsee_id: 'user-123', status: 'active', - sponsee: { - id: 'sponsee-1', - display_name: 'Jane S.', - sobriety_date: '2024-06-01', + connected_at: '2024-01-15T00:00:00Z', + sponsor: { + id: 'sponsor-123', + display_name: 'Bob S.', + sobriety_date: '2020-01-01', }, }, ]; - mockSponsorRelationships = []; - // Mock supabase to return tasks for the sponsees const { supabase } = jest.requireMock('@/lib/supabase'); supabase.from.mockImplementation((table: string) => { if (table === 'sponsor_sponsee_relationships') { return { select: jest.fn().mockImplementation(() => ({ eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsor_id') { + if (field === 'sponsee_id') { return { eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, + data: mockSponsorRelationships, error: null, }), }; } - if (field === 'sponsee_id') { + if (field === 'sponsor_id') { return { eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, + data: mockSponseeRelationships, error: null, }), }; @@ -2041,14 +1947,7 @@ describe('ProfileScreen', () => { return { select: jest.fn().mockReturnValue({ eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ - data: [ - { id: 'task-1', sponsee_id: 'sponsee-1', status: 'assigned' }, - { id: 'task-2', sponsee_id: 'sponsee-1', status: 'completed' }, - { id: 'task-3', sponsee_id: 'sponsee-1', status: 'completed' }, - ], - error: null, - }), + in: jest.fn().mockResolvedValue({ data: [], error: null }), }), }), }; @@ -2056,16 +1955,6 @@ describe('ProfileScreen', () => { if (table === 'invite_codes') { return { insert: jest.fn().mockResolvedValue({ error: null }), - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - gt: jest.fn().mockReturnValue({ - is: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ data: null, error: null }), - }), - }), - maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), - }), - }), }; } return { @@ -2075,851 +1964,694 @@ describe('ProfileScreen', () => { }); }); - it('displays sponsee with task statistics', async () => { + it('shows Connect to Another Sponsor button when already has sponsor', async () => { render(); await waitFor(() => { - expect(screen.getByText('Jane S.')).toBeTruthy(); + expect(screen.getByText('Bob S.')).toBeTruthy(); }); - // Should show task progress (2 completed out of 3 total) await waitFor(() => { - expect(screen.getByText(/2\/3/)).toBeTruthy(); + expect(screen.getByText('Connect to Another Sponsor')).toBeTruthy(); }); }); }); - describe('error handling for relationships fetch', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockSponsorRelationships = []; - mockSponseeRelationships = []; + // Note: Sponsor Profile Fetch Error test is now covered in EnterInviteCodeSheet.test.tsx + // Error from joinWithInviteCode is thrown and displayed by the sheet - // Mock supabase to throw an error - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation(() => { - throw new Error('Database connection failed'); + // ============================================================================= + // Integration Tests: Profile + EnterInviteCodeSheet + // ============================================================================= + // These tests verify the integration between profile screen and the sheet, + // specifically testing the joinWithInviteCode callback that gets passed to the sheet. + + describe('Profile + EnterInviteCodeSheet Integration', () => { + describe('joinWithInviteCode callback', () => { + it('passes joinWithInviteCode to EnterInviteCodeSheet', async () => { + render(); + + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + expect(typeof capturedOnSubmit).toBe('function'); + }); }); - }); - it('handles error during relationships fetch gracefully', async () => { - // Suppress console.error for this test - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + it('presents sheet when Enter Invite Code button is pressed', async () => { + render(); - render(); + await waitFor(() => { + expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + }); - // Should still render without crashing - // Name may appear in multiple places (profile header and edit sheet) - await waitFor(() => { - const nameElements = screen.getAllByText('John D.'); - expect(nameElements.length).toBeGreaterThan(0); - }); + fireEvent.press(screen.getByText('Enter Invite Code')); - consoleSpy.mockRestore(); + expect(mockEnterInviteCodePresent).toHaveBeenCalled(); + }); }); - }); - describe('Join with Invite Code - Already Connected', () => { - beforeEach(() => { - mockSponsorRelationships = []; - mockSponseeRelationships = []; + describe('joinWithInviteCode - Success Flow', () => { + beforeEach(() => { + mockSponsorRelationships = []; + mockSponseeRelationships = []; - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'VALIDCOD', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() + 86400000).toISOString(), - used_by: null, - }, - error: null, + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + insert: jest.fn().mockResolvedValue({ error: null }), + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + gt: jest.fn().mockReturnValue({ + is: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-123', + code: 'TEST1234', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() + 86400000).toISOString(), // Tomorrow + used_by: null, + }, + error: null, + }), }), }), - }), - }; - } - if (table === 'profiles') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'sponsor-123', display_name: 'Jane S.' }, - error: null, + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), + }), + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'sponsor-456', display_name: 'Test Sponsor' }, + error: null, + }), }), }), - }), - }; - } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsor_id') { - // For the existing relationship check - return an existing relationship - return { - eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { id: 'existing-rel' }, // Already connected - error: null, - }), + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockResolvedValue({ error: null }), + }), + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponsorRelationships, + error: null, }), - }), - }; - } - if (field === 'sponsee_id') { + }; + } + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockImplementation((field2: string) => { + if (field2 === 'sponsee_id') { + return { + eq: jest.fn().mockImplementation(() => ({ + maybeSingle: jest.fn().mockResolvedValue({ + data: null, // No existing relationship + error: null, + }), + })), + maybeSingle: jest.fn().mockResolvedValue({ + data: null, + error: null, + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ + data: mockSponseeRelationships, + error: null, + }), + }; + }), + }; + } return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), + eq: jest.fn().mockResolvedValue({ data: [], error: null }), }; - } - return { - eq: jest.fn().mockResolvedValue({ data: mockSponseeRelationships, error: null }), - }; + }), + })), + insert: jest.fn().mockResolvedValue({ error: null }), + }; + } + if (table === 'notifications') { + return { + insert: jest.fn().mockResolvedValue({ error: null }), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - })), - }; - } - if (table === 'tasks') { + }; + } return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; + }); }); - }); - it('shows error when already connected to sponsor', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); + it('connects to sponsor successfully when invite code is valid', async () => { + const { Alert } = jest.requireMock('react-native'); + render(); - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - fireEvent.press(screen.getByText('Enter Invite Code')); + // Call the captured callback (simulating sheet submission) + await capturedOnSubmit!('TEST1234'); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + // Should show success alert + expect(Alert.alert).toHaveBeenCalledWith('Success', 'Connected with Test Sponsor'); }); + }); + + describe('joinWithInviteCode - Error Cases', () => { + it('throws error for invalid/expired invite code', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: null, + error: null, + }), + }), + }), + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), + }), + }; + } + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + }; + }); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'VALIDCOD'); - fireEvent.press(screen.getByText('Connect')); + render(); - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Error', - 'You are already connected to this sponsor' + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); + + // Should throw error for invalid code + await expect(capturedOnSubmit!('INVALID1')).rejects.toThrow( + 'Invalid or expired invite code' ); }); - }); - }); - - describe('Join with Invite Code - Relationship Creation Error', () => { - beforeEach(() => { - mockSponsorRelationships = []; - mockSponseeRelationships = []; - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'NEWCODE1', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() + 86400000).toISOString(), - used_by: null, - }, - error: null, + it('throws error for expired invite code', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-expired', + code: 'EXPIRED1', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() - 86400000).toISOString(), // Yesterday + used_by: null, + }, + error: null, + }), }), }), - }), - }; - } - if (table === 'profiles') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: { id: 'sponsor-123', display_name: 'Jane S.' }, - error: null, + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'sponsor-456', display_name: 'Test Sponsor' }, + error: null, + }), }), }), - }), - }; - } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsor_id') { - return { - eq: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), - }), - }), - }; - } - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - return { - eq: jest.fn().mockResolvedValue({ data: mockSponseeRelationships, error: null }), - }; + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - })), - insert: jest - .fn() - .mockResolvedValue({ error: { message: 'Relationship creation failed' } }), - }; - } - if (table === 'tasks') { + }; + } return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); + }); - it('shows error when relationship creation fails', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); + render(); - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); + + await expect(capturedOnSubmit!('EXPIRED1')).rejects.toThrow('This invite code has expired'); }); - fireEvent.press(screen.getByText('Enter Invite Code')); + it('throws error for already used invite code', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-used', + code: 'USEDCODE', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() + 86400000).toISOString(), + used_by: 'other-user-id', // Already used + }, + error: null, + }), + }), + }), + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'sponsor-456', display_name: 'Test Sponsor' }, + error: null, + }), + }), + }), + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), + }), + }; + } + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + }; + }); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); + render(); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'NEWCODE1'); - fireEvent.press(screen.getByText('Connect')); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Error', - 'Failed to connect: Relationship creation failed' + await expect(capturedOnSubmit!('USEDCODE')).rejects.toThrow( + 'This invite code has already been used' ); }); - }); - }); - - describe('Join with Invite Code - Network Error', () => { - beforeEach(() => { - mockSponsorRelationships = []; - mockSponseeRelationships = []; - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockRejectedValue(new Error('Network error')), + it('throws error when trying to connect to self', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-self', + code: 'SELFCODE', + sponsor_id: 'user-123', // Same as mock user id + expires_at: new Date(Date.now() + 86400000).toISOString(), + used_by: null, + }, + error: null, + }), + }), }), - }), - }; - } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), - }; - } - return { + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'user-123', display_name: 'John D.' }, + error: null, + }), + }), + }), + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation(() => ({ eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; + })), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - })), - }; - } - if (table === 'tasks') { + }; + } return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); - - it('shows network error message when fetch throws', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Enter Invite Code')); + }); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); + render(); - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'NETCODE1'); - fireEvent.press(screen.getByText('Connect')); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Network error'); + await expect(capturedOnSubmit!('SELFCODE')).rejects.toThrow( + 'You cannot connect to yourself as a sponsor' + ); }); - }); - }); - describe('Disconnect Sponsee Flow', () => { - beforeEach(() => { - mockSponsorRelationships = []; - mockSponseeRelationships = [ - { - id: 'rel-2', - sponsor_id: 'user-123', - sponsee_id: 'sponsee-456', - status: 'active', - connected_at: '2024-02-01T00:00:00Z', - sponsee: { - id: 'sponsee-456', - display_name: 'Jane D.', - sobriety_date: '2024-01-01', - }, - }, - ]; - - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), - }; - } - return { - eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; + it('throws error when already connected to sponsor', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-dup', + code: 'DUPCODE1', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() + 86400000).toISOString(), + used_by: null, + }, + error: null, + }), + }), }), - })), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: null }), - }), - }; - } - if (table === 'notifications') { - return { - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - if (table === 'tasks') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'sponsor-456', display_name: 'Test Sponsor' }, + error: null, + }), + }), }), - }), - }; - } - if (table === 'invite_codes') { - return { - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); - - it('shows disconnect confirmation dialog for sponsee', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - expect(screen.getByText('Jane D.')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Disconnect')); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Confirm Disconnection', - expect.stringContaining('Jane D.'), - expect.any(Array) - ); - }); - }); - - it('successfully disconnects sponsee when confirmed', async () => { - const { Alert } = jest.requireMock('react-native'); - // Mock Alert.alert to auto-confirm - Alert.alert.mockImplementation( - (_title: string, _message: string, buttons: { text: string; onPress?: () => void }[]) => { - const disconnectButton = buttons?.find((b) => b.text === 'Disconnect'); - if (disconnectButton?.onPress) { - disconnectButton.onPress(); + }; } - } - ); - - render(); - - await waitFor(() => { - expect(screen.getByText('Jane D.')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Disconnect')); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Success', 'Successfully disconnected'); - }); - }); - }); - - describe('Disconnect Sponsor Flow', () => { - beforeEach(() => { - mockSponseeRelationships = []; - mockSponsorRelationships = [ - { - id: 'rel-1', - sponsor_id: 'sponsor-123', - sponsee_id: 'user-123', - status: 'active', - connected_at: '2024-01-15T00:00:00Z', - sponsor: { - id: 'sponsor-123', - display_name: 'Bob S.', - sobriety_date: '2020-01-01', - }, - }, - ]; - - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + // Check for existing relationship query + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockImplementation((field2: string) => { + if (field2 === 'sponsee_id') { + return { + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { id: 'existing-rel' }, // Existing relationship + error: null, + }), + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + }), + }; + } + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + } return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), + eq: jest.fn().mockResolvedValue({ data: [], error: null }), }; - } - return { - eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; - }), - })), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: null }), - }), - }; - } - if (table === 'notifications') { - return { - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - if (table === 'tasks') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - }), - }; - } - if (table === 'invite_codes') { + }; + } return { - insert: jest.fn().mockResolvedValue({ error: null }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); + }); - it('shows disconnect confirmation for sponsor relationship', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); + render(); - await waitFor(() => { - expect(screen.getByText('Bob S.')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Disconnect')); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith( - 'Confirm Disconnection', - expect.stringContaining('Bob S.'), - expect.any(Array) + await expect(capturedOnSubmit!('DUPCODE1')).rejects.toThrow( + 'You are already connected to this sponsor' ); }); - }); - }); - describe('Disconnect - Error Handling', () => { - beforeEach(() => { - mockSponseeRelationships = []; - mockSponsorRelationships = [ - { - id: 'rel-1', - sponsor_id: 'sponsor-123', - sponsee_id: 'user-123', - status: 'active', - connected_at: '2024-01-15T00:00:00Z', - sponsor: { - id: 'sponsor-123', - display_name: 'Bob S.', - sobriety_date: '2020-01-01', - }, - }, - ]; - - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), - }; - } - return { - eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; + it('throws error when sponsor profile fetch fails', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-123', + code: 'PROFAIL1', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() + 86400000).toISOString(), + used_by: null, + }, + error: null, + }), + }), }), - })), - update: jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue({ error: { message: 'Disconnect failed' } }), - }), - }; - } - if (table === 'tasks') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: null, + error: { message: 'Profile not found' }, + }), + }), }), - }), - }; - } - if (table === 'invite_codes') { - return { - insert: jest.fn().mockResolvedValue({ error: null }), - }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); - - it('shows error when disconnect fails', async () => { - const { Alert } = jest.requireMock('react-native'); - // Mock Alert.alert to auto-confirm disconnect - Alert.alert.mockImplementation( - (_title: string, _message: string, buttons: { text: string; onPress?: () => void }[]) => { - const disconnectButton = buttons?.find((b) => b.text === 'Disconnect'); - if (disconnectButton?.onPress) { - disconnectButton.onPress(); + }; } - } - ); - - render(); - - await waitFor(() => { - expect(screen.getByText('Bob S.')).toBeTruthy(); - }); - - fireEvent.press(screen.getByText('Disconnect')); - - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to disconnect.'); - }); - }); - }); - - describe('Connect to Another Sponsor', () => { - beforeEach(() => { - mockSponseeRelationships = []; - mockSponsorRelationships = [ - { - id: 'rel-1', - sponsor_id: 'sponsor-123', - sponsee_id: 'user-123', - status: 'active', - connected_at: '2024-01-15T00:00:00Z', - sponsor: { - id: 'sponsor-123', - display_name: 'Bob S.', - sobriety_date: '2020-01-01', - }, - }, - ]; - - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), - }; - } - return { + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation(() => ({ eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; - }), - })), - }; - } - if (table === 'tasks') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), + })), + })), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - }), - }; - } - if (table === 'invite_codes') { + }; + } return { - insert: jest.fn().mockResolvedValue({ error: null }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); + }); - it('shows Connect to Another Sponsor button when already has sponsor', async () => { - render(); + render(); - await waitFor(() => { - expect(screen.getByText('Bob S.')).toBeTruthy(); - }); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - await waitFor(() => { - expect(screen.getByText('Connect to Another Sponsor')).toBeTruthy(); + await expect(capturedOnSubmit!('PROFAIL1')).rejects.toThrow( + 'Unable to fetch sponsor information' + ); }); - }); - }); - describe('Sponsor Profile Fetch Error', () => { - beforeEach(() => { - mockSponsorRelationships = []; - mockSponseeRelationships = []; - - const { supabase } = jest.requireMock('@/lib/supabase'); - supabase.from.mockImplementation((table: string) => { - if (table === 'invite_codes') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { - id: 'invite-1', - code: 'PROFCODE', - sponsor_id: 'sponsor-123', - expires_at: new Date(Date.now() + 86400000).toISOString(), - used_by: null, - }, - error: null, + it('throws error when relationship creation fails', async () => { + const { supabase } = jest.requireMock('@/lib/supabase'); + supabase.from.mockImplementation((table: string) => { + if (table === 'invite_codes') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: { + id: 'invite-123', + code: 'RELFAIL1', + sponsor_id: 'sponsor-456', + expires_at: new Date(Date.now() + 86400000).toISOString(), + used_by: null, + }, + error: null, + }), }), }), - }), - }; - } - if (table === 'profiles') { - return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue({ - data: null, - error: { message: 'Profile not found' }, + }; + } + if (table === 'profiles') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ + data: { id: 'sponsor-456', display_name: 'Test Sponsor' }, + error: null, + }), }), }), - }), - }; - } - if (table === 'sponsor_sponsee_relationships') { - return { - select: jest.fn().mockImplementation(() => ({ - eq: jest.fn().mockImplementation((field: string) => { - if (field === 'sponsee_id') { - return { - eq: jest.fn().mockResolvedValue({ - data: mockSponsorRelationships, - error: null, - }), - }; - } - if (field === 'sponsor_id') { + }; + } + if (table === 'sponsor_sponsee_relationships') { + return { + select: jest.fn().mockImplementation(() => ({ + eq: jest.fn().mockImplementation((field: string) => { + if (field === 'sponsor_id') { + return { + eq: jest.fn().mockImplementation((field2: string) => { + if (field2 === 'sponsee_id') { + return { + eq: jest.fn().mockReturnValue({ + maybeSingle: jest.fn().mockResolvedValue({ + data: null, // No existing relationship + error: null, + }), + }), + }; + } + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + }), + }; + } + if (field === 'sponsee_id') { + return { + eq: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + } return { - eq: jest.fn().mockResolvedValue({ - data: mockSponseeRelationships, - error: null, - }), + eq: jest.fn().mockResolvedValue({ data: [], error: null }), }; - } - return { - eq: jest.fn().mockResolvedValue({ data: [], error: null }), - }; + }), + })), + insert: jest + .fn() + .mockResolvedValue({ error: { message: 'Database constraint error' } }), + }; + } + if (table === 'tasks') { + return { + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }), }), - })), - }; - } - if (table === 'tasks') { + }; + } return { - select: jest.fn().mockReturnValue({ - eq: jest.fn().mockReturnValue({ - in: jest.fn().mockResolvedValue({ data: [], error: null }), - }), - }), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), }; - } - return { - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - }); - }); - - it('shows error when sponsor profile fetch fails', async () => { - const { Alert } = jest.requireMock('react-native'); - render(); - - await waitFor(() => { - expect(screen.getByText('Enter Invite Code')).toBeTruthy(); - }); + }); - fireEvent.press(screen.getByText('Enter Invite Code')); + render(); - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); - }); - - const input = screen.getByPlaceholderText('Enter 8-character code'); - fireEvent.changeText(input, 'PROFCODE'); - fireEvent.press(screen.getByText('Connect')); + await waitFor(() => { + expect(capturedOnSubmit).not.toBeNull(); + }); - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledWith('Error', 'Unable to fetch sponsor information'); + await expect(capturedOnSubmit!('RELFAIL1')).rejects.toThrow('Database constraint error'); }); }); }); diff --git a/__tests__/components/GlassBottomSheet.test.tsx b/__tests__/components/GlassBottomSheet.test.tsx index 5c31d44d..1cf1b4e6 100644 --- a/__tests__/components/GlassBottomSheet.test.tsx +++ b/__tests__/components/GlassBottomSheet.test.tsx @@ -3,9 +3,15 @@ // ============================================================================= import React, { createRef } from 'react'; import { Dimensions, Platform, Text } from 'react-native'; +import { act } from '@testing-library/react-native'; import GlassBottomSheet, { GlassBottomSheetRef } from '@/components/GlassBottomSheet'; import { renderWithProviders } from '@/__tests__/test-utils'; +// Extend global for TypeScript +declare global { + var __backHandlerListeners: (() => boolean)[] | undefined; +} + // ============================================================================= // Device Size Constants // ============================================================================= @@ -86,6 +92,41 @@ jest.mock('expo-blur', () => ({ }, })); +// ============================================================================= +// BackHandler Test Helpers +// ============================================================================= + +/** + * Returns the current BackHandler listeners from the global mock. + * BackHandler is mocked in jest.setup.js with listeners stored in global.__backHandlerListeners. + */ +const getBackHandlerListeners = (): (() => boolean)[] => { + return global.__backHandlerListeners || []; +}; + +/** + * Clears all BackHandler listeners. + */ +const clearBackHandlerListeners = (): void => { + global.__backHandlerListeners = []; +}; + +/** + * Simulates pressing the Android hardware back button. + * Calls all registered BackHandler listeners in reverse order (LIFO). + * Returns true if any listener handled the event (returned true). + */ +const simulateBackPress = (): boolean => { + const listeners = getBackHandlerListeners(); + // Process listeners in reverse order (most recent first, like real BackHandler) + for (let i = listeners.length - 1; i >= 0; i--) { + if (listeners[i]()) { + return true; // Event was handled + } + } + return false; // Event was not handled +}; + // ============================================================================= // Test Suite // ============================================================================= @@ -98,6 +139,8 @@ describe('GlassBottomSheet', () => { afterEach(() => { jest.clearAllMocks(); + // Clear BackHandler listeners between tests + clearBackHandlerListeners(); // Restore Platform.OS using Object.defineProperty for robustness across Jest environments Object.defineProperty(Platform, 'OS', { get: () => originalPlatformOS, @@ -336,6 +379,113 @@ describe('GlassBottomSheet', () => { }); }); + // --------------------------------------------------------------------------- + // Android Back Button Tests + // --------------------------------------------------------------------------- + describe('Android Back Button Behavior', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { + get: () => 'android', + configurable: true, + }); + }); + + it('should register BackHandler listener when sheet is presented on Android', () => { + const ref = createRef(); + renderWithProviders(); + + // Initially no listeners + expect(getBackHandlerListeners().length).toBe(0); + + // Present the sheet + act(() => { + ref.current?.present(); + }); + + // BackHandler listener should be registered when sheet is open + expect(getBackHandlerListeners().length).toBe(1); + }); + + it('should dismiss sheet when back button is pressed while sheet is open', () => { + const ref = createRef(); + const onDismiss = jest.fn(); + renderWithProviders(); + + // Present the sheet + act(() => { + ref.current?.present(); + }); + + // Simulate back press - should return true (handled) and dismiss the sheet + const handled = simulateBackPress(); + expect(handled).toBe(true); + }); + + it('should not register BackHandler listener when sheet is not open', () => { + const ref = createRef(); + renderWithProviders(); + + // Sheet not presented - no listener should be registered + expect(getBackHandlerListeners().length).toBe(0); + }); + + it('should remove BackHandler listener when sheet is dismissed', () => { + const ref = createRef(); + renderWithProviders(); + + // Present the sheet + act(() => { + ref.current?.present(); + }); + + expect(getBackHandlerListeners().length).toBe(1); + + // Dismiss the sheet + act(() => { + ref.current?.dismiss(); + }); + + // Listener should be removed + expect(getBackHandlerListeners().length).toBe(0); + }); + + it('should not intercept back button on iOS', () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + configurable: true, + }); + + const ref = createRef(); + renderWithProviders(); + + // Present the sheet + act(() => { + ref.current?.present(); + }); + + // iOS should not register BackHandler listener + expect(getBackHandlerListeners().length).toBe(0); + }); + + it('should not intercept back button on web', () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'web', + configurable: true, + }); + + const ref = createRef(); + renderWithProviders(); + + // Present the sheet + act(() => { + ref.current?.present(); + }); + + // Web should not register BackHandler listener (uses Escape key instead) + expect(getBackHandlerListeners().length).toBe(0); + }); + }); + // --------------------------------------------------------------------------- // iPhone Screen Size Tests // --------------------------------------------------------------------------- diff --git a/__tests__/components/sheets/EnterInviteCodeSheet.test.tsx b/__tests__/components/sheets/EnterInviteCodeSheet.test.tsx new file mode 100644 index 00000000..56a07077 --- /dev/null +++ b/__tests__/components/sheets/EnterInviteCodeSheet.test.tsx @@ -0,0 +1,500 @@ +/** + * @fileoverview Tests for EnterInviteCodeSheet component + * + * Tests invite code entry behavior including: + * - Sheet rendering and structure + * - Input validation (8-character alphanumeric) + * - Form submission + * - Error handling + * - Loading states + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; +import EnterInviteCodeSheet, { + EnterInviteCodeSheetRef, +} from '@/components/sheets/EnterInviteCodeSheet'; + +// ============================================================================= +// Mocks +// ============================================================================= + +// Mock GlassBottomSheet +const mockPresent = jest.fn(); +const mockDismiss = jest.fn(); + +jest.mock('@/components/GlassBottomSheet', () => { + const React = require('react'); + const MockGlassBottomSheet = React.forwardRef( + ( + { children, onDismiss }: { children: React.ReactNode; onDismiss?: () => void }, + ref: React.Ref<{ present: () => void; dismiss: () => void }> + ) => { + React.useImperativeHandle(ref, () => ({ + present: mockPresent, + dismiss: () => { + mockDismiss(); + onDismiss?.(); + }, + })); + return React.createElement('View', { testID: 'glass-bottom-sheet' }, children); + } + ); + MockGlassBottomSheet.displayName = 'GlassBottomSheet'; + return { + __esModule: true, + default: MockGlassBottomSheet, + }; +}); + +// Mock BottomSheet components +jest.mock('@gorhom/bottom-sheet', () => { + const React = require('react'); + const { ScrollView, TextInput } = require('react-native'); + return { + BottomSheetScrollView: ({ children, ...props }: { children: React.ReactNode }) => + React.createElement(ScrollView, { ...props, testID: 'bottom-sheet-scroll-view' }, children), + BottomSheetTextInput: (props: Record) => React.createElement(TextInput, props), + }; +}); + +// Mock lucide-react-native icons +jest.mock('lucide-react-native', () => ({ + X: () => null, + QrCode: () => null, +})); + +// Mock theme +const mockTheme = { + primary: '#007AFF', + primaryLight: '#E5F1FF', + text: '#111827', + textSecondary: '#6b7280', + textTertiary: '#9ca3af', + background: '#ffffff', + surface: '#ffffff', + card: '#ffffff', + border: '#e5e7eb', + success: '#10b981', + danger: '#ef4444', + dangerLight: '#fef2f2', + white: '#ffffff', + fontRegular: 'JetBrainsMono-Regular', +}; + +// ============================================================================= +// Test Suite +// ============================================================================= + +describe('EnterInviteCodeSheet', () => { + const mockOnSubmit = jest.fn(); + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderSheet = () => { + const ref = React.createRef(); + const result = render( + + ); + return { ...result, ref }; + }; + + describe('Component Rendering', () => { + it('renders without crashing', () => { + renderSheet(); + expect(screen.getByTestId('glass-bottom-sheet')).toBeTruthy(); + }); + + it('renders title', () => { + renderSheet(); + expect(screen.getByText('Enter Invite Code')).toBeTruthy(); + }); + + it('renders subtitle with instructions', () => { + renderSheet(); + expect(screen.getByText(/Enter the 8-character invite code from your sponsor/)).toBeTruthy(); + }); + + it('renders invite code input', () => { + renderSheet(); + expect(screen.getByPlaceholderText('Enter 8-character code')).toBeTruthy(); + }); + + it('renders character count', () => { + renderSheet(); + expect(screen.getByText('0/8 characters')).toBeTruthy(); + }); + + it('renders Connect button', () => { + renderSheet(); + expect(screen.getByTestId('connect-button')).toBeTruthy(); + }); + + it('renders Cancel button', () => { + renderSheet(); + expect(screen.getByTestId('cancel-button')).toBeTruthy(); + }); + + it('renders close icon button', () => { + renderSheet(); + expect(screen.getByTestId('close-icon-button')).toBeTruthy(); + }); + }); + + describe('Imperative API', () => { + it('exposes present method via ref', () => { + const { ref } = renderSheet(); + expect(ref.current?.present).toBeDefined(); + }); + + it('exposes dismiss method via ref', () => { + const { ref } = renderSheet(); + expect(ref.current?.dismiss).toBeDefined(); + }); + + it('calls GlassBottomSheet present when present is called', () => { + const { ref } = renderSheet(); + ref.current?.present(); + expect(mockPresent).toHaveBeenCalled(); + }); + + it('calls GlassBottomSheet dismiss when dismiss is called', () => { + const { ref } = renderSheet(); + ref.current?.dismiss(); + expect(mockDismiss).toHaveBeenCalled(); + }); + }); + + describe('Input Validation', () => { + it('sanitizes input to alphanumeric only', () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + fireEvent.changeText(input, 'ABC-123!@#'); + + expect(input.props.value).toBe('ABC123'); + }); + + it('auto-capitalizes input', () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + fireEvent.changeText(input, 'abcdef12'); + + expect(input.props.value).toBe('ABCDEF12'); + }); + + it('limits input to 8 characters', () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + // Input has maxLength=8, but we test the sanitization + fireEvent.changeText(input, 'ABC12345'); + + expect(input.props.value).toBe('ABC12345'); + }); + + it('updates character count as user types', () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + fireEvent.changeText(input, 'ABC'); + + expect(screen.getByText('3/8 characters')).toBeTruthy(); + }); + }); + + describe('Submit Button State', () => { + it('Connect button is disabled when code is empty', async () => { + renderSheet(); + const button = screen.getByTestId('connect-button'); + + // Button should be disabled with empty input + expect(button.props.disabled).toBe(true); + }); + + it('Connect button is disabled when code is less than 8 characters', async () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + fireEvent.changeText(input, 'ABC123'); + + await waitFor(() => { + const button = screen.getByTestId('connect-button'); + expect(button.props.disabled).toBe(true); + }); + }); + + it('Connect button is enabled when code is exactly 8 characters', async () => { + renderSheet(); + const input = screen.getByPlaceholderText('Enter 8-character code'); + + fireEvent.changeText(input, 'ABC12345'); + + await waitFor(() => { + const button = screen.getByTestId('connect-button'); + expect(button.props.disabled).toBe(false); + }); + }); + }); + + describe('Form Submission', () => { + it('calls onSubmit with invite code when Connect is pressed', async () => { + mockOnSubmit.mockResolvedValueOnce(undefined); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith('ABC12345'); + }); + }); + + it('shows error message when onSubmit throws and resets submitting state', async () => { + mockOnSubmit.mockRejectedValueOnce(new Error('Invalid invite code')); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(screen.getByText('Invalid invite code')).toBeTruthy(); + }); + + // Verify isSubmitting was reset - button should be enabled again + const button = screen.getByTestId('connect-button'); + expect(button.props.disabled).toBe(false); + }); + + it('shows generic error when onSubmit throws non-Error and resets submitting state', async () => { + mockOnSubmit.mockRejectedValueOnce('Something went wrong'); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(screen.getByText('Failed to connect. Please try again.')).toBeTruthy(); + }); + + // Verify isSubmitting was reset - button should be enabled again + const button = screen.getByTestId('connect-button'); + expect(button.props.disabled).toBe(false); + }); + + it('clears error when user starts typing', async () => { + mockOnSubmit.mockRejectedValueOnce(new Error('Invalid invite code')); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(screen.getByText('Invalid invite code')).toBeTruthy(); + }); + + // Start typing again + fireEvent.changeText(input, 'XYZ'); + + await waitFor(() => { + expect(screen.queryByText('Invalid invite code')).toBeNull(); + }); + }); + + it('dismisses sheet on successful submission', async () => { + mockOnSubmit.mockResolvedValueOnce(undefined); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(mockDismiss).toHaveBeenCalled(); + }); + }); + + it('shows validation error when submitting with less than 8 characters', async () => { + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC'); + + // Directly trigger submit via keyboard + fireEvent(input, 'submitEditing'); + + await waitFor(() => { + expect(screen.getByText('Please enter an 8-character invite code')).toBeTruthy(); + }); + }); + }); + + describe('Cancel and Close', () => { + it('dismisses sheet when Cancel is pressed', () => { + renderSheet(); + + fireEvent.press(screen.getByTestId('cancel-button')); + + expect(mockDismiss).toHaveBeenCalled(); + }); + + it('dismisses sheet when close icon is pressed', () => { + renderSheet(); + + fireEvent.press(screen.getByTestId('close-icon-button')); + + expect(mockDismiss).toHaveBeenCalled(); + }); + + it('calls onClose when sheet is dismissed via Cancel', () => { + renderSheet(); + + fireEvent.press(screen.getByTestId('cancel-button')); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('does NOT call onClose after successful submission', async () => { + mockOnSubmit.mockResolvedValueOnce(undefined); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(mockDismiss).toHaveBeenCalled(); + }); + + // onClose should NOT be called after successful submission + // (per documented contract: "invoked when the sheet is dismissed without submitting") + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('resets form when sheet is presented again', async () => { + const { ref } = renderSheet(); + + // Enter some text + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ABC12345'); + + // Present the sheet (which resets the form) + ref.current?.present(); + + // Form should be reset - query fresh after present + await waitFor(() => { + const freshInput = screen.getByPlaceholderText('Enter 8-character code'); + expect(freshInput.props.value).toBe(''); + }); + }); + }); + + describe('Integration with onSubmit callback', () => { + it('passes the exact invite code entered to onSubmit', async () => { + mockOnSubmit.mockResolvedValueOnce(undefined); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'TEST1234'); + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledTimes(1); + expect(mockOnSubmit).toHaveBeenCalledWith('TEST1234'); + }); + }); + + it('handles async onSubmit that resolves', async () => { + mockOnSubmit.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'ASYNC123'); + fireEvent.press(screen.getByTestId('connect-button')); + + // Should show loading state (button disabled during submission) + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith('ASYNC123'); + }); + + // After resolution, sheet should dismiss + await waitFor(() => { + expect(mockDismiss).toHaveBeenCalled(); + }); + }); + + it('handles async onSubmit that rejects with Error', async () => { + const testError = new Error('Sponsor not found'); + mockOnSubmit.mockRejectedValueOnce(testError); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'FAIL1234'); + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(screen.getByText('Sponsor not found')).toBeTruthy(); + }); + + // Sheet should NOT dismiss on error + expect(mockDismiss).not.toHaveBeenCalled(); + }); + + it('allows retry after onSubmit failure', async () => { + // First attempt fails + mockOnSubmit.mockRejectedValueOnce(new Error('Network error')); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + fireEvent.changeText(input, 'RETRY123'); + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeTruthy(); + }); + + // Second attempt succeeds + mockOnSubmit.mockResolvedValueOnce(undefined); + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledTimes(2); + expect(mockDismiss).toHaveBeenCalled(); + }); + }); + + it('sanitizes and capitalizes code before passing to onSubmit', async () => { + mockOnSubmit.mockResolvedValueOnce(undefined); + renderSheet(); + + const input = screen.getByPlaceholderText('Enter 8-character code'); + // Enter lowercase - should be capitalized + fireEvent.changeText(input, 'abcd1234'); + fireEvent.press(screen.getByTestId('connect-button')); + + await waitFor(() => { + // Should receive sanitized, capitalized code + expect(mockOnSubmit).toHaveBeenCalledWith('ABCD1234'); + }); + }); + }); +}); diff --git a/__tests__/components/sheets/LogSlipUpSheet.test.tsx b/__tests__/components/sheets/LogSlipUpSheet.test.tsx index 3d8dd6d6..eeca7b39 100644 --- a/__tests__/components/sheets/LogSlipUpSheet.test.tsx +++ b/__tests__/components/sheets/LogSlipUpSheet.test.tsx @@ -44,14 +44,15 @@ jest.mock('@/components/GlassBottomSheet', () => { }; }); -// Mock BottomSheetScrollView +// Mock BottomSheetScrollView and BottomSheetTextInput jest.mock('@gorhom/bottom-sheet', () => { const React = require('react'); - const { ScrollView } = require('react-native'); + const { ScrollView, TextInput } = require('react-native'); return { BottomSheetScrollView: ({ children, ...props }: any) => ( {children} ), + BottomSheetTextInput: (props: any) => , }; }); diff --git a/__tests__/lib/validation.test.ts b/__tests__/lib/validation.test.ts index eb2a823c..765bd188 100644 --- a/__tests__/lib/validation.test.ts +++ b/__tests__/lib/validation.test.ts @@ -169,6 +169,18 @@ describe('validation utilities', () => { it('counts trimmed length', () => { expect(validateDisplayName(' J ')).toBe('Display name must be at least 2 characters'); }); + + it('rejects input containing only special characters', () => { + expect(validateDisplayName('!!!!')).toBe( + 'Display name can only contain letters, spaces, periods, and hyphens' + ); + expect(validateDisplayName('@#$%')).toBe( + 'Display name can only contain letters, spaces, periods, and hyphens' + ); + expect(validateDisplayName('****')).toBe( + 'Display name can only contain letters, spaces, periods, and hyphens' + ); + }); }); describe('boundary conditions', () => { diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 24c02d0d..b5c343ec 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -2,18 +2,16 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { View, Text, - ScrollView, StyleSheet, TouchableOpacity, - TextInput, Alert, Share, Platform, ActivityIndicator, Modal, - InteractionManager, + ScrollView, } from 'react-native'; -import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAuth } from '@/contexts/AuthContext'; import { useTheme } from '@/contexts/ThemeContext'; @@ -35,6 +33,9 @@ import { logger, LogCategory } from '@/lib/logger'; import { formatDateWithTimezone, parseDateAsLocal, getUserTimezone } from '@/lib/date'; import SettingsSheet, { SettingsSheetRef } from '@/components/SettingsSheet'; import LogSlipUpSheet, { LogSlipUpSheetRef } from '@/components/sheets/LogSlipUpSheet'; +import EnterInviteCodeSheet, { + EnterInviteCodeSheetRef, +} from '@/components/sheets/EnterInviteCodeSheet'; import { useTabBarPadding } from '@/hooks/useTabBarPadding'; // ============================================================================= @@ -163,17 +164,15 @@ function SponsorDaysDisplay({ * @returns A React element representing the profile screen */ export default function ProfileScreen() { - const { profile, refreshProfile } = useAuth(); + const { user, profile, refreshProfile } = useAuth(); const { theme } = useTheme(); const insets = useSafeAreaInsets(); // Account for native tab bar height and safe area const scrollPadding = useTabBarPadding(); const settingsSheetRef = useRef(null); const logSlipUpSheetRef = useRef(null); + const inviteCodeSheetRef = useRef(null); const scrollViewRef = useRef(null); - const [inviteCode, setInviteCode] = useState(''); - const [showInviteInput, setShowInviteInput] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); const [sponsorRelationships, setSponsorRelationships] = useState( [] ); @@ -313,21 +312,19 @@ export default function ProfileScreen() { } }; - const joinWithInviteCode = async () => { - if (!inviteCode.trim() || !profile) return; - - const trimmedCode = inviteCode.trim().toUpperCase(); - - if (trimmedCode.length !== 8) { - if (Platform.OS === 'web') { - window.alert('Invite code must be 8 characters'); - } else { - Alert.alert('Error', 'Invite code must be 8 characters'); - } - return; + /** + * Handles connecting to a sponsor using an invite code. + * Called from the EnterInviteCodeSheet component. + * + * @param inviteCode - The 8-character invite code from the sponsor + * @throws Error if connection fails (handled by the sheet) + */ + const joinWithInviteCode = async (inviteCode: string) => { + if (!profile || !user) { + throw new Error('Please sign in to connect with a sponsor'); } - setIsConnecting(true); + const trimmedCode = inviteCode.trim().toUpperCase(); try { const { data: invite, error: fetchError } = await supabase @@ -337,13 +334,7 @@ export default function ProfileScreen() { .maybeSingle(); if (fetchError || !invite) { - if (Platform.OS === 'web') { - window.alert('Invalid or expired invite code'); - } else { - Alert.alert('Error', 'Invalid or expired invite code'); - } - setIsConnecting(false); - return; + throw new Error('Invalid or expired invite code'); } // Fetch sponsor profile separately (we can't access it via join due to RLS) @@ -361,68 +352,39 @@ export default function ProfileScreen() { category: LogCategory.DATABASE, } ); - if (Platform.OS === 'web') { - window.alert('Unable to fetch sponsor information'); - } else { - Alert.alert('Error', 'Unable to fetch sponsor information'); - } - setIsConnecting(false); - return; + throw new Error('Unable to fetch sponsor information'); } if (new Date(invite.expires_at) < new Date()) { - if (Platform.OS === 'web') { - window.alert('This invite code has expired'); - } else { - Alert.alert('Error', 'This invite code has expired'); - } - setIsConnecting(false); - return; + throw new Error('This invite code has expired'); } if (invite.used_by) { - if (Platform.OS === 'web') { - window.alert('This invite code has already been used'); - } else { - Alert.alert('Error', 'This invite code has already been used'); - } - setIsConnecting(false); - return; + throw new Error('This invite code has already been used'); } - if (invite.sponsor_id === profile.id) { - if (Platform.OS === 'web') { - window.alert('You cannot connect to yourself as a sponsor'); - } else { - Alert.alert('Error', 'You cannot connect to yourself as a sponsor'); - } - setIsConnecting(false); - return; + if (invite.sponsor_id === user.id || invite.sponsor_id === profile.id) { + throw new Error('You cannot connect to yourself as a sponsor'); } const { data: existingRelationship } = await supabase .from('sponsor_sponsee_relationships') .select('id') .eq('sponsor_id', invite.sponsor_id) - .eq('sponsee_id', profile.id) + .eq('sponsee_id', user.id) .eq('status', 'active') .maybeSingle(); if (existingRelationship) { - if (Platform.OS === 'web') { - window.alert('You are already connected to this sponsor'); - } else { - Alert.alert('Error', 'You are already connected to this sponsor'); - } - setIsConnecting(false); - return; + throw new Error('You are already connected to this sponsor'); } + // Use user.id for sponsee_id to satisfy RLS policy: sponsee_id = auth.uid() const { error: relationshipError } = await supabase .from('sponsor_sponsee_relationships') .insert({ sponsor_id: invite.sponsor_id, - sponsee_id: profile.id, + sponsee_id: user.id, status: 'active', }); @@ -430,28 +392,22 @@ export default function ProfileScreen() { logger.error('Sponsor-sponsee relationship creation failed', relationshipError as Error, { category: LogCategory.DATABASE, }); - const errorMessage = - relationshipError.message || 'Failed to connect with sponsor. Please try again.'; - if (Platform.OS === 'web') { - window.alert(`Failed to connect: ${errorMessage}`); - } else { - Alert.alert('Error', `Failed to connect: ${errorMessage}`); - } - setIsConnecting(false); - return; + throw new Error( + relationshipError.message || 'Failed to connect with sponsor. Please try again.' + ); } + // Use user.id (auth.uid()) instead of profile.id to satisfy RLS policy + // The WITH CHECK clause requires: used_by = auth.uid() const { error: updateError } = await supabase .from('invite_codes') - .update({ used_by: profile.id, used_at: new Date().toISOString() }) + .update({ used_by: user.id, used_at: new Date().toISOString() }) .eq('id', invite.id); if (updateError) { logger.error('Invite code update failed', updateError as Error, { category: LogCategory.DATABASE, }); - // Note: This error usually indicates a missing RLS policy in Supabase. - // Run scripts/fix_invite_codes_rls.sql to fix it. } await supabase.from('notifications').insert([ @@ -460,10 +416,10 @@ export default function ProfileScreen() { type: 'connection_request', title: 'New Sponsee Connected', content: `${profile.display_name ?? 'Unknown'} has connected with you as their sponsor.`, - data: { sponsee_id: profile.id }, + data: { sponsee_id: user.id }, }, { - user_id: profile.id, + user_id: user.id, type: 'connection_request', title: 'Connected to Sponsor', content: `You are now connected with ${sponsorProfile.display_name ?? 'Unknown'} as your sponsor.`, @@ -473,27 +429,20 @@ export default function ProfileScreen() { await fetchRelationships(); + // Show success message if (Platform.OS === 'web') { window.alert(`Connected with ${sponsorProfile.display_name ?? 'Unknown'}`); } else { Alert.alert('Success', `Connected with ${sponsorProfile.display_name ?? 'Unknown'}`); } - - setShowInviteInput(false); - setInviteCode(''); } catch (error: unknown) { logger.error('Join with invite code failed', error as Error, { category: LogCategory.DATABASE, }); - const message = - error instanceof Error ? error.message : 'Network error. Please check your connection.'; - if (Platform.OS === 'web') { - window.alert(message); - } else { - Alert.alert('Error', message); - } - } finally { - setIsConnecting(false); + // Re-throw the error so the sheet can display it + throw error instanceof Error + ? error + : new Error('Network error. Please check your connection.'); } }; @@ -589,70 +538,90 @@ export default function ProfileScreen() { setShowSobrietyDatePicker(true); }; - const updateSobrietyDate = async (newDate: Date) => { - if (!profile) return; + const updateSobrietyDate = useCallback( + async (newDate: Date) => { + if (!profile) return; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const selectedDate = new Date(newDate); - selectedDate.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const selectedDate = new Date(newDate); + selectedDate.setHours(0, 0, 0, 0); - if (selectedDate > today) { - if (Platform.OS === 'web') { - window.alert('Sobriety date cannot be in the future'); - } else { - Alert.alert('Invalid Date', 'Sobriety date cannot be in the future'); + if (selectedDate > today) { + if (Platform.OS === 'web') { + window.alert('Sobriety date cannot be in the future'); + } else { + Alert.alert('Invalid Date', 'Sobriety date cannot be in the future'); + } + return; } - return; - } - const confirmMessage = `Update your sobriety date to ${newDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}?`; + const confirmMessage = `Update your sobriety date to ${newDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}?`; - const confirmed = - Platform.OS === 'web' - ? window.confirm(confirmMessage) - : await new Promise((resolve) => { - Alert.alert('Confirm Date Change', confirmMessage, [ - { - text: 'Cancel', - style: 'cancel', - onPress: () => resolve(false), - }, - { text: 'Update', onPress: () => resolve(true) }, - ]); - }); + const confirmed = + Platform.OS === 'web' + ? window.confirm(confirmMessage) + : await new Promise((resolve) => { + Alert.alert('Confirm Date Change', confirmMessage, [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => resolve(false), + }, + { text: 'Update', onPress: () => resolve(true) }, + ]); + }); - if (!confirmed) return; + if (!confirmed) return; - try { - const { error } = await supabase - .from('profiles') - .update({ - sobriety_date: formatDateWithTimezone(newDate, userTimezone), - }) - .eq('id', profile.id); + try { + const { error } = await supabase + .from('profiles') + .update({ + sobriety_date: formatDateWithTimezone(newDate, userTimezone), + }) + .eq('id', profile.id); - if (error) throw error; + if (error) throw error; - await refreshProfile(); + await refreshProfile(); - if (Platform.OS === 'web') { - window.alert('Sobriety date updated successfully'); - } else { - Alert.alert('Success', 'Sobriety date updated successfully'); + if (Platform.OS === 'web') { + window.alert('Sobriety date updated successfully'); + } else { + Alert.alert('Success', 'Sobriety date updated successfully'); + } + } catch (error: unknown) { + logger.error('Sobriety date update failed', error as Error, { + category: LogCategory.DATABASE, + }); + const message = error instanceof Error ? error.message : 'Failed to update sobriety date.'; + if (Platform.OS === 'web') { + window.alert(message); + } else { + Alert.alert('Error', message); + } } - } catch (error: unknown) { - logger.error('Sobriety date update failed', error as Error, { - category: LogCategory.DATABASE, - }); - const message = error instanceof Error ? error.message : 'Failed to update sobriety date.'; - if (Platform.OS === 'web') { - window.alert(message); - } else { - Alert.alert('Error', message); + }, + [profile, userTimezone, refreshProfile] + ); + + /** + * Shared handler for confirming a sobriety date selection. + * Closes the date picker and triggers the update. + * Used by both iOS (Update button) and Android (native OK) date pickers. + * + * @param date - The selected date to update, or undefined to cancel + */ + const handleSobrietyDateConfirm = useCallback( + (date: Date | undefined) => { + setShowSobrietyDatePicker(false); + if (date) { + updateSobrietyDate(date); } - } - }; + }, + [updateSobrietyDate] + ); const handleLogSlipUp = () => { logSlipUpSheetRef.current?.present(); @@ -663,21 +632,26 @@ export default function ProfileScreen() { }; /** - * Shows the invite code input and scrolls to the bottom so it's visible. - * Uses InteractionManager to wait for the state update to render before scrolling. + * Opens the invite code entry sheet. */ - const handleShowInviteInput = () => { - setShowInviteInput(true); - // Wait for interactions to complete (state update rendered) before scrolling - InteractionManager.runAfterInteractions(() => { - scrollViewRef.current?.scrollToEnd({ animated: true }); - }); + const handleShowInviteCodeSheet = () => { + inviteCodeSheetRef.current?.present(); }; const styles = createStyles(theme, insets); return ( - + {/* Navigation Header Bar */} @@ -695,13 +669,7 @@ export default function ProfileScreen() { - + @@ -806,55 +774,17 @@ export default function ProfileScreen() { ) : ( No sponsor connected yet - {!showInviteInput ? ( - - - Enter Invite Code - - ) : ( - - - - {isConnecting ? ( - - ) : ( - Connect - )} - - { - setShowInviteInput(false); - setInviteCode(''); - }} - disabled={isConnecting} - > - Cancel - - - )} + + + Enter Invite Code + )} - {sponsorRelationships.length > 0 && !showInviteInput && ( + {sponsorRelationships.length > 0 && ( - + Connect to Another Sponsor @@ -905,7 +835,10 @@ export default function ProfileScreen() { )} - {Platform.OS !== 'web' && showSobrietyDatePicker && ( + {/* iOS: Custom modal with spinner picker requires explicit "Update" button + because the spinner UI allows continuous scrolling without clear confirmation. + This matches iOS platform conventions for date selection. */} + {Platform.OS === 'ios' && showSobrietyDatePicker && ( @@ -922,16 +855,13 @@ export default function ProfileScreen() { setShowSobrietyDatePicker(false)} + onPress={() => handleSobrietyDateConfirm(undefined)} > Cancel { - updateSobrietyDate(selectedSobrietyDate); - setShowSobrietyDatePicker(false); - }} + onPress={() => handleSobrietyDateConfirm(selectedSobrietyDate)} > Update @@ -941,6 +871,19 @@ export default function ProfileScreen() { )} + {/* Android: Native dialog has built-in OK/Cancel buttons. Pressing OK confirms + the selection immediately, which matches Android platform conventions. + The dialog auto-closes on any action, so we sync state accordingly. */} + {Platform.OS === 'android' && showSobrietyDatePicker && ( + handleSobrietyDateConfirm(date)} + maximumDate={maximumDate} + /> + )} + {/* Settings Sheet */} @@ -956,8 +899,17 @@ export default function ProfileScreen() { onSlipUpLogged={handleSlipUpLogged} /> )} - - + + {/* Enter Invite Code Sheet */} + {profile && user && ( + + )} + + ); } @@ -1132,48 +1084,6 @@ const createStyles = ( color: theme.text, marginLeft: 12, }, - inviteInputContainer: { - backgroundColor: theme.card, - padding: 16, - borderRadius: 12, - shadowColor: theme.black, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 3, - }, - inviteInput: { - backgroundColor: theme.borderLight, - borderRadius: 8, - padding: 12, - fontSize: 16, - fontFamily: theme.fontRegular, - marginBottom: 12, - color: theme.text, - }, - inviteSubmitButton: { - backgroundColor: theme.primary, - borderRadius: 8, - padding: 12, - alignItems: 'center', - marginBottom: 8, - }, - inviteSubmitText: { - fontSize: 16, - fontFamily: theme.fontRegular, - fontWeight: '600', - color: theme.white, - }, - inviteCancelButton: { - padding: 12, - alignItems: 'center', - }, - inviteCancelText: { - fontSize: 16, - fontFamily: theme.fontRegular, - fontWeight: '600', - color: theme.textSecondary, - }, loadingContainer: { padding: 20, alignItems: 'center', diff --git a/components/GlassBottomSheet.tsx b/components/GlassBottomSheet.tsx index cbce4e96..59803ea3 100644 --- a/components/GlassBottomSheet.tsx +++ b/components/GlassBottomSheet.tsx @@ -2,7 +2,7 @@ // Imports // ============================================================================= import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; -import { Platform, ViewStyle } from 'react-native'; +import { BackHandler, Platform, ViewStyle } from 'react-native'; import { BottomSheetModal, BottomSheetBackdrop, @@ -103,6 +103,17 @@ export interface GlassBottomSheetProps { * @optional */ keyboardBehavior?: 'interactive' | 'extend' | 'fillParent'; + + /** + * Keyboard blur behavior configuration for iOS. + * + * - `'none'` - No action when keyboard blurs + * - `'restore'` - Restore sheet position when keyboard dismisses (recommended for iOS) + * + * @default 'none' + * @optional + */ + keyboardBlurBehavior?: 'none' | 'restore'; } // ============================================================================= @@ -139,7 +150,16 @@ export interface GlassBottomSheetProps { * ``` */ const GlassBottomSheet = forwardRef( - ({ snapPoints, children, onDismiss, keyboardBehavior = 'interactive' }, ref) => { + ( + { + snapPoints, + children, + onDismiss, + keyboardBehavior = 'interactive', + keyboardBlurBehavior = 'none', + }, + ref + ) => { // --------------------------------------------------------------------------- // Hooks // --------------------------------------------------------------------------- @@ -164,6 +184,22 @@ const GlassBottomSheet = forwardRef( return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen]); + // --------------------------------------------------------------------------- + // Android Hardware Back Button Handler + // --------------------------------------------------------------------------- + useEffect(() => { + // Only add BackHandler listener on Android platform when sheet is open + if (Platform.OS !== 'android' || !isOpen) return; + + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + // Dismiss the sheet and return true to indicate event was handled + bottomSheetRef.current?.dismiss(); + return true; + }); + + return () => subscription.remove(); + }, [isOpen]); + // --------------------------------------------------------------------------- // Imperative API // --------------------------------------------------------------------------- @@ -258,6 +294,7 @@ const GlassBottomSheet = forwardRef( backgroundStyle={backgroundStyle} handleIndicatorStyle={handleIndicatorStyle} keyboardBehavior={keyboardBehavior} + keyboardBlurBehavior={keyboardBlurBehavior} onDismiss={handleDismiss} enablePanDownToClose={true} enableDismissOnClose={true} diff --git a/components/sheets/EnterInviteCodeSheet.tsx b/components/sheets/EnterInviteCodeSheet.tsx new file mode 100644 index 00000000..bc5718b6 --- /dev/null +++ b/components/sheets/EnterInviteCodeSheet.tsx @@ -0,0 +1,402 @@ +// ============================================================================= +// Imports +// ============================================================================= +import React, { useState, useCallback, forwardRef, useImperativeHandle, useRef } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { ThemeColors } from '@/contexts/ThemeContext'; +import { X, QrCode } from 'lucide-react-native'; +import GlassBottomSheet, { GlassBottomSheetRef } from '@/components/GlassBottomSheet'; + +// ============================================================================= +// Types & Interfaces +// ============================================================================= + +/** + * Imperative methods exposed by EnterInviteCodeSheet via ref. + * + * @example + * ```tsx + * const sheetRef = useRef(null); + * + * // Present the sheet + * sheetRef.current?.present(); + * + * // Dismiss the sheet + * sheetRef.current?.dismiss(); + * ``` + */ +export interface EnterInviteCodeSheetRef { + /** + * Presents the invite code entry sheet. + */ + present: () => void; + + /** + * Dismisses the invite code entry sheet. + */ + dismiss: () => void; +} + +/** + * Props for the EnterInviteCodeSheet component. + */ +export interface EnterInviteCodeSheetProps { + /** + * Theme colors used to style the sheet. + */ + theme: ThemeColors; + + /** + * Callback invoked when the user submits a valid invite code. + * Receives the 8-character invite code. + * + * @param inviteCode - The invite code entered by the user + */ + onSubmit: (inviteCode: string) => Promise; + + /** + * Callback invoked when the sheet is dismissed without submitting. + * + * @optional + */ + onClose?: () => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +/** + * Bottom sheet UI for entering a sponsor invite code. + * + * Features: + * - Text input with 8-character limit and auto-capitalization + * - Interactive keyboard behavior for smooth scrolling + * - Loading state during submission + * - Liquid Glass styling via GlassBottomSheet + * + * @param theme - Theme colors used to style the sheet + * @param onSubmit - Callback invoked when the user submits the invite code + * @param onClose - Optional callback invoked when the sheet is dismissed + * + * @example + * ```tsx + * const sheetRef = useRef(null); + * + * { + * await connectToSponsor(code); + * }} + * onClose={() => {}} + * /> + * ``` + */ +const EnterInviteCodeSheet = forwardRef( + ({ theme, onSubmit, onClose }, ref) => { + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + const [inviteCode, setInviteCode] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const sheetRef = useRef(null); + const didSubmitSuccessfullyRef = useRef(false); + + // --------------------------------------------------------------------------- + // Imperative API + // --------------------------------------------------------------------------- + useImperativeHandle(ref, () => ({ + present: () => { + resetForm(); + sheetRef.current?.present(); + }, + dismiss: () => sheetRef.current?.dismiss(), + })); + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- + const resetForm = useCallback(() => { + setInviteCode(''); + setError(null); + setIsSubmitting(false); + didSubmitSuccessfullyRef.current = false; + }, []); + + /** + * Handler for GlassBottomSheet's onDismiss callback. + * Performs cleanup and notifies parent only if dismissed without submitting. + */ + const handleDismiss = useCallback(() => { + const wasSuccessfulSubmission = didSubmitSuccessfullyRef.current; + resetForm(); + // Only call onClose if dismissed without successful submission + if (!wasSuccessfulSubmission) { + onClose?.(); + } + }, [resetForm, onClose]); + + /** + * Handler for close button press. + */ + const handleClose = useCallback(() => { + sheetRef.current?.dismiss(); + }, []); + + const handleChangeText = useCallback((text: string) => { + // Only allow alphanumeric characters and auto-capitalize + const sanitized = text.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); + setInviteCode(sanitized); + setError(null); + }, []); + + const handleSubmit = useCallback(async () => { + // Guard: Prevent multiple simultaneous submits + if (isSubmitting) { + return; + } + + // Validate input + if (inviteCode.length !== 8) { + setError('Please enter an 8-character invite code'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await onSubmit(inviteCode); + // Mark as successful submission before dismissing + // This prevents onClose from being called (per documented contract) + didSubmitSuccessfullyRef.current = true; + sheetRef.current?.dismiss(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to connect. Please try again.'); + } finally { + setIsSubmitting(false); + } + }, [inviteCode, isSubmitting, onSubmit]); + + const styles = createStyles(theme); + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return ( + + + + + + Enter Invite Code + + + + + + + + Enter the 8-character invite code from your sponsor to connect with them. + + + {error ? ( + + {error} + + ) : null} + + + Invite Code + + {inviteCode.length}/8 characters + + + + + {isSubmitting ? ( + + ) : ( + Connect + )} + + + + Cancel + + + + + ); + } +); + +// Set display name for debugging +EnterInviteCodeSheet.displayName = 'EnterInviteCodeSheet'; + +// ============================================================================= +// Styles +// ============================================================================= + +/** + * Creates StyleSheet for the EnterInviteCodeSheet based on current theme. + * + * @param theme - Theme colors from ThemeContext + * @returns StyleSheet object with all component styles + */ +const createStyles = (theme: ThemeColors) => + StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: 16, + }, + headerIcon: { + marginRight: 12, + }, + title: { + flex: 1, + fontSize: 20, + fontFamily: theme.fontRegular, + fontWeight: '700', + color: theme.text, + }, + closeButton: { + padding: 4, + }, + scrollView: { + flex: 1, + }, + scrollViewContent: { + paddingHorizontal: 20, + paddingBottom: 40, + }, + subtitle: { + fontSize: 15, + fontFamily: theme.fontRegular, + color: theme.textSecondary, + lineHeight: 22, + marginBottom: 24, + }, + errorContainer: { + backgroundColor: theme.dangerLight, + borderRadius: 12, + padding: 12, + marginBottom: 16, + }, + errorText: { + fontSize: 14, + fontFamily: theme.fontRegular, + color: theme.danger, + textAlign: 'center', + }, + formGroup: { + marginBottom: 24, + }, + label: { + fontSize: 14, + fontFamily: theme.fontRegular, + fontWeight: '600', + color: theme.textSecondary, + marginBottom: 8, + }, + input: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.border, + borderRadius: 12, + padding: 16, + fontSize: 18, + fontFamily: theme.fontRegular, + color: theme.text, + textAlign: 'center', + letterSpacing: 4, + }, + characterCount: { + fontSize: 12, + fontFamily: theme.fontRegular, + color: theme.textTertiary, + marginTop: 8, + textAlign: 'right', + }, + footer: { + gap: 12, + }, + submitButton: { + backgroundColor: theme.primary, + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + submitButtonDisabled: { + opacity: 0.5, + }, + submitButtonText: { + fontSize: 16, + fontFamily: theme.fontRegular, + fontWeight: '600', + color: theme.white, + }, + cancelButton: { + padding: 12, + alignItems: 'center', + }, + cancelButtonText: { + fontSize: 16, + fontFamily: theme.fontRegular, + color: theme.textSecondary, + }, + }); + +// ============================================================================= +// Exports +// ============================================================================= +export default EnterInviteCodeSheet; diff --git a/components/sheets/LogSlipUpSheet.tsx b/components/sheets/LogSlipUpSheet.tsx index 85f09700..fb5df5ad 100644 --- a/components/sheets/LogSlipUpSheet.tsx +++ b/components/sheets/LogSlipUpSheet.tsx @@ -14,12 +14,11 @@ import { Text, StyleSheet, TouchableOpacity, - TextInput, ActivityIndicator, Platform, Alert, } from 'react-native'; -import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import { BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { supabase } from '@/lib/supabase'; import { ThemeColors } from '@/contexts/ThemeContext'; import { X, Calendar, AlertCircle } from 'lucide-react-native'; @@ -296,9 +295,10 @@ const LogSlipUpSheet = forwardRef( return ( @@ -368,8 +368,13 @@ const LogSlipUpSheet = forwardRef( mode="date" display="default" onChange={(event, date) => { + // Native dialog auto-closes on both OK and Cancel press. + // We always hide the picker to sync React state with native UI. + // When date is defined (OK pressed), update it before hiding. + if (date) { + setSlipUpDate(date); + } setShowDatePicker(false); - if (date) setSlipUpDate(date); }} maximumDate={new Date()} /> @@ -380,7 +385,7 @@ const LogSlipUpSheet = forwardRef( Notes (Optional) - component mounted at root +``` + +### Toast API (`lib/toast.ts`) + +```typescript +import Toast, { BaseToast, ToastConfig } from 'react-native-toast-message'; + +/** + * Show a success toast notification. + * Auto-dismisses after 3 seconds. + */ +export const showToast = { + success: (message: string) => { + Toast.show({ + type: 'success', + text1: message, + visibilityTime: 3000, + }); + }, + + /** + * Show an error toast notification. + * Auto-dismisses after 5 seconds (longer for readability). + */ + error: (message: string) => { + Toast.show({ + type: 'error', + text1: message, + visibilityTime: 5000, + }); + }, + + /** + * Show an info toast notification. + * Auto-dismisses after 3 seconds. + */ + info: (message: string) => { + Toast.show({ + type: 'info', + text1: message, + visibilityTime: 3000, + }); + }, +}; +``` + +### Custom Toast Config + +The `toastConfig` provides themed renderers for each toast type: + +- **Background:** Theme surface color (adapts to dark/light) +- **Border accent:** Green (success), red (error), blue (info) +- **Text:** Theme text color +- **Font:** JetBrains Mono for consistency +- **Shadow/elevation:** Subtle depth +- **Rounded corners:** Match app design language + +### Root Layout Integration (`app/_layout.tsx`) + +```typescript +import Toast from 'react-native-toast-message'; +import { toastConfig } from '@/lib/toast'; + +export default function RootLayout() { + return ( + <> + {/* existing layout content */} + + + ); +} +``` + +**`topOffset={60}`** accounts for status bar and headers. + +## Migration Pattern + +### Before (current): + +```typescript +if (Platform.OS === 'web') { + window.alert('Task deleted successfully'); +} else { + Alert.alert('Success', 'Task deleted successfully'); +} +``` + +### After (unified): + +```typescript +import { showToast } from '@/lib/toast'; + +showToast.success('Task deleted successfully'); +``` + +### Confirmations stay unchanged: + +```typescript +Alert.alert('Confirm Delete', 'Are you sure?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: handleDelete }, +]); +``` + +## Files to Migrate + +| File | Success→Toast | Error→Toast | Confirm→Keep | +| -------------------------------------------- | ------------- | ----------- | ------------ | +| `app/login.tsx` | 0 | 3 | 0 | +| `app/signup.tsx` | 0 | 5 | 0 | +| `app/onboarding.tsx` | 0 | 3 | 0 | +| `app/settings.tsx` | 2 | 3 | 2 | +| `app/(tabs)/profile.tsx` | 4 | 4 | 2 | +| `app/(tabs)/tasks.tsx` | 2 | 2 | 1 | +| `app/(tabs)/manage-tasks.tsx` | 1 | 1 | 1 | +| `app/(tabs)/index.tsx` | 1 | 1 | 1 | +| `components/SettingsSheet.tsx` | 1 | 3 | 2 | +| `components/sheets/LogSlipUpSheet.tsx` | 0 | 0 | 1 | +| `components/sheets/EditDisplayNameSheet.tsx` | 0 | 0 | 1 | + +**Total:** ~35 conversions to toast, ~11 confirmations unchanged. + +## Testing Strategy + +### New Tests (`__tests__/lib/toast.test.ts`) + +- Verify `showToast.success()` calls `Toast.show()` with correct params +- Verify `showToast.error()` uses 5000ms visibility +- Verify `showToast.info()` uses 3000ms visibility + +### Jest Mock (`jest.setup.js`) + +```typescript +jest.mock('react-native-toast-message', () => ({ + show: jest.fn(), + hide: jest.fn(), + __esModule: true, + default: jest.fn(), +})); +``` + +### Test Migration Pattern + +```typescript +// Before +expect(Alert.alert).toHaveBeenCalledWith('Success', 'Task deleted'); + +// After +import Toast from 'react-native-toast-message'; +expect(Toast.show).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success', text1: 'Task deleted' }) +); +``` + +### Visual Verification Checklist + +- [ ] Toast appears at top of screen +- [ ] Success toast auto-dismisses after ~3s +- [ ] Error toast auto-dismisses after ~5s +- [ ] Styling matches theme in light mode +- [ ] Styling matches theme in dark mode +- [ ] Toast doesn't overlap tab bar +- [ ] Toast doesn't overlap headers +- [ ] Multiple toasts queue properly + +## Implementation Order + +1. Install `react-native-toast-message` +2. Create `lib/toast.ts` with config and API +3. Add `` to root layout +4. Update jest mock in `jest.setup.js` +5. Create `__tests__/lib/toast.test.ts` +6. Migrate files (login → signup → onboarding → settings → tabs → sheets) +7. Update affected test files +8. Visual verification (light + dark mode) +9. Run full test suite and ensure coverage ≥80% diff --git a/jest.setup.js b/jest.setup.js index 445e2c56..d27836c9 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -128,6 +128,30 @@ jest.mock('react-native', () => { clearInteractionHandle: jest.fn(), setDeadline: jest.fn(), }, + // BackHandler mock for Android back button testing + // Tests can access the listeners via global.__backHandlerListeners + BackHandler: { + addEventListener: jest.fn((event, handler) => { + if (event === 'hardwareBackPress') { + if (!global.__backHandlerListeners) { + global.__backHandlerListeners = []; + } + global.__backHandlerListeners.push(handler); + } + return { + remove: jest.fn(() => { + if (global.__backHandlerListeners) { + const index = global.__backHandlerListeners.indexOf(handler); + if (index > -1) { + global.__backHandlerListeners.splice(index, 1); + } + } + }), + }; + }), + removeEventListener: jest.fn(), + exitApp: jest.fn(), + }, }; }); @@ -137,6 +161,56 @@ global.processColor = (color) => color; // Define __DEV__ for tests global.__DEV__ = false; +// Mock document for web platform tests (keyboard event handling, DOM access, etc.) +// This provides a more complete mock to avoid errors when code accesses document properties. +// Note: Jest is configured to use the 'node' environment (see CLAUDE.md), so jsdom is not available. +// This manual mock provides basic document functionality for compatibility in tests. +global.document = { + // Event handling + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + + // DOM queries (return null/empty to simulate no matches) + getElementById: jest.fn(() => null), + getElementsByClassName: jest.fn(() => []), + getElementsByTagName: jest.fn(() => []), + querySelector: jest.fn(() => null), + querySelectorAll: jest.fn(() => []), + + // Document properties + body: { + appendChild: jest.fn(), + removeChild: jest.fn(), + style: {}, + }, + head: { + appendChild: jest.fn(), + removeChild: jest.fn(), + }, + documentElement: { + style: {}, + }, + + // Element creation + createElement: jest.fn((tagName) => ({ + tagName, + style: {}, + setAttribute: jest.fn(), + getAttribute: jest.fn(), + appendChild: jest.fn(), + removeChild: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + createTextNode: jest.fn((text) => ({ textContent: text })), + + // Document state + readyState: 'complete', + visibilityState: 'visible', + hidden: false, +}; + // Mock expo-router jest.mock('expo-router', () => ({ useRouter: () => ({ diff --git a/package.json b/package.json index 7d53cf23..c82e269d 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "expo-device": "^8.0.10", "expo-font": "~14.0.10", "expo-glass-effect": "~0.1.8", - "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-insights": "~0.10.8", "expo-linking": "~8.0.10", @@ -74,7 +73,7 @@ "react-native-bottom-tabs": "^1.1.0", "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "~2.28.0", - "react-native-keyboard-controller": "^1.20.1", + "react-native-keyboard-controller": "^1.18.5", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88d516cc..7639b4d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 23.7.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)))(expo@54.0.27)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/bottom-tabs': specifier: ^7.4.0 - version: 7.8.11(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 7.8.12(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/elements': specifier: ^2.9.1 version: 2.9.1(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -92,9 +92,6 @@ importers: expo-glass-effect: specifier: ~0.1.8 version: 0.1.8(expo@54.0.27)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-haptics: - specifier: ~15.0.8 - version: 15.0.8(expo@54.0.27) expo-image: specifier: ~3.0.11 version: 3.0.11(expo@54.0.27)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -153,8 +150,8 @@ importers: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-keyboard-controller: - specifier: ^1.20.1 - version: 1.20.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + specifier: ^1.18.5 + version: 1.18.5(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: specifier: ~4.1.1 version: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -1730,10 +1727,10 @@ packages: '@types/react': optional: true - '@react-navigation/bottom-tabs@7.8.11': - resolution: {integrity: sha512-lUc8cYpez3uVi7IlqKgIBpLEEkYiL4LkZnpstDsb0OSRxW8VjVYVrH29AqKU7n1svk++vffJvv3EeW+IgxkJtg==} + '@react-navigation/bottom-tabs@7.8.12': + resolution: {integrity: sha512-efVt5ydHK+b4ZtjmN81iduaO5dPCmzhLBFwjCR8pV4x4VzUfJmtUJizLqTXpT3WatHdeon2gDPwhhoelsvu/JA==} peerDependencies: - '@react-navigation/native': ^7.1.24 + '@react-navigation/native': ^7.1.25 react: '>= 18.2.0' react-native: '*' react-native-safe-area-context: '>= 4.0.0' @@ -1756,6 +1753,18 @@ packages: '@react-native-masked-view/masked-view': optional: true + '@react-navigation/elements@2.9.2': + resolution: {integrity: sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.1.25 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + '@react-navigation/native-stack@7.8.5': resolution: {integrity: sha512-IfAe80IQWlJec2Pri91FRi4EEBIc5+j191XZIJZKpexumCLfT+AKnfc0g3Sr4m0P6jrVVGtKb+XW+2jYj5mWRg==} peerDependencies: @@ -2038,6 +2047,9 @@ packages: '@types/react@19.1.17': resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -3244,11 +3256,6 @@ packages: react: '*' react-native: '*' - expo-haptics@15.0.8: - resolution: {integrity: sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==} - peerDependencies: - expo: '*' - expo-image@3.0.11: resolution: {integrity: sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==} peerDependencies: @@ -4901,8 +4908,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.20.1: - resolution: {integrity: sha512-nNSf+ypfnRfR50KU95ejPHb9xv0/SX1rs9GterrMmx+eGfTKCAdPw1//ZTvuza7vts7Tvfhj9IWGPT06SYM72Q==} + react-native-keyboard-controller@1.18.5: + resolution: {integrity: sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ==} peerDependencies: react: '*' react-native: '*' @@ -7876,9 +7883,9 @@ snapshots: optionalDependencies: '@types/react': 19.1.17 - '@react-navigation/bottom-tabs@7.8.11(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@react-navigation/bottom-tabs@7.8.12(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: - '@react-navigation/elements': 2.9.1(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/elements': 2.9.2(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/native': 7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) color: 4.2.3 react: 19.1.0 @@ -7911,6 +7918,16 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) + '@react-navigation/elements@2.9.2(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/native': 7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + use-latest-callback: 0.2.6(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + '@react-navigation/native-stack@7.8.5(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.9.1(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -8219,13 +8236,18 @@ snapshots: '@types/react-native@0.70.19': dependencies: - '@types/react': 19.1.17 + '@types/react': 19.2.7 optional: true '@types/react@19.1.17': dependencies: csstype: 3.2.3 + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + optional: true + '@types/stack-utils@2.0.3': {} '@types/tough-cookie@4.0.5': {} @@ -9606,10 +9628,6 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - expo-haptics@15.0.8(expo@54.0.27): - dependencies: - expo: 54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.17)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-image@3.0.11(expo@54.0.27)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.17)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -9668,7 +9686,7 @@ snapshots: '@expo/schema-utils': 0.1.8 '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) '@radix-ui/react-tabs': 1.1.13(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@react-navigation/bottom-tabs': 7.8.11(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/bottom-tabs': 7.8.12(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/native': 7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@react-navigation/native-stack': 7.8.5(@react-navigation/native@7.1.25(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) client-only: 0.0.1 @@ -11736,7 +11754,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - react-native-keyboard-controller@1.20.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-keyboard-controller@1.18.5(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)