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;