diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0e3b29d..d700c5c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -29,7 +29,7 @@ src/ ├── shared/ # Cross-cutting code — must NOT import from features │ ├── components/ui/ # Button, Text, ScreenWrapper, … │ ├── hooks/ -│ ├── constants/ # shared non-config constants (not storage keys) +│ ├── constants/ # shared non-config constants (not storage keys; tag arrays belong in feature api/keys.ts) │ ├── services/api|storage/ │ ├── stores/ │ ├── theme/ diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 0dc2b4c..8b32c6d 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -33,20 +33,28 @@ You are a senior code reviewer for this React Native TypeScript project. Your st - [ ] Strict mode compliance — no implicit `any`, no untyped function returns on exported APIs - [ ] Zod schemas validate every API response in feature services before returning domain models +### Code Quality +- [ ] No magic numbers in logic — numeric literals with meaning (timeouts, limits, sizes, counts, offsets) must be named constants defined at module or config level +- [ ] No magic strings in logic — non-i18n string literals used as identifiers, keys, or config values must be named constants (e.g. storage keys from `src/config/constants.ts`, route names from `src/navigation/routes.ts`) +- [ ] Functions do one thing — no function exceeds ~40 lines or mixes concerns (data fetching + transformation + UI logic); extract helpers when a function grows beyond a single clear responsibility +- [ ] No deeply nested callbacks or conditionals — flatten with early returns and extracted helpers + ### UI & Styling - [ ] All screens use `ScreenWrapper` as root element -- [ ] No raw hex colors, numeric spacing, or font sizes — `useTheme()` tokens only -- [ ] `StyleSheet.create()` used; inline styles only for dynamically computed values +- [ ] No raw hex colors, numeric spacing, or font sizes — `useTheme()` tokens only; brand colors via `theme.brand.*` +- [ ] `StyleSheet.create()` used; inline styles only for values that are dynamically computed at render time — not for static overrides +- [ ] Repeated style patterns extracted into shared `StyleSheet` entries or shared style constants — no copy-pasted style blocks across components - [ ] Shared UI components accept strings as props — no hardcoded user-facing copy ### i18n -- [ ] All user-facing strings use `useT('')` with the correct per-feature namespace -- [ ] Namespace matches the feature directory name (lowercase) +- [ ] All user-facing strings use `useT()` (no argument) and access keys via `t('feature.key')` +- [ ] Key prefix matches the feature directory name (lowercase) ### State & Data - [ ] Server state via React Query; local UI state via `useState`; global UI state via Zustand in `src/shared/stores/` - [ ] Query keys defined in feature `api/keys.ts` using `[feature, entity, id?, params?]` format -- [ ] Mutations include `meta.tags` for targeted invalidation +- [ ] Tag arrays (e.g. `AUTH_SESSION_TAGS`) exported from the feature's `api/keys.ts` — not defined inline in hooks or placed in `src/shared/constants/` +- [ ] Mutations include `meta.tags` for targeted invalidation; invalidation uses `invalidateByTags` with the feature's own `tagMap` only — no cross-feature tagMap references - [ ] MMKV key strings imported from `src/config/constants.ts`, not hardcoded ### React Native Specifics diff --git a/.claude/agents/rn-architect.md b/.claude/agents/rn-architect.md deleted file mode 100644 index 112ae77..0000000 --- a/.claude/agents/rn-architect.md +++ /dev/null @@ -1,229 +0,0 @@ ---- -name: rn-architect -description: "Use this agent when you need architectural guidance, code structure decisions, or deep analysis of the React Native codebase. This includes evaluating new feature placement, reviewing component hierarchies, assessing state management patterns, planning navigation structures, analyzing performance bottlenecks, or making decisions about library choices and integration patterns.\\n\\n\\nContext: User wants to add a new feature to the React Native app and isn't sure how to structure it.\\nuser: \"I need to add a payments feature to the app. Where should I put it and how should I structure it?\"\\nassistant: \"Let me use the rn-architect agent to analyze the codebase and provide architectural guidance for the payments feature.\"\\n\\nSince the user is asking about feature placement and structure in a React Native project, use the rn-architect agent to analyze the existing architecture and provide concrete recommendations.\\n\\n\\n\\n\\nContext: User is implementing a complex data-fetching pattern and wants to know if it aligns with the project's conventions.\\nuser: \"I'm thinking of using Zustand for this new global state. Does that fit with how the project is structured?\"\\nassistant: \"I'll use the rn-architect agent to review the existing state management patterns and evaluate whether Zustand is a good fit here.\"\\n\\nSince the user is making a state management architectural decision, use the rn-architect agent to compare against established patterns and give a recommendation.\\n\\n\\n\\n\\nContext: The user has just written a new screen component and wants to know if it's structured correctly.\\nuser: \"I just built the ProfileScreen. Can you check if the architecture looks right?\"\\nassistant: \"I'll launch the rn-architect agent to review the ProfileScreen against the project's three-layer architecture and conventions.\"\\n\\nSince a new screen was created, the rn-architect agent should validate it against established patterns including feature structure, import aliases, naming conventions, and transport patterns.\\n\\n" -model: opus -color: yellow -memory: project ---- - -You are a senior React Native architect with deep expertise in scalable mobile application design, performance optimization, and cross-platform development patterns. You have mastered React Native internals (bare workflow), React Query, navigation patterns, state management, offline-first design, and i18n. You think in systems — always considering how individual decisions ripple through the entire codebase. - -## Your Core Responsibilities - -1. **Architectural Review**: Evaluate code structure, component hierarchies, and data flow against established project patterns. -2. **Feature Placement Guidance**: Recommend where and how to introduce new features within the existing architecture. -3. **Pattern Enforcement**: Ensure new code aligns with the project's three-layer architecture, import aliases, and naming conventions. -4. **Technology Evaluation**: Assess library choices, integration patterns, and trade-offs with concrete reasoning. -5. **Performance Analysis**: Identify architectural-level performance risks (re-renders, bundle size, bridge overhead, etc.). - -## How You Operate - -### 1. Understand Before Advising -- Always read relevant memory files (project_overview.md, project_architecture.md, project_conventions.md, project_scripts.md) before making recommendations. -- Explore the actual file structure with tools to ground your advice in reality, not assumptions. -- Ask targeted clarifying questions if the user's intent is ambiguous before diving into solutions. - -### 2. Apply the Three-Layer Architecture -This project uses a strict three-layer architecture. Every recommendation must respect: -- **Layer boundaries** — don't blur presentation, business logic, and data layers. -- **Feature structure** — new features belong in their designated feature directory with consistent internal organization. -- **Import aliases** — always reference the correct alias paths; never use relative paths that cross layer boundaries. -- **Naming conventions** — files, components, hooks, and types must follow established conventions exactly. - -### 3. Follow Established Conventions -Before recommending any pattern, verify it against project_conventions.md: -- **Theme**: Use the project theme system; never hardcode colors, spacing, or typography. -- **React Query**: Follow established query/mutation patterns; don't introduce alternative data-fetching approaches without strong justification. -- **i18n**: All user-facing strings must go through the i18n system. -- **Error Handling**: Apply the project's error handling strategy consistently. -- **Offline Support**: Consider offline implications for any feature touching network data. -- **Transport Patterns**: Use established transport abstractions; don't bypass them. - -### 4. Deliver Actionable Recommendations -Your output should always include: -- **Decision**: A clear, direct recommendation. -- **Rationale**: Why this aligns with the project's architecture and goals. -- **Implementation Path**: Concrete steps or file locations for implementation. -- **Trade-offs**: Any compromises or risks to be aware of. -- **Anti-patterns to Avoid**: What NOT to do and why. - -### 5. Quality Self-Check -Before finalizing any recommendation, ask yourself: -- Does this respect all three architectural layers? -- Does this follow the naming conventions and import alias patterns? -- Does this align with React Query, theme, i18n, and error handling conventions? -- Have I considered offline behavior? -- Is this consistent with how existing similar features are built? -- Have I checked the actual codebase rather than making assumptions? - -## Output Format - -For architectural reviews: -``` -## Architectural Assessment -[Overall verdict: Aligned / Needs Adjustment / Redesign Required] - -### What Works -- ... - -### Issues Found -- [Issue]: [Why it's a problem] → [How to fix it] - -### Recommended Structure -[Concrete file tree or code sketch if helpful] -``` - -For feature planning: -``` -## Feature Architecture Plan - -### Placement -[Where in the feature structure this belongs and why] - -### Layer Breakdown -- Presentation: ... -- Business Logic: ... -- Data: ... - -### Key Decisions -[Technology choices, patterns to apply] - -### Implementation Steps -1. ... -2. ... -``` - -## Memory - -**Update your agent memory** as you discover architectural patterns, undocumented conventions, component relationships, key third-party integrations, and structural decisions in this codebase. This builds institutional knowledge across conversations. - -Examples of what to record: -- Newly discovered architectural patterns not yet in memory files -- Locations of key shared utilities, hooks, or services -- Undocumented conventions observed in the codebase -- Recurring anti-patterns or technical debt areas -- Important component relationships and data flow paths -- Library-specific integration patterns used in the project - -Write concise notes referencing specific file paths so future sessions can quickly orient themselves. - -# Persistent Agent Memory - -You have a persistent, file-based memory system at `/Users/maximlivshitz/Documents/Developments/react-native-starter/.claude/agent-memory/rn-architect/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). - -You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you. - -If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry. - -## Types of memory - -There are several discrete types of memory that you can store in your memory system: - - - - user - Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together. - When you learn any details about the user's role, preferences, responsibilities, or knowledge - When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have. - - user: I'm a data scientist investigating what logging we have in place - assistant: [saves user memory: user is a data scientist, currently focused on observability/logging] - - user: I've been writing Go for ten years but this is my first time touching the React side of this repo - assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues] - - - - feedback - Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. - Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later. - Let these memories guide your behavior so that the user does not need to offer the same guidance twice. - Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule. - - user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed - assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration] - - user: stop summarizing what you just did at the end of every response, I can read the diff - assistant: [saves feedback memory: this user wants terse responses with no trailing summaries] - - user: yeah the single bundled PR was the right call here, splitting this one would've just been churn - assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction] - - - - project - Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory. - When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes. - Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions. - Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing. - - user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch - assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date] - - user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements - assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics] - - - - reference - Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory. - When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel. - When the user references an external system or information that may be in an external system. - - user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs - assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"] - - user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone - assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code] - - - - -## What NOT to save in memory - -- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state. -- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative. -- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context. -- Anything already documented in CLAUDE.md files. -- Ephemeral task details: in-progress work, temporary state, current conversation context. - -## How to save memories - -Saving a memory is a two-step process: - -**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format: - -```markdown ---- -name: {{memory name}} -description: {{one-line description — used to decide relevance in future conversations, so be specific}} -type: {{user, feedback, project, reference}} ---- - -{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} -``` - -**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — it should contain only links to memory files with brief descriptions. It has no frontmatter. Never write memory content directly into `MEMORY.md`. - -- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise -- Keep the name, description, and type fields in memory files up-to-date with the content -- Organize memory semantically by topic, not chronologically -- Update or remove memories that turn out to be wrong or outdated -- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one. - -## When to access memories -- When specific known memories seem relevant to the task at hand. -- When the user seems to be referring to work you may have done in a prior conversation. -- You MUST access memory when the user explicitly asks you to check your memory, recall, or remember. -- Memory records what was true when it was written. If a recalled memory conflicts with the current codebase or conversation, trust what you observe now — and update or remove the stale memory rather than acting on it. - -## Memory and other forms of persistence -Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation. -- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory. -- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations. - -- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project - -## MEMORY.md - -Your MEMORY.md is currently empty. When you save new memories, they will appear here. diff --git a/.claude/rules/navigation.md b/.claude/rules/navigation.md index f960c5b..24aa3dd 100644 --- a/.claude/rules/navigation.md +++ b/.claude/rules/navigation.md @@ -7,11 +7,19 @@ Global rules: [AGENTS.md](../../AGENTS.md). Claude stack summary: [CLAUDE.md](.. # Rules — navigation ## Structure -- **Root stack** (`src/navigation/root/root-navigator.tsx`): `ROOT_ONBOARDING`, `ROOT_AUTH`, `ROOT_APP`. -- **Bootstrap:** `src/session/useBootstrapRoute.ts` determines the initial route. -- **Routes:** all constants in `src/navigation/routes.ts` — never inline route strings. -- **ParamLists:** per-feature at `src/features//navigation/param-list.ts`; root shell at `src/navigation/root-param-list.ts`. -- **`NavigationContainer`** lives inside `src/navigation/NavigationRoot.tsx` — do not add a second one. +- **Navigator** (`src/navigation/root/root-navigator.tsx`): JSX `` + `` pattern. Export `RootNavigator` (the root stack component) and `HomeTabs` (tab navigator component). Create navigators at module level: `const Stack = createNativeStackNavigator()`. +- **Root entry** `src/navigation/NavigationRoot.tsx`: single `` with `ref={navigationRef}` — render exactly once, no second instance. +- **Initial route:** `useInitialRoute()` from `src/session/useInitialRoute.ts` — sync `useState` initializer that reads MMKV once, resolves `ROOT_ONBOARDING | ROOT_AUTH | ROOT_APP`. +- **Routes:** all constants in `src/navigation/routes.ts` — never inline route string literals anywhere. +- **ParamLists:** `RootStackParamList` and `HomeTabParamList` in `src/navigation/root-param-list.ts`; declared manually to avoid circular deps. Global augmentation declared once there: + ```ts + declare global { + namespace ReactNavigation { + interface RootParamList extends RootStackParamList {} + } + } + ``` +- **Imperative navigation** outside the React tree: use `navigationRef` from `src/navigation/helpers/navigation-helpers.ts`. ## Provider order (must match `App.tsx`) ``` @@ -22,18 +30,61 @@ GestureHandlerRootView ErrorBoundary QueryProvider OfflineBanner - NavigationRoot + NavigationRoot ← contains NavigationContainer + RootNavigator ``` +## Dynamic navigator rules (React Navigation v7) +- Create navigator instances (`const Stack = createNativeStackNavigator()`) at module scope — not inside components. +- Add new screens as `` inside the appropriate navigator JSX. +- Share `screenOptions` via the `screenOptions` prop on `` — do not repeat options on individual screens if they apply to all. +- For auth gating or feature flags: conditionally render `` in JSX — do not call `navigation.navigate()` after auth state changes. +- Nested navigators (e.g. tabs inside a stack screen): create a dedicated component (e.g. `HomeTabs`) and register it as the screen component. + +## Params +- Params must be **JSON-serializable** — required for state persistence and deep linking. +- Pass **IDs only**, never full data objects. Fetch data from React Query cache using the ID inside the screen. +- Do not use reserved param keys: `screen`, `params`, `initial`, `state`. +- To update params from within a screen: `navigation.setParams({...})` (merge) or `navigation.replaceParams({...})` (replace). +- To pass data back to a previous screen: `navigation.popTo('ScreenName', { result })`. +- To navigate to a nested screen with params: `navigation.navigate('Parent', { screen: 'Child', params: { id } })`. + +## Navigation actions +- `navigate()` — standard transition; no-op if already on that screen (safe, no duplication). +- `push()` — forces a new instance of the same screen; use when multiple instances are needed. +- `goBack()` — standard back; hardware back and swipe gestures call this automatically. +- `popTo('ScreenName')` — jump back to a specific screen, skipping intermediates. +- `popToTop()` — reset a stack to its root screen. + +## Screen lifecycle +- Use `useFocusEffect(useCallback(() => { ... return cleanup }, []))` for side effects that must run on focus (data refresh, analytics). Always return a cleanup function. +- Use `useIsFocused()` when a component must re-render on focus state change. +- Use `navigation.addListener('focus' | 'blur', cb)` inside `useEffect` — always return the unsubscribe result. +- Do not use plain `useEffect` for focus-aware operations without listener subscriptions. + +## TypeScript +- ParamList entries must use `type`, never `interface`. +- Do not annotate `useNavigation()` with a specific type — declare the global `RootParamList` instead. +- Do not annotate `useRoute()` — use `route` from screen props (`NativeStackScreenProps`). +- Use `CompositeScreenProps` for screens inside nested navigators that need access to parent navigation. +- Use `NavigatorScreenParams` when a parent ParamList entry wraps a nested navigator. + ## Half-sheet modals -Use `presentation: 'transparentModal'`, `animation: 'none'`, `gestureEnabled: false` (see `HALF_SHEET_OPTIONS` in `root-navigator.tsx`). +- Register with `presentation: 'transparentModal'`, `animation: 'none'`, `gestureEnabled: false` (see `HALF_SHEET_OPTIONS` in `root-navigator.tsx`). +- Use `HalfSheet` from `src/shared/components/ui/HalfSheet.tsx` as the content wrapper. ## Must -- All route constants from `src/navigation/routes.ts` — never use inline string literals for routes. -- New screens: register in `routes.ts` and add the ParamList entry to the feature's `navigation/param-list.ts`. -- Use `navigationRef` from `src/navigation/helpers/navigation-helpers.ts` for imperative navigation outside React tree. +- All route constants from `src/navigation/routes.ts` — never inline strings. +- New screens: add constant to `routes.ts`, add `ParamList` entry to `root-param-list.ts`, register in `root-navigator.tsx`. +- `navigationRef` for imperative navigation outside the React tree. +- `useFocusEffect` + `useCallback` for focus-scoped side effects. +- `InteractionManager.runAfterInteractions` for heavy non-UI work triggered by navigation. ## Must not -- Do not nest a second `NavigationContainer`. -- Do not navigate from `src/shared/**` directly — navigate only via helpers or callbacks passed in as props. +- Do not render a second `` — one root only. +- Do not call `navigation.navigate()` after an auth state change — conditional screen rendering handles it. +- Do not nest navigators of the same type (tabs-in-tabs, stack-in-stack at the same level). +- Do not create a nested navigator solely for code organization — group screens under a shared `screenOptions` prop instead. +- Do not navigate from `src/shared/**` directly — pass callbacks as props or use `navigationRef` helpers. - Do not add navigation logic inside `src/shared/components/ui/` components. +- Do not pass full data objects as params — pass IDs and fetch data inside the screen. +- Do not leave empty directories under `src/navigation/` — remove the folder when the last file is deleted. diff --git a/.claude/rules/react-query.md b/.claude/rules/react-query.md index 79090e4..cd91cfc 100644 --- a/.claude/rules/react-query.md +++ b/.claude/rules/react-query.md @@ -38,6 +38,7 @@ Never hardcode numeric stale/gc values — always reference a `Freshness` profil ## Must not - No React Query keys inline in components — always use `api/keys.ts`. +- No tag arrays (e.g. `['auth:me', 'auth:session']`) inline in hooks — export named arrays from the feature's `api/keys.ts` and import from there. - No `invalidateQueries()` without a targeted key — use tag-based invalidation. - No magic `staleTime` / `gcTime` numbers — use `Freshness` profiles. - No server data duplicated in Zustand. diff --git a/.claude/rules/shared-services.md b/.claude/rules/shared-services.md index 8f31e5f..0ffb47c 100644 --- a/.claude/rules/shared-services.md +++ b/.claude/rules/shared-services.md @@ -35,7 +35,7 @@ shared/services/ - All HTTP calls must go through `http/http.client.ts` (exported `httpClient`) / `http/api.ts` helpers or a `transport/` adapter — never bare `fetch`. - Every adapter must pass responses through `src/shared/utils/normalize-error.ts` for consistent error shapes. - React Query client configuration (staleTime, retry, persistence) must live in `query/policy/`, `query/client/`, or `query/persistence/` — not scattered across feature hooks. -- Tag-based cache invalidation must use `query/helpers/invalidate-by-tags.ts` and tag constants from `query/tags.ts`. +- Tag-based cache invalidation must use `query/helpers/invalidate-by-tags.ts`. The `Tag`, `TagMap`, and `KeyGetter` types live in `query/tags.ts`; feature tag arrays (e.g. `AUTH_SESSION_TAGS`) and tagMaps belong in each feature's `api/keys.ts`. - MMKV key strings must be imported from `src/config/constants.ts`. - Sentry calls (`captureException`, `captureMessage`) must go through `monitoring/sentry.ts` helpers — do not call the Sentry SDK directly in feature code. - The mock transport adapter (`transport/adapters/mock.adapter.ts`) is dev-only; it must be gated by `flags.USE_MOCK` from `src/config/constants.ts`. diff --git a/.claude/skills/rn-code-reviewer/CHECKLIST.md b/.claude/skills/rn-code-reviewer/CHECKLIST.md new file mode 100644 index 0000000..1b3fc6f --- /dev/null +++ b/.claude/skills/rn-code-reviewer/CHECKLIST.md @@ -0,0 +1,29 @@ +# RN Starter Review Checklist + +Use this as a concise pass/fail checklist during reviews. + +## Correctness +- [ ] Logic changes handle happy path and edge cases. +- [ ] Async flows handle loading, success, and error states. +- [ ] Errors are not swallowed; context is preserved. + +## Architecture and imports +- [ ] `src/shared` does not import feature code. +- [ ] Imports use `@/` aliases, not deep relative paths. +- [ ] New feature code is placed under `src/features//...`. + +## RN UI and theming +- [ ] No raw colors/spacing/fonts in UI code. +- [ ] Styling uses theme tokens and `StyleSheet.create()`. +- [ ] Platform-specific behavior is handled where required. + +## Data and state +- [ ] Server state uses React Query patterns from feature `api/keys.ts`. +- [ ] Mutations include targeted invalidation patterns. +- [ ] No server/domain data is moved into Zustand global stores. +- [ ] No direct `fetch` usage; transport/API layer is used. + +## Tests +- [ ] Non-trivial behavior changes include tests. +- [ ] Missing tests are called out with exact suggested scenarios. +- [ ] Residual risk and untested paths are explicitly noted. diff --git a/.claude/skills/rn-code-reviewer/SKILL.md b/.claude/skills/rn-code-reviewer/SKILL.md new file mode 100644 index 0000000..382087f --- /dev/null +++ b/.claude/skills/rn-code-reviewer/SKILL.md @@ -0,0 +1,82 @@ +--- +name: rn-code-reviewer +description: Reviews React Native bare-workflow changes in this starter for correctness, regressions, architecture boundaries, React Query usage, theming, and test coverage. Use when reviewing PRs, diffs, or requested code reviews. +--- + +# RN Code Reviewer + +Project-specific code review workflow for this React Native starter. + +## When to apply + +Use this skill when: +- Reviewing pull requests or local diffs +- Asked to "review" code quality, bugs, or regressions +- Validating RN architecture and repo conventions before merge + +## Review priorities + +Always prioritize findings in this order: +1. Correctness and behavioral regressions +2. Security/privacy risks and error handling gaps +3. Architecture and boundary violations +4. Performance concerns with real user impact +5. Missing or weak tests + +## Required checks + +### 1) Correctness and risk +- Verify changed logic matches intended behavior and handles edge cases. +- Flag silent failures, swallowed errors, or lossy error handling. +- Check async flows for loading, success, and error-state handling. + +### 2) Repository architecture constraints +- Enforce feature/shared boundaries (`src/shared` must not import from features). +- Enforce alias imports (`@/`); flag deep relative imports. +- Verify feature code stays under `src/features//...` with expected folders. + +### 3) React Native UI conventions +- Flag raw colors/spacing/fonts in UI; require theme tokens. +- Prefer `StyleSheet.create()` over broad inline styles (except dynamic values). +- Verify platform-specific behavior is handled where needed (`Platform.select`, native differences). + +### 4) Data/state conventions +- Server state should use React Query (feature-level `api/keys.ts` key patterns). +- Mutations should use targeted invalidation (`meta.tags` / invalidation paths). +- Flag server data leaking into Zustand stores. +- Flag direct `fetch` usage (project transport layer should be used). + +### 5) Tests and verification +- Ensure non-trivial logic changes include tests or justify why not. +- Suggest concrete missing tests (what behavior, where to add). +- Highlight risky untested paths before merge. + +## Output format + +Return findings first, ordered by severity: + +1. `Critical` — must fix before merge +2. `Major` — high-impact issues, should fix +3. `Minor` — quality/maintainability improvements + +For each finding include: +- File path +- Why it is a problem (risk/regression) +- Specific fix direction + +Then include: +- Open questions/assumptions +- Brief change summary (secondary) +- Residual testing gaps + +## Review behavior rules + +- Do not lead with a broad summary before findings. +- Be explicit about potential regressions and user impact. +- Prefer actionable, minimal suggestions over large refactors. +- If no issues are found, explicitly state "No findings" and note residual risks or test gaps. + +## Additional reference + +Use this checklist for quick pass/fail scanning: +- [CHECKLIST.md](CHECKLIST.md) diff --git a/AGENTS.md b/AGENTS.md index 742b574..c5812af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ When changing SVGs, run `npm run gen:icons`. When changing i18n keys, run `npm r - **src/i18n/** — useT, locales, extraction. - **src/shared/components/ui/** — Reusable UI primitives (Button, Text, ScreenWrapper, …). If a component needs sharing, it either belongs here (truly generic) or stays in the feature that owns it — there is no intermediate category. - **src/shared/hooks/** — Shared hooks. -- **src/shared/constants/** — Shared non-config constants (e.g. query invalidation tag lists). Storage key names and env-backed flags stay in **`src/config/`**. +- **src/shared/constants/** — Shared non-config constants. Storage key names and env-backed flags stay in **`src/config/`**. Tag arrays for cache invalidation (e.g. `AUTH_SESSION_TAGS`) belong in each feature's `api/keys.ts` — not here. - **src/shared/services/api/** — HTTP, transport, query client, network, offline. - **src/shared/services/monitoring/** — Optional crash reporting (e.g. Sentry init); see `docs/OPERATIONS.md#sentry`. - **src/shared/services/storage/** — MMKV, cache, Zustand persistence adapter. @@ -73,6 +73,7 @@ Use path alias `@/` only (e.g. `@/navigation/`, `@/session/`, `@/config/`, `@/i1 - No server data in Zustand; no feature logic in `src/shared/components/ui/` or `src/shared/stores/`. - No deep relative imports; path aliases only (`check:imports`). - When adding/changing SVGs: run `npm run gen:icons` and keep `check:icons` passing. +- No empty directories in `src/`. When moving or deleting files, remove the containing folder if it becomes empty. Empty folders signal dead code and confuse codebase navigation. ## When adding diff --git a/App.tsx b/App.tsx index a122585..7500d30 100644 --- a/App.tsx +++ b/App.tsx @@ -37,7 +37,6 @@ export default function App() { // Android: exit app from root-level leaves (main tabs, login, onboarding). useBackButtonHandler( routeName => - routeName === ROUTES.HOME_TABS || routeName === ROUTES.TAB_HOME || routeName === ROUTES.TAB_SETTINGS || routeName === ROUTES.AUTH_LOGIN || diff --git a/assets/bootsplash/logo.png b/assets/bootsplash/logo.png index 69752b8..6d8a93a 100644 Binary files a/assets/bootsplash/logo.png and b/assets/bootsplash/logo.png differ diff --git a/assets/bootsplash/logo@1,5x.png b/assets/bootsplash/logo@1,5x.png index fc98e66..2e9ac42 100644 Binary files a/assets/bootsplash/logo@1,5x.png and b/assets/bootsplash/logo@1,5x.png differ diff --git a/assets/bootsplash/logo@2x.png b/assets/bootsplash/logo@2x.png index 61988ab..b807058 100644 Binary files a/assets/bootsplash/logo@2x.png and b/assets/bootsplash/logo@2x.png differ diff --git a/assets/bootsplash/logo@3x.png b/assets/bootsplash/logo@3x.png index 6227a58..616dba4 100644 Binary files a/assets/bootsplash/logo@3x.png and b/assets/bootsplash/logo@3x.png differ diff --git a/assets/bootsplash/logo@4x.png b/assets/bootsplash/logo@4x.png index 6a3634c..b363678 100644 Binary files a/assets/bootsplash/logo@4x.png and b/assets/bootsplash/logo@4x.png differ diff --git a/assets/icons.ts b/assets/icons.ts index 0c80d1d..c4ae3a5 100644 --- a/assets/icons.ts +++ b/assets/icons.ts @@ -1,19 +1,40 @@ // AUTO-GENERATED FILE — DO NOT EDIT MANUALLY // Run: npm run gen:icons +import Check from '@assets/svgs/check.svg' +import Globe from '@assets/svgs/globe.svg' import Home from '@assets/svgs/home.svg' +import Info from '@assets/svgs/info.svg' +import Layers from '@assets/svgs/layers.svg' +import Logout from '@assets/svgs/logout.svg' +import Moon from '@assets/svgs/moon.svg' import Settings from '@assets/svgs/settings.svg' +import Sun from '@assets/svgs/sun.svg' import User from '@assets/svgs/user.svg' export enum IconName { + CHECK = 'CHECK', + GLOBE = 'GLOBE', HOME = 'HOME', + INFO = 'INFO', + LAYERS = 'LAYERS', + LOGOUT = 'LOGOUT', + MOON = 'MOON', SETTINGS = 'SETTINGS', + SUN = 'SUN', USER = 'USER', } export const AppIcon = { + [IconName.CHECK]: Check, + [IconName.GLOBE]: Globe, [IconName.HOME]: Home, + [IconName.INFO]: Info, + [IconName.LAYERS]: Layers, + [IconName.LOGOUT]: Logout, + [IconName.MOON]: Moon, [IconName.SETTINGS]: Settings, + [IconName.SUN]: Sun, [IconName.USER]: User, } as const diff --git a/assets/svgs/check.svg b/assets/svgs/check.svg new file mode 100644 index 0000000..7f9f169 --- /dev/null +++ b/assets/svgs/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svgs/globe.svg b/assets/svgs/globe.svg new file mode 100644 index 0000000..ae5669f --- /dev/null +++ b/assets/svgs/globe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/svgs/info.svg b/assets/svgs/info.svg new file mode 100644 index 0000000..869571a --- /dev/null +++ b/assets/svgs/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/svgs/layers.svg b/assets/svgs/layers.svg new file mode 100644 index 0000000..4ae50d4 --- /dev/null +++ b/assets/svgs/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/svgs/logout.svg b/assets/svgs/logout.svg new file mode 100644 index 0000000..29abad8 --- /dev/null +++ b/assets/svgs/logout.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/svgs/moon.svg b/assets/svgs/moon.svg new file mode 100644 index 0000000..e6b6fbd --- /dev/null +++ b/assets/svgs/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svgs/sun.svg b/assets/svgs/sun.svg new file mode 100644 index 0000000..bd8a763 --- /dev/null +++ b/assets/svgs/sun.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/TODO.md b/docs/TODO.md index 92bd70e..349bdad 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,17 @@ --- +## Dependency upgrades to track + +### React Navigation v8 +- **Status:** alpha only (`8.0.0-alpha.16` as of 2026-03-25) — do not upgrade until stable/RC. +- **Current:** `@react-navigation/native ^7.1.24`, `@react-navigation/native-stack ^7.8.5`, `@react-navigation/bottom-tabs ^7.8.11` +- **Watch:** https://github.com/react-navigation/react-navigation/releases +- **When stable:** bump all three `@react-navigation/*` packages together; check peer dep requirements for `react-native-screens` and `react-native-safe-area-context`; review breaking changes in changelog before migrating. +- **Expected changes:** further static config improvements, possible `screenOptions` API changes, `react-native-screens` peer dep bump. + +--- + ## Before / when you go public (maintainer) **Copy-paste text and templates:** [OPERATIONS.md § Publishing & discoverability](OPERATIONS.md#publishing--discoverability). diff --git a/docs/development.md b/docs/development.md index 07d18e5..133f74d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,7 +17,7 @@ src/ ├── shared/ # Cross-app code (must NOT import from features) │ ├── components/ui/ # Button, Text, ScreenWrapper, OfflineBanner, IconSvg, ErrorBoundary, Activity, SuspenseBoundary │ ├── hooks/ # Shared hooks (useAppState, useToggle, …) -│ ├── constants/ # Shared non-config constants (e.g. query tag lists) +│ ├── constants/ # Shared non-config constants (tag arrays belong in feature api/keys.ts) │ ├── services/ │ │ ├── api/ # http, transport, query, network, offline │ │ ├── monitoring/ # Optional Sentry init + boundary reporting (see OPERATIONS.md#sentry) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 631a885..9e0f493 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1989,6 +1989,34 @@ PODS: - SocketRocket - Yoga - react-native-vector-icons-ionicons (12.5.0) + - react-native-webview (13.16.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - React-NativeModulesApple (0.82.1): - boost - DoubleConversion @@ -3049,6 +3077,7 @@ DEPENDENCIES: - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-vector-icons-ionicons (from `../node_modules/@react-native-vector-icons/ionicons`)" + - react-native-webview (from `../node_modules/react-native-webview`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) @@ -3194,6 +3223,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-vector-icons-ionicons: :path: "../node_modules/@react-native-vector-icons/ionicons" + react-native-webview: + :path: "../node_modules/react-native-webview" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -3329,6 +3360,7 @@ SPEC CHECKSUMS: react-native-netinfo: 57447b5a45c98808f8eae292cf641f3d91d13830 react-native-safe-area-context: befb5404eb8a16fdc07fa2bebab3568ecabcbb8a react-native-vector-icons-ionicons: 7e633927db6ab96e0255b5920c053777cacca505 + react-native-webview: 8407aaaf6b539b1e38b72fabb55d6885de03beaf React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0 React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b diff --git a/jest.config.js b/jest.config.js index c752c3e..2bfe301 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,6 @@ module.exports = { preset: 'react-native', setupFiles: ['/jest.setup.js'], // Change to .js transformIgnorePatterns: [ - 'node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|react-native-config|@shopify/flash-list)/)', + 'node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|react-native-config|@shopify/flash-list|react-native-webview)/)', ], } diff --git a/jest.setup.js b/jest.setup.js index 08c57e5..035af7e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -110,6 +110,16 @@ jest.mock('react-native-mmkv', () => { return { createMMKV } }) +jest.mock('react-native-webview', () => { + const React = require('react') + const { View } = require('react-native') + const WebView = React.forwardRef((props, _ref) => + React.createElement(View, props), + ) + WebView.displayName = 'WebView' + return { __esModule: true, default: WebView } +}) + jest.mock('react-native-gesture-handler', () => { const View = require('react-native').View return { diff --git a/package-lock.json b/package-lock.json index 3177782..f194966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "ReactNativeStarter", - "version": "0.0.1", + "name": "react-native-starter", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ReactNativeStarter", - "version": "0.0.1", + "name": "react-native-starter", + "version": "1.0.0", + "license": "MIT", "dependencies": { "@react-native-clipboard/clipboard": "^1.14.1", "@react-native-community/netinfo": "^11.4.1", @@ -37,6 +38,7 @@ "react-native-svg": "^15.15.1", "react-native-svg-transformer": "^1.5.2", "react-native-vector-icons": "^10.3.0", + "react-native-webview": "^13.16.1", "react-native-worklet": "^0.0.0", "react-native-worklets": "^0.7.1", "zod": "^4.1.13", @@ -12204,6 +12206,20 @@ "node": ">=10" } }, + "node_modules/react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklet": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/react-native-worklet/-/react-native-worklet-0.0.0.tgz", diff --git a/package.json b/package.json index 3d9a427..f145054 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "react-native-svg": "^15.15.1", "react-native-svg-transformer": "^1.5.2", "react-native-vector-icons": "^10.3.0", + "react-native-webview": "^13.16.1", "react-native-worklet": "^0.0.0", "react-native-worklets": "^0.7.1", "zod": "^4.1.13", diff --git a/src/features/auth/api/keys.ts b/src/features/auth/api/keys.ts index d497b5a..2dbe0af 100644 --- a/src/features/auth/api/keys.ts +++ b/src/features/auth/api/keys.ts @@ -43,3 +43,8 @@ export const authKeys = { } as const export type AuthTag = keyof typeof tagMap + +export const AUTH_SESSION_TAGS = [ + 'auth:me', + 'auth:session', +] as const satisfies readonly AuthTag[] diff --git a/src/features/auth/constants/oauth-brand-colors.ts b/src/features/auth/constants/oauth-brand-colors.ts deleted file mode 100644 index 5f52b10..0000000 --- a/src/features/auth/constants/oauth-brand-colors.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Official OAuth provider brand colors (marketing assets). - * Do not remap to app theme.primary — vendors require these hues for logo marks. - */ -export const OAUTH_GOOGLE_BLUE = '#4285F4' -export const OAUTH_GOOGLE_GREEN = '#34A853' -export const OAUTH_GOOGLE_YELLOW = '#FBBC05' -export const OAUTH_GOOGLE_RED = '#EA4335' -export const OAUTH_FACEBOOK_BLUE = '#1877F2' diff --git a/src/features/auth/hooks/useAuthSessionQuery.ts b/src/features/auth/hooks/useAuthSessionQuery.ts index 243664e..867c6a5 100644 --- a/src/features/auth/hooks/useAuthSessionQuery.ts +++ b/src/features/auth/hooks/useAuthSessionQuery.ts @@ -1,18 +1,11 @@ import { useQuery } from '@tanstack/react-query' -import { z } from 'zod' import { authKeys } from '@/features/auth/api/keys' +import { zSessionResponse } from '@/features/auth/services/auth/auth.schemas' import { Freshness } from '@/shared/services/api/query/policy/freshness' import { OPS } from '@/shared/services/api/transport/operations' import { transport } from '@/shared/services/api/transport/transport' import { normalizeError } from '@/shared/utils/normalize-error' -const SessionSchema = z.object({ - userId: z.string().or(z.number()), - email: z.string().email().optional(), -}) - -export type AuthSessionDTO = z.infer - export function useAuthSessionQuery() { return useQuery({ queryKey: authKeys.session(), @@ -21,7 +14,7 @@ export function useAuthSessionQuery() { queryFn: async () => { try { const data = await transport.query(OPS.AUTH_SESSION) - return SessionSchema.parse(data) + return zSessionResponse.parse(data) } catch (e) { throw normalizeError(e) } diff --git a/src/features/auth/hooks/useLoginMutation.ts b/src/features/auth/hooks/useLoginMutation.ts index 9fd7496..7c5396a 100644 --- a/src/features/auth/hooks/useLoginMutation.ts +++ b/src/features/auth/hooks/useLoginMutation.ts @@ -1,14 +1,9 @@ // src/features/auth/hooks/useLoginMutation.ts import { useMutation, useQueryClient } from '@tanstack/react-query' -import { authKeys } from '@/features/auth/api/keys' -import { - LoginRequest, - zLoginRequest, -} from '@/features/auth/services/auth/auth.schemas' +import { AUTH_SESSION_TAGS, authKeys } from '@/features/auth/api/keys' +import type { LoginRequest } from '@/features/auth/services/auth/auth.schemas' import { AuthService } from '@/features/auth/services/auth/auth.service' -import { userKeys } from '@/features/user/api/keys' -import { SESSION_RELATED_QUERY_TAGS } from '@/shared/constants' import { invalidateByTags } from '@/shared/services/api/query/helpers/invalidate-by-tags' import { normalizeError } from '@/shared/utils/normalize-error' @@ -16,22 +11,15 @@ export function useLoginMutation() { const qc = useQueryClient() return useMutation({ - mutationKey: ['auth', 'login'], mutationFn: async (payload: LoginRequest) => { - const ok = zLoginRequest.safeParse(payload) - if (!ok.success) throw normalizeError(ok.error) - try { - return await AuthService.login(ok.data) + return await AuthService.login(payload) } catch (e) { throw normalizeError(e) } }, onSuccess: async () => { - await invalidateByTags(qc, SESSION_RELATED_QUERY_TAGS, [ - authKeys.tagMap, - userKeys.tagMap, - ]) + await invalidateByTags(qc, AUTH_SESSION_TAGS, [authKeys.tagMap]) }, }) } diff --git a/src/features/auth/hooks/useLogout.ts b/src/features/auth/hooks/useLogout.ts index fafa5bf..f9926a3 100644 --- a/src/features/auth/hooks/useLogout.ts +++ b/src/features/auth/hooks/useLogout.ts @@ -9,7 +9,7 @@ export function useLogout() { return useCallback(async () => { try { await AuthService.logout() - await qc.cancelQueries().catch(() => undefined) + await qc.cancelQueries() qc.clear() } catch (e) { throw normalizeError(e) diff --git a/src/features/auth/navigation/param-list.ts b/src/features/auth/navigation/param-list.ts deleted file mode 100644 index 72125ec..0000000 --- a/src/features/auth/navigation/param-list.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ROUTES } from '@/navigation/routes' - -export type AuthStackParamList = { - [ROUTES.AUTH_LOGIN]: undefined -} diff --git a/src/features/auth/screens/AuthScreen.tsx b/src/features/auth/screens/AuthScreen.tsx index 658e849..232cf3f 100644 --- a/src/features/auth/screens/AuthScreen.tsx +++ b/src/features/auth/screens/AuthScreen.tsx @@ -4,10 +4,10 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import { Dimensions, Platform, + Pressable, ScrollView, StyleSheet, TextInput, - TouchableOpacity, useColorScheme, View, } from 'react-native' @@ -34,13 +34,6 @@ import Svg, { } from 'react-native-svg' import { flags } from '@/config/constants' import { AUTH_MOCK_DEMO } from '@/features/auth/constants' -import { - OAUTH_FACEBOOK_BLUE, - OAUTH_GOOGLE_BLUE, - OAUTH_GOOGLE_GREEN, - OAUTH_GOOGLE_RED, - OAUTH_GOOGLE_YELLOW, -} from '@/features/auth/constants/oauth-brand-colors' import { useLoginMutation } from '@/features/auth/hooks/useLoginMutation' import { useT } from '@/i18n/useT' import { resetRoot } from '@/navigation/helpers/navigation-helpers' @@ -48,6 +41,7 @@ import { ROUTES } from '@/navigation/routes' import { Button } from '@/shared/components/ui/Button' import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' import { Text } from '@/shared/components/ui/Text' +import { useToggle } from '@/shared/hooks/useToggle' import { useTheme } from '@/shared/theme' import { normalizeError } from '@/shared/utils/normalize-error' import { showErrorToast } from '@/shared/utils/toast' @@ -219,38 +213,44 @@ function usePressScale(toValue = 0.96) { } // ─── SVG Icons ───────────────────────────────────────────────────── -const GoogleIcon = () => ( - - - - - - -) +const GoogleIcon = () => { + const { theme } = useTheme() + return ( + + + + + + + ) +} -const FacebookIcon = () => ( - - - -) +const FacebookIcon = () => { + const { theme } = useTheme() + return ( + + + + ) +} const AppleIcon = ({ color }: { color: string }) => ( - + ) @@ -352,9 +352,9 @@ export default function AuthScreen() { const [password, setPassword] = useState(() => flags.USE_MOCK ? AUTH_MOCK_DEMO.password : '', ) - const [showPassword, setShowPassword] = useState(false) - const [emailFocused, setEmailFocused] = useState(false) - const [passFocused, setPassFocused] = useState(false) + const [showPassword, toggleShowPassword] = useToggle(false) + const [emailFocused, , setEmailFocused, clearEmailFocused] = useToggle(false) + const [passFocused, , setPassFocused, clearPassFocused] = useToggle(false) // Input focus — Reanimated shared values for color interpolation const emailFocus = useSharedValue(0) @@ -472,12 +472,11 @@ export default function AuthScreen() { {/* Theme toggle */} - [ styles.themeBtn, { width: AUTH_SCREEN_LAYOUT.themeToggleSize, @@ -485,6 +484,7 @@ export default function AuthScreen() { backgroundColor: c.surfaceSecondary, borderColor: c.border, borderRadius: r.xl, + opacity: pressed ? 0.7 : 1, }, ]} > @@ -507,7 +507,7 @@ export default function AuthScreen() { )} - + {/* Logo + heading */} @@ -555,13 +555,20 @@ export default function AuthScreen() { {t('auth.subtitle')} {flags.USE_MOCK ? ( @@ -589,23 +597,29 @@ export default function AuthScreen() { ]} > {[ - { key: 'google' as const, Icon: , press: social0 }, + { + key: 'google' as const, + Icon: , + label: 'Google', + press: social0, + }, { key: 'facebook' as const, Icon: , + label: 'Facebook', press: social1, }, { key: 'apple' as const, Icon: , + label: 'Apple', press: social2, }, - ].map(({ key, Icon, press }) => ( - ( + {Icon} + + {label} + - + ))} @@ -673,11 +691,11 @@ export default function AuthScreen() { value={email} onChangeText={setEmail} onFocus={() => { - setEmailFocused(true) + setEmailFocused() animateFocus(emailFocus, true) }} onBlur={() => { - setEmailFocused(false) + clearEmailFocused() animateFocus(emailFocus, false) }} keyboardType="email-address" @@ -700,11 +718,13 @@ export default function AuthScreen() { > {t('auth.password')} - + pressed && styles.pressedOpacity} + > {t('auth.forgot_password')} - + { - setPassFocused(true) + setPassFocused() animateFocus(passFocus, true) }} onBlur={() => { - setPassFocused(false) + clearPassFocused() animateFocus(passFocus, false) }} secureTextEntry={!showPassword} @@ -736,13 +756,15 @@ export default function AuthScreen() { autoComplete="password" textContentType="password" /> - setShowPassword(!showPassword)} - activeOpacity={0.6} - style={styles.eyeSlot} + [ + styles.eyeSlot, + pressed && styles.pressedOpacity, + ]} > - + @@ -786,16 +808,16 @@ export default function AuthScreen() { {t('auth.no_account')}{' '} - + pressed && styles.pressedOpacity}> {t('auth.sign_up')} - + {/* Terms */} export type LoginResponse = z.infer + +export const zSessionResponse = z.object({ + userId: z.string().or(z.number()), + email: z.string().email().optional(), +}) + +export type SessionResponse = z.infer diff --git a/src/features/auth/services/auth/auth.service.ts b/src/features/auth/services/auth/auth.service.ts index cf3491a..9fa48ca 100644 --- a/src/features/auth/services/auth/auth.service.ts +++ b/src/features/auth/services/auth/auth.service.ts @@ -27,12 +27,9 @@ export const AuthService = { throw normalizeError(new Error(message)) } - // when MOCK is on, skip network entirely - if (!flags.USE_MOCK) { - if (isOffline()) { - // Message prefix matches normalize-error offline detection - throw new Error('Offline: login requires network') - } + // skip network check when mock transport is active + if (!flags.USE_MOCK && isOffline()) { + throw new Error('Offline: login requires network') } // use OPS (Operation = union of OPS) diff --git a/src/features/home/api/keys.ts b/src/features/home/api/keys.ts new file mode 100644 index 0000000..fb0e3aa --- /dev/null +++ b/src/features/home/api/keys.ts @@ -0,0 +1,3 @@ +export const homeKeys = { + feed: () => ['home', 'feed'] as const, +} diff --git a/src/features/home/hooks/useFeedQuery.ts b/src/features/home/hooks/useFeedQuery.ts new file mode 100644 index 0000000..93c2862 --- /dev/null +++ b/src/features/home/hooks/useFeedQuery.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { homeKeys } from '@/features/home/api/keys' +import { fetchHnFeed } from '@/features/home/services/hn/hn.service' +import type { FeedItem } from '@/features/home/types' +import { Freshness } from '@/shared/services/api/query/policy/freshness' +import { formatRelativeTime } from '@/shared/utils/format-relative-time' + +export function useFeedQuery() { + const query = useQuery({ + queryKey: homeKeys.feed(), + queryFn: fetchHnFeed, + staleTime: Freshness.nearRealtime.staleTime, + gcTime: Freshness.nearRealtime.gcTime, + meta: { persistence: 'nearRealtime' }, + placeholderData: prev => prev, + }) + + const syncedAtLabel = useMemo(() => { + const ts = query.dataUpdatedAt + return ts ? formatRelativeTime(ts) : null + }, [query.dataUpdatedAt]) + + return { + feed: query.data ?? [], + isLoading: query.isLoading, + isRefetching: query.isRefetching, + refetch: query.refetch, + hasCache: !!query.data, + syncedAtLabel, + } +} diff --git a/src/features/home/hooks/useShimmer.ts b/src/features/home/hooks/useShimmer.ts new file mode 100644 index 0000000..9b548c3 --- /dev/null +++ b/src/features/home/hooks/useShimmer.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react' +import { Animated } from 'react-native' + +/** + * Returns an Animated.Value that pulses between 0.4 and 1 opacity — + * use it to drive shimmer/skeleton placeholder animations. + */ +export function useShimmer(): Animated.Value { + const anim = useRef(new Animated.Value(0.4)).current + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(anim, { + toValue: 1, + duration: 750, + useNativeDriver: true, + }), + Animated.timing(anim, { + toValue: 0.4, + duration: 750, + useNativeDriver: true, + }), + ]), + ) + loop.start() + return () => loop.stop() + }, [anim]) + return anim +} diff --git a/src/features/home/navigation/param-list.ts b/src/features/home/navigation/param-list.ts deleted file mode 100644 index 78bf78a..0000000 --- a/src/features/home/navigation/param-list.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ROUTES } from '@/navigation/routes' - -/** Bottom tabs inside the authenticated app (Home + Settings entry). */ -export type HomeTabsParamList = { - [ROUTES.TAB_HOME]: undefined - [ROUTES.TAB_SETTINGS]: undefined -} - -/** Home area stack param list when using nested stack + tabs. */ -export type HomeStackParamList = { - [ROUTES.TAB_HOME]: undefined - [ROUTES.HOME_TABS]: undefined -} diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index e412e4f..c0bc8da 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -1,252 +1,84 @@ // src/features/home/screens/HomeScreen.tsx +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { FlashList } from '@shopify/flash-list' -import React, { memo, useCallback, useEffect, useRef } from 'react' +import React, { memo, useCallback } from 'react' import { Animated, - Dimensions, Platform, Pressable, ScrollView, StyleSheet, View, } from 'react-native' -import Svg, { Circle, Line, Path, Polyline, Rect } from 'react-native-svg' -import { useMeQuery } from '@/features/user/hooks/useMeQuery' +import { useFeedQuery } from '@/features/home/hooks/useFeedQuery' +import { useShimmer } from '@/features/home/hooks/useShimmer' +import type { FeedItem } from '@/features/home/types' import { useT } from '@/i18n/useT' +import type { RootStackParamList } from '@/navigation/root-param-list' +import { ROUTES } from '@/navigation/routes' import { ScreenHeader } from '@/shared/components/ui/ScreenHeader' import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' +import { SectionHeader } from '@/shared/components/ui/SectionHeader' import { Text } from '@/shared/components/ui/Text' +import { useOnlineStatus } from '@/shared/hooks/useOnlineStatus' import { useTheme } from '@/shared/theme/useTheme' -const { width: SCREEN_W } = Dimensions.get('window') -const TAB_BAR_CLEARANCE = 88 - -// ─── Demo data ─────────────────────────────────────────────────────────────── -type ActivityType = 'task' | 'message' | 'alert' | 'success' -type FeedItem = { - id: string - type: ActivityType - title: string - subtitle: string - time: string -} +type HomeNavProp = NativeStackNavigationProp -const FEED: FeedItem[] = [ - { - id: '1', - type: 'success', - title: 'Sprint completed', - subtitle: 'Q1 goals — 100% achieved', - time: '2m ago', - }, - { - id: '2', - type: 'task', - title: 'Task assigned', - subtitle: 'Design review for v2.0', - time: '15m ago', - }, - { - id: '3', - type: 'message', - title: 'Team update', - subtitle: 'Alice: API changes are ready', - time: '1h ago', - }, - { - id: '4', - type: 'alert', - title: 'Deadline tomorrow', - subtitle: 'Mobile release candidate', - time: '2h ago', - }, - { - id: '5', - type: 'success', - title: 'Deployment live', - subtitle: 'v1.4.2 shipped successfully', - time: '3h ago', - }, - { - id: '6', - type: 'task', - title: 'Code review', - subtitle: 'PR #142 — Auth refactor', - time: '4h ago', - }, - { - id: '7', - type: 'message', - title: 'Meeting moved', - subtitle: 'Daily standup → 10:00 AM', - time: '5h ago', - }, - { - id: '8', - type: 'alert', - title: 'Storage at 80%', - subtitle: 'Archive old logs soon', - time: 'Yesterday', - }, -] - -type QuickActionIcon = 'task' | 'message' | 'schedule' | 'report' | 'upload' - -function QuickActionSvg({ - icon, - color, -}: { - icon: QuickActionIcon - color: string -}) { - const props = { - width: 20, - height: 20, - viewBox: '0 0 24 24', - fill: 'none', - stroke: color, - strokeWidth: 2, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - } - switch (icon) { - case 'task': - return ( - - - - - ) - case 'message': - return ( - - - - ) - case 'schedule': - return ( - - - - - ) - case 'report': - return ( - - - - ) - case 'upload': - return ( - - - - - - ) - } -} - -type QuickActionColorKey = 'success' | 'info' | 'primary' | 'warning' | 'danger' -const QUICK_ACTIONS: { - id: string - icon: QuickActionIcon - colorKey: QuickActionColorKey - labelKey: - | 'home.quick_action_task' - | 'home.quick_action_message' - | 'home.quick_action_schedule' - | 'home.quick_action_report' - | 'home.quick_action_upload' -}[] = [ - { - id: '1', - icon: 'task', - colorKey: 'success', - labelKey: 'home.quick_action_task', - }, - { - id: '2', - icon: 'message', - colorKey: 'info', - labelKey: 'home.quick_action_message', - }, - { - id: '3', - icon: 'schedule', - colorKey: 'primary', - labelKey: 'home.quick_action_schedule', - }, - { - id: '4', - icon: 'report', - colorKey: 'warning', - labelKey: 'home.quick_action_report', - }, - { - id: '5', - icon: 'upload', - colorKey: 'danger', - labelKey: 'home.quick_action_upload', - }, -] - -// ─── Shimmer ───────────────────────────────────────────────────────────────── -function useShimmer() { - const anim = useRef(new Animated.Value(0.4)).current - useEffect(() => { - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(anim, { - toValue: 1, - duration: 750, - useNativeDriver: true, - }), - Animated.timing(anim, { - toValue: 0.4, - duration: 750, - useNativeDriver: true, - }), - ]), - ) - loop.start() - return () => loop.stop() - }, [anim]) - return anim -} +const TAB_BAR_CLEARANCE = 88 -function SkeletonBlock({ - w, - h = 14, - radius, - shimmer, - style, -}: { - w: number | string - h?: number - radius?: number - shimmer: Animated.Value - style?: object -}) { +// ─── Shimmer skeleton ───────────────────────────────────────────────────────── +function SkeletonCard({ shimmer }: { shimmer: Animated.Value }) { const { theme } = useTheme() + const c = theme.colors + const sp = theme.spacing + const r = theme.radius return ( - + + + + + + + ) } -// ─── Skeleton layout ───────────────────────────────────────────────────────── function HomeScreenSkeleton() { const { theme } = useTheme() const shimmer = useShimmer() @@ -254,10 +86,6 @@ function HomeScreenSkeleton() { const sp = theme.spacing const r = theme.radius - const S = (props: Omit[0], 'shimmer'>) => ( - - ) - return ( {/* Greeting */} - - - - + + + {/* Section header */} - - - {/* Stats row */} - - {[0, 1, 2].map(i => ( - - - - - - ))} - - - {/* Featured card */} - - - - - - - - - - - - - {/* Section header */} - - {/* Activity skeletons */} - {[0, 1, 2, 3, 4].map(i => ( - - - - - - - - + {/* Story card skeletons */} + {[0, 1, 2, 3, 4, 5].map(i => ( + ))} ) } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Helpers ────────────────────────────────────────────────────────────────── function useGreetingKey(): | 'home.greeting_morning' | 'home.greeting_afternoon' @@ -385,79 +155,27 @@ function useGreetingKey(): return 'home.greeting_evening' } -type ActivityColors = { bg: string; accent: string } -function typeConfig( - type: ActivityType, +/** Maps AccentVariant → theme colour token for the feed card dot. */ +function accentColor( + type: FeedItem['type'], c: ReturnType['theme']['colors'], -): ActivityColors { +): string { switch (type) { case 'success': - return { accent: c.success, bg: c.success + '1F' } - case 'task': - return { accent: c.primary, bg: c.primaryAmbient } - case 'message': - return { accent: c.info, bg: c.info + '1F' } - case 'alert': - return { accent: c.warning, bg: c.warning + '1F' } + return c.success + case 'primary': + return c.primary + case 'info': + return c.info + case 'warning': + return c.warning } } -function ActivityIcon({ - type, - accent, -}: { - type: ActivityType - accent: string -}) { - const props = { - width: 18, - height: 18, - viewBox: '0 0 24 24', - fill: 'none', - stroke: accent, - strokeWidth: 2, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - } - switch (type) { - case 'success': - return ( - - - - - ) - case 'task': - return ( - - - - - - ) - case 'message': - return ( - - - - ) - case 'alert': - return ( - - - - - ) - } -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - +// ─── Greeting ───────────────────────────────────────────────────────────────── function GreetingSection({ - name, greetingKey, }: { - name: string greetingKey: ReturnType }) { const t = useT() @@ -483,466 +201,183 @@ function GreetingSection({ {today} {t(greetingKey)} - {name ? `, ${name.split(' ')[0]}` : ''} 👋 ) } -function SectionHeader({ label }: { label: string }) { - const { theme } = useTheme() - return ( - - {label} - - ) -} - -type StatTrend = { kind: 'text'; value: string } | { kind: 'flame' } - -function StatTrendBadge({ - trend, - color, - radius, -}: { - trend: StatTrend - color: string - radius: number -}) { - const { theme } = useTheme() - const ty = theme.typography - const SIZE = 28 - return ( - - {trend.kind === 'text' ? ( - {trend.value} - ) : ( - - - - )} - - ) -} - -function StatsRow() { - const t = useT() +// ─── News story card ────────────────────────────────────────────────────────── +const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { const { theme } = useTheme() const c = theme.colors const sp = theme.spacing const r = theme.radius const ty = theme.typography - - const stats: { - key: string - label: string - value: string - trend: StatTrend - trendColor: string - }[] = [ - { - key: 'done', - label: t('home.stat_done'), - value: '12', - trend: { kind: 'text', value: '+3' }, - trendColor: c.success, - }, - { - key: 'active', - label: t('home.stat_active'), - value: '4', - trend: { kind: 'text', value: '–1' }, - trendColor: c.danger, - }, - { - key: 'streak', - label: t('home.stat_streak'), - value: '7d', - trend: { kind: 'flame' }, - trendColor: c.warning, - }, - ] + const accent = accentColor(item.type, c) + const navigation = useNavigation() + + const onPress = useCallback(() => { + navigation.navigate(ROUTES.HOME_STORY, { + id: item.id, + title: item.title, + url: item.url, + points: item.points, + author: item.author, + numComments: item.numComments, + time: item.time, + domain: item.subtitle, + }) + }, [navigation, item]) return ( - - {stats.map(s => ( - - - {s.label} - - - - {s.value} - - - - - ))} - - ) -} - -function FeaturedCard() { - const t = useT() - const { theme } = useTheme() - const c = theme.colors - const sp = theme.spacing - const r = theme.radius - const ty = theme.typography - const done = 8 - const total = 12 - const progress = done / total - - return ( - ({ marginHorizontal: sp.lg, - marginBottom: sp.lg, - borderRadius: r.xxl, - backgroundColor: c.primaryAmbient, + marginBottom: sp.xs, + backgroundColor: pressed ? c.surfaceSecondary : c.surface, + borderRadius: r.xl, borderWidth: 1, - borderColor: c.primary + '2E', - padding: sp.lg, - gap: sp.sm, - }} + borderColor: c.border, + paddingHorizontal: sp.md, + paddingVertical: sp.md, + gap: sp.xs, + ...Platform.select({ + ios: { ...theme.elevation.card }, + android: { elevation: 1 }, + }), + })} > - - - - {t('home.featured_title')} - - - {t('home.featured_subtitle')} - - - - - {done}/{total} - - - - + {item.title} + + + - - - - {Math.round(progress * 100)}% complete - - - - ) -} - -// ─── Activity card (redesigned — full card, more breathing room) ────────────── -const ActivityRow = memo(function ActivityRow({ item }: { item: FeedItem }) { - const { theme } = useTheme() - const c = theme.colors - const sp = theme.spacing - const r = theme.radius - const ty = theme.typography - const cfg = typeConfig(item.type, c) - - return ( - - {/* Colored left accent bar */} - - - {/* Icon */} - - - - - {/* Content */} - - - {item.title} - + /> {item.subtitle} - - - {/* Time badge */} - + {item.points != null && ( + <> + + {'·'} + + {`▲ ${item.points}`} + + )} + {item.numComments != null && ( + <> + + {'·'} + + {`${item.numComments} comments`} + + )} + {'·'} {item.time} - + ) }) -const NUM_COLS = 4 - -function QuickActionsSection() { - const t = useT() - const { theme } = useTheme() - const c = theme.colors - const sp = theme.spacing - const r = theme.radius - const ty = theme.typography - - // Exact item width so all columns are equal regardless of row count - const gap = sp.sm - const itemWidth = (SCREEN_W - sp.lg * 2 - gap * (NUM_COLS - 1)) / NUM_COLS - - return ( - - - - {QUICK_ACTIONS.map(a => ( - ({ - width: itemWidth, - backgroundColor: pressed ? c.surfaceSecondary : c.surface, - borderColor: c.border, - borderWidth: 1, - borderRadius: r.xl, - paddingVertical: sp.sm, - alignItems: 'center' as const, - justifyContent: 'center' as const, - gap: sp.xxs, - })} - > - - - - - {t(a.labelKey)} - - - ))} - - - ) -} - -// ─── Screen ────────────────────────────────────────────────────────────────── +// ─── Screen ─────────────────────────────────────────────────────────────────── export default function HomeScreen() { const t = useT() const { theme } = useTheme() const c = theme.colors - - const me = useMeQuery() const greetingKey = useGreetingKey() - const name = me.data?.name ?? '' + + const { + feed, + isLoading: feedLoading, + isRefetching, + refetch, + hasCache, + syncedAtLabel, + } = useFeedQuery() + const { isOffline } = useOnlineStatus() + + const sublabel = isOffline + ? syncedAtLabel + ? `Offline · ${syncedAtLabel}` + : 'Offline' + : syncedAtLabel + ? `Synced ${syncedAtLabel}` + : null const ListHeader = useCallback( () => ( <> - - - - - + + ), - [name, greetingKey, t], + [greetingKey, t, sublabel, isOffline], ) - const ListFooter = useCallback( - () => ( - <> - - - - ), - [], - ) + const ListFooter = useCallback(() => , []) const renderItem = useCallback( - ({ item }: { item: FeedItem }) => , + ({ item }: { item: FeedItem }) => , [], ) const keyExtractor = useCallback((item: FeedItem) => item.id, []) return ( }> - {me.isLoading ? ( + {feedLoading && !hasCache ? ( ) : ( )} ) } -// ─── Styles ────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ - statCard: { borderWidth: 1, alignItems: 'center' }, - activityCard: { flexDirection: 'row', alignItems: 'center' }, - activityContent: { flex: 1 }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + }, + listFooter: { + height: TAB_BAR_CLEARANCE, + }, }) diff --git a/src/features/home/screens/StoryScreen.tsx b/src/features/home/screens/StoryScreen.tsx new file mode 100644 index 0000000..0815667 --- /dev/null +++ b/src/features/home/screens/StoryScreen.tsx @@ -0,0 +1,206 @@ +// src/features/home/screens/StoryScreen.tsx + +import type { NativeStackScreenProps } from '@react-navigation/native-stack' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { Animated, Pressable, StyleSheet, View } from 'react-native' +import Svg, { Path, Polyline } from 'react-native-svg' +import WebView from 'react-native-webview' +import type { + WebViewNavigation, + WebViewProgressEvent, +} from 'react-native-webview/lib/WebViewTypes' +import type { RootStackParamList } from '@/navigation/root-param-list' +import { ROUTES } from '@/navigation/routes' +import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' +import { Text } from '@/shared/components/ui/Text' +import { spacing } from '@/shared/theme/tokens/spacing' +import { useTheme } from '@/shared/theme/useTheme' + +type Props = NativeStackScreenProps< + RootStackParamList, + typeof ROUTES.HOME_STORY +> + +// ─── Layout constants (derived from design tokens, never raw numbers) ───────── +const HEADER_HEIGHT = spacing.xxxxxl // 56 — matches ScreenHeader +const ICON_SIZE = spacing.lg // 20 +const ICON_STROKE = 2.2 +const PROGRESS_BAR_HEIGHT = spacing.xxs // 4 + +const HN_ITEM_BASE = 'https://news.ycombinator.com/item?id=' + +export default function StoryScreen({ route, navigation }: Props) { + const { id, title, url, domain } = route.params + const { theme } = useTheme() + const c = theme.colors + const sp = theme.spacing + const ty = theme.typography + const r = theme.radius + + const uri = url ?? `${HN_ITEM_BASE}${id}` + const displayHost = domain ?? 'news.ycombinator.com' + + const [isLoading, setIsLoading] = useState(true) + const [canGoBack, setCanGoBack] = useState(false) + const webViewRef = useRef(null) + const loadProgress = useRef(new Animated.Value(0)).current + + const iconProps = useMemo( + () => ({ + width: ICON_SIZE, + height: ICON_SIZE, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c.textPrimary, + strokeWidth: ICON_STROKE, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + }), + [c.textPrimary], + ) + + const handleClose = useCallback(() => navigation.goBack(), [navigation]) + + const handleBack = useCallback(() => { + if (canGoBack) { + webViewRef.current?.goBack() + } else { + navigation.goBack() + } + }, [canGoBack, navigation]) + + const handleNavigationStateChange = useCallback( + (state: WebViewNavigation) => setCanGoBack(state.canGoBack), + [], + ) + + const handleLoadStart = useCallback(() => { + loadProgress.setValue(0) + setIsLoading(true) + }, [loadProgress]) + + const handleLoadEnd = useCallback(() => setIsLoading(false), []) + + const handleLoadProgress = useCallback( + ({ nativeEvent }: WebViewProgressEvent) => { + Animated.timing(loadProgress, { + toValue: nativeEvent.progress, + duration: 80, + useNativeDriver: false, + }).start() + }, + [loadProgress], + ) + + const progressWidth = loadProgress.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'], + }) + + return ( + + + + + + + + + + {displayHost} + + + + + + + + + + } + > + + + {isLoading && ( + + )} + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: StyleSheet.hairlineWidth, + }, + titleSlot: { + flex: 1, + alignItems: 'center', + }, + iconBtn: { + width: spacing.xxxl, + height: spacing.xxxl, + alignItems: 'center', + justifyContent: 'center', + }, + progressBar: { + position: 'absolute', + top: 0, + left: 0, + }, +}) diff --git a/src/features/home/services/hn/hn.mappers.test.ts b/src/features/home/services/hn/hn.mappers.test.ts new file mode 100644 index 0000000..7469f2c --- /dev/null +++ b/src/features/home/services/hn/hn.mappers.test.ts @@ -0,0 +1,106 @@ +import { mapHnHitToFeedItem, parseDomain } from './hn.mappers' +import type { HnHit } from './hn.schemas' + +// ─── parseDomain ───────────────────────────────────────────────────────────── + +describe('parseDomain', () => { + it('extracts domain from https URL', () => { + expect(parseDomain('https://example.com/article/123')).toBe('example.com') + }) + + it('strips www prefix', () => { + expect(parseDomain('https://www.nytimes.com/path')).toBe('nytimes.com') + }) + + it('handles http', () => { + expect(parseDomain('http://blog.github.com/')).toBe('blog.github.com') + }) + + it('returns null for null input', () => { + expect(parseDomain(null)).toBeNull() + }) + + it('returns null for undefined input', () => { + expect(parseDomain(undefined)).toBeNull() + }) + + it('returns null for empty string', () => { + expect(parseDomain('')).toBeNull() + }) +}) + +// ─── mapHnHitToFeedItem ─────────────────────────────────────────────────────── + +function makeHit(overrides: Partial = {}): HnHit { + return { + objectID: '40000000', + title: 'Test Title', + url: 'https://example.com/article', + points: 100, + num_comments: 42, + author: 'testuser', + created_at_i: Math.floor(Date.now() / 1000) - 3600, // 1h ago + ...overrides, + } +} + +describe('mapHnHitToFeedItem', () => { + it('maps all fields correctly', () => { + const item = mapHnHitToFeedItem(makeHit()) + expect(item.id).toBe('40000000') + expect(item.title).toBe('Test Title') + expect(item.subtitle).toBe('example.com') + expect(item.points).toBe(100) + expect(item.numComments).toBe(42) + expect(item.author).toBe('testuser') + expect(item.url).toBe('https://example.com/article') + }) + + it('uses "Untitled" when title is null', () => { + const item = mapHnHitToFeedItem(makeHit({ title: null })) + expect(item.title).toBe('Untitled') + }) + + it('falls back to author attribution when no URL', () => { + const item = mapHnHitToFeedItem(makeHit({ url: null, author: 'johndoe' })) + expect(item.subtitle).toBe('by johndoe') + expect(item.url).toBeUndefined() + }) + + it('falls back to "unknown" when both URL and author are missing', () => { + const item = mapHnHitToFeedItem(makeHit({ url: null, author: null })) + expect(item.subtitle).toBe('by unknown') + }) + + it('produces a deterministic accent from objectID', () => { + const ACCENT_VARIANTS = ['success', 'primary', 'info', 'warning'] as const + const item0 = mapHnHitToFeedItem(makeHit({ objectID: '0' })) + const item1 = mapHnHitToFeedItem(makeHit({ objectID: '1' })) + const item2 = mapHnHitToFeedItem(makeHit({ objectID: '2' })) + const item3 = mapHnHitToFeedItem(makeHit({ objectID: '3' })) + expect(item0.type).toBe(ACCENT_VARIANTS[0]) + expect(item1.type).toBe(ACCENT_VARIANTS[1]) + expect(item2.type).toBe(ACCENT_VARIANTS[2]) + expect(item3.type).toBe(ACCENT_VARIANTS[3]) + // Wraps around + const item4 = mapHnHitToFeedItem(makeHit({ objectID: '4' })) + expect(item4.type).toBe(ACCENT_VARIANTS[0]) + }) + + it('formats recent time as relative string', () => { + const recent = Math.floor(Date.now() / 1000) - 30 // 30s ago + const item = mapHnHitToFeedItem(makeHit({ created_at_i: recent })) + expect(item.time).toBe('just now') + }) + + it('formats old time as hour-based relative string', () => { + const twoHoursAgo = Math.floor(Date.now() / 1000) - 7200 + const item = mapHnHitToFeedItem(makeHit({ created_at_i: twoHoursAgo })) + expect(item.time).toBe('2h ago') + }) + + it('numComments defaults to 0 when null', () => { + const item = mapHnHitToFeedItem(makeHit({ num_comments: null })) + expect(item.numComments).toBe(0) + }) +}) diff --git a/src/features/home/services/hn/hn.mappers.ts b/src/features/home/services/hn/hn.mappers.ts new file mode 100644 index 0000000..33cbe9c --- /dev/null +++ b/src/features/home/services/hn/hn.mappers.ts @@ -0,0 +1,40 @@ +import type { FeedItem } from '@/features/home/types' +import { formatRelativeTime } from '@/shared/utils/format-relative-time' +import type { HnHit } from './hn.schemas' + +const ACCENT_VARIANTS: FeedItem['type'][] = [ + 'success', + 'primary', + 'info', + 'warning', +] + +/** Deterministic accent colour from objectID — gives visual variety without random flicker. */ +function mapAccent(objectID: string): FeedItem['type'] { + return ( + ACCENT_VARIANTS[parseInt(objectID, 10) % ACCENT_VARIANTS.length] ?? + 'primary' + ) +} + +export function parseDomain(url: string | null | undefined): string | null { + if (!url) return null + const match = url.match(/^https?:\/\/(?:www\.)?([^/]+)/) + return match?.[1] ?? null +} + +export function mapHnHitToFeedItem(hit: HnHit): FeedItem { + const domain = parseDomain(hit.url) + + return { + id: hit.objectID, + type: mapAccent(hit.objectID), + title: hit.title ?? 'Untitled', + subtitle: domain ?? `by ${hit.author ?? 'unknown'}`, + time: formatRelativeTime(hit.created_at_i * 1_000), + url: hit.url ?? undefined, + points: hit.points ?? undefined, + author: hit.author ?? undefined, + numComments: hit.num_comments ?? 0, + } +} diff --git a/src/features/home/services/hn/hn.schemas.ts b/src/features/home/services/hn/hn.schemas.ts new file mode 100644 index 0000000..034ebe2 --- /dev/null +++ b/src/features/home/services/hn/hn.schemas.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const HnHitSchema = z.object({ + objectID: z.string(), + title: z.string().nullable(), + url: z.string().nullable().optional(), + points: z.number().nullable().optional(), + num_comments: z.number().nullable().optional(), + author: z.string().nullable().optional(), + created_at_i: z.number(), +}) + +export const HnSearchResponseSchema = z.object({ + hits: z.array(HnHitSchema), +}) + +export type HnHit = z.infer diff --git a/src/features/home/services/hn/hn.service.ts b/src/features/home/services/hn/hn.service.ts new file mode 100644 index 0000000..b876b62 --- /dev/null +++ b/src/features/home/services/hn/hn.service.ts @@ -0,0 +1,25 @@ +import { create } from 'apisauce' +import type { FeedItem } from '@/features/home/types' +import { attachLogging } from '@/shared/services/api/http/interceptors/logging.interceptor' +import { normalizeError } from '@/shared/utils/normalize-error' +import { mapHnHitToFeedItem } from './hn.mappers' +import { HnSearchResponseSchema } from './hn.schemas' + +const hnClient = create({ + baseURL: 'https://hn.algolia.com/api/v1', + timeout: 10_000, + headers: { Accept: 'application/json' }, +}) + +attachLogging(hnClient) + +export async function fetchHnFeed(): Promise { + const res = await hnClient.get('/search', { tags: 'front_page' }) + if (!res.ok) { + throw normalizeError( + res.originalError ?? new Error('HN API request failed'), + ) + } + const parsed = HnSearchResponseSchema.parse(res.data) + return parsed.hits.map(mapHnHitToFeedItem) +} diff --git a/src/features/home/types/index.ts b/src/features/home/types/index.ts index 46f5068..a02d606 100644 --- a/src/features/home/types/index.ts +++ b/src/features/home/types/index.ts @@ -1,6 +1,18 @@ /** * Home feature — shared interfaces and type aliases. - * Add exports as the slice grows; import via `@/features/home/types`. */ -export {} +/** Visual accent variant — drives dot colour in the feed card. Purely cosmetic; not a domain concept. */ +export type AccentVariant = 'success' | 'primary' | 'info' | 'warning' + +export type FeedItem = { + id: string + type: AccentVariant + title: string + subtitle: string + time: string + url?: string + points?: number + author?: string + numComments?: number +} diff --git a/src/features/settings/components/SettingsRow.tsx b/src/features/settings/components/SettingsRow.tsx index 71445ac..57d368e 100644 --- a/src/features/settings/components/SettingsRow.tsx +++ b/src/features/settings/components/SettingsRow.tsx @@ -1,5 +1,7 @@ +import { IconName } from '@assets/icons' import React from 'react' import { Pressable, StyleSheet, View } from 'react-native' +import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme/useTheme' @@ -8,6 +10,9 @@ interface SettingsRowProps { value?: string onPress?: () => void danger?: boolean + icon?: IconName + iconBg?: string + iconColor?: string } export function SettingsRow({ @@ -15,26 +20,52 @@ export function SettingsRow({ value, onPress, danger, + icon, + iconBg, + iconColor, }: SettingsRowProps) { const { theme } = useTheme() const labelColor = danger ? theme.colors.danger : theme.colors.textPrimary const chevronColor = theme.colors.textTertiary + const resolvedIconColor = + iconColor ?? (danger ? theme.colors.danger : theme.colors.textPrimary) return ( [ styles.row, { - paddingVertical: theme.spacing.sm, + paddingVertical: theme.spacing.md, paddingHorizontal: theme.spacing.md, }, pressed && { backgroundColor: theme.colors.overlayLight }, ]} > - + {icon != null ? ( + + + + ) : null} + + {label} @@ -74,10 +105,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - minHeight: 44, + minHeight: 56, }, trailing: { flexDirection: 'row', alignItems: 'center', }, + iconBadge: { + alignItems: 'center', + justifyContent: 'center', + }, }) diff --git a/src/features/settings/components/SettingsSection.tsx b/src/features/settings/components/SettingsSection.tsx index 133d400..bf0e24d 100644 --- a/src/features/settings/components/SettingsSection.tsx +++ b/src/features/settings/components/SettingsSection.tsx @@ -45,8 +45,8 @@ export function SettingsSection({ title, children }: SettingsSectionProps) { {idx < childArray.length - 1 ? ( diff --git a/src/features/settings/navigation/param-list.ts b/src/features/settings/navigation/param-list.ts deleted file mode 100644 index 5b3aab7..0000000 --- a/src/features/settings/navigation/param-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ROUTES } from '@/navigation/routes' - -export type SettingsStackParamList = { - [ROUTES.SETTINGS_ROOT]: undefined - [ROUTES.SETTINGS_LANGUAGE]: undefined - [ROUTES.SETTINGS_THEME]: undefined -} - -export type OnboardingStackParamList = { - [ROUTES.ONBOARDING_MAIN]: undefined -} diff --git a/src/features/settings/screens/LanguagePickerModal.tsx b/src/features/settings/screens/LanguagePickerModal.tsx index 9ae46d4..f3df1fe 100644 --- a/src/features/settings/screens/LanguagePickerModal.tsx +++ b/src/features/settings/screens/LanguagePickerModal.tsx @@ -1,12 +1,13 @@ // src/features/settings/screens/LanguagePickerModal.tsx -import React, { useCallback } from 'react' +import { IconName } from '@assets/icons' +import React, { memo, useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' -import Svg, { Polyline } from 'react-native-svg' import i18n from '@/i18n/i18n' import { useT } from '@/i18n/useT' import { goBack } from '@/navigation/helpers/navigation-helpers' -import HalfSheet from '@/navigation/modals/half-sheet' +import HalfSheet from '@/shared/components/ui/HalfSheet' +import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme/useTheme' @@ -16,14 +17,26 @@ const LANGUAGE_OPTIONS: { | 'settings.language.english' | 'settings.language.russian' | 'settings.language.german' - flag: string + abbr: string }[] = [ - { code: 'en', labelKey: 'settings.language.english', flag: '🇬🇧' }, - { code: 'ru', labelKey: 'settings.language.russian', flag: '🇷🇺' }, - { code: 'de', labelKey: 'settings.language.german', flag: '🇩🇪' }, + { code: 'en', labelKey: 'settings.language.english', abbr: 'EN' }, + { code: 'ru', labelKey: 'settings.language.russian', abbr: 'RU' }, + { code: 'de', labelKey: 'settings.language.german', abbr: 'DE' }, ] -export default function LanguagePickerModal() { +// ─── Item ───────────────────────────────────────────────────────────────────── + +interface LanguageOptionRowProps { + opt: (typeof LANGUAGE_OPTIONS)[number] + selected: boolean + onSelect: (code: string) => void +} + +const LanguageOptionRow = memo(function LanguageOptionRow({ + opt, + selected, + onSelect, +}: LanguageOptionRowProps) { const t = useT() const { theme } = useTheme() const c = theme.colors @@ -31,6 +44,80 @@ export default function LanguagePickerModal() { const r = theme.radius const ty = theme.typography + const handlePress = useCallback( + () => onSelect(opt.code), + [opt.code, onSelect], + ) + + return ( + [ + styles.row, + { + backgroundColor: selected + ? c.primaryAmbient + : pressed + ? c.surfaceSecondary + : c.surface, + borderColor: selected ? c.primary : c.border, + borderRadius: r.xl, + paddingVertical: sp.md, + paddingHorizontal: sp.md, + }, + ]} + > + + + {opt.abbr} + + + + {t(opt.labelKey)} + + {selected ? ( + + ) : null} + + ) +}) + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function LanguagePickerModal() { + const t = useT() + const { theme } = useTheme() + const c = theme.colors + const sp = theme.spacing + const ty = theme.typography + const currentLang = i18n.language const handleClose = useCallback(() => goBack(), []) @@ -42,66 +129,21 @@ export default function LanguagePickerModal() { return ( - {/* Title */} {t('settings.language.label')} - {/* Options */} - {LANGUAGE_OPTIONS.map(opt => { - const selected = currentLang === opt.code - return ( - handleSelect(opt.code)} - style={({ pressed }) => [ - styles.row, - { - backgroundColor: selected - ? c.primaryAmbient - : pressed - ? c.surfaceSecondary - : c.surface, - borderColor: selected ? c.primary : c.border, - borderRadius: r.xl, - paddingVertical: sp.md, - paddingHorizontal: sp.md, - }, - ]} - > - {opt.flag} - - {t(opt.labelKey)} - - {selected ? ( - - - - ) : null} - - ) - })} + {LANGUAGE_OPTIONS.map(opt => ( + + ))} ) @@ -113,4 +155,8 @@ const styles = StyleSheet.create({ alignItems: 'center', borderWidth: 1, }, + badge: { + alignItems: 'center', + justifyContent: 'center', + }, }) diff --git a/src/features/settings/screens/LanguageScreen.tsx b/src/features/settings/screens/LanguageScreen.tsx index c7ac3b6..6169eb5 100644 --- a/src/features/settings/screens/LanguageScreen.tsx +++ b/src/features/settings/screens/LanguageScreen.tsx @@ -13,6 +13,9 @@ export default function LanguageScreen() { const t = useT() const handleBack = useCallback(() => goBack(), []) + const handleEnglish = useCallback(() => i18n.changeLanguage('en'), []) + const handleRussian = useCallback(() => i18n.changeLanguage('ru'), []) + const handleGerman = useCallback(() => i18n.changeLanguage('de'), []) return (