diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 473344b..d203c19 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,3 @@ { - "recommendations": [ - "expo.vscode-expo-tools", - "bradlc.vscode-tailwindcss" - ] + "recommendations": ["expo.vscode-expo-tools", "bradlc.vscode-tailwindcss"] } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 85f9947..8cfbd5a 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,33 +1,22 @@ import { Tabs } from "expo-router"; import React from "react"; -import { HapticTab } from "@/components/haptic-tab"; -import { IconSymbol } from "@/components/ui/icon-symbol"; +import { BottomTabBar } from "@/src/components/common/BottomTabBar"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; -// 임시 - 공통 컴포넌트 구현 후 교체 예정 -// 네비게이션 탭으로 이동하여 보여지는 페이지들을 라우팅 export default function TabLayout() { const colorScheme = useColorScheme(); return ( } screenOptions={{ tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint, headerShown: false, - tabBarButton: HapticTab, }} > - ( - - ), - }} - /> + diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2a69ac4..3224fbb 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,11 +1,171 @@ -import { Text } from "react-native"; +import { useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { router } from "expo-router"; +import { AddScheduleButton } from "@/src/components/common/AddScheduleButton"; +import { ScheduleCheckItem } from "@/src/components/common/ScheduleCheckItem"; -import { ScreenContainer } from "@/src/components/common"; +type Schedule = { + id: string; + categoryLabel: string; + categoryColor: string; + description: string; + checked: boolean; +}; + +const MOCK_SCHEDULES: Schedule[] = [ + { + id: "1", + categoryLabel: "CATEGORY", + categoryColor: "#5B8FD9", + description: "Lorem ipsum dolor sit amet consectetur.", + checked: false, + }, + { + id: "2", + categoryLabel: "CATEGORY", + categoryColor: "#D9A05B", + description: "Lorem ipsum dolor sit amet consectetur.", + checked: false, + }, + { + id: "3", + categoryLabel: "CATEGORY", + categoryColor: "#5FAE72", + description: "Lorem ipsum dolor sit amet consectetur.", + checked: false, + }, + { + id: "4", + categoryLabel: "CATEGORY", + categoryColor: "#9B7ED9", + description: "Lorem ipsum dolor sit amet consectetur.", + checked: false, + }, + { + id: "5", + categoryLabel: "CATEGORY", + categoryColor: "#D95B8F", + description: "Lorem ipsum dolor sit amet consectetur.", + checked: false, + }, +]; + +function useFormattedDate() { + return useMemo(() => { + const days = ["일", "월", "화", "수", "목", "금", "토"]; + const now = new Date(); + return `${now.getMonth() + 1}월 ${now.getDate()}일 ${days[now.getDay()]}요일`; + }, []); +} export default function HomeScreen() { + const insets = useSafeAreaInsets(); + const dateLabel = useFormattedDate(); + const [schedules, setSchedules] = useState(MOCK_SCHEDULES); + const hasSchedules = schedules.length > 0; + + const toggleSchedule = (id: string) => { + setSchedules((prev) => + prev.map((s) => (s.id === id ? { ...s, checked: !s.checked } : s)) + ); + }; + return ( - - - + + + {/* 로고 영역 */} + + + DA·LOG + + + + {/* 날짜 */} + + {dateLabel} + + + {/* 일정 카드 */} + + + {hasSchedules + ? "오늘 계획한 일정은 모두 마치셨나요?" + : "오늘 계획된 일정이 없어요."} + + + {hasSchedules && + schedules.map((s) => ( + toggleSchedule(s.id)} + /> + ))} + + + router.push("/schedule")} /> + + + + {/* 오늘의 질문 */} + + + 오늘의 질문 + + + + 질문 받기 + + + + + ); } diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000..e828def --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/checkbox.svg b/assets/icons/checkbox.svg new file mode 100644 index 0000000..3aa1b31 --- /dev/null +++ b/assets/icons/checkbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/diary.svg b/assets/icons/diary.svg new file mode 100644 index 0000000..c2710dc --- /dev/null +++ b/assets/icons/diary.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/schedule.svg b/assets/icons/schedule.svg new file mode 100644 index 0000000..0374d6d --- /dev/null +++ b/assets/icons/schedule.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/tab-calendar.svg b/assets/icons/tab-calendar.svg new file mode 100644 index 0000000..0995103 --- /dev/null +++ b/assets/icons/tab-calendar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/tab-cancle.svg b/assets/icons/tab-cancle.svg new file mode 100644 index 0000000..b396f4d --- /dev/null +++ b/assets/icons/tab-cancle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tab-home.svg b/assets/icons/tab-home.svg new file mode 100644 index 0000000..af74e12 --- /dev/null +++ b/assets/icons/tab-home.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tab-plus.svg b/assets/icons/tab-plus.svg new file mode 100644 index 0000000..0450019 --- /dev/null +++ b/assets/icons/tab-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tab-setting.svg b/assets/icons/tab-setting.svg new file mode 100644 index 0000000..5389c35 --- /dev/null +++ b/assets/icons/tab-setting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/tab-statistic.svg b/assets/icons/tab-statistic.svg new file mode 100644 index 0000000..4db25da --- /dev/null +++ b/assets/icons/tab-statistic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx index a0455e1..db2f02c 100644 --- a/components/ui/icon-symbol.tsx +++ b/components/ui/icon-symbol.tsx @@ -16,9 +16,7 @@ type IconSymbolName = keyof typeof MAPPING; * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. */ -const MAPPING = { - "house.fill": "home", -} as IconMapping; +const MAPPING = {} as IconMapping; /** * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. diff --git a/src/components/common/AddButton.tsx b/src/components/common/AddButton.tsx index bfdcec6..f5da114 100644 --- a/src/components/common/AddButton.tsx +++ b/src/components/common/AddButton.tsx @@ -18,7 +18,7 @@ export function AddButton({ label, className, ...rest }: AddButtonProps) { {...rest} > - {label} + {label} ); } diff --git a/src/components/common/AddScheduleButton.tsx b/src/components/common/AddScheduleButton.tsx index 75a1cc1..d0de945 100644 --- a/src/components/common/AddScheduleButton.tsx +++ b/src/components/common/AddScheduleButton.tsx @@ -21,8 +21,8 @@ export function AddScheduleButton({ )} {...rest} > - {label} + {label} ); -} +} \ No newline at end of file diff --git a/src/components/common/BottomTabBar.tsx b/src/components/common/BottomTabBar.tsx new file mode 100644 index 0000000..940f1f7 --- /dev/null +++ b/src/components/common/BottomTabBar.tsx @@ -0,0 +1,176 @@ +import DiaryIcon from "@/assets/icons/diary.svg"; +import ScheduleIcon from "@/assets/icons/schedule.svg"; +import TabCalendarIcon from "@/assets/icons/tab-calendar.svg"; +import TabCancleIcon from "@/assets/icons/tab-cancle.svg"; +import TabHomeIcon from "@/assets/icons/tab-home.svg"; +import TabPlusIcon from "@/assets/icons/tab-plus.svg"; +import TabSettingIcon from "@/assets/icons/tab-setting.svg"; +import TabStatisticIcon from "@/assets/icons/tab-statistic.svg"; +import { cn } from "@/src/lib/cn"; +import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { Pressable, Text, useWindowDimensions, View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const TAB_ICONS = { + index: TabHomeIcon, + calendar: TabCalendarIcon, + statistics: TabStatisticIcon, + settings: TabSettingIcon, +} as const; + +const TAB_BAR_SHADOW_STYLE = { + shadowColor: "#4D826C", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.14, + shadowRadius: 16, + elevation: 6, +} as const; + +export function BottomTabBar({ state, navigation }: BottomTabBarProps) { + const insets = useSafeAreaInsets(); + const [isOpen, setIsOpen] = useState(false); + + // 현재 폰의 가로 사이즈를 자동으로 가져옴 + const { width } = useWindowDimensions(); + + // 애니메이션 값 설정 (0: 닫힘/플러스, 1: 열림/엑스) + const animationProgress = useSharedValue(0); + + // 열릴 때 300ms ease-out, 닫힐 때 지정된 스프링값 + useEffect(() => { + if (isOpen) { + animationProgress.value = withTiming(1, { duration: 300 }); + } else { + animationProgress.value = withSpring(0, { + mass: 1, + stiffness: 100, + damping: 15, + }); + } + }, [isOpen]); + + // 피그마 디졸브 배경색 계산 (Reanimated 스타일) + const animatedBoxStyle = useAnimatedStyle(() => { + const backgroundColor = + animationProgress.value > 0.5 ? "#F5F9F6" : "#FCFDFD"; + return { + backgroundColor, + }; + }); + + return ( + 0 ? insets.bottom + 10 : 30, + }} + > + {/* ----------------- 왼쪽 박스 영역 ----------------- */} + {!isOpen ? ( + + {state.routes.map((route, index) => { + const isFocused = state.index === index; + const TabIcon = + TAB_ICONS[route.name as keyof typeof TAB_ICONS] ?? TabHomeIcon; + const onPress = () => { + const event = navigation.emit({ + type: "tabPress", + target: route.key, + canPreventDefault: true, + }); + if (!isFocused && !event.defaultPrevented) { + navigation.navigate(route.name); + } + }; + return ( + + + + ); + })} + + ) : ( + // 플러스 버튼 열린 상태 (일정등록 | 일기작성) + + {/* 일정등록 버튼 */} + { + console.log("일정등록 클릭"); // 터미널 확인용 로그 + setIsOpen(false); + router.push("/schedule"); + }} + className="h-[24px] w-[55px] flex-row items-center justify-center gap-[8px]" + > + + 일정등록 + + + + + {/* 가운데 세로 구분선 */} + + + {/* 일기작성 버튼 */} + { + console.log("일기작성 클릭"); // 터미널 확인용 로그 + setIsOpen(false); + router.push("/diary/write"); + }} + className="h-[24px] w-[55px] flex-row items-center justify-center gap-[8px]" + > + + 일기작성 + + + + + )} + + {/* ----------------- 오른쪽 박스: 플러스 / 엑스 버튼 ----------------- */} + + setIsOpen(!isOpen)} + className="h-full w-full items-center justify-center rounded-full bg-transparent web:outline-none" + > + {isOpen ? ( + + ) : ( + + )} + + + + ); +} + +export default BottomTabBar; diff --git a/src/components/common/CategoryCircle.tsx b/src/components/common/CategoryCircle.tsx index d07430a..b13d770 100644 --- a/src/components/common/CategoryCircle.tsx +++ b/src/components/common/CategoryCircle.tsx @@ -58,7 +58,7 @@ export function CategoryCircle({ "h-9 w-9 items-center justify-center rounded-full", isDisabled ? CATEGORY_COLOR_CLASS_NAMES[color].soft - : CATEGORY_COLOR_CLASS_NAMES[color].solid, + : CATEGORY_COLOR_CLASS_NAMES[color].solid )} > {isSelected || isDisabled ? ( @@ -70,4 +70,4 @@ export function CategoryCircle({ ) : null} ); -} \ No newline at end of file +} diff --git a/src/components/common/Checkbox.tsx b/src/components/common/Checkbox.tsx new file mode 100644 index 0000000..31d172f --- /dev/null +++ b/src/components/common/Checkbox.tsx @@ -0,0 +1,32 @@ +import { Pressable, type PressableProps } from "react-native"; + +import BoxIcon from "@/assets/icons/box.svg"; +import CheckboxIcon from "@/assets/icons/checkbox.svg"; +import { cn } from "@/src/lib/cn"; + +export type CheckboxProps = Omit & { + checked: boolean; + onToggle: () => void; + className?: string; +}; + +export function Checkbox({ + checked, + onToggle, + className, + ...rest +}: CheckboxProps) { + const Icon = checked ? CheckboxIcon : BoxIcon; + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/common/DiaryCard.tsx b/src/components/common/DiaryCard.tsx index 16521af..b8c298d 100644 --- a/src/components/common/DiaryCard.tsx +++ b/src/components/common/DiaryCard.tsx @@ -1,10 +1,5 @@ import { useState } from "react"; -import { - Image, - type LayoutChangeEvent, - Text, - View, -} from "react-native"; +import { Image, type LayoutChangeEvent, Text, View } from "react-native"; import { cn } from "@/src/lib/cn"; import type { @@ -36,10 +31,7 @@ const CONTENT_LINE_COUNTS: Record< "three-images": 5, }; -const IMAGE_CONTAINER_CLASS_NAMES: Record< - DiaryCardImageVariant, - string -> = { +const IMAGE_CONTAINER_CLASS_NAMES: Record = { "one-image": "w-full items-center", "two-images": "w-full flex-row gap-2", "three-images": "w-full flex-row gap-2", @@ -58,13 +50,13 @@ export function DiaryCard(props: DiaryCardProps) { style={CARD_SHADOW_STYLE} className={cn( "h-12 w-full justify-center rounded-xl bg-gray-0 px-4", - props.containerClassName, + props.containerClassName )} > {props.summary} @@ -72,8 +64,7 @@ export function DiaryCard(props: DiaryCardProps) { ); } - const imageVariant = - props.variant === "no-image" ? null : props.variant; + const imageVariant = props.variant === "no-image" ? null : props.variant; const images = props.variant === "no-image" ? [] : props.images; @@ -88,7 +79,7 @@ export function DiaryCard(props: DiaryCardProps) { style={CARD_SHADOW_STYLE} className={cn( "w-full gap-2 rounded-xl bg-gray-0 px-4 py-3", - props.containerClassName, + props.containerClassName )} > {imageVariant ? ( @@ -111,7 +102,7 @@ export function DiaryCard(props: DiaryCardProps) { {props.title} @@ -119,11 +110,11 @@ export function DiaryCard(props: DiaryCardProps) { {props.content} ); -} \ No newline at end of file +} diff --git a/src/components/common/ScheduleCheckItem.tsx b/src/components/common/ScheduleCheckItem.tsx new file mode 100644 index 0000000..8c146b4 --- /dev/null +++ b/src/components/common/ScheduleCheckItem.tsx @@ -0,0 +1,47 @@ + +import { Text, View } from "react-native"; +import { Checkbox } from "@/src/components/common/Checkbox"; + +export type ScheduleCheckItemProps = { + categoryLabel: string; + categoryColor: string; // e.g. "#5B8FD9" + description: string; + checked: boolean; + onToggle: () => void; +}; + +export function ScheduleCheckItem({ + categoryLabel, + categoryColor, + description, + checked, + onToggle, +}: ScheduleCheckItemProps) { + return ( + + {/* 왼쪽: 카테고리 dot + 텍스트 */} + + + + + {categoryLabel.toUpperCase()} + + + + {description} + + + + {/* 오른쪽: 체크박스 (공용 컴포넌트) */} + + + ); +} + +export default ScheduleCheckItem; diff --git a/src/components/common/TextField.tsx b/src/components/common/TextField.tsx index 00d6a46..a228b8d 100644 --- a/src/components/common/TextField.tsx +++ b/src/components/common/TextField.tsx @@ -1,10 +1,5 @@ import { useState } from "react"; -import { - Text, - TextInput, - type TextInputProps, - View, -} from "react-native"; +import { Text, TextInput, type TextInputProps, View } from "react-native"; import { cn } from "@/src/lib/cn"; @@ -42,7 +37,7 @@ export function TextField({ isTextarea ? "h-[108px] items-start p-3" : "h-12 items-center px-3", isFocused ? "border-gray-400" : "border-gray-200", disabled && "opacity-50", - containerClassName, + containerClassName )} > { setIsFocused(true); @@ -67,10 +62,10 @@ export function TextField({ /> {rightText ? ( - + {rightText} ) : null} ); -} \ No newline at end of file +} diff --git a/src/types/diaries/diaryCard.types.ts b/src/types/diaries/diaryCard.types.ts index fe7312f..010579c 100644 --- a/src/types/diaries/diaryCard.types.ts +++ b/src/types/diaries/diaryCard.types.ts @@ -40,11 +40,7 @@ export type DiaryCardThreeImagesProps = DiaryCardBaseProps & { variant: "three-images"; title: string; content: string; - images: [ - ImageSourcePropType, - ImageSourcePropType, - ImageSourcePropType, - ]; + images: [ImageSourcePropType, ImageSourcePropType, ImageSourcePropType]; }; export type DiaryCardProps = @@ -54,7 +50,4 @@ export type DiaryCardProps = | DiaryCardTwoImagesProps | DiaryCardThreeImagesProps; -export type DiaryCardImageVariant = - | "one-image" - | "two-images" - | "three-images"; \ No newline at end of file +export type DiaryCardImageVariant = "one-image" | "two-images" | "three-images";