diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4233fa1..0f4b6c2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,12 @@ "Bash(npx skills:*)", "Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/typescript)", "Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react)", - "Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react-native)" + "Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react-native)", + "mcp__plugin_engram_engram__mem_search", + "Bash(npm install:*)", + "Bash(ls /Users/eduardograciano/Documents/laboratory/crypto-tracker/app/\\\\\\(tabs\\\\\\)/)", + "mcp__plugin_engram_engram__mem_save", + "Bash(ls /Users/eduardograciano/Documents/laboratory/crypto-tracker/app/\\\\\\(tabs\\\\\\))" ] } } diff --git a/.claude/skills/react-native/SKILL.md b/.claude/skills/react-native/SKILL.md index b35c151..ab96b45 100644 --- a/.claude/skills/react-native/SKILL.md +++ b/.claude/skills/react-native/SKILL.md @@ -4,7 +4,6 @@ description: > React Native and Expo conventions for this project. Trigger: When writing React Native components, screens, styles, or Expo-specific code (.tsx). For performance patterns (FlatList, animations, bundle size), also use react-native-best-practices. -version: "0.1.0" --- > **Base skill — work in progress.** Add patterns here as conventions are established. @@ -34,6 +33,35 @@ const styles = StyleSheet.create({ ``` +## Component Definition Location (REQUIRED) + +Components MUST always be defined at the top level of their file. Never define a component function inside another component's function body. + +```typescript +// ✅ ALWAYS: Top-level definition in its own file, receives data via props +export function CoinRow({ coin }: CoinRowProps) { + return ...; +} + +// ❌ NEVER: Component defined inside parent +export default function HomeScreen() { + function CoinRow({ coin }: CoinRowProps) { // NO — new reference on every parent render + return ...; + } + return } />; +} +``` + +**Why it matters:** +- React Compiler cannot treat an inner function as a stable component identity +- React DevTools cannot display a stable component name for debugging +- The inner component is untestable in isolation +- Preparing for bottom-up state management: state should live in or near the component that owns it, not bubble up + +**Rule of thumb**: if you find yourself writing `function X()` inside `function Y()` and X returns JSX, X belongs in its own file under the nearest `components/` or `features/*/components/` folder. + +**Note**: JSX *expressions* (e.g. `const listHeader = (...)`) are fine inside a component body — the rule applies to *component function definitions* only. + ## Platform-Specific Files Use file suffixes for platform variants instead of inline `Platform.OS` checks when the difference is substantial. @@ -62,6 +90,73 @@ import { colors, spacing } from "@/constants/theme"; backgroundColor: "#1a1a2e" ``` +## Component Naming — Use Base Names (REQUIRED) + +Project-owned primitive components use the base name, not a prefixed name. + +```typescript +// ✅ ALWAYS: import from project components +import { Text } from '@/components/text'; +import { View } from '@/components/view'; + +// ❌ NEVER: prefixed names for your own primitives +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +``` + +Inside the component file itself, alias the React Native primitive to avoid the name collision: + +```typescript +import { Text as RNText } from 'react-native'; +import { View as RNView } from 'react-native'; +``` + +## FlatList Pure Callbacks at Module Level (REQUIRED) + +`keyExtractor` and `getItemLayout` are pure functions — they only depend on their arguments, never on component state or props. Define them at module level, not inside the component. + +```typescript +// ✅ ALWAYS: module-level — no closure over component state +function keyExtractor(item: CmcCoin) { + return String(item.id); +} + +function getItemLayout(_: ArrayLike | null | undefined, index: number) { + return { length: COIN_ITEM_HEIGHT, offset: COIN_ITEM_HEIGHT * index, index }; +} + +export default function CoinList() { + return ; +} + +// ❌ NEVER: inside the component — recreated on every render (even if Compiler handles it) +export default function CoinList() { + function keyExtractor(item: CmcCoin) { return String(item.id); } + return ; +} +``` + +`renderItem` stays inside the component when it needs to reference other component-level functions or state. If it's also pure, move it out too. + +## Percentage Heights in Scroll Containers + +`height: '25%'` does NOT work inside `FlatList` / `ScrollView` content. React Native resolves percentage dimensions relative to the parent's size — scroll content has no defined height, so `25%` computes to `0`. + +```typescript +// ❌ BROKEN inside ListHeaderComponent / ScrollView content + + +// ✅ CORRECT: get screen height from the screen and pass it down +import { useWindowDimensions } from 'react-native'; +const { height } = useWindowDimensions(); + + +// Inside HeroSection: + +``` + +`height: '25%'` only works when the parent has a fixed/flex-defined height (e.g. a full-screen `View` with `flex: 1`, not a scroll container). + ## Navigation (Expo Router) ```typescript diff --git a/.nvmrc b/.nvmrc index 209e3ef..645ae0c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +20.15.0 \ No newline at end of file diff --git a/app.json b/app.json index f9fe1e9..2b718cc 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,8 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.cryptotracker" }, "android": { "adaptiveIcon": { @@ -19,7 +20,8 @@ "monochromeImage": "./assets/images/android-icon-monochrome.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "com.anonymous.cryptotracker" }, "web": { "output": "static", diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ab43b40..163ba95 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -9,6 +9,7 @@ import { HomeHero } from "@/features/home/components/home-hero"; import { ListSectionHeader } from "@/features/home/components/list-section-header"; import { APP_NAME, BTC_SYMBOL, COIN_ITEM_HEIGHT } from "@/features/home/config"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useCoinLogos } from "@/hooks/use-coin-logos"; import { useCryptoListings } from "@/hooks/use-crypto-listings"; import type { CmcCoin } from "@/types/cmc"; @@ -33,10 +34,14 @@ export default function HomeScreen() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? "light"]; const { data: coins, isLoading, isError } = useCryptoListings(); + const ids = coins?.map((c) => c.id) ?? []; + const logoMap = useCoinLogos(ids); const btc = coins?.find((c) => c.symbol === BTC_SYMBOL); - const renderCoin = ({ item }: { item: CmcCoin }) => ; + const renderCoin = ({ item }: { item: CmcCoin }) => ( + + ); const listHeader = ( @@ -48,6 +53,7 @@ export default function HomeScreen() { isLoading={isLoading} isError={isError} height={height} + logoUrl={btc ? logoMap[btc.id] : undefined} /> diff --git a/features/home/components/coin-logo.tsx b/features/home/components/coin-logo.tsx new file mode 100644 index 0000000..32019e0 --- /dev/null +++ b/features/home/components/coin-logo.tsx @@ -0,0 +1,23 @@ +import { Image } from 'expo-image'; +import { StyleSheet } from 'react-native'; + +interface CoinLogoProps { + uri?: string; + size?: number; +} + +export const CoinLogo = ({ uri, size = 32 }: CoinLogoProps) => ( + +); + +const styles = StyleSheet.create({ + logo: { + backgroundColor: '#E5E7EB', + }, +}); diff --git a/features/home/components/coin-row.tsx b/features/home/components/coin-row.tsx index e870182..c05af8b 100644 --- a/features/home/components/coin-row.tsx +++ b/features/home/components/coin-row.tsx @@ -1,23 +1,25 @@ import { StyleSheet } from 'react-native'; import { View } from '@/components/view'; - import { Text } from '@/components/text'; +import { CoinLogo } from '@/features/home/components/coin-logo'; import { CHANGE_COLORS, COIN_ITEM_HEIGHT } from '@/features/home/config'; import { formatChange, formatPrice } from '@/features/home/utils'; import type { CmcCoin } from '@/types/cmc'; interface CoinRowProps { coin: CmcCoin; + logoUrl?: string; } -export function CoinRow({ coin }: CoinRowProps) { +export function CoinRow({ coin, logoUrl }: CoinRowProps) { const change = coin.quote.USD.percent_change_24h; const changeColor = change >= 0 ? CHANGE_COLORS.positive : CHANGE_COLORS.negative; return ( #{coin.cmc_rank} + {coin.name} {coin.symbol} diff --git a/features/home/components/home-hero.tsx b/features/home/components/home-hero.tsx index d00407a..2f88a94 100644 --- a/features/home/components/home-hero.tsx +++ b/features/home/components/home-hero.tsx @@ -1,8 +1,8 @@ import { StyleSheet } from 'react-native'; import { View } from '@/components/view'; - import { Text } from '@/components/text'; +import { CoinLogo } from '@/features/home/components/coin-logo'; import { CHANGE_COLORS } from '@/features/home/config'; import { formatChange, formatPrice } from '@/features/home/utils'; import type { CmcCoin } from '@/types/cmc'; @@ -12,9 +12,10 @@ interface HomeHeroProps { isLoading: boolean; isError: boolean; height: number; + logoUrl?: string; } -export function HomeHero({ coin, isLoading, isError, height }: HomeHeroProps) { +export function HomeHero({ coin, isLoading, isError, height, logoUrl }: HomeHeroProps) { if (isLoading) { return ( @@ -38,6 +39,7 @@ export function HomeHero({ coin, isLoading, isError, height }: HomeHeroProps) { return ( + {coin.name} ({coin.symbol}) {formatPrice(coin.quote.USD.price)} diff --git a/hooks/use-coin-logos.ts b/hooks/use-coin-logos.ts new file mode 100644 index 0000000..1a4c27e --- /dev/null +++ b/hooks/use-coin-logos.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchCoinMetadata } from '@/lib/cmc-client'; +import { readLogoCache, writeLogoCache } from '@/lib/logo-cache'; +import type { LogoMap } from '@/types/cmc'; + +export const useCoinLogos = (ids: number[]): LogoMap => { + const cached = readLogoCache(); + + const { data } = useQuery({ + queryKey: ['crypto', 'logos'], + queryFn: async (): Promise => { + const response = await fetchCoinMetadata(ids); + const map: LogoMap = {}; + for (const [idStr, meta] of Object.entries(response.data)) { + map[Number(idStr)] = meta.logo; + } + writeLogoCache(map); + return map; + }, + enabled: ids.length > 0 && cached === null, + staleTime: Infinity, + gcTime: Infinity, + }); + + return data ?? cached ?? {}; +}; diff --git a/lib/cmc-client.ts b/lib/cmc-client.ts index 8aa9cb1..6ca0b70 100644 --- a/lib/cmc-client.ts +++ b/lib/cmc-client.ts @@ -1,4 +1,4 @@ -import type { CmcListingsResponse } from '@/types/cmc'; +import type { CmcListingsResponse, CmcMetadataResponse } from '@/types/cmc'; const CMC_BASE_URL = 'https://pro-api.coinmarketcap.com'; const CMC_API_KEY = process.env.EXPO_PUBLIC_CMC_API_KEY; @@ -24,3 +24,16 @@ export const fetchCryptoListings = async (limit = 100): Promise; }; + +export const fetchCoinMetadata = async (ids: number[]): Promise => { + const url = new URL(`${CMC_BASE_URL}/v1/cryptocurrency/info`); + url.searchParams.set('id', ids.join(',')); + + const response = await fetch(url.toString(), { headers: getCmcHeaders() }); + + if (!response.ok) { + throw new Error(`CMC metadata API error: ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; +}; diff --git a/lib/logo-cache.ts b/lib/logo-cache.ts new file mode 100644 index 0000000..f643295 --- /dev/null +++ b/lib/logo-cache.ts @@ -0,0 +1,26 @@ +import { createMMKV } from 'react-native-mmkv'; + +import type { LogoMap } from '@/types/cmc'; + +const storage = createMMKV({ id: 'logo-cache' }); + +const LOGO_MAP_KEY = 'logoMap'; +const TIMESTAMP_KEY = 'logoMapTimestamp'; +const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export const readLogoCache = (): LogoMap | null => { + const raw = storage.getString(LOGO_MAP_KEY); + const ts = storage.getNumber(TIMESTAMP_KEY); + if (!raw || !ts || Date.now() - ts > TTL_MS) return null; + return JSON.parse(raw) as LogoMap; +}; + +export const writeLogoCache = (map: LogoMap): void => { + storage.set(LOGO_MAP_KEY, JSON.stringify(map)); + storage.set(TIMESTAMP_KEY, Date.now()); +}; + +export const clearLogoCache = (): void => { + storage.remove(LOGO_MAP_KEY); + storage.remove(TIMESTAMP_KEY); +}; diff --git a/package-lock.json b/package-lock.json index dcfccc6..6bf4b60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "crypto-tracker", "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -29,6 +28,8 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-mmkv": "^4.3.0", + "react-native-nitro-modules": "^0.35.2", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -1911,6 +1912,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "peer": true, "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -9542,6 +9544,25 @@ "react-native": "*" } }, + "node_modules/react-native-mmkv": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.3.0.tgz", + "integrity": "sha512-D1wB2ViMrm+0rs7FcbLoct/BV+qugASi+XAZT8MzXy5yl0CI0qxToh2LPnw9UENHrNefpfDZgE5FpMhIB37I5Q==", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.2.tgz", + "integrity": "sha512-97cZcCh3ZAuWAfutel2Q3qLfc45XXh7F9Ei5tEjahP0kV3q8hQelwLIulKXmjN+f0JI5Zf/wCsfwwdVWYU2tKA==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-reanimated": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", @@ -13188,6 +13209,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "peer": true, "requires": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -13649,7 +13671,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", "requires": { - "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", @@ -13701,7 +13722,6 @@ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", "requires": { - "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", @@ -14352,7 +14372,6 @@ "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", "requires": { - "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, @@ -15615,8 +15634,7 @@ "dev": true, "requires": { "@typescript-eslint/types": "^8.29.1", - "@typescript-eslint/utils": "^8.29.1", - "eslint": "^9.24.0" + "@typescript-eslint/utils": "^8.29.1" } }, "eslint-plugin-import": { @@ -15999,7 +16017,6 @@ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", "requires": { - "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", @@ -18515,6 +18532,18 @@ "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "requires": {} }, + "react-native-mmkv": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.3.0.tgz", + "integrity": "sha512-D1wB2ViMrm+0rs7FcbLoct/BV+qugASi+XAZT8MzXy5yl0CI0qxToh2LPnw9UENHrNefpfDZgE5FpMhIB37I5Q==", + "requires": {} + }, + "react-native-nitro-modules": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.2.tgz", + "integrity": "sha512-97cZcCh3ZAuWAfutel2Q3qLfc45XXh7F9Ei5tEjahP0kV3q8hQelwLIulKXmjN+f0JI5Zf/wCsfwwdVWYU2tKA==", + "requires": {} + }, "react-native-reanimated": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", diff --git a/package.json b/package.json index 8f9076b..7f3fc1e 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint" }, @@ -32,6 +32,8 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-mmkv": "^4.3.0", + "react-native-nitro-modules": "^0.35.2", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/types/cmc.ts b/types/cmc.ts index 7f35ef4..fce7dc3 100644 --- a/types/cmc.ts +++ b/types/cmc.ts @@ -38,3 +38,16 @@ export interface CmcListingsResponse { status: CmcStatus; data: CmcCoin[]; } + +export interface CmcCoinMetadata { + id: number; + name: string; + logo: string; +} + +export interface CmcMetadataResponse { + status: CmcStatus; + data: Record; +} + +export type LogoMap = Record;