From 9113efe206897082e25e5142bda73b0b0e398f60 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 01:07:59 +0200 Subject: [PATCH 01/17] feat: HN news reader with offline-first feed and premium UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home screen: replaces demo feed with live Hacker News Algolia API (Zod schema + mapper → React Query nearRealtime + MMKV persister) - StoryScreen: full in-app WebView reader with back/close navigation, falls back to HN discussion page for link-less posts - Settings: premium redesign — glassmorphism-style profile card, icon badges per row (sun/moon/globe/info/logout), improved dividers - Auth: social login buttons with text labels, Apple icon viewport fix, subtitle wrapping fix, demo hint single-line, terms padding fix - Icons: add check, sun, moon, globe, info, logout, layers SVGs - i18n: updated home section labels across en/de/ru - Navigation: HOME_STORY route added to root stack param list - Install react-native-webview for in-app article reading - Fix Jest config: add react-native-webview to transformIgnorePatterns and add WebView mock to jest.setup.js Co-Authored-By: Claude Sonnet 4.6 --- assets/icons.ts | 35 +- assets/svgs/check.svg | 3 + assets/svgs/globe.svg | 4 + assets/svgs/info.svg | 5 + assets/svgs/layers.svg | 5 + assets/svgs/logout.svg | 4 + assets/svgs/moon.svg | 3 + assets/svgs/sun.svg | 4 + ios/Podfile.lock | 32 + jest.config.js | 2 +- jest.setup.js | 8 + package-lock.json | 24 +- package.json | 1 + src/config/constants.ts | 1 + src/features/auth/screens/AuthScreen.tsx | 29 +- src/features/home/api/keys.ts | 3 + src/features/home/hooks/useFeedQuery.ts | 40 + src/features/home/screens/HomeScreen.tsx | 935 ++++-------------- src/features/home/screens/StoryScreen.tsx | 155 +++ src/features/home/services/hn/hn.mappers.ts | 46 + src/features/home/services/hn/hn.schemas.ts | 17 + src/features/home/services/hn/hn.service.ts | 20 + src/features/home/types/index.ts | 15 +- .../settings/components/SettingsRow.tsx | 36 +- .../settings/components/SettingsSection.tsx | 4 +- .../settings/screens/LanguagePickerModal.tsx | 44 +- .../settings/screens/SettingsScreen.tsx | 134 ++- .../settings/screens/ThemePickerModal.tsx | 31 +- src/features/uikit/screens/UIKitScreen.tsx | 278 ++++++ src/i18n/i18n-types.d.ts | 167 ++-- src/i18n/locales/de.json | 9 +- src/i18n/locales/en.json | 9 +- src/i18n/locales/ru.json | 9 +- src/navigation/root-param-list.ts | 12 + src/navigation/root/root-navigator.tsx | 6 + src/navigation/routes.ts | 6 + src/navigation/tabs/AnimatedTabBar.tsx | 2 + 37 files changed, 1166 insertions(+), 972 deletions(-) create mode 100644 assets/svgs/check.svg create mode 100644 assets/svgs/globe.svg create mode 100644 assets/svgs/info.svg create mode 100644 assets/svgs/layers.svg create mode 100644 assets/svgs/logout.svg create mode 100644 assets/svgs/moon.svg create mode 100644 assets/svgs/sun.svg create mode 100644 src/features/home/api/keys.ts create mode 100644 src/features/home/hooks/useFeedQuery.ts create mode 100644 src/features/home/screens/StoryScreen.tsx create mode 100644 src/features/home/services/hn/hn.mappers.ts create mode 100644 src/features/home/services/hn/hn.schemas.ts create mode 100644 src/features/home/services/hn/hn.service.ts create mode 100644 src/features/uikit/screens/UIKitScreen.tsx diff --git a/assets/icons.ts b/assets/icons.ts index 0c80d1d..c7dcff0 100644 --- a/assets/icons.ts +++ b/assets/icons.ts @@ -1,20 +1,45 @@ + // AUTO-GENERATED FILE — DO NOT EDIT MANUALLY // Run: npm run gen:icons -import Home from '@assets/svgs/home.svg' -import Settings from '@assets/svgs/settings.svg' -import User from '@assets/svgs/user.svg' +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 -export type IconNameType = keyof typeof AppIcon +} as const; + +export type IconNameType = keyof typeof AppIcon; 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/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..4506b0c 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -110,6 +110,14 @@ 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/config/constants.ts b/src/config/constants.ts index b19a162..4888aae 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -20,6 +20,7 @@ export const constants = { /** MMKV key (`navigationStorage`) for persisted React Navigation root state. */ NAVIGATION_STATE_V1: 'navigation.state.v1', + } export const flags = { diff --git a/src/features/auth/screens/AuthScreen.tsx b/src/features/auth/screens/AuthScreen.tsx index 658e849..c3965dc 100644 --- a/src/features/auth/screens/AuthScreen.tsx +++ b/src/features/auth/screens/AuthScreen.tsx @@ -250,7 +250,7 @@ const FacebookIcon = () => ( ) const AppleIcon = ({ color }: { color: string }) => ( - + ) @@ -555,13 +555,15 @@ export default function AuthScreen() { {t('auth.subtitle')} {flags.USE_MOCK ? ( @@ -589,18 +592,10 @@ export default function AuthScreen() { ]} > {[ - { key: 'google' as const, Icon: , press: social0 }, - { - key: 'facebook' as const, - Icon: , - press: social1, - }, - { - key: 'apple' as const, - Icon: , - press: social2, - }, - ].map(({ key, Icon, press }) => ( + { 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, label, press }) => ( {Icon} + {label} ))} @@ -795,7 +792,7 @@ export default function AuthScreen() { {/* Terms */} ['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..e543682 --- /dev/null +++ b/src/features/home/hooks/useFeedQuery.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' +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' + +function formatSyncedAt(ts: number): string { + if (!ts) return '' + const diffMs = Date.now() - ts + const m = Math.floor(diffMs / 60_000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + return `${h}h ago` +} + +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 syncedAt = query.dataUpdatedAt ?? 0 + const syncedAtLabel = syncedAt ? formatSyncedAt(syncedAt) : null + + return { + feed: query.data ?? [], + isLoading: query.isLoading, + isRefetching: query.isRefetching, + refetch: query.refetch, + hasCache: !!query.data, + syncedAtLabel, + } +} diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index e412e4f..c591852 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -1,213 +1,33 @@ // src/features/home/screens/HomeScreen.tsx import { FlashList } from '@shopify/flash-list' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import React, { memo, useCallback, useEffect, useRef } 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 { Animated, Platform, Pressable, ScrollView, View } from 'react-native' +import { useFeedQuery } from '@/features/home/hooks/useFeedQuery' +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 { 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 -} - -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 HomeNavProp = NativeStackNavigationProp -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', - }, -] +const TAB_BAR_CLEARANCE = 88 -// ─── Shimmer ───────────────────────────────────────────────────────────────── +// ─── Shimmer skeleton ───────────────────────────────────────────────────────── 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, - }), + Animated.timing(anim, { toValue: 1, duration: 750, useNativeDriver: true }), + Animated.timing(anim, { toValue: 0.4, duration: 750, useNativeDriver: true }), ]), ) loop.start() @@ -216,37 +36,34 @@ function useShimmer() { return anim } -function SkeletonBlock({ - w, - h = 14, - radius, - shimmer, - style, -}: { - w: number | string - h?: number - radius?: number - shimmer: Animated.Value - style?: object -}) { +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,127 +71,48 @@ 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 +123,22 @@ function useGreetingKey(): return 'home.greeting_evening' } -type ActivityColors = { bg: string; accent: string } -function typeConfig( - type: ActivityType, +function dotColor( + type: FeedItem['type'], c: ReturnType['theme']['colors'], -): ActivityColors { - 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' } - } -} - -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, - } +): string { switch (type) { - case 'success': - return ( - - - - - ) - case 'task': - return ( - - - - - - ) - case 'message': - return ( - - - - ) - case 'alert': - return ( - - - - - ) + case 'success': return c.success + case 'task': return c.primary + case 'message': return c.info + case 'alert': return c.warning } } -// ─── Sub-components ─────────────────────────────────────────────────────────── - +// ─── Greeting ───────────────────────────────────────────────────────────────── function GreetingSection({ - name, greetingKey, }: { - name: string greetingKey: ReturnType }) { const t = useT() @@ -472,477 +153,197 @@ function GreetingSection({ }) return ( - + {today} - - {t(greetingKey)} - {name ? `, ${name.split(' ')[0]}` : ''} 👋 - + {t(greetingKey)} ) } -function SectionHeader({ label }: { label: string }) { - const { theme } = useTheme() - return ( - - {label} - - ) -} - -type StatTrend = { kind: 'text'; value: string } | { kind: 'flame' } - -function StatTrendBadge({ - trend, - color, - radius, +// ─── Section header with optional sync status ───────────────────────────────── +function SectionHeader({ + label, + sublabel, + sublabelIsOffline, }: { - trend: StatTrend - color: string - radius: number + label: string + sublabel?: string | null + sublabelIsOffline?: boolean }) { - const { theme } = useTheme() - const ty = theme.typography - const SIZE = 28 - return ( - - {trend.kind === 'text' ? ( - {trend.value} - ) : ( - - - - )} - - ) -} - -function StatsRow() { - const t = useT() 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, - }, - ] - 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 ( - - - - - {t('home.featured_title')} - - - {t('home.featured_subtitle')} - - - - - {done}/{total} - - - - + {label} + {sublabel ? ( + + {sublabel} + - - {Math.round(progress * 100)}% complete - - + ) : null} ) } -// ─── Activity card (redesigned — full card, more breathing room) ────────────── -const ActivityRow = memo(function ActivityRow({ item }: { item: FeedItem }) { +// ─── 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 cfg = typeConfig(item.type, c) + const accent = dotColor(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 ?? undefined, + }) + }, [navigation, item]) return ( - ({ + marginHorizontal: sp.lg, + marginBottom: sp.xs, + backgroundColor: pressed ? c.surfaceSecondary : c.surface, + borderRadius: r.xl, + borderWidth: 1, + borderColor: c.border, + paddingHorizontal: sp.md, + paddingVertical: sp.md, + gap: sp.xs, + ...Platform.select({ ios: { ...theme.elevation.card }, android: { elevation: 1 } }), + })} > - {/* Colored left accent bar */} - - - {/* Icon */} - - - + {item.title} + - {/* Content */} - - - {item.title} - - + + + {item.subtitle} + {item.points != null && ( + <> + {'·'} + {`▲ ${item.points}`} + + )} + {item.numComments != null && ( + <> + {'·'} + {`${item.numComments} comments`} + + )} + {'·'} + {item.time} - - {/* Time badge */} - - - {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 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 }, -}) diff --git a/src/features/home/screens/StoryScreen.tsx b/src/features/home/screens/StoryScreen.tsx new file mode 100644 index 0000000..259a7de --- /dev/null +++ b/src/features/home/screens/StoryScreen.tsx @@ -0,0 +1,155 @@ +// src/features/home/screens/StoryScreen.tsx + +import type { NativeStackScreenProps } from '@react-navigation/native-stack' +import React, { useRef, useState } from 'react' +import { ActivityIndicator, Pressable, View } from 'react-native' +import Svg, { Path, Polyline } from 'react-native-svg' +import WebView from 'react-native-webview' +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 { useTheme } from '@/shared/theme/useTheme' + +type Props = NativeStackScreenProps + +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 [loading, setLoading] = useState(true) + const [canGoBack, setCanGoBack] = useState(false) + const webViewRef = useRef(null) + + const iconProps = { + width: 20, + height: 20, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c.textPrimary, + strokeWidth: 2.2, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + } + + function handleBack() { + if (canGoBack) { + webViewRef.current?.goBack() + } else { + navigation.goBack() + } + } + + return ( + + {/* Back / WebView back */} + + + + + + + {/* Close (always goes to feed) */} + + + {displayHost} + + + + {/* Close button */} + navigation.goBack()} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + accessibilityRole="button" + accessibilityLabel="Close article" + style={{ + padding: sp.xs, + backgroundColor: c.surfaceSecondary, + borderRadius: r.pill, + }} + > + + + + + + } + > + setLoading(true)} + onLoadEnd={() => setLoading(false)} + onNavigationStateChange={state => setCanGoBack(state.canGoBack)} + allowsBackForwardNavigationGestures + allowsInlineMediaPlayback + mediaPlaybackRequiresUserAction + startInLoadingState={false} + /> + + {/* Loading bar */} + {loading && ( + + + + + )} + + ) +} 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..7c32997 --- /dev/null +++ b/src/features/home/services/hn/hn.mappers.ts @@ -0,0 +1,46 @@ +import type { FeedItem } from '@/features/home/types' +import type { HnHit } from './hn.schemas' + +const TYPES: FeedItem['type'][] = ['success', 'task', 'message', 'alert'] + +function mapType(objectID: string): FeedItem['type'] { + return TYPES[parseInt(objectID, 10) % TYPES.length] ?? 'task' +} + +function parseDomain(url: string | null | undefined): string | null { + if (!url) return null + try { + const match = url.match(/^https?:\/\/(?:www\.)?([^/]+)/) + return match?.[1] ?? null + } catch { + return null + } +} + +function relativeTime(epochSeconds: number): string { + const diffMs = Date.now() - epochSeconds * 1000 + const m = Math.floor(diffMs / 60_000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +export function mapHnHitToFeedItem(hit: HnHit): FeedItem { + const domain = parseDomain(hit.url) + const numComments = hit.num_comments ?? 0 + const subtitle = domain ?? `by ${hit.author ?? 'unknown'}` + + return { + id: hit.objectID, + type: mapType(hit.objectID), + title: hit.title ?? 'Untitled', + subtitle, + time: relativeTime(hit.created_at_i), + url: hit.url ?? undefined, + points: hit.points ?? undefined, + author: hit.author ?? undefined, + numComments, + } +} 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..81fa341 --- /dev/null +++ b/src/features/home/services/hn/hn.service.ts @@ -0,0 +1,20 @@ +import { create } from 'apisauce' +import type { FeedItem } from '@/features/home/types' +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' }, +}) + +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..02d358a 100644 --- a/src/features/home/types/index.ts +++ b/src/features/home/types/index.ts @@ -1,6 +1,17 @@ /** * Home feature — shared interfaces and type aliases. - * Add exports as the slice grows; import via `@/features/home/types`. */ -export {} +export type ActivityType = 'task' | 'message' | 'alert' | 'success' + +export type FeedItem = { + id: string + type: ActivityType + 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..f5c0971 100644 --- a/src/features/settings/components/SettingsRow.tsx +++ b/src/features/settings/components/SettingsRow.tsx @@ -1,5 +1,7 @@ import React from 'react' import { Pressable, StyleSheet, View } from 'react-native' +import { IconName } from '@assets/icons' +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,11 +20,15 @@ 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 +100,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/screens/LanguagePickerModal.tsx b/src/features/settings/screens/LanguagePickerModal.tsx index 9ae46d4..2d6575e 100644 --- a/src/features/settings/screens/LanguagePickerModal.tsx +++ b/src/features/settings/screens/LanguagePickerModal.tsx @@ -2,11 +2,12 @@ import React, { 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 { IconName } from '@assets/icons' +import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme/useTheme' @@ -16,11 +17,11 @@ 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() { @@ -72,7 +73,21 @@ export default function LanguagePickerModal() { }, ]} > - {opt.flag} + + + {opt.abbr} + + {selected ? ( - - - + ) : null} ) @@ -113,4 +117,8 @@ const styles = StyleSheet.create({ alignItems: 'center', borderWidth: 1, }, + badge: { + alignItems: 'center', + justifyContent: 'center', + }, }) diff --git a/src/features/settings/screens/SettingsScreen.tsx b/src/features/settings/screens/SettingsScreen.tsx index e7ecb2b..2294ae6 100644 --- a/src/features/settings/screens/SettingsScreen.tsx +++ b/src/features/settings/screens/SettingsScreen.tsx @@ -8,6 +8,7 @@ import { useT } from '@/i18n/useT' import { navigate } from '@/navigation/helpers/navigation-helpers' import { ROUTES } from '@/navigation/routes' import { performLogout } from '@/session/logout' +import { IconName } from '@assets/icons' import { ScreenHeader } from '@/shared/components/ui/ScreenHeader' import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' import { Text } from '@/shared/components/ui/Text' @@ -29,22 +30,18 @@ export default function SettingsScreen() { const { theme, mode } = useTheme() const t = useT() const me = useMeQuery() - const themeModeLabel = t(THEME_MODE_KEYS[mode]) + const c = theme.colors + const sp = theme.spacing + const r = theme.radius + const ty = theme.typography + const themeModeLabel = t(THEME_MODE_KEYS[mode]) const currentLang = i18n.language const languageLabel = LANGUAGE_LABELS[currentLang] ?? currentLang - const openThemePicker = useCallback(() => { - navigate(ROUTES.MODAL_THEME_PICKER) - }, []) - - const openLanguagePicker = useCallback(() => { - navigate(ROUTES.MODAL_LANGUAGE_PICKER) - }, []) - - const handleLogout = useCallback(() => { - performLogout() - }, []) + const openThemePicker = useCallback(() => navigate(ROUTES.MODAL_THEME_PICKER), []) + const openLanguagePicker = useCallback(() => navigate(ROUTES.MODAL_LANGUAGE_PICKER), []) + const handleLogout = useCallback(() => performLogout(), []) const userName = me.data?.name ?? '—' const userEmail = me.data?.email ?? undefined @@ -58,87 +55,110 @@ export default function SettingsScreen() { .join('') }, [me.data?.name]) + const themeIcon = mode === 'light' ? IconName.SUN : IconName.MOON + return ( }> - - {/* Account card */} - - + + + {/* Profile card */} + + + {/* Avatar with ring */} - - {initials} - + + {initials} + + - - + {/* Name + email */} + + {userName} {userEmail != null ? ( - + {userEmail} ) : null} - + - {/* Appearance section */} + {/* Appearance */} - {/* About section */} + {/* About */} - + {/* Logout */} @@ -149,15 +169,23 @@ export default function SettingsScreen() { } const styles = StyleSheet.create({ - accountRow: { + profileCard: { + overflow: 'hidden', + }, + profileRow: { flexDirection: 'row', alignItems: 'center', + gap: 16, + }, + avatarRing: { + alignItems: 'center', + justifyContent: 'center', }, avatar: { alignItems: 'center', justifyContent: 'center', }, - accountInfo: { + profileInfo: { flex: 1, }, }) diff --git a/src/features/settings/screens/ThemePickerModal.tsx b/src/features/settings/screens/ThemePickerModal.tsx index 70f0ede..d328ee5 100644 --- a/src/features/settings/screens/ThemePickerModal.tsx +++ b/src/features/settings/screens/ThemePickerModal.tsx @@ -2,10 +2,11 @@ import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' -import Svg, { Polyline } from 'react-native-svg' import { useT } from '@/i18n/useT' import { goBack } from '@/navigation/helpers/navigation-helpers' import HalfSheet from '@/navigation/modals/half-sheet' +import { IconName } from '@assets/icons' +import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import type { ThemeMode } from '@/shared/theme/ThemeContext' import { useTheme } from '@/shared/theme/useTheme' @@ -16,11 +17,11 @@ const THEME_OPTIONS: { | 'settings.theme_light' | 'settings.theme_dark' | 'settings.theme_system' - emoji: string + icon: IconName }[] = [ - { mode: 'light', labelKey: 'settings.theme_light', emoji: '☀️' }, - { mode: 'dark', labelKey: 'settings.theme_dark', emoji: '🌙' }, - { mode: 'system', labelKey: 'settings.theme_system', emoji: '⚙️' }, + { mode: 'light', labelKey: 'settings.theme_light', icon: IconName.SUN }, + { mode: 'dark', labelKey: 'settings.theme_dark', icon: IconName.MOON }, + { mode: 'system', labelKey: 'settings.theme_system', icon: IconName.SETTINGS }, ] export default function ThemePickerModal() { @@ -73,7 +74,12 @@ export default function ThemePickerModal() { }, ]} > - {opt.emoji} + {selected ? ( - - - + ) : null} ) diff --git a/src/features/uikit/screens/UIKitScreen.tsx b/src/features/uikit/screens/UIKitScreen.tsx new file mode 100644 index 0000000..ea6f8ea --- /dev/null +++ b/src/features/uikit/screens/UIKitScreen.tsx @@ -0,0 +1,278 @@ +// src/features/uikit/screens/UIKitScreen.tsx + +import React, { useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { IconName } from '@assets/icons' +import { useT } from '@/i18n/useT' +import { Activity } from '@/shared/components/ui/Activity' +import { Button } from '@/shared/components/ui/Button' +import { IconSvg } from '@/shared/components/ui/IconSvg' +import { ScreenHeader } from '@/shared/components/ui/ScreenHeader' +import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' +import { SuspenseBoundary } from '@/shared/components/ui/SuspenseBoundary' +import { Text } from '@/shared/components/ui/Text' +import { useTheme } from '@/shared/theme/useTheme' + +const ALL_ICONS: IconName[] = [ + IconName.HOME, + IconName.SETTINGS, + IconName.USER, + IconName.CHECK, + IconName.SUN, + IconName.MOON, + IconName.GLOBE, + IconName.INFO, + IconName.LOGOUT, + IconName.LAYERS, +] + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + const { theme } = useTheme() + return ( + + + {title} + + + {children} + + + ) +} + +function ColorSwatch({ color, label }: { color: string; label: string }) { + const { theme } = useTheme() + return ( + + + + {label} + + + {color} + + + ) +} + +export default function UIKitScreen() { + const t = useT() + const { theme } = useTheme() + const c = theme.colors + const sp = theme.spacing + const ty = theme.typography + + const [activityVisible, setActivityVisible] = useState(true) + const [loadingBtn, setLoadingBtn] = useState(false) + + const simulateLoading = () => { + setLoadingBtn(true) + setTimeout(() => setLoadingBtn(false), 2000) + } + + return ( + }> + + + {/* ── Buttons ── */} +
+
+ + {/* ── Typography ── */} +
+ {([ + ['displayLarge', 'Display Large'], + ['displayMedium', 'Display Medium'], + ['headlineLarge', 'Headline Large'], + ['headlineMedium', 'Headline Medium'], + ['headlineSmall', 'Headline Small'], + ['titleLarge', 'Title Large'], + ['titleMedium', 'Title Medium'], + ['bodyLarge', 'Body Large'], + ['bodyMedium', 'Body Medium'], + ['bodySmall', 'Body Small'], + ['labelLarge', 'Label Large'], + ['labelMedium', 'Label Medium'], + ['labelSmall', 'Label Small'], + ['caps', 'CAPS LABEL'], + ['mono', 'mono / code'], + ] as const).map(([scale, sample]) => ( + + {sample} + {scale} + + ))} +
+ + {/* ── Icons ── */} +
+ + {ALL_ICONS.map(name => ( + + + + + + {name.toLowerCase()} + + + ))} + +
+ + {/* ── Surfaces & States ── */} +
+ {/* Color swatches */} + Theme Colors + + + + + + + + + {/* Activity toggle */} + Activity (visibility toggle) +
+ +
+
+ ) +} + +const styles = StyleSheet.create({ + card: { + overflow: 'hidden', + }, + row: { + flexDirection: 'row', + }, + typographyRow: { + flexDirection: 'row', + alignItems: 'center', + }, + iconGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + iconCell: { + width: '20%', + alignItems: 'center', + }, + iconBadge: { + alignItems: 'center', + justifyContent: 'center', + }, + swatchRow: { + flexDirection: 'row', + alignItems: 'center', + }, + swatch: { + width: 28, + height: 28, + }, +}) diff --git a/src/i18n/i18n-types.d.ts b/src/i18n/i18n-types.d.ts index d480f96..1b7651f 100644 --- a/src/i18n/i18n-types.d.ts +++ b/src/i18n/i18n-types.d.ts @@ -4,87 +4,94 @@ import 'i18next' declare module 'i18next' { interface CustomTypeOptions { - defaultNS: 'translation' + defaultNS: 'translation'; resources: { translation: { - common: { - error_title: string - error_hint: string - retry: string - offline_banner: string - loading: string - } - auth: { - a11y_toggle_theme: string - welcome: string - subtitle: string - mock_demo_hint: string - or: string - email: string - email_placeholder: string - password: string - forgot_password: string - password_placeholder: string - signing_in: string - sign_in: string - no_account: string - sign_up: string - terms_prefix: string - terms_of_service: string - and: string - privacy_policy: string - } - home: { - stat_done: string - stat_active: string - stat_streak: string - featured_title: string - featured_subtitle: string - quick_actions: string - overview: string - recent_activity: string - title: string - greeting_morning: string - greeting_afternoon: string - greeting_evening: string - quick_action_task: string - quick_action_message: string - quick_action_schedule: string - quick_action_report: string - quick_action_upload: string - } - settings: { - language: { - label: string - english: string - russian: string - german: string - } - title: string - appearance: string - theme: string - about: string - version: string - logout: string - theme_light: string - theme_dark: string - theme_system: string - } - onboarding: { - headline: string - tagline: string - feature_1_title: string - feature_1_body: string - feature_2_title: string - feature_2_body: string - feature_3_title: string - feature_3_body: string - get_started: string - } - app: { - title: string - } - } - } + "common": { + "error_title": string; + "error_hint": string; + "retry": string; + "offline_banner": string; + "loading": string; + }; + "auth": { + "a11y_toggle_theme": string; + "welcome": string; + "subtitle": string; + "mock_demo_hint": string; + "or": string; + "email": string; + "email_placeholder": string; + "password": string; + "forgot_password": string; + "password_placeholder": string; + "signing_in": string; + "sign_in": string; + "no_account": string; + "sign_up": string; + "terms_prefix": string; + "terms_of_service": string; + "and": string; + "privacy_policy": string; + }; + "home": { + "stat_done": string; + "stat_active": string; + "stat_streak": string; + "featured_title": string; + "featured_subtitle": string; + "quick_actions": string; + "overview": string; + "recent_activity": string; + "title": string; + "greeting_morning": string; + "greeting_afternoon": string; + "greeting_evening": string; + "quick_action_task": string; + "quick_action_message": string; + "quick_action_schedule": string; + "quick_action_report": string; + "quick_action_upload": string; + }; + "settings": { + "language": { + "label": string; + "english": string; + "russian": string; + "german": string; + }; + "title": string; + "appearance": string; + "theme": string; + "about": string; + "version": string; + "logout": string; + "theme_light": string; + "theme_dark": string; + "theme_system": string; + }; + "onboarding": { + "headline": string; + "tagline": string; + "feature_1_title": string; + "feature_1_body": string; + "feature_2_title": string; + "feature_2_body": string; + "feature_3_title": string; + "feature_3_body": string; + "get_started": string; + }; + "uikit": { + "title": string; + "section_buttons": string; + "section_typography": string; + "section_icons": string; + "section_surfaces": string; + }; + "app": { + "title": string; + }; + }; + }; } } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 53fcf52..7e2086c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -34,7 +34,7 @@ "featured_subtitle": "Du machst das super — halte deinen Streak aufrecht.", "quick_actions": "Schnellaktionen", "overview": "Übersicht", - "recent_activity": "Letzte Aktivitäten", + "recent_activity": "Top-Storys", "title": "Startseite", "greeting_morning": "Guten Morgen", "greeting_afternoon": "Guten Tag", @@ -73,6 +73,13 @@ "feature_3_body": "Sofortige Synchronisierung auf allen Geräten", "get_started": "Loslegen" }, + "uikit": { + "title": "Komponenten", + "section_buttons": "Schaltflächen", + "section_typography": "Typografie", + "section_icons": "Icons", + "section_surfaces": "Oberflächen & Zustände" + }, "app": { "title": "App" } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5d3221e..fcb8293 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -34,7 +34,7 @@ "featured_subtitle": "You're doing great — keep your streak alive.", "quick_actions": "Quick actions", "overview": "Overview", - "recent_activity": "Recent activity", + "recent_activity": "Top Stories", "title": "Home", "greeting_morning": "Good morning", "greeting_afternoon": "Good afternoon", @@ -73,6 +73,13 @@ "feature_3_body": "Instant sync across all your devices", "get_started": "Get started" }, + "uikit": { + "title": "Components", + "section_buttons": "Buttons", + "section_typography": "Typography", + "section_icons": "Icons", + "section_surfaces": "Surfaces & States" + }, "app": { "title": "App" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 473ab1c..ade5ac0 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -34,7 +34,7 @@ "featured_subtitle": "Отличная работа — не останавливайтесь на достигнутом.", "quick_actions": "Быстрые действия", "overview": "Обзор", - "recent_activity": "Недавняя активность", + "recent_activity": "Топ статей", "title": "Главная", "greeting_morning": "Доброе утро", "greeting_afternoon": "Добрый день", @@ -73,6 +73,13 @@ "feature_3_body": "Мгновенная синхронизация на всех устройствах", "get_started": "Начать" }, + "uikit": { + "title": "Компоненты", + "section_buttons": "Кнопки", + "section_typography": "Типографика", + "section_icons": "Иконки", + "section_surfaces": "Поверхности и состояния" + }, "app": { "title": "Приложение" } diff --git a/src/navigation/root-param-list.ts b/src/navigation/root-param-list.ts index 1ca8c93..9889744 100644 --- a/src/navigation/root-param-list.ts +++ b/src/navigation/root-param-list.ts @@ -1,10 +1,22 @@ import { ROUTES } from '@/navigation/routes' +export type StoryScreenParams = { + id: string + title: string + url?: string + points?: number + author?: string + numComments?: number + time: string + domain?: string +} + /** Root navigator: onboarding, auth, main app shell, and global modals. */ export type RootStackParamList = { [ROUTES.ROOT_APP]: undefined [ROUTES.ROOT_AUTH]: undefined [ROUTES.ROOT_ONBOARDING]: undefined + [ROUTES.HOME_STORY]: StoryScreenParams [ROUTES.MODAL_THEME_PICKER]: undefined [ROUTES.MODAL_LANGUAGE_PICKER]: undefined } diff --git a/src/navigation/root/root-navigator.tsx b/src/navigation/root/root-navigator.tsx index 6d73cc9..0dec48c 100644 --- a/src/navigation/root/root-navigator.tsx +++ b/src/navigation/root/root-navigator.tsx @@ -5,6 +5,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' import React from 'react' +import StoryScreen from '@/features/home/screens/StoryScreen' import LanguagePickerModal from '@/features/settings/screens/LanguagePickerModal' import ThemePickerModal from '@/features/settings/screens/ThemePickerModal' import { RootStackParamList } from '@/navigation' @@ -34,6 +35,11 @@ export default function RootNavigator() { + Date: Wed, 25 Mar 2026 10:39:22 +0200 Subject: [PATCH 02/17] =?UTF-8?q?refactor(home):=20expert-level=20cleanup?= =?UTF-8?q?=20=E2=80=94=20tokens,=20memo,=20shared=20utils,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace magic numbers in StoryScreen with design token constants (HEADER_HEIGHT=spacing.xxxxxl, ICON_SIZE=spacing.lg, PROGRESS_BAR_HEIGHT=spacing.xxs) - Extract formatRelativeTime to shared/utils to eliminate duplication between hn.mappers and useFeedQuery - Rename ActivityType → AccentVariant; align values with theme color keys ('primary'/'info'/'warning' replacing 'task'/'message'/'alert') - Add useMemo/useCallback throughout StoryScreen and useFeedQuery - Add attachLogging(hnClient) for dev HTTP visibility - Remove dead code: TAB_COMPONENTS route, UIKitScreen, redundant subtitle??undefined - Add 14 unit tests for parseDomain and mapHnHitToFeedItem - All 41 tests pass, zero TypeScript errors Co-Authored-By: Claude Sonnet 4.6 --- src/features/home/hooks/useFeedQuery.ts | 22 +- src/features/home/screens/HomeScreen.tsx | 13 +- src/features/home/screens/StoryScreen.tsx | 183 +++++++----- .../home/services/hn/hn.mappers.test.ts | 106 +++++++ src/features/home/services/hn/hn.mappers.ts | 38 +-- src/features/home/services/hn/hn.service.ts | 3 + src/features/home/types/index.ts | 5 +- src/features/uikit/screens/UIKitScreen.tsx | 278 ------------------ src/navigation/routes.ts | 1 - src/navigation/tabs/AnimatedTabBar.tsx | 2 - src/shared/utils/format-relative-time.ts | 14 + 11 files changed, 265 insertions(+), 400 deletions(-) create mode 100644 src/features/home/services/hn/hn.mappers.test.ts delete mode 100644 src/features/uikit/screens/UIKitScreen.tsx create mode 100644 src/shared/utils/format-relative-time.ts diff --git a/src/features/home/hooks/useFeedQuery.ts b/src/features/home/hooks/useFeedQuery.ts index e543682..93c2862 100644 --- a/src/features/home/hooks/useFeedQuery.ts +++ b/src/features/home/hooks/useFeedQuery.ts @@ -1,18 +1,10 @@ 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' - -function formatSyncedAt(ts: number): string { - if (!ts) return '' - const diffMs = Date.now() - ts - const m = Math.floor(diffMs / 60_000) - if (m < 1) return 'just now' - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - return `${h}h ago` -} +import { formatRelativeTime } from '@/shared/utils/format-relative-time' export function useFeedQuery() { const query = useQuery({ @@ -20,14 +12,14 @@ export function useFeedQuery() { queryFn: fetchHnFeed, staleTime: Freshness.nearRealtime.staleTime, gcTime: Freshness.nearRealtime.gcTime, - meta: { - persistence: 'nearRealtime', - }, + meta: { persistence: 'nearRealtime' }, placeholderData: prev => prev, }) - const syncedAt = query.dataUpdatedAt ?? 0 - const syncedAtLabel = syncedAt ? formatSyncedAt(syncedAt) : null + const syncedAtLabel = useMemo(() => { + const ts = query.dataUpdatedAt + return ts ? formatRelativeTime(ts) : null + }, [query.dataUpdatedAt]) return { feed: query.data ?? [], diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index c591852..b831efd 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -123,15 +123,16 @@ function useGreetingKey(): return 'home.greeting_evening' } -function dotColor( +/** Maps AccentVariant → theme colour token for the feed card dot. */ +function accentColor( type: FeedItem['type'], c: ReturnType['theme']['colors'], ): string { switch (type) { case 'success': return c.success - case 'task': return c.primary - case 'message': return c.info - case 'alert': return c.warning + case 'primary': return c.primary + case 'info': return c.info + case 'warning': return c.warning } } @@ -222,7 +223,7 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { const sp = theme.spacing const r = theme.radius const ty = theme.typography - const accent = dotColor(item.type, c) + const accent = accentColor(item.type, c) const navigation = useNavigation() const onPress = useCallback(() => { @@ -234,7 +235,7 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { author: item.author, numComments: item.numComments, time: item.time, - domain: item.subtitle ?? undefined, + domain: item.subtitle, }) }, [navigation, item]) diff --git a/src/features/home/screens/StoryScreen.tsx b/src/features/home/screens/StoryScreen.tsx index 259a7de..e708088 100644 --- a/src/features/home/screens/StoryScreen.tsx +++ b/src/features/home/screens/StoryScreen.tsx @@ -1,18 +1,26 @@ // src/features/home/screens/StoryScreen.tsx import type { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useRef, useState } from 'react' -import { ActivityIndicator, Pressable, View } from 'react-native' +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 +// ─── 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) { @@ -26,59 +34,89 @@ export default function StoryScreen({ route, navigation }: Props) { const uri = url ?? `${HN_ITEM_BASE}${id}` const displayHost = domain ?? 'news.ycombinator.com' - const [loading, setLoading] = useState(true) + 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 iconProps = { - width: 20, - height: 20, - viewBox: '0 0 24 24', - fill: 'none', - stroke: c.textPrimary, - strokeWidth: 2.2, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - } - - function handleBack() { + 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 ( - {/* Back / WebView back */} - {/* Close (always goes to feed) */} - + - {/* Close button */} navigation.goBack()} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + hitSlop={{ top: sp.xs, bottom: sp.xs, left: sp.xs, right: sp.xs }} accessibilityRole="button" accessibilityLabel="Close article" - style={{ - padding: sp.xs, - backgroundColor: c.surfaceSecondary, - borderRadius: r.pill, - }} + style={[ + styles.iconBtn, + { backgroundColor: c.surfaceSecondary, borderRadius: r.pill }, + ]} > @@ -109,47 +145,54 @@ export default function StoryScreen({ route, navigation }: Props) { setLoading(true)} - onLoadEnd={() => setLoading(false)} - onNavigationStateChange={state => setCanGoBack(state.canGoBack)} + onLoadStart={handleLoadStart} + onLoadEnd={handleLoadEnd} + onLoadProgress={handleLoadProgress} + onNavigationStateChange={handleNavigationStateChange} allowsBackForwardNavigationGestures allowsInlineMediaPlayback mediaPlaybackRequiresUserAction startInLoadingState={false} /> - {/* Loading bar */} - {loading && ( - - - - + width: progressWidth, + }, + ]} + /> )} ) } + +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 index 7c32997..3e26cd1 100644 --- a/src/features/home/services/hn/hn.mappers.ts +++ b/src/features/home/services/hn/hn.mappers.ts @@ -1,46 +1,32 @@ import type { FeedItem } from '@/features/home/types' +import { formatRelativeTime } from '@/shared/utils/format-relative-time' import type { HnHit } from './hn.schemas' -const TYPES: FeedItem['type'][] = ['success', 'task', 'message', 'alert'] +const ACCENT_VARIANTS: FeedItem['type'][] = ['success', 'primary', 'info', 'warning'] -function mapType(objectID: string): FeedItem['type'] { - return TYPES[parseInt(objectID, 10) % TYPES.length] ?? 'task' +/** 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' } -function parseDomain(url: string | null | undefined): string | null { +export function parseDomain(url: string | null | undefined): string | null { if (!url) return null - try { - const match = url.match(/^https?:\/\/(?:www\.)?([^/]+)/) - return match?.[1] ?? null - } catch { - return null - } -} - -function relativeTime(epochSeconds: number): string { - const diffMs = Date.now() - epochSeconds * 1000 - const m = Math.floor(diffMs / 60_000) - if (m < 1) return 'just now' - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - return `${Math.floor(h / 24)}d ago` + const match = url.match(/^https?:\/\/(?:www\.)?([^/]+)/) + return match?.[1] ?? null } export function mapHnHitToFeedItem(hit: HnHit): FeedItem { const domain = parseDomain(hit.url) - const numComments = hit.num_comments ?? 0 - const subtitle = domain ?? `by ${hit.author ?? 'unknown'}` return { id: hit.objectID, - type: mapType(hit.objectID), + type: mapAccent(hit.objectID), title: hit.title ?? 'Untitled', - subtitle, - time: relativeTime(hit.created_at_i), + 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, + numComments: hit.num_comments ?? 0, } } diff --git a/src/features/home/services/hn/hn.service.ts b/src/features/home/services/hn/hn.service.ts index 81fa341..9bca106 100644 --- a/src/features/home/services/hn/hn.service.ts +++ b/src/features/home/services/hn/hn.service.ts @@ -1,5 +1,6 @@ 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' @@ -10,6 +11,8 @@ const hnClient = create({ headers: { Accept: 'application/json' }, }) +attachLogging(hnClient) + export async function fetchHnFeed(): Promise { const res = await hnClient.get('/search', { tags: 'front_page' }) if (!res.ok) { diff --git a/src/features/home/types/index.ts b/src/features/home/types/index.ts index 02d358a..a02d606 100644 --- a/src/features/home/types/index.ts +++ b/src/features/home/types/index.ts @@ -2,11 +2,12 @@ * Home feature — shared interfaces and type aliases. */ -export type ActivityType = 'task' | 'message' | 'alert' | 'success' +/** 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: ActivityType + type: AccentVariant title: string subtitle: string time: string diff --git a/src/features/uikit/screens/UIKitScreen.tsx b/src/features/uikit/screens/UIKitScreen.tsx deleted file mode 100644 index ea6f8ea..0000000 --- a/src/features/uikit/screens/UIKitScreen.tsx +++ /dev/null @@ -1,278 +0,0 @@ -// src/features/uikit/screens/UIKitScreen.tsx - -import React, { useState } from 'react' -import { StyleSheet, View } from 'react-native' -import { IconName } from '@assets/icons' -import { useT } from '@/i18n/useT' -import { Activity } from '@/shared/components/ui/Activity' -import { Button } from '@/shared/components/ui/Button' -import { IconSvg } from '@/shared/components/ui/IconSvg' -import { ScreenHeader } from '@/shared/components/ui/ScreenHeader' -import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' -import { SuspenseBoundary } from '@/shared/components/ui/SuspenseBoundary' -import { Text } from '@/shared/components/ui/Text' -import { useTheme } from '@/shared/theme/useTheme' - -const ALL_ICONS: IconName[] = [ - IconName.HOME, - IconName.SETTINGS, - IconName.USER, - IconName.CHECK, - IconName.SUN, - IconName.MOON, - IconName.GLOBE, - IconName.INFO, - IconName.LOGOUT, - IconName.LAYERS, -] - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - const { theme } = useTheme() - return ( - - - {title} - - - {children} - - - ) -} - -function ColorSwatch({ color, label }: { color: string; label: string }) { - const { theme } = useTheme() - return ( - - - - {label} - - - {color} - - - ) -} - -export default function UIKitScreen() { - const t = useT() - const { theme } = useTheme() - const c = theme.colors - const sp = theme.spacing - const ty = theme.typography - - const [activityVisible, setActivityVisible] = useState(true) - const [loadingBtn, setLoadingBtn] = useState(false) - - const simulateLoading = () => { - setLoadingBtn(true) - setTimeout(() => setLoadingBtn(false), 2000) - } - - return ( - }> - - - {/* ── Buttons ── */} -
-
- - {/* ── Typography ── */} -
- {([ - ['displayLarge', 'Display Large'], - ['displayMedium', 'Display Medium'], - ['headlineLarge', 'Headline Large'], - ['headlineMedium', 'Headline Medium'], - ['headlineSmall', 'Headline Small'], - ['titleLarge', 'Title Large'], - ['titleMedium', 'Title Medium'], - ['bodyLarge', 'Body Large'], - ['bodyMedium', 'Body Medium'], - ['bodySmall', 'Body Small'], - ['labelLarge', 'Label Large'], - ['labelMedium', 'Label Medium'], - ['labelSmall', 'Label Small'], - ['caps', 'CAPS LABEL'], - ['mono', 'mono / code'], - ] as const).map(([scale, sample]) => ( - - {sample} - {scale} - - ))} -
- - {/* ── Icons ── */} -
- - {ALL_ICONS.map(name => ( - - - - - - {name.toLowerCase()} - - - ))} - -
- - {/* ── Surfaces & States ── */} -
- {/* Color swatches */} - Theme Colors - - - - - - - - - {/* Activity toggle */} - Activity (visibility toggle) -
- -
-
- ) -} - -const styles = StyleSheet.create({ - card: { - overflow: 'hidden', - }, - row: { - flexDirection: 'row', - }, - typographyRow: { - flexDirection: 'row', - alignItems: 'center', - }, - iconGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - iconCell: { - width: '20%', - alignItems: 'center', - }, - iconBadge: { - alignItems: 'center', - justifyContent: 'center', - }, - swatchRow: { - flexDirection: 'row', - alignItems: 'center', - }, - swatch: { - width: 28, - height: 28, - }, -}) diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index c7ccc46..c6fdfcf 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -39,7 +39,6 @@ export const ROUTES = { // TAB_HOME: 'TAB_HOME', TAB_SETTINGS: 'TAB_SETTINGS', - TAB_COMPONENTS: 'TAB_COMPONENTS', // // SETTINGS STACK diff --git a/src/navigation/tabs/AnimatedTabBar.tsx b/src/navigation/tabs/AnimatedTabBar.tsx index 64f11c9..6c16a61 100644 --- a/src/navigation/tabs/AnimatedTabBar.tsx +++ b/src/navigation/tabs/AnimatedTabBar.tsx @@ -24,8 +24,6 @@ function iconForRoute(routeName: string): IconName { return IconName.HOME case ROUTES.TAB_SETTINGS: return IconName.SETTINGS - case ROUTES.TAB_COMPONENTS: - return IconName.LAYERS default: return IconName.USER } diff --git a/src/shared/utils/format-relative-time.ts b/src/shared/utils/format-relative-time.ts new file mode 100644 index 0000000..5f4c364 --- /dev/null +++ b/src/shared/utils/format-relative-time.ts @@ -0,0 +1,14 @@ +/** + * Returns a human-readable relative time string from an epoch timestamp. + * + * @param epochMs - Unix timestamp in **milliseconds** (pass `Date.now()` style) + */ +export function formatRelativeTime(epochMs: number): string { + const diffMs = Date.now() - epochMs + const m = Math.floor(diffMs / 60_000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} From d5e24b3abc8941e430a3a2e00ed9f07aa4b3e661 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 12:07:43 +0200 Subject: [PATCH 03/17] refactor(nav): migrate to static config (React Navigation 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace JSX-based dynamic config with static config objects: - Inline all thin stack wrappers (auth, onboarding, settings, home-tabs) into a single root-navigator.tsx using createNativeStackNavigator({screens}) - Replace NavigationContainer + AppLayout with createStaticNavigation(RootStack) - Add useT() to AnimatedTabBar for label derivation — no more tabBarLabel prop needed in tab screen options - Remove dead routes: HOME_STACK, HOME_TABS, SETTINGS_ROOT/LANGUAGE/THEME - Delete 6 files: auth-stack, onboarding-stack, settings-stack, home-stack, home-tabs, AppLayout - Delete 3 dead feature param-list directories (types now inferred by RN) Zero type errors, 41 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- App.tsx | 1 - src/features/auth/navigation/param-list.ts | 5 -- src/features/home/navigation/param-list.ts | 13 ---- .../settings/navigation/param-list.ts | 11 --- src/navigation/AppLayout.tsx | 11 --- src/navigation/NavigationRoot.tsx | 12 +-- src/navigation/index.ts | 15 +--- src/navigation/root/root-navigator.tsx | 74 +++++++++---------- src/navigation/routes.ts | 13 ---- src/navigation/stacks/auth-stack.tsx | 19 ----- src/navigation/stacks/home-stack.tsx | 52 ------------- src/navigation/stacks/onboarding-stack.tsx | 23 ------ src/navigation/stacks/settings-stack.tsx | 22 ------ src/navigation/tabs/AnimatedTabBar.tsx | 12 ++- src/navigation/tabs/home-tabs.tsx | 33 --------- 15 files changed, 52 insertions(+), 264 deletions(-) delete mode 100644 src/features/auth/navigation/param-list.ts delete mode 100644 src/features/home/navigation/param-list.ts delete mode 100644 src/features/settings/navigation/param-list.ts delete mode 100644 src/navigation/AppLayout.tsx delete mode 100644 src/navigation/stacks/auth-stack.tsx delete mode 100644 src/navigation/stacks/home-stack.tsx delete mode 100644 src/navigation/stacks/onboarding-stack.tsx delete mode 100644 src/navigation/stacks/settings-stack.tsx delete mode 100644 src/navigation/tabs/home-tabs.tsx 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/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/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/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/navigation/AppLayout.tsx b/src/navigation/AppLayout.tsx deleted file mode 100644 index 5801db9..0000000 --- a/src/navigation/AppLayout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react' - -import RootNavigator from '@/navigation/root/root-navigator' - -/** - * Root navigation shell (app layout). - * Screens live under features; this wires stacks, tabs, and root flow. - */ -export default function AppLayout() { - return -} diff --git a/src/navigation/NavigationRoot.tsx b/src/navigation/NavigationRoot.tsx index fd6ebe1..aa7ef0a 100644 --- a/src/navigation/NavigationRoot.tsx +++ b/src/navigation/NavigationRoot.tsx @@ -1,15 +1,15 @@ import type { NavigationState, PartialState } from '@react-navigation/native' import { + createStaticNavigation, DarkTheme, DefaultTheme, - NavigationContainer, } from '@react-navigation/native' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Linking, Platform, useColorScheme } from 'react-native' import BootSplash from 'react-native-bootsplash' -import AppLayout from '@/navigation/AppLayout' import { navigationRef } from '@/navigation/helpers/navigation-helpers' +import { RootStack } from '@/navigation/root/root-navigator' import { loadPersistedNavigationState, persistNavigationState, @@ -17,6 +17,8 @@ import { import { ThemedStatusBar } from '@/shared/components/ui/ThemedStatusBar' import { useTheme } from '@/shared/theme' +const Navigation = createStaticNavigation(RootStack) + /** * Navigation + StatusBar wired to app theme (inside ThemeProvider). * Persists root navigation state to MMKV unless a cold-start deep link is present. @@ -92,15 +94,13 @@ export function NavigationRoot() { return ( <> - BootSplash.hide({ fade: true })} - > - - + /> ) } diff --git a/src/navigation/index.ts b/src/navigation/index.ts index 6f474aa..ec29848 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -1,18 +1,5 @@ -export type { AuthStackParamList } from '@/features/auth/navigation/param-list' -export type { - HomeStackParamList, - HomeTabsParamList, -} from '@/features/home/navigation/param-list' -export type { - OnboardingStackParamList, - SettingsStackParamList, -} from '@/features/settings/navigation/param-list' -export type { RootStackParamList } from '@/navigation/root-param-list' +export type { RootStackParamList, StoryScreenParams } from '@/navigation/root-param-list' export * from './helpers/navigation-helpers' export * from './modals/global-modal' export * from './modals/half-sheet' export * from './root/root-navigator' -export * from './stacks/auth-stack' -export * from './stacks/home-stack' -export * from './stacks/onboarding-stack' -export * from './tabs/home-tabs' diff --git a/src/navigation/root/root-navigator.tsx b/src/navigation/root/root-navigator.tsx index 0dec48c..7dc2f65 100644 --- a/src/navigation/root/root-navigator.tsx +++ b/src/navigation/root/root-navigator.tsx @@ -1,22 +1,18 @@ -/** - * FILE: root-navigator.tsx - * LAYER: navigation/root - */ +// src/navigation/root/root-navigator.tsx +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' import { createNativeStackNavigator } from '@react-navigation/native-stack' import React from 'react' +import AuthScreen from '@/features/auth/screens/AuthScreen' +import HomeScreen from '@/features/home/screens/HomeScreen' import StoryScreen from '@/features/home/screens/StoryScreen' import LanguagePickerModal from '@/features/settings/screens/LanguagePickerModal' +import OnboardingScreen from '@/features/settings/screens/OnboardingScreen' +import SettingsScreen from '@/features/settings/screens/SettingsScreen' import ThemePickerModal from '@/features/settings/screens/ThemePickerModal' -import { RootStackParamList } from '@/navigation' import { ROUTES } from '@/navigation/routes' -import AuthStack from '@/navigation/stacks/auth-stack' -import OnboardingStack from '@/navigation/stacks/onboarding-stack' -import HomeTabs from '@/navigation/tabs/home-tabs' - -import { useBootstrapRoute } from '@/session/useBootstrapRoute' - -const Stack = createNativeStackNavigator() +import { AnimatedTabBar } from '@/navigation/tabs/AnimatedTabBar' +import { getBootstrapRoute } from '@/session/bootstrap' const HALF_SHEET_OPTIONS = { presentation: 'transparentModal', @@ -24,32 +20,30 @@ const HALF_SHEET_OPTIONS = { gestureEnabled: false, } as const -export default function RootNavigator() { - const boot = useBootstrapRoute() +const HomeTabs = createBottomTabNavigator({ + tabBar: (props) => , + screenOptions: { headerShown: false }, + screens: { + [ROUTES.TAB_HOME]: HomeScreen, + [ROUTES.TAB_SETTINGS]: SettingsScreen, + }, +}) - return ( - - - - - - - - - ) -} +export const RootStack = createNativeStackNavigator({ + initialRouteName: getBootstrapRoute(), + screenOptions: { headerShown: false }, + screens: { + [ROUTES.ROOT_ONBOARDING]: OnboardingScreen, + [ROUTES.ROOT_AUTH]: AuthScreen, + [ROUTES.ROOT_APP]: HomeTabs, + [ROUTES.HOME_STORY]: StoryScreen, + [ROUTES.MODAL_THEME_PICKER]: { + screen: ThemePickerModal, + options: HALF_SHEET_OPTIONS, + }, + [ROUTES.MODAL_LANGUAGE_PICKER]: { + screen: LanguagePickerModal, + options: HALF_SHEET_OPTIONS, + }, + }, +}) diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index c6fdfcf..ca1cc41 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -28,25 +28,12 @@ export const ROUTES = { // AUTH_LOGIN: 'AUTH_LOGIN', - // - // HOME FLOW - // - HOME_STACK: 'HOME_STACK', // parent stack - HOME_TABS: 'HOME_TABS', // bottom tabs container - // // HOME → TABS // TAB_HOME: 'TAB_HOME', TAB_SETTINGS: 'TAB_SETTINGS', - // - // SETTINGS STACK - // - SETTINGS_ROOT: 'SETTINGS_ROOT', - SETTINGS_LANGUAGE: 'SETTINGS_LANGUAGE', - SETTINGS_THEME: 'SETTINGS_THEME', - // // HOME SCREENS (pushed over tabs) // diff --git a/src/navigation/stacks/auth-stack.tsx b/src/navigation/stacks/auth-stack.tsx deleted file mode 100644 index 4c2ccb7..0000000 --- a/src/navigation/stacks/auth-stack.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createNativeStackNavigator } from '@react-navigation/native-stack' -import React from 'react' -import type { AuthStackParamList } from '@/features/auth/navigation/param-list' -import AuthScreen from '@/features/auth/screens/AuthScreen' -import { ROUTES } from '@/navigation/routes' - -const Stack = createNativeStackNavigator() - -export default function AuthStack() { - return ( - - - - ) -} diff --git a/src/navigation/stacks/home-stack.tsx b/src/navigation/stacks/home-stack.tsx deleted file mode 100644 index 7dfe15e..0000000 --- a/src/navigation/stacks/home-stack.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// src/app/navigation/options/createHomeScreenOptions.ts -import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs' -import type { TFunction } from 'i18next' -import { makeTabScreenOptions } from '@/navigation/options/tabOptions' -import { ROUTES } from '@/navigation/routes' - -type ThemeLike = { - colors: Record -} - -type TFunc = TFunction<'translation'> - -/** - * Factory that composes/extends base tab options. - * - No hooks here (safe to import anywhere). - * - Pass in theme & t from a component that can use hooks. - */ -export function createHomeScreenOptions(theme: ThemeLike, t: TFunc) { - const baseFactory = makeTabScreenOptions(theme, t) - - return ({ - route, - }: { - route: { name: string } - }): BottomTabNavigationOptions => { - // Start with the base options from our shared helper - const base = baseFactory({ route }) - - // Add/override per-route tweaks (examples below) - const perRouteOverrides: Partial = {} - - if (route.name === ROUTES.TAB_SETTINGS) { - // Example: custom label for Settings - perRouteOverrides.tabBarLabel = t('settings.title') - // Example: hide header (already false in base, shown just as a demo) - perRouteOverrides.headerShown = false - } - - // Global tweaks for all tabs (optional) - const globalOverrides: Partial = { - // Example: ensure consistent visibility of labels - tabBarShowLabel: true, - } - - // Merge order: base → per-route → global (last wins) - return { - ...base, - ...perRouteOverrides, - ...globalOverrides, - } - } -} diff --git a/src/navigation/stacks/onboarding-stack.tsx b/src/navigation/stacks/onboarding-stack.tsx deleted file mode 100644 index 361f9c7..0000000 --- a/src/navigation/stacks/onboarding-stack.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * FILE: onboarding-stack.tsx - * LAYER: navigation/stacks/onboarding - */ - -import { createNativeStackNavigator } from '@react-navigation/native-stack' -import React from 'react' -import type { OnboardingStackParamList } from '@/features/settings/navigation/param-list' -import OnboardingScreen from '@/features/settings/screens/OnboardingScreen' -import { ROUTES } from '@/navigation/routes' - -const Stack = createNativeStackNavigator() - -export default function OnboardingStack() { - return ( - - - - ) -} diff --git a/src/navigation/stacks/settings-stack.tsx b/src/navigation/stacks/settings-stack.tsx deleted file mode 100644 index e37467e..0000000 --- a/src/navigation/stacks/settings-stack.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createNativeStackNavigator } from '@react-navigation/native-stack' -import React from 'react' -import type { SettingsStackParamList } from '@/features/settings/navigation/param-list' -import LanguageScreen from '@/features/settings/screens/LanguageScreen' -import SettingsScreen from '@/features/settings/screens/SettingsScreen' -import ThemeScreen from '@/features/settings/screens/ThemeScreen' -import { ROUTES } from '@/navigation/routes' - -const Stack = createNativeStackNavigator() - -export default function SettingsStack() { - return ( - - - - - - ) -} diff --git a/src/navigation/tabs/AnimatedTabBar.tsx b/src/navigation/tabs/AnimatedTabBar.tsx index 6c16a61..f953407 100644 --- a/src/navigation/tabs/AnimatedTabBar.tsx +++ b/src/navigation/tabs/AnimatedTabBar.tsx @@ -10,6 +10,7 @@ import type { BottomTabBarProps } from '@react-navigation/bottom-tabs' import React, { useEffect, useRef } from 'react' import { Animated, Platform, Pressable, StyleSheet, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useT } from '@/i18n/useT' import { ROUTES } from '@/navigation/routes' import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' @@ -29,12 +30,21 @@ function iconForRoute(routeName: string): IconName { } } +function labelForRoute(routeName: string, t: ReturnType): string { + switch (routeName) { + case ROUTES.TAB_HOME: return t('home.title') + case ROUTES.TAB_SETTINGS: return t('settings.title') + default: return routeName + } +} + export function AnimatedTabBar({ state, descriptors, navigation, }: BottomTabBarProps) { const { theme } = useTheme() + const t = useT() const insets = useSafeAreaInsets() const c = theme.colors const sp = theme.spacing @@ -99,7 +109,7 @@ export function AnimatedTabBar({ ? options.tabBarLabel : typeof options.title === 'string' ? options.title - : route.name + : labelForRoute(route.name, t) const isFocused = state.index === index const p = progress[index] diff --git a/src/navigation/tabs/home-tabs.tsx b/src/navigation/tabs/home-tabs.tsx deleted file mode 100644 index 65b8b0b..0000000 --- a/src/navigation/tabs/home-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// src/navigation/tabs/home-tabs.tsx - -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' -import React from 'react' -import HomeScreen from '@/features/home/screens/HomeScreen' -import SettingsScreen from '@/features/settings/screens/SettingsScreen' -import { useT } from '@/i18n/useT' -import { ROUTES } from '@/navigation/routes' -import { AnimatedTabBar } from './AnimatedTabBar' - -const Tab = createBottomTabNavigator() - -export default function HomeTabs() { - const t = useT() - - return ( - } - screenOptions={{ headerShown: false }} - > - - - - ) -} From 666333cc7e28855da50bc1e9c9cdd7462eedd38c Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 12:37:40 +0200 Subject: [PATCH 04/17] fix(lint): apply biome format fixes across changed files Auto-fix 17 files: import organization, trailing commas, line wrapping. Zero lint errors, zero type errors, 41 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- assets/icons.ts | 28 ++- jest.setup.js | 4 +- src/config/constants.ts | 1 - src/features/auth/screens/AuthScreen.tsx | 32 +++- src/features/home/screens/HomeScreen.tsx | 162 +++++++++++++--- src/features/home/screens/StoryScreen.tsx | 14 +- src/features/home/services/hn/hn.mappers.ts | 12 +- src/features/home/services/hn/hn.service.ts | 4 +- .../settings/components/SettingsRow.tsx | 9 +- .../settings/screens/LanguagePickerModal.tsx | 9 +- .../settings/screens/SettingsScreen.tsx | 20 +- .../settings/screens/ThemePickerModal.tsx | 15 +- src/i18n/i18n-types.d.ts | 174 +++++++++--------- src/navigation/NavigationRoot.tsx | 2 +- src/navigation/index.ts | 5 +- src/navigation/root/root-navigator.tsx | 2 +- src/navigation/tabs/AnimatedTabBar.tsx | 9 +- 17 files changed, 336 insertions(+), 166 deletions(-) diff --git a/assets/icons.ts b/assets/icons.ts index c7dcff0..c4ae3a5 100644 --- a/assets/icons.ts +++ b/assets/icons.ts @@ -1,18 +1,16 @@ - // 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'; - +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', @@ -25,7 +23,6 @@ export enum IconName { SETTINGS = 'SETTINGS', SUN = 'SUN', USER = 'USER', - } export const AppIcon = { @@ -39,7 +36,6 @@ export const AppIcon = { [IconName.SETTINGS]: Settings, [IconName.SUN]: Sun, [IconName.USER]: User, +} as const -} as const; - -export type IconNameType = keyof typeof AppIcon; +export type IconNameType = keyof typeof AppIcon diff --git a/jest.setup.js b/jest.setup.js index 4506b0c..035af7e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -113,7 +113,9 @@ jest.mock('react-native-mmkv', () => { jest.mock('react-native-webview', () => { const React = require('react') const { View } = require('react-native') - const WebView = React.forwardRef((props, _ref) => React.createElement(View, props)) + const WebView = React.forwardRef((props, _ref) => + React.createElement(View, props), + ) WebView.displayName = 'WebView' return { __esModule: true, default: WebView } }) diff --git a/src/config/constants.ts b/src/config/constants.ts index 4888aae..b19a162 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -20,7 +20,6 @@ export const constants = { /** MMKV key (`navigationStorage`) for persisted React Navigation root state. */ NAVIGATION_STATE_V1: 'navigation.state.v1', - } export const flags = { diff --git a/src/features/auth/screens/AuthScreen.tsx b/src/features/auth/screens/AuthScreen.tsx index c3965dc..c17eb84 100644 --- a/src/features/auth/screens/AuthScreen.tsx +++ b/src/features/auth/screens/AuthScreen.tsx @@ -555,7 +555,12 @@ export default function AuthScreen() { {t('auth.subtitle')} @@ -592,9 +597,24 @@ export default function AuthScreen() { ]} > {[ - { 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 }, + { + 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, label, press }) => ( {Icon} - {label} + + {label} + ))} diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index b831efd..8dbe70f 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -1,8 +1,8 @@ // src/features/home/screens/HomeScreen.tsx -import { FlashList } from '@shopify/flash-list' 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 { Animated, Platform, Pressable, ScrollView, View } from 'react-native' import { useFeedQuery } from '@/features/home/hooks/useFeedQuery' @@ -26,8 +26,16 @@ function useShimmer() { 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 }), + Animated.timing(anim, { + toValue: 1, + duration: 750, + useNativeDriver: true, + }), + Animated.timing(anim, { + toValue: 0.4, + duration: 750, + useNativeDriver: true, + }), ]), ) loop.start() @@ -56,9 +64,30 @@ function SkeletonCard({ shimmer }: { shimmer: Animated.Value }) { }} > - - - + + +
) @@ -74,7 +103,10 @@ function HomeScreenSkeleton() { return ( {/* Greeting */} @@ -87,8 +119,22 @@ function HomeScreenSkeleton() { gap: sp.xs, }} > - - + + {/* Section header */} @@ -129,10 +175,14 @@ function accentColor( c: ReturnType['theme']['colors'], ): string { switch (type) { - case 'success': return c.success - case 'primary': return c.primary - case 'info': return c.info - case 'warning': return c.warning + case 'success': + return c.success + case 'primary': + return c.primary + case 'info': + return c.info + case 'warning': + return c.warning } } @@ -154,9 +204,18 @@ function GreetingSection({ }) return ( - + {today} - {t(greetingKey)} + + {t(greetingKey)} + ) } @@ -192,7 +251,9 @@ function SectionHeader({ style={{ flexDirection: 'row', alignItems: 'center', - backgroundColor: sublabelIsOffline ? c.warning + '22' : c.success + '22', + backgroundColor: sublabelIsOffline + ? c.warning + '22' + : c.success + '22', borderRadius: r.pill, paddingHorizontal: sp.xs, paddingVertical: 2, @@ -207,7 +268,12 @@ function SectionHeader({ backgroundColor: sublabelIsOffline ? c.warning : c.success, }} /> - + {sublabel} @@ -254,35 +320,61 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { paddingHorizontal: sp.md, paddingVertical: sp.md, gap: sp.xs, - ...Platform.select({ ios: { ...theme.elevation.card }, android: { elevation: 1 } }), + ...Platform.select({ + ios: { ...theme.elevation.card }, + android: { elevation: 1 }, + }), })} > {item.title} - - + + {item.subtitle} {item.points != null && ( <> - {'·'} - {`▲ ${item.points}`} + + {'·'} + + {`▲ ${item.points}`} )} {item.numComments != null && ( <> - {'·'} - {`${item.numComments} comments`} + + {'·'} + + {`${item.numComments} comments`} )} {'·'} - {item.time} + + {item.time} + ) @@ -295,13 +387,23 @@ export default function HomeScreen() { const c = theme.colors const greetingKey = useGreetingKey() - const { feed, isLoading: feedLoading, isRefetching, refetch, hasCache, syncedAtLabel } = - useFeedQuery() + const { + feed, + isLoading: feedLoading, + isRefetching, + refetch, + hasCache, + syncedAtLabel, + } = useFeedQuery() const { isOffline } = useOnlineStatus() const sublabel = isOffline - ? syncedAtLabel ? `Offline · ${syncedAtLabel}` : 'Offline' - : syncedAtLabel ? `Synced ${syncedAtLabel}` : null + ? syncedAtLabel + ? `Offline · ${syncedAtLabel}` + : 'Offline' + : syncedAtLabel + ? `Synced ${syncedAtLabel}` + : null const ListHeader = useCallback( () => ( diff --git a/src/features/home/screens/StoryScreen.tsx b/src/features/home/screens/StoryScreen.tsx index e708088..20f3c43 100644 --- a/src/features/home/screens/StoryScreen.tsx +++ b/src/features/home/screens/StoryScreen.tsx @@ -5,7 +5,10 @@ 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 { + 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' @@ -13,11 +16,14 @@ import { Text } from '@/shared/components/ui/Text' import { spacing } from '@/shared/theme/tokens/spacing' import { useTheme } from '@/shared/theme/useTheme' -type Props = NativeStackScreenProps +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 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 diff --git a/src/features/home/services/hn/hn.mappers.ts b/src/features/home/services/hn/hn.mappers.ts index 3e26cd1..33cbe9c 100644 --- a/src/features/home/services/hn/hn.mappers.ts +++ b/src/features/home/services/hn/hn.mappers.ts @@ -2,11 +2,19 @@ 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'] +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' + return ( + ACCENT_VARIANTS[parseInt(objectID, 10) % ACCENT_VARIANTS.length] ?? + 'primary' + ) } export function parseDomain(url: string | null | undefined): string | null { diff --git a/src/features/home/services/hn/hn.service.ts b/src/features/home/services/hn/hn.service.ts index 9bca106..b876b62 100644 --- a/src/features/home/services/hn/hn.service.ts +++ b/src/features/home/services/hn/hn.service.ts @@ -16,7 +16,9 @@ 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')) + 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/settings/components/SettingsRow.tsx b/src/features/settings/components/SettingsRow.tsx index f5c0971..d303292 100644 --- a/src/features/settings/components/SettingsRow.tsx +++ b/src/features/settings/components/SettingsRow.tsx @@ -1,6 +1,6 @@ +import { IconName } from '@assets/icons' import React from 'react' import { Pressable, StyleSheet, View } from 'react-native' -import { IconName } from '@assets/icons' import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme/useTheme' @@ -28,7 +28,8 @@ export function SettingsRow({ const labelColor = danger ? theme.colors.danger : theme.colors.textPrimary const chevronColor = theme.colors.textTertiary - const resolvedIconColor = iconColor ?? (danger ? theme.colors.danger : theme.colors.textPrimary) + const resolvedIconColor = + iconColor ?? (danger ? theme.colors.danger : theme.colors.textPrimary) return ( ) : null} - + {label} diff --git a/src/features/settings/screens/LanguagePickerModal.tsx b/src/features/settings/screens/LanguagePickerModal.tsx index 2d6575e..0531335 100644 --- a/src/features/settings/screens/LanguagePickerModal.tsx +++ b/src/features/settings/screens/LanguagePickerModal.tsx @@ -1,12 +1,12 @@ // src/features/settings/screens/LanguagePickerModal.tsx +import { IconName } from '@assets/icons' import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' 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 { IconName } from '@assets/icons' import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme/useTheme' @@ -101,7 +101,12 @@ export default function LanguagePickerModal() { {t(opt.labelKey)} {selected ? ( - + ) : null} ) diff --git a/src/features/settings/screens/SettingsScreen.tsx b/src/features/settings/screens/SettingsScreen.tsx index 2294ae6..cb62e91 100644 --- a/src/features/settings/screens/SettingsScreen.tsx +++ b/src/features/settings/screens/SettingsScreen.tsx @@ -1,3 +1,4 @@ +import { IconName } from '@assets/icons' import React, { useCallback, useMemo } from 'react' import { StyleSheet, View } from 'react-native' import { SettingsRow } from '@/features/settings/components/SettingsRow' @@ -8,7 +9,6 @@ import { useT } from '@/i18n/useT' import { navigate } from '@/navigation/helpers/navigation-helpers' import { ROUTES } from '@/navigation/routes' import { performLogout } from '@/session/logout' -import { IconName } from '@assets/icons' import { ScreenHeader } from '@/shared/components/ui/ScreenHeader' import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper' import { Text } from '@/shared/components/ui/Text' @@ -39,8 +39,14 @@ export default function SettingsScreen() { const currentLang = i18n.language const languageLabel = LANGUAGE_LABELS[currentLang] ?? currentLang - const openThemePicker = useCallback(() => navigate(ROUTES.MODAL_THEME_PICKER), []) - const openLanguagePicker = useCallback(() => navigate(ROUTES.MODAL_LANGUAGE_PICKER), []) + const openThemePicker = useCallback( + () => navigate(ROUTES.MODAL_THEME_PICKER), + [], + ) + const openLanguagePicker = useCallback( + () => navigate(ROUTES.MODAL_LANGUAGE_PICKER), + [], + ) const handleLogout = useCallback(() => performLogout(), []) const userName = me.data?.name ?? '—' @@ -60,7 +66,6 @@ export default function SettingsScreen() { return ( }> - {/* Profile card */} {userEmail != null ? ( - + {userEmail} ) : null} diff --git a/src/features/settings/screens/ThemePickerModal.tsx b/src/features/settings/screens/ThemePickerModal.tsx index d328ee5..da5fb86 100644 --- a/src/features/settings/screens/ThemePickerModal.tsx +++ b/src/features/settings/screens/ThemePickerModal.tsx @@ -1,11 +1,11 @@ // src/features/settings/screens/ThemePickerModal.tsx +import { IconName } from '@assets/icons' import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import { useT } from '@/i18n/useT' import { goBack } from '@/navigation/helpers/navigation-helpers' import HalfSheet from '@/navigation/modals/half-sheet' -import { IconName } from '@assets/icons' import { IconSvg } from '@/shared/components/ui/IconSvg' import { Text } from '@/shared/components/ui/Text' import type { ThemeMode } from '@/shared/theme/ThemeContext' @@ -21,7 +21,11 @@ const THEME_OPTIONS: { }[] = [ { mode: 'light', labelKey: 'settings.theme_light', icon: IconName.SUN }, { mode: 'dark', labelKey: 'settings.theme_dark', icon: IconName.MOON }, - { mode: 'system', labelKey: 'settings.theme_system', icon: IconName.SETTINGS }, + { + mode: 'system', + labelKey: 'settings.theme_system', + icon: IconName.SETTINGS, + }, ] export default function ThemePickerModal() { @@ -93,7 +97,12 @@ export default function ThemePickerModal() { {t(opt.labelKey)} {selected ? ( - + ) : null} ) diff --git a/src/i18n/i18n-types.d.ts b/src/i18n/i18n-types.d.ts index 1b7651f..a959072 100644 --- a/src/i18n/i18n-types.d.ts +++ b/src/i18n/i18n-types.d.ts @@ -4,94 +4,94 @@ import 'i18next' declare module 'i18next' { interface CustomTypeOptions { - defaultNS: 'translation'; + defaultNS: 'translation' resources: { translation: { - "common": { - "error_title": string; - "error_hint": string; - "retry": string; - "offline_banner": string; - "loading": string; - }; - "auth": { - "a11y_toggle_theme": string; - "welcome": string; - "subtitle": string; - "mock_demo_hint": string; - "or": string; - "email": string; - "email_placeholder": string; - "password": string; - "forgot_password": string; - "password_placeholder": string; - "signing_in": string; - "sign_in": string; - "no_account": string; - "sign_up": string; - "terms_prefix": string; - "terms_of_service": string; - "and": string; - "privacy_policy": string; - }; - "home": { - "stat_done": string; - "stat_active": string; - "stat_streak": string; - "featured_title": string; - "featured_subtitle": string; - "quick_actions": string; - "overview": string; - "recent_activity": string; - "title": string; - "greeting_morning": string; - "greeting_afternoon": string; - "greeting_evening": string; - "quick_action_task": string; - "quick_action_message": string; - "quick_action_schedule": string; - "quick_action_report": string; - "quick_action_upload": string; - }; - "settings": { - "language": { - "label": string; - "english": string; - "russian": string; - "german": string; - }; - "title": string; - "appearance": string; - "theme": string; - "about": string; - "version": string; - "logout": string; - "theme_light": string; - "theme_dark": string; - "theme_system": string; - }; - "onboarding": { - "headline": string; - "tagline": string; - "feature_1_title": string; - "feature_1_body": string; - "feature_2_title": string; - "feature_2_body": string; - "feature_3_title": string; - "feature_3_body": string; - "get_started": string; - }; - "uikit": { - "title": string; - "section_buttons": string; - "section_typography": string; - "section_icons": string; - "section_surfaces": string; - }; - "app": { - "title": string; - }; - }; - }; + common: { + error_title: string + error_hint: string + retry: string + offline_banner: string + loading: string + } + auth: { + a11y_toggle_theme: string + welcome: string + subtitle: string + mock_demo_hint: string + or: string + email: string + email_placeholder: string + password: string + forgot_password: string + password_placeholder: string + signing_in: string + sign_in: string + no_account: string + sign_up: string + terms_prefix: string + terms_of_service: string + and: string + privacy_policy: string + } + home: { + stat_done: string + stat_active: string + stat_streak: string + featured_title: string + featured_subtitle: string + quick_actions: string + overview: string + recent_activity: string + title: string + greeting_morning: string + greeting_afternoon: string + greeting_evening: string + quick_action_task: string + quick_action_message: string + quick_action_schedule: string + quick_action_report: string + quick_action_upload: string + } + settings: { + language: { + label: string + english: string + russian: string + german: string + } + title: string + appearance: string + theme: string + about: string + version: string + logout: string + theme_light: string + theme_dark: string + theme_system: string + } + onboarding: { + headline: string + tagline: string + feature_1_title: string + feature_1_body: string + feature_2_title: string + feature_2_body: string + feature_3_title: string + feature_3_body: string + get_started: string + } + uikit: { + title: string + section_buttons: string + section_typography: string + section_icons: string + section_surfaces: string + } + app: { + title: string + } + } + } } } diff --git a/src/navigation/NavigationRoot.tsx b/src/navigation/NavigationRoot.tsx index aa7ef0a..2dec76c 100644 --- a/src/navigation/NavigationRoot.tsx +++ b/src/navigation/NavigationRoot.tsx @@ -9,11 +9,11 @@ import { Linking, Platform, useColorScheme } from 'react-native' import BootSplash from 'react-native-bootsplash' import { navigationRef } from '@/navigation/helpers/navigation-helpers' -import { RootStack } from '@/navigation/root/root-navigator' import { loadPersistedNavigationState, persistNavigationState, } from '@/navigation/persistence/navigation-persistence' +import { RootStack } from '@/navigation/root/root-navigator' import { ThemedStatusBar } from '@/shared/components/ui/ThemedStatusBar' import { useTheme } from '@/shared/theme' diff --git a/src/navigation/index.ts b/src/navigation/index.ts index ec29848..c6d3c3a 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -1,4 +1,7 @@ -export type { RootStackParamList, StoryScreenParams } from '@/navigation/root-param-list' +export type { + RootStackParamList, + StoryScreenParams, +} from '@/navigation/root-param-list' export * from './helpers/navigation-helpers' export * from './modals/global-modal' export * from './modals/half-sheet' diff --git a/src/navigation/root/root-navigator.tsx b/src/navigation/root/root-navigator.tsx index 7dc2f65..2bad72d 100644 --- a/src/navigation/root/root-navigator.tsx +++ b/src/navigation/root/root-navigator.tsx @@ -21,7 +21,7 @@ const HALF_SHEET_OPTIONS = { } as const const HomeTabs = createBottomTabNavigator({ - tabBar: (props) => , + tabBar: props => , screenOptions: { headerShown: false }, screens: { [ROUTES.TAB_HOME]: HomeScreen, diff --git a/src/navigation/tabs/AnimatedTabBar.tsx b/src/navigation/tabs/AnimatedTabBar.tsx index f953407..9dd53d5 100644 --- a/src/navigation/tabs/AnimatedTabBar.tsx +++ b/src/navigation/tabs/AnimatedTabBar.tsx @@ -32,9 +32,12 @@ function iconForRoute(routeName: string): IconName { function labelForRoute(routeName: string, t: ReturnType): string { switch (routeName) { - case ROUTES.TAB_HOME: return t('home.title') - case ROUTES.TAB_SETTINGS: return t('settings.title') - default: return routeName + case ROUTES.TAB_HOME: + return t('home.title') + case ROUTES.TAB_SETTINGS: + return t('settings.title') + default: + return routeName } } From 446e52e8110030992568e8699d7c866eb8c42d02 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 12:49:02 +0200 Subject: [PATCH 05/17] refactor(shared): promote SectionHeader and useShimmer to shared layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract useShimmer() → src/shared/hooks/useShimmer.ts Pure Animated.Value pulse hook, no feature coupling - Extract SectionHeader → src/shared/components/ui/SectionHeader.tsx Label row + optional offline/synced status pill; reusable by any list screen - HomeScreen imports both from shared; removes ~70 lines of inline code Co-Authored-By: Claude Sonnet 4.6 --- src/features/home/screens/HomeScreen.tsx | 89 +--------------------- src/shared/components/ui/SectionHeader.tsx | 68 +++++++++++++++++ src/shared/hooks/useShimmer.ts | 29 +++++++ 3 files changed, 100 insertions(+), 86 deletions(-) create mode 100644 src/shared/components/ui/SectionHeader.tsx create mode 100644 src/shared/hooks/useShimmer.ts diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index 8dbe70f..dafa40b 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -3,7 +3,7 @@ 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, Platform, Pressable, ScrollView, View } from 'react-native' import { useFeedQuery } from '@/features/home/hooks/useFeedQuery' import type { FeedItem } from '@/features/home/types' @@ -12,8 +12,10 @@ 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 { useShimmer } from '@/shared/hooks/useShimmer' import { useTheme } from '@/shared/theme/useTheme' type HomeNavProp = NativeStackNavigationProp @@ -21,29 +23,6 @@ type HomeNavProp = NativeStackNavigationProp const TAB_BAR_CLEARANCE = 88 // ─── Shimmer skeleton ───────────────────────────────────────────────────────── -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 -} - function SkeletonCard({ shimmer }: { shimmer: Animated.Value }) { const { theme } = useTheme() const c = theme.colors @@ -220,68 +199,6 @@ function GreetingSection({ ) } -// ─── Section header with optional sync status ───────────────────────────────── -function SectionHeader({ - label, - sublabel, - sublabelIsOffline, -}: { - label: string - sublabel?: string | null - sublabelIsOffline?: boolean -}) { - const { theme } = useTheme() - const c = theme.colors - const sp = theme.spacing - const r = theme.radius - const ty = theme.typography - return ( - - {label} - {sublabel ? ( - - - - {sublabel} - - - ) : null} - - ) -} - // ─── News story card ────────────────────────────────────────────────────────── const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { const { theme } = useTheme() diff --git a/src/shared/components/ui/SectionHeader.tsx b/src/shared/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..67dddc5 --- /dev/null +++ b/src/shared/components/ui/SectionHeader.tsx @@ -0,0 +1,68 @@ +// src/shared/components/ui/SectionHeader.tsx + +import React from 'react' +import { View } from 'react-native' +import { Text } from '@/shared/components/ui/Text' +import { useTheme } from '@/shared/theme/useTheme' + +interface Props { + label: string + /** Optional badge shown on the right. */ + sublabel?: string | null + /** When true the badge renders in warning colour; otherwise success. */ + sublabelIsOffline?: boolean +} + +export function SectionHeader({ label, sublabel, sublabelIsOffline }: Props) { + const { theme } = useTheme() + const c = theme.colors + const sp = theme.spacing + const r = theme.radius + const ty = theme.typography + + return ( + + {label} + {sublabel ? ( + + + + {sublabel} + + + ) : null} + + ) +} diff --git a/src/shared/hooks/useShimmer.ts b/src/shared/hooks/useShimmer.ts new file mode 100644 index 0000000..9b548c3 --- /dev/null +++ b/src/shared/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 +} From 46da4d7ee2006c011a6cdeb527d56e97048c9e66 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 12:51:32 +0200 Subject: [PATCH 06/17] refactor(auth): replace manual boolean useState with useToggle showPassword, emailFocused, passFocused all replaced by useToggle() from shared/hooks. Toggle handler is now a stable ref (toggleShowPassword) instead of an inline arrow. Co-Authored-By: Claude Sonnet 4.6 --- src/features/auth/screens/AuthScreen.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/features/auth/screens/AuthScreen.tsx b/src/features/auth/screens/AuthScreen.tsx index c17eb84..b5ead2c 100644 --- a/src/features/auth/screens/AuthScreen.tsx +++ b/src/features/auth/screens/AuthScreen.tsx @@ -48,6 +48,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' @@ -352,9 +353,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) @@ -692,11 +693,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" @@ -742,11 +743,11 @@ export default function AuthScreen() { value={password} onChangeText={setPassword} onFocus={() => { - setPassFocused(true) + setPassFocused() animateFocus(passFocus, true) }} onBlur={() => { - setPassFocused(false) + clearPassFocused() animateFocus(passFocus, false) }} secureTextEntry={!showPassword} @@ -756,7 +757,7 @@ export default function AuthScreen() { textContentType="password" /> setShowPassword(!showPassword)} + onPress={toggleShowPassword} activeOpacity={0.6} style={styles.eyeSlot} > From d1b137a0759e52427a2352bd44596ddc5999ab41 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 12:56:38 +0200 Subject: [PATCH 07/17] refactor(auth): fix service layer issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate zLoginRequest validation in useLoginMutation (AuthService.login already validates; hook was parsing twice) - Remove hardcoded mutationKey ['auth','login'] — optional and not in authKeys - Flatten nested offline guard: if (!mock) { if (offline) } → if (!mock && offline) - Move SessionSchema out of useAuthSessionQuery into auth.schemas.ts where all Zod schemas belong (zSessionResponse / SessionResponse) - Remove .catch(() => undefined) on qc.cancelQueries() in useLogout Co-Authored-By: Claude Sonnet 4.6 --- src/features/auth/hooks/useAuthSessionQuery.ts | 11 ++--------- src/features/auth/hooks/useLoginMutation.ts | 11 ++--------- src/features/auth/hooks/useLogout.ts | 2 +- src/features/auth/services/auth/auth.schemas.ts | 7 +++++++ src/features/auth/services/auth/auth.service.ts | 9 +++------ 5 files changed, 15 insertions(+), 25 deletions(-) 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..918ef7f 100644 --- a/src/features/auth/hooks/useLoginMutation.ts +++ b/src/features/auth/hooks/useLoginMutation.ts @@ -2,10 +2,7 @@ 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 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' @@ -16,13 +13,9 @@ 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) } 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/services/auth/auth.schemas.ts b/src/features/auth/services/auth/auth.schemas.ts index 1edb606..efac3c3 100644 --- a/src/features/auth/services/auth/auth.schemas.ts +++ b/src/features/auth/services/auth/auth.schemas.ts @@ -40,3 +40,10 @@ export const zLoginResponse = z.object({ export type LoginRequest = z.infer 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) From 4432b1759e38a5a347fab9d8e4c4d3bacc8e62cc Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 13:02:43 +0200 Subject: [PATCH 08/17] Scope hooks and constants to feature/session boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move useAppLaunch → src/session/ (imports from session layer) - Move useShimmer → src/features/home/hooks/ (only used in HomeScreen) - Delete useBackHandler from shared (zero consumers; superseded by navigation/helpers/use-back-handler) - Remove SESSION_RELATED_QUERY_TAGS from shared/constants (cross-feature coupling); replace with feature-local tag arrays in useLoginMutation and useUpdateProfile, each scoped to their own tag map Co-Authored-By: Claude Sonnet 4.6 --- src/features/auth/hooks/useLoginMutation.ts | 10 ++++------ src/{shared => features/home}/hooks/useShimmer.ts | 0 src/features/home/screens/HomeScreen.tsx | 2 +- src/features/user/hooks/useUpdateProfile.ts | 12 +++++------- src/{shared/hooks => session}/useAppLaunch.ts | 0 src/shared/constants/index.ts | 14 -------------- src/shared/hooks/index.ts | 4 ---- src/shared/hooks/useBackHandler.ts | 15 --------------- 8 files changed, 10 insertions(+), 47 deletions(-) rename src/{shared => features/home}/hooks/useShimmer.ts (100%) rename src/{shared/hooks => session}/useAppLaunch.ts (100%) delete mode 100644 src/shared/constants/index.ts delete mode 100644 src/shared/hooks/useBackHandler.ts diff --git a/src/features/auth/hooks/useLoginMutation.ts b/src/features/auth/hooks/useLoginMutation.ts index 918ef7f..18ca0c2 100644 --- a/src/features/auth/hooks/useLoginMutation.ts +++ b/src/features/auth/hooks/useLoginMutation.ts @@ -1,14 +1,15 @@ // src/features/auth/hooks/useLoginMutation.ts import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { AuthTag } from '@/features/auth/api/keys' import { 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' +const AUTH_LOGIN_TAGS: readonly AuthTag[] = ['auth:me', 'auth:session'] + export function useLoginMutation() { const qc = useQueryClient() @@ -21,10 +22,7 @@ export function useLoginMutation() { } }, onSuccess: async () => { - await invalidateByTags(qc, SESSION_RELATED_QUERY_TAGS, [ - authKeys.tagMap, - userKeys.tagMap, - ]) + await invalidateByTags(qc, AUTH_LOGIN_TAGS, [authKeys.tagMap]) }, }) } diff --git a/src/shared/hooks/useShimmer.ts b/src/features/home/hooks/useShimmer.ts similarity index 100% rename from src/shared/hooks/useShimmer.ts rename to src/features/home/hooks/useShimmer.ts diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index dafa40b..fe69960 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -6,6 +6,7 @@ import { FlashList } from '@shopify/flash-list' import React, { memo, useCallback } from 'react' import { Animated, Platform, Pressable, ScrollView, View } from 'react-native' 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' @@ -15,7 +16,6 @@ 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 { useShimmer } from '@/shared/hooks/useShimmer' import { useTheme } from '@/shared/theme/useTheme' type HomeNavProp = NativeStackNavigationProp diff --git a/src/features/user/hooks/useUpdateProfile.ts b/src/features/user/hooks/useUpdateProfile.ts index db6d318..61c2da2 100644 --- a/src/features/user/hooks/useUpdateProfile.ts +++ b/src/features/user/hooks/useUpdateProfile.ts @@ -11,9 +11,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' -import { authKeys } from '@/features/auth/api/keys' +import type { UserTag } from '@/features/user/api/keys' 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 { OPS } from '@/shared/services/api/transport/operations' import { transport } from '@/shared/services/api/transport/transport' @@ -32,6 +31,8 @@ export type UpdateProfileInput = z.infer type TransportResult = { offline?: boolean; queued?: boolean } | unknown +const USER_UPDATE_TAGS: readonly UserTag[] = ['user:me', 'user:list'] + export function useUpdateProfile() { const qc = useQueryClient() @@ -42,17 +43,14 @@ export function useUpdateProfile() { const parsed = UpdateProfileInput.parse(payload) return transport.mutate(OPS.USER_UPDATE_PROFILE, parsed, { - tags: SESSION_RELATED_QUERY_TAGS, + tags: USER_UPDATE_TAGS, }) as Promise }, onSuccess: async result => { if ((result as any)?.queued) return - await invalidateByTags(qc, SESSION_RELATED_QUERY_TAGS, [ - userKeys.tagMap, - authKeys.tagMap, - ]) + await invalidateByTags(qc, USER_UPDATE_TAGS, [userKeys.tagMap]) }, }) } diff --git a/src/shared/hooks/useAppLaunch.ts b/src/session/useAppLaunch.ts similarity index 100% rename from src/shared/hooks/useAppLaunch.ts rename to src/session/useAppLaunch.ts diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts deleted file mode 100644 index 1c076b3..0000000 --- a/src/shared/constants/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared app-level constants (non-environment, non-config). - * Storage keys live in src/config/constants.ts — not here. - */ - -/** Tags to invalidate when session/profile state changes (login, profile update). */ -export const SESSION_RELATED_QUERY_TAGS = [ - 'auth:me', - 'auth:session', - 'user:me', - 'user:list', -] as const - -export type SessionRelatedTag = (typeof SESSION_RELATED_QUERY_TAGS)[number] diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index df17ce6..bea6e22 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -9,7 +9,6 @@ * HOOK WHAT IT DOES * ──────────────────── ────────────────────────────────────── * useAppState Track foreground / background lifecycle - * useBackHandler Intercept Android hardware back button * useKeyboard Keyboard visibility & height * useRefreshControl Pull-to-refresh for lists * useDebounce Delay a value (search, autosave) @@ -26,16 +25,13 @@ * useClipBoard Clipboard read/write + local state * useWindowDimensions Screen dimensions (re-export from RN) * useToast Show toast / error toast - * useAppLaunch App bootstrap ready * useSafeAreaScroll Safe area insets for ScrollView * useOnlineStatus Online/offline status */ -export { useAppLaunch } from './useAppLaunch' export { useAppState } from './useAppState' export { useArray } from './useArray' export { useAsync } from './useAsync' -export { useBackHandler } from './useBackHandler' export { useClipBoard } from './useClipBoard' export { useCountdown } from './useCountdown' export { diff --git a/src/shared/hooks/useBackHandler.ts b/src/shared/hooks/useBackHandler.ts deleted file mode 100644 index 07b480f..0000000 --- a/src/shared/hooks/useBackHandler.ts +++ /dev/null @@ -1,15 +0,0 @@ -// src/app/hooks/useBackHandler.ts -import { useEffect } from 'react' -import { BackHandler, Platform } from 'react-native' - -/** - * Intercept Android hardware back button. Return true to prevent default (e.g. exit). - * On non-Android, the effect is a no-op. - */ -export function useBackHandler(onBack: () => boolean) { - useEffect(() => { - if (Platform.OS !== 'android') return undefined - const sub = BackHandler.addEventListener('hardwareBackPress', onBack) - return () => sub.remove() - }, [onBack]) -} From 004901d526d380cff3a920adf6b09af0941e6136 Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 13:19:54 +0200 Subject: [PATCH 09/17] Apply React Native best practices: Pressable and StyleSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace TouchableOpacity with Pressable throughout (ScreenHeader, AuthScreen) per react-native-skills guidelines; use style callback for pressed opacity feedback instead of activeOpacity - Remove activeOpacity={1} on social buttons — Reanimated handles press animation; Pressable has no built-in feedback - Extract static inline styles in HomeScreen to StyleSheet.create(): StoryCard meta row (rendered in FlashList on every item) and ListFooter height (constant value) Co-Authored-By: Claude Sonnet 4.6 --- src/features/auth/screens/AuthScreen.tsx | 105 +++++++++++----------- src/features/home/screens/HomeScreen.tsx | 26 ++++-- src/shared/components/ui/ScreenHeader.tsx | 14 ++- 3 files changed, 84 insertions(+), 61 deletions(-) diff --git a/src/features/auth/screens/AuthScreen.tsx b/src/features/auth/screens/AuthScreen.tsx index b5ead2c..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' @@ -220,35 +213,41 @@ 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 }) => ( @@ -473,12 +472,11 @@ export default function AuthScreen() { {/* Theme toggle */} - [ styles.themeBtn, { width: AUTH_SCREEN_LAYOUT.themeToggleSize, @@ -486,6 +484,7 @@ export default function AuthScreen() { backgroundColor: c.surfaceSecondary, borderColor: c.border, borderRadius: r.xl, + opacity: pressed ? 0.7 : 1, }, ]} > @@ -508,7 +507,7 @@ export default function AuthScreen() { )} - + {/* Logo + heading */} @@ -617,11 +616,10 @@ export default function AuthScreen() { press: social2, }, ].map(({ key, Icon, label, press }) => ( - - + ))}
@@ -720,11 +718,13 @@ export default function AuthScreen() { > {t('auth.password')} - + pressed && styles.pressedOpacity} + > {t('auth.forgot_password')} - +
- [ + styles.eyeSlot, + pressed && styles.pressedOpacity, + ]} > - + @@ -806,11 +808,11 @@ export default function AuthScreen() { {t('auth.no_account')}{' '} - + pressed && styles.pressedOpacity}> {t('auth.sign_up')} - + {/* Terms */} @@ -838,6 +840,7 @@ export default function AuthScreen() { // ─── Styles ──────────────────────────────────────────────────────── const styles = StyleSheet.create({ flex: { flex: 1 }, + pressedOpacity: { opacity: 0.6 }, glowWrap: { position: 'absolute', diff --git a/src/features/home/screens/HomeScreen.tsx b/src/features/home/screens/HomeScreen.tsx index fe69960..c0bc8da 100644 --- a/src/features/home/screens/HomeScreen.tsx +++ b/src/features/home/screens/HomeScreen.tsx @@ -4,7 +4,14 @@ import { useNavigation } from '@react-navigation/native' import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { FlashList } from '@shopify/flash-list' import React, { memo, useCallback } from 'react' -import { Animated, Platform, Pressable, ScrollView, View } from 'react-native' +import { + Animated, + Platform, + Pressable, + ScrollView, + StyleSheet, + View, +} from 'react-native' import { useFeedQuery } from '@/features/home/hooks/useFeedQuery' import { useShimmer } from '@/features/home/hooks/useShimmer' import type { FeedItem } from '@/features/home/types' @@ -253,7 +260,7 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) { {item.title} - + , - [], - ) + const ListFooter = useCallback(() => , []) const renderItem = useCallback( ({ item }: { item: FeedItem }) => , @@ -367,3 +371,13 @@ export default function HomeScreen() { ) } + +const styles = StyleSheet.create({ + metaRow: { + flexDirection: 'row', + alignItems: 'center', + }, + listFooter: { + height: TAB_BAR_CLEARANCE, + }, +}) diff --git a/src/shared/components/ui/ScreenHeader.tsx b/src/shared/components/ui/ScreenHeader.tsx index fd7d6b2..191027b 100644 --- a/src/shared/components/ui/ScreenHeader.tsx +++ b/src/shared/components/ui/ScreenHeader.tsx @@ -1,7 +1,7 @@ // src/shared/components/ui/ScreenHeader.tsx import React from 'react' -import { StyleSheet, TouchableOpacity, View } from 'react-native' +import { Pressable, StyleSheet, View } from 'react-native' import Svg, { Polyline } from 'react-native-svg' import { Text } from '@/shared/components/ui/Text' import { useTheme } from '@/shared/theme' @@ -49,12 +49,15 @@ export function ScreenHeader({ {/* Left: back button or placeholder to balance centering */} {onBack ? ( - [ + styles.iconBtn, + pressed && styles.iconBtnPressed, + ]} > - + ) : null} @@ -109,6 +112,9 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + iconBtnPressed: { + opacity: 0.6, + }, titleCenter: { flex: 1, alignItems: 'center', From 2f8fda0e6dff55d2332d177d70c57ee5059d39bf Mon Sep 17 00:00:00 2001 From: maximcoding Date: Wed, 25 Mar 2026 13:28:27 +0200 Subject: [PATCH 10/17] Fix inline callbacks and accessibility in settings screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memoization: - ThemePickerModal/LanguagePickerModal: extract ThemeOptionRow/ LanguageOptionRow as memo'd components — onPress is now a stable useCallback([opt.mode/code, onSelect]) instead of a new closure created per render per map item - ThemeScreen: extract ModeButton memo component for same reason - LanguageScreen: replace 3 inline arrow functions with named useCallback handlers (handleEnglish/Russian/German) - StoryScreen: extract handleClose useCallback for the dismiss button Accessibility: - ThemePickerModal/LanguagePickerModal items: add accessibilityRole, accessibilityLabel (translated option name), accessibilityState - SettingsRow: add accessibilityRole (button when pressable, none otherwise) and accessibilityLabel derived from label prop Co-Authored-By: Claude Sonnet 4.6 --- src/features/home/screens/StoryScreen.tsx | 4 +- .../settings/components/SettingsRow.tsx | 2 + .../settings/screens/LanguagePickerModal.tsx | 159 +++++++++++------- .../settings/screens/LanguageScreen.tsx | 12 +- .../settings/screens/ThemePickerModal.tsx | 141 ++++++++++------ src/features/settings/screens/ThemeScreen.tsx | 17 +- 6 files changed, 209 insertions(+), 126 deletions(-) diff --git a/src/features/home/screens/StoryScreen.tsx b/src/features/home/screens/StoryScreen.tsx index 20f3c43..0815667 100644 --- a/src/features/home/screens/StoryScreen.tsx +++ b/src/features/home/screens/StoryScreen.tsx @@ -59,6 +59,8 @@ export default function StoryScreen({ route, navigation }: Props) { [c.textPrimary], ) + const handleClose = useCallback(() => navigation.goBack(), [navigation]) + const handleBack = useCallback(() => { if (canGoBack) { webViewRef.current?.goBack() @@ -132,7 +134,7 @@ export default function StoryScreen({ route, navigation }: Props) { navigation.goBack()} + onPress={handleClose} hitSlop={{ top: sp.xs, bottom: sp.xs, left: sp.xs, right: sp.xs }} accessibilityRole="button" accessibilityLabel="Close article" diff --git a/src/features/settings/components/SettingsRow.tsx b/src/features/settings/components/SettingsRow.tsx index d303292..57d368e 100644 --- a/src/features/settings/components/SettingsRow.tsx +++ b/src/features/settings/components/SettingsRow.tsx @@ -35,6 +35,8 @@ export function SettingsRow({ [ styles.row, { diff --git a/src/features/settings/screens/LanguagePickerModal.tsx b/src/features/settings/screens/LanguagePickerModal.tsx index 0531335..634435a 100644 --- a/src/features/settings/screens/LanguagePickerModal.tsx +++ b/src/features/settings/screens/LanguagePickerModal.tsx @@ -1,7 +1,7 @@ // src/features/settings/screens/LanguagePickerModal.tsx import { IconName } from '@assets/icons' -import React, { useCallback } from 'react' +import React, { memo, useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import i18n from '@/i18n/i18n' import { useT } from '@/i18n/useT' @@ -24,7 +24,19 @@ const LANGUAGE_OPTIONS: { { 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 @@ -32,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(), []) @@ -43,74 +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.abbr} - - - - {t(opt.labelKey)} - - {selected ? ( - - ) : null} - - ) - })} + {LANGUAGE_OPTIONS.map(opt => ( + + ))} ) 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 (