Skip to content
Merged
89 changes: 87 additions & 2 deletions apps/mobile/.claude/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,49 @@ const StyledComponent = withUniwind(SomeComponent);

---

## 테마 색상 사용
## 테마 색상 사용 (다크/라이트 모드)

### 필수: CSS 변수(Tailwind 클래스)만 사용

다크/라이트 모드가 제대로 작동하려면 **반드시** `global.css`에 정의된 CSS 변수를 사용해야 합니다.

**하드코딩된 색상은 테마가 변경되어도 바뀌지 않습니다!**

```tsx
// ❌ 금지 - 다크 모드에서 안 바뀜
backgroundColor: '#F5F5F5'
backgroundColor: 'white'
color: '#9CA3AF'
tabBarInactiveTintColor: '#8E8E93'

// ✅ 올바름 - 다크 모드에서 자동 변환
className="bg-gray-3"
className="bg-white"
className="text-gray-5"
```

### 사용 가능한 색상 변수

| 변수 | 용도 |
|------|------|
| `bg-white` | 카드, 섹션 배경 (다크 모드에서 #121212) |
| `bg-gray-1` ~ `bg-gray-10` | 배경색 계열 |
| `text-gray-1` ~ `text-gray-10` | 텍스트 색상 계열 |
| `bg-main`, `text-main` | 메인 브랜드 색상 |
| `bg-error`, `text-error` | 에러 색상 |
| `bg-success`, `text-success` | 성공 색상 |
| `bg-warning`, `text-warning` | 경고 색상 |
| `bg-background` | 전체 화면 배경 |
| `bg-surface` | 컴포넌트 표면 |
| `text-foreground` | 기본 텍스트 |
| `text-muted` | 보조 텍스트 |

### className으로 색상 적용 (권장)

```tsx
<Text className="text-gray-6">텍스트</Text>
<View className="bg-main" />
<View className="bg-white rounded-2xl" /> // 다크 모드에서 자동 변환
```

### JS에서 색상값이 필요한 경우: useResolveClassNames
Expand All @@ -126,12 +162,14 @@ import { useResolveClassNames } from 'uniwind';

function MyComponent() {
const activeStyle = useResolveClassNames('text-main');
const inactiveStyle = useResolveClassNames('text-gray-6');
const borderStyle = useResolveClassNames('border-gray-2');

return (
<Tabs
screenOptions={{
tabBarActiveTintColor: activeStyle.color as string,
tabBarInactiveTintColor: inactiveStyle.color as string, // ✅ 하드코딩 금지
tabBarStyle: { borderTopColor: borderStyle.borderColor as string },
}}
/>
Expand Down Expand Up @@ -165,12 +203,59 @@ export const NewIcon = createStyledIcon(NewIconSvg);

---

## 애니메이션 상수

React Native Reanimated 애니메이션에서 duration, delay 값은 **반드시** `ANIMATION` 상수를 사용합니다.

```tsx
import { ANIMATION } from '@src/shared/constants/animation.constants';

// ✅ 올바름
withTiming(value, { duration: ANIMATION.duration.slow });
withTiming(value, { duration: ANIMATION.duration.normal });
withDelay(ANIMATION.delay.short, withTiming(...));

// ❌ 금지 - 매직 넘버 하드코딩
withTiming(value, { duration: 300 });
withTiming(value, { duration: 200 });
```

| 상수 | 값 | 용도 |
|------|-----|------|
| `ANIMATION.duration.fast` | 150ms | 빠른 전환 |
| `ANIMATION.duration.normal` | 200ms | 일반 전환 |
| `ANIMATION.duration.slow` | 300ms | 느린 전환 |
| `ANIMATION.delay.short` | 50ms | 짧은 지연 |
| `ANIMATION.delay.medium` | 100ms | 중간 지연 |
| `ANIMATION.delay.long` | 200ms | 긴 지연 |

---

## 금지 사항

### 1. 색상 하드코딩 금지 (중요!)

**다크/라이트 모드 지원을 위해 색상은 반드시 Tailwind 클래스를 사용해야 합니다.**

```tsx
// ❌ 금지 - 다크 모드에서 안 바뀜
backgroundColor: '#F5F5F5'
backgroundColor: 'white'
color: '#9CA3AF'
style={{ backgroundColor: 'white' }}

// ✅ 올바름
className="bg-gray-3"
className="bg-white"
className="text-gray-5"
```

### 2. Shared UI 컴포넌트 중복 import 금지

Shared UI에 있는 컴포넌트를 다른 곳에서 가져오면 안 됩니다.

```tsx
// 금지 - Shared UI에 Text가 있으므로
// 금지 - Shared UI에 Text가 있으므로
import { Text, View } from 'react-native';
```

Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ function IOSLiquidGlassTabs() {

function AndroidBottomTabs() {
const activeStyle = useResolveClassNames('text-main');
const inactiveStyle = useResolveClassNames('text-gray-6');

return (
<Tabs
screenOptions={{
tabBarActiveTintColor: activeStyle.color as string,
tabBarInactiveTintColor: '#8E8E93',
tabBarInactiveTintColor: inactiveStyle.color as string,
headerShown: false,
animation: 'shift',
}}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/app/(app)/(tabs)/feed/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { BellIcon } from '@src/shared/ui/Icon';
import { Stack } from 'expo-router';
import { Pressable, View } from 'react-native';
import { useResolveClassNames } from 'uniwind';

export default function FeedLayout() {
const headerBg = useResolveClassNames('bg-white');

return (
<Stack
screenOptions={{
headerShown: true,
headerShadowVisible: false,
headerTitle: '',
headerStyle: { backgroundColor: headerBg.backgroundColor as string },
headerRight: () => (
<View className="justify-center items-center">
<Pressable onPress={() => console.log('알림')} hitSlop={8} className="p-2">
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/app/(app)/(tabs)/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ const HomeScreen = () => {
<>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1, backgroundColor: 'white' }}
style={{ flex: 1 }}
className="bg-white"
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<ScrollView
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/app/(app)/(tabs)/mypage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ const MyPageScreen = () => {
label="알림 설정"
onPress={() => router.push('/settings/notifications')}
/>
<SettingNavigationItem label="화면 테마" onPress={() => router.push('/settings/theme')} />
</SettingNavigationSection>

<Spacing size={12} />

<SettingNavigationSection>
<SettingNavigationItem label="공지사항" onPress={() => console.log('공지사항 클릭')} />
<SettingNavigationItem
label="의견 보내기"
Expand Down Expand Up @@ -144,7 +150,7 @@ const SettingNavigationItem = ({ label, onPress }: SettingNavigationItemProps) =
<PressableFeedback.Highlight className="rounded-xl" />
<ListRow
contents={<ListRow.Texts type="1RowTypeA" top={label} />}
right={<ArrowRightIcon colorClassName="accent-gray-6" />}
right={<ArrowRightIcon colorClassName="text-gray-6" />}
horizontalPadding="medium"
/>
</PressableFeedback>
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useTheme } from '@src/shared/providers/theme-provider';
import { Stack } from 'expo-router';
import { Platform } from 'react-native';

const THEME_COLORS = {
light: '#ffffff',
dark: '#262626',
} as const;

const AppLayout = () => {
const { resolvedTheme } = useTheme();

return (
<Stack
screenOptions={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_right' : 'slide_from_bottom',
animationTypeForReplace: 'push',
contentStyle: { backgroundColor: THEME_COLORS[resolvedTheme] },
}}
>
<Stack.Screen name="(tabs)" />
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/app/(app)/settings/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function SettingsLayout() {
headerShadowVisible: false,
headerStyle: { backgroundColor: headerBg.backgroundColor as string },
headerTitleStyle: {
fontSize: 18,
fontSize: 16,
fontWeight: '600',
color: titleColor.color as string,
},
Expand All @@ -29,6 +29,7 @@ export default function SettingsLayout() {
}}
>
<Stack.Screen name="notifications" options={{ title: '알림 설정' }} />
<Stack.Screen name="theme" options={{ title: '화면 테마' }} />
<Stack.Screen name="terms" options={{ title: '약관 및 정책' }} />
</Stack>
);
Expand Down
97 changes: 97 additions & 0 deletions apps/mobile/app/(app)/settings/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ANIMATION } from '@src/shared/constants/animation.constants';
import { type ThemeMode, useTheme } from '@src/shared/providers/theme-provider';
import { DeviceIcon, MoonIcon, type StyledIconType, SunIcon } from '@src/shared/ui/Icon';
import { ListRow } from '@src/shared/ui/ListRow/ListRow';
import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView';
import { RadioGroup, Spinner } from 'heroui-native';
import { ScrollView } from 'react-native';
import Animated, { Easing, useAnimatedStyle, withTiming } from 'react-native-reanimated';

const ThemeSettingsScreen = () => {
const { mode, setMode, isLoading } = useTheme();

if (isLoading) {
return (
<StyledSafeAreaView
className="flex-1 bg-gray-1 items-center justify-center"
edges={['bottom']}
>
<Spinner />
</StyledSafeAreaView>
);
}

return (
<StyledSafeAreaView className="flex-1 bg-gray-1" edges={['bottom']}>
<ScrollView className="px-4 flex-1">
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as ThemeMode)}
className="bg-white rounded-2xl overflow-hidden gap-0"
>
<ThemeRadioItem value="light" label="라이트 모드" Icon={SunIcon} />
<ThemeRadioItem value="dark" label="다크 모드" Icon={MoonIcon} />
<ThemeRadioItem value="system" label="시스템 설정" Icon={DeviceIcon} />
</RadioGroup>
</ScrollView>
</StyledSafeAreaView>
);
};

export default ThemeSettingsScreen;

interface ThemeRadioItemProps {
value: ThemeMode;
label: string;
Icon: StyledIconType;
}

function ThemeRadioItem({ value, label, Icon }: ThemeRadioItemProps) {
return (
<RadioGroup.Item value={value}>
{({ isSelected }) => (
<ListRow
contents={
<ListRow.Texts
type="1RowTypeA"
top={label}
topProps={{ size: 'b3', weight: 'semibold' }}
/>
}
right={
<RadioGroup.Indicator>
<AnimatedThumbIcon Icon={Icon} isSelected={isSelected} />
</RadioGroup.Indicator>
}
horizontalPadding="medium"
verticalPadding="large"
/>
)}
</RadioGroup.Item>
);
}

interface AnimatedThumbIconProps {
Icon: StyledIconType;
isSelected: boolean;
}

function AnimatedThumbIcon({ Icon, isSelected }: AnimatedThumbIconProps) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: withTiming(isSelected ? 1 : 1.8, {
duration: ANIMATION.duration.slow,
easing: Easing.out(Easing.ease),
}),
},
],
opacity: withTiming(isSelected ? 1 : 0, { duration: ANIMATION.duration.normal }),
}));

return (
<Animated.View style={animatedStyle}>
<Icon colorClassName="text-white" width={14} height={14} />
</Animated.View>
);
}
34 changes: 31 additions & 3 deletions apps/mobile/app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
import { Stack } from 'expo-router';
import { Platform } from 'react-native';
import { useTheme } from '@src/shared/providers/theme-provider';
import { ArrowLeftIcon } from '@src/shared/ui/Icon';
import { router, Stack } from 'expo-router';
import { Platform, Pressable, View } from 'react-native';
import { useResolveClassNames } from 'uniwind';

const THEME_COLORS = {
light: '#ffffff',
dark: '#262626',
} as const;

const AuthLayout = () => {
const { resolvedTheme } = useTheme();
const headerBg = useResolveClassNames('bg-background');
const titleColor = useResolveClassNames('text-gray-9');

return (
<Stack
screenOptions={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'default' : 'fade_from_bottom',
animationDuration: 200,
contentStyle: { backgroundColor: THEME_COLORS[resolvedTheme] },
headerShadowVisible: false,
headerStyle: { backgroundColor: headerBg.backgroundColor as string },
headerTitleStyle: {
fontSize: 16,
fontWeight: '600',
color: titleColor.color as string,
},
headerTitleAlign: 'center',
headerLeft: () => (
<View className="justify-center items-center">
<Pressable onPress={() => router.back()} hitSlop={8} className="p-2">
<ArrowLeftIcon width={20} height={20} colorClassName="text-gray-9" />
</Pressable>
</View>
),
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="email-login" />
<Stack.Screen name="sign-up" />
<Stack.Screen name="sign-up" options={{ headerShown: true }} />
<Stack.Screen name="verify-email" />
</Stack>
);
Expand Down
Loading