diff --git a/apps/mobile/.claude/ui-components.md b/apps/mobile/.claude/ui-components.md index 65e4e3e8..4871cb1b 100644 --- a/apps/mobile/.claude/ui-components.md +++ b/apps/mobile/.claude/ui-components.md @@ -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 텍스트 + // 다크 모드에서 자동 변환 ``` ### JS에서 색상값이 필요한 경우: useResolveClassNames @@ -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 ( @@ -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'; ``` diff --git a/apps/mobile/app/(app)/(tabs)/_layout.tsx b/apps/mobile/app/(app)/(tabs)/_layout.tsx index 00ebc74b..2640c98a 100644 --- a/apps/mobile/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/app/(app)/(tabs)/_layout.tsx @@ -39,12 +39,13 @@ function IOSLiquidGlassTabs() { function AndroidBottomTabs() { const activeStyle = useResolveClassNames('text-main'); + const inactiveStyle = useResolveClassNames('text-gray-6'); return ( ( console.log('알림')} hitSlop={8} className="p-2"> diff --git a/apps/mobile/app/(app)/(tabs)/home/index.tsx b/apps/mobile/app/(app)/(tabs)/home/index.tsx index ea38dda3..2c7840b1 100644 --- a/apps/mobile/app/(app)/(tabs)/home/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/home/index.tsx @@ -84,7 +84,8 @@ const HomeScreen = () => { <> { label="알림 설정" onPress={() => router.push('/settings/notifications')} /> + router.push('/settings/theme')} /> + + + + + console.log('공지사항 클릭')} /> } - right={} + right={} horizontalPadding="medium" /> diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 66ad83e5..d7051723 100644 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -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 ( diff --git a/apps/mobile/app/(app)/settings/_layout.tsx b/apps/mobile/app/(app)/settings/_layout.tsx index 32dea786..4e822f98 100644 --- a/apps/mobile/app/(app)/settings/_layout.tsx +++ b/apps/mobile/app/(app)/settings/_layout.tsx @@ -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, }, @@ -29,6 +29,7 @@ export default function SettingsLayout() { }} > + ); diff --git a/apps/mobile/app/(app)/settings/theme.tsx b/apps/mobile/app/(app)/settings/theme.tsx new file mode 100644 index 00000000..528422eb --- /dev/null +++ b/apps/mobile/app/(app)/settings/theme.tsx @@ -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 ( + + + setMode(value as ThemeMode)} + className="bg-white rounded-2xl overflow-hidden gap-0" + > + + + + + + + ); +}; + +export default ThemeSettingsScreen; + +interface ThemeRadioItemProps { + value: ThemeMode; + label: string; + Icon: StyledIconType; +} + +function ThemeRadioItem({ value, label, Icon }: ThemeRadioItemProps) { + return ( + + {({ isSelected }) => ( + + } + right={ + + + + } + horizontalPadding="medium" + verticalPadding="large" + /> + )} + + ); +} + +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 ( + + + + ); +} diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx index ff585ffb..b1167721 100644 --- a/apps/mobile/app/(auth)/_layout.tsx +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -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 ( ( + + router.back()} hitSlop={8} className="p-2"> + + + + ), }} > - + ); diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index aadd5436..577e3a35 100644 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -87,6 +87,7 @@ const LoginScreen = () => { onPress={handleKakaoLogin} isLoading={kakaoLoginMutation.isPending || exchangeCodeMutation.isPending} className="bg-kakao" + labelClassName="dark:text-gray-1" /> { label="Google로 계속하기" onPress={handleGoogleLogin} isLoading={googleLoginMutation.isPending || exchangeCodeMutation.isPending} - className="bg-white border border-gray-200" + className="bg-white border border-gray-2 dark:border-gray-2 dark:bg-gray-2" + labelClassName="dark:text-gray-9" /> @@ -112,7 +114,7 @@ const LoginScreen = () => { icon={} onPress={handleAppleLogin} isLoading={appleLoginMutation.isPending} - className="bg-black" + className="bg-black dark:border dark:border-gray-2" /> )} ; interface SocialLoginButtonProps extends Omit { icon: ReactNode; label: string; + labelClassName?: string; } -const SocialLoginButton = ({ icon, label, className, ...props }: SocialLoginButtonProps) => { +const SocialLoginButton = ({ + icon, + label, + className, + labelClassName, + ...props +}: SocialLoginButtonProps) => { return (