Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Stack } from 'expo-router';
import { Platform } from 'react-native';
import { useResolveClassNames } from 'uniwind';

const AppLayout = () => {
const { backgroundColor } = useResolveClassNames('bg-white');

return (
<Stack
screenOptions={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_right' : 'slide_from_bottom',
animationTypeForReplace: 'push',
contentStyle: { backgroundColor: backgroundColor as string },
}}
>
<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
86 changes: 86 additions & 0 deletions apps/mobile/app/(app)/settings/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 } from 'heroui-native';
import { ScrollView } from 'react-native';
import Animated, { Easing, useAnimatedStyle, withTiming } from 'react-native-reanimated';

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

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>
);
}
28 changes: 25 additions & 3 deletions apps/mobile/app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
import { Stack } from 'expo-router';
import { Platform } from 'react-native';
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 AuthLayout = () => {
const { backgroundColor } = useResolveClassNames('bg-white');
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: backgroundColor as string },
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