diff --git a/.gitignore b/.gitignore index e889fc2f..b463c5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ yarn-error.log # build cache & logs msbuild.binlog android/.kotlin/* +tsconfig.tsbuildinfo # env .env diff --git a/src/components/Button/Icon/index.tsx b/src/components/Button/Icon/index.tsx index f8937b66..2b0065fa 100644 --- a/src/components/Button/Icon/index.tsx +++ b/src/components/Button/Icon/index.tsx @@ -7,7 +7,7 @@ import type { Icon, IconProps } from '@/types/icon'; interface IconButtonProps extends Omit { Icon: Icon; buttonPadding?: number; - style?: StyleProp; + containerStyle?: StyleProp; iconStyle?: StyleProp; buttonType?: 'touchableOpacity' | 'pressable'; onPress?: () => void; @@ -17,7 +17,7 @@ const IconButton = ({ Icon, onPress, buttonPadding = 2, - style: containerStyle, + containerStyle, iconStyle, buttonType = 'touchableOpacity', ...props diff --git a/src/components/Panel/PanelView/PanelHeader.style.ts b/src/components/Panel/PanelView/PanelHeader.style.ts index d9b45c60..d6ec6ec6 100644 --- a/src/components/Panel/PanelView/PanelHeader.style.ts +++ b/src/components/Panel/PanelView/PanelHeader.style.ts @@ -17,7 +17,7 @@ export const TITLE_TEXT_STYLE: TextProps = { export const BUTTON_ICON_STYLE: IconProps = { width: 20, height: 20, - style: { + containerStyle: { position: 'absolute', right: 0, top: 0, }, diff --git a/src/features/category/hooks/useCategoryColor.ts b/src/features/category/hooks/useCategoryColor.ts index 303e0432..55ff9a5d 100644 --- a/src/features/category/hooks/useCategoryColor.ts +++ b/src/features/category/hooks/useCategoryColor.ts @@ -1,7 +1,7 @@ import { type GroupColorKey } from '@/constants/groupColors'; import { useGroupsQuery } from '@/features/groups/api/queries'; -import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey'; +import { useGroupColorKeyMap } from '@/features/groups/hooks/useGroupColorKey'; import { theme } from '@/theme'; const DEFAULT_COLOR_KEY: GroupColorKey = theme.color.primary[500]; @@ -12,7 +12,8 @@ export const useCategoryColor = (categoryId: number) => { const matchingGroupId = groupsData?.find((g) => g.categoryIds.includes(categoryId))?.groupId ?? null; - const { colorKey: groupColor } = useGroupColorKey(matchingGroupId); + const { colorKeyMap } = useGroupColorKeyMap(); + const groupColor = matchingGroupId ? colorKeyMap.get(matchingGroupId) : null; const colorKey = groupColor ?? DEFAULT_COLOR_KEY; return { colorKey }; diff --git a/src/features/category/ui/InlineCategoryItem/index.style.ts b/src/features/category/ui/InlineCategoryItem/index.style.ts index f3355dc4..be3fa861 100644 --- a/src/features/category/ui/InlineCategoryItem/index.style.ts +++ b/src/features/category/ui/InlineCategoryItem/index.style.ts @@ -9,7 +9,7 @@ export const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[100], + gap: theme.spacing[200], }, }); diff --git a/src/features/groups/hooks/useGroupColorKey.ts b/src/features/groups/hooks/useGroupColorKey.ts index 4e546ea8..ccb81933 100644 --- a/src/features/groups/hooks/useGroupColorKey.ts +++ b/src/features/groups/hooks/useGroupColorKey.ts @@ -1,11 +1,15 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; import type { GroupColorKey } from '@/constants/groupColors'; import { GROUP_COLORS } from '@/constants/groupColors'; import { useProfileQuery } from '@/features/user/api/queries'; import { ASYNC_KEYS, asyncStorage } from '@/utils/asyncStorage'; +import { useGroupsQuery } from '../api/queries'; + const GROUP_COLOR_KEYS = Object.keys(GROUP_COLORS) as GroupColorKey[]; +const queryKey = (userId: number | null, groupId: number) => ['groupColor', userId, groupId]; const pickRandomGroupColorKey = (): GroupColorKey => GROUP_COLOR_KEYS[Math.floor(Math.random() * GROUP_COLOR_KEYS.length)]; @@ -13,38 +17,54 @@ const pickRandomGroupColorKey = (): GroupColorKey => const isGroupColorKey = (v: string): v is GroupColorKey => (GROUP_COLOR_KEYS as readonly string[]).includes(v); -export const useGroupColorKey = (groupId: number | null) => { - const { data: profileData } = useProfileQuery(); - const userId = profileData?.id; +export const useGroupColorKeyMap = () => { + const groups = useGroupsQuery().data; + const groupIds = useMemo( + () => Array.from(new Set(groups?.map(g => g.groupId))).sort((a, b) => a - b), + [groups], + ); + const userId = useProfileQuery().data?.id ?? null; const qc = useQueryClient(); - const queryKey = ['groupColor', userId, groupId]; - - const { data: colorKey } = useQuery({ - queryKey, - enabled: userId != null && groupId != null, - queryFn: async () => { - if (userId == null || groupId == null) return null; + const results = useQueries({ + queries: groupIds.map((groupId) => ({ + queryKey: queryKey(userId, groupId), + enabled: userId != null && groupId != null, + queryFn: async (): Promise => { + if (userId == null) return null; + const storageKey = ASYNC_KEYS.groupColor(userId, groupId); + const stored = await asyncStorage.get(storageKey); - const storageKey = ASYNC_KEYS.groupColor(userId, groupId); - const stored = await asyncStorage.get(storageKey); + if (stored && isGroupColorKey(stored)) return stored; - if (stored && isGroupColorKey(stored)) return stored; + const newKey = pickRandomGroupColorKey(); + await asyncStorage.set(storageKey, newKey); + return newKey; + }, + staleTime: Infinity, + gcTime: Infinity, + })), + }); - const newKey = pickRandomGroupColorKey(); - await asyncStorage.set(storageKey, newKey); - return newKey; - }, - staleTime: Infinity, - gcTime: Infinity, + const colorKeyMap = new Map(); + groupIds.forEach((groupId, idx) => { + const colorKey = results[idx]?.data ?? null; + if (colorKey) colorKeyMap.set(groupId, colorKey); }); - const updateColorKey = async (next: GroupColorKey) => { - if (userId == null || groupId == null) return; + const updateColorKey = useCallback( + async (groupId: number, next: GroupColorKey) => { + if (userId == null) return; + qc.setQueryData(queryKey(userId, groupId), next); + await asyncStorage.set(ASYNC_KEYS.groupColor(userId, groupId), next); + }, + [qc, userId], + ); - qc.setQueryData(queryKey, next); - await asyncStorage.set(ASYNC_KEYS.groupColor(userId, groupId), next); + return { + colorKeyMap: colorKeyMap, + updateColorKey, + isReady: userId != null, }; - - return { colorKey: colorKey ?? null, updateColorKey }; }; + diff --git a/src/hooks/useCategoryData.ts b/src/hooks/useCategoryData.ts index 4b8989a7..7cf157ea 100644 --- a/src/hooks/useCategoryData.ts +++ b/src/hooks/useCategoryData.ts @@ -1,4 +1,8 @@ +import type { GroupColorKey } from '@/constants/groupColors'; import { useCategoriesQuery } from '@/features/category/api/queries'; +import type { GetCategoriesResponse } from '@/features/category/model'; +import { useGroupsQuery } from '@/features/groups/api/queries'; +import { useGroupColorKeyMap } from '@/features/groups/hooks/useGroupColorKey'; import { useStudyStatByDateQuery } from '@/features/study/api/queries'; import { studyDataUtils } from '@/store/studySlice'; @@ -39,4 +43,33 @@ export const useTodayGoals = () => { export const useTodayStudyRecords = () => { const { data: todayStudyRecords = [] } = useStudyStatByDateQuery(new Date()); return todayStudyRecords; +}; + +export const useSplitCategoriesByGroup = ( + categories: GetCategoriesResponse['data'], +) => { + const { data: groups = [] } = useGroupsQuery(); + const { colorKeyMap } = useGroupColorKeyMap(); + + return categories.reduce( + (acc, category) => { + const belongingGroup = groups.find(group => + group.categoryIds.includes(category.id), + ); + if (belongingGroup) { + const groupColorKey = colorKeyMap.get(belongingGroup.groupId); + if (!groupColorKey) return acc; + acc.groupCategories.push({ ...category, groupColorKey: groupColorKey }); + } else { + acc.myCategories.push(category); + } + return acc; + }, + { + groupCategories: [] as ( + GetCategoriesResponse['data'][number] & { groupColorKey: GroupColorKey } + )[], + myCategories: [] as GetCategoriesResponse['data'], + }, + ); }; \ No newline at end of file diff --git a/src/locales/ko/manageCategory.json b/src/locales/ko/manageCategory.json index 41fbede3..10636226 100644 --- a/src/locales/ko/manageCategory.json +++ b/src/locales/ko/manageCategory.json @@ -1,6 +1,7 @@ { "manage-my-goals": "내 목표 관리", - "my-goals": "나의 목표", + "my-goals": "내가 만든 목표", + "group-goals": "그룹에 속한 목표", "goal-count": "총 {{count}}개", "goal-time": "목표 시간 {{time}}", "goal-time-null": "목표 시간 없음", diff --git a/src/navigation/BottomTabNavigator/BottomNavHeader.tsx b/src/navigation/BottomTabNavigator/BottomNavHeader.tsx index 04b031f1..ed6e4380 100644 --- a/src/navigation/BottomTabNavigator/BottomNavHeader.tsx +++ b/src/navigation/BottomTabNavigator/BottomNavHeader.tsx @@ -15,13 +15,13 @@ const BottomNavHeader = () => { navigation.navigate('Settings')} diff --git a/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx b/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx index 75b43c35..13198db1 100644 --- a/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx +++ b/src/screens/bottomTab/GroupsScreen/components/JoinedGroupPanel/index.tsx @@ -2,7 +2,7 @@ import { GroupButton } from '@features/groups/ui'; import { Button, Icon, Panel } from '@/components'; import { useGroupsQuery } from '@/features/groups/api/queries'; -import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey'; +import { useGroupColorKeyMap } from '@/features/groups/hooks/useGroupColorKey'; import type { GetGroupsResponse } from '@/features/groups/model/groups'; import { useStackNavigation } from '@/hooks'; import { useTranslatedText } from '@/hooks/useTranslatedText'; @@ -38,8 +38,9 @@ const JoinedGroupPanel = () => { }; const GroupListItem = ({ group }: { group: GetGroupsResponse['data'][number] }) => { - const { colorKey: groupColorKey } = useGroupColorKey(group.groupId); - if (!groupColorKey) return null; + const { colorKeyMap } = useGroupColorKeyMap(); + const groupColorKey = colorKeyMap.get(group.groupId); + if (groupColorKey == null) return null; const leaderImageUrl = group.profiles.find(p => p.role === 'LEADER')?.imageUrl ?? null; diff --git a/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.style.ts b/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.style.ts index 75a4f963..c1672626 100644 --- a/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.style.ts +++ b/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.style.ts @@ -14,7 +14,18 @@ export const styles = StyleSheet.create({ content: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[100], + gap: theme.spacing[200], + }, + groupColorIndicator: { + backgroundColor: theme.color.primary[500], + width: 10, height: 10, + borderRadius: theme.radius['max'], + }, + iconContainer: { + borderWidth: 1, + borderRadius: 8, + paddingVertical: 4, + paddingHorizontal: 6, }, }); diff --git a/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.tsx b/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.tsx index 53ddfc83..fc7a976a 100644 --- a/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.tsx +++ b/src/screens/category/ManageCategoryScreen/components/CategoryItem/index.tsx @@ -4,6 +4,7 @@ import { Button, Icon, Shapes, Text, TText } from '@/components'; import { useDeleteCategoryMutation } from '@/features/category/api/mutations'; import type { CategoryTag } from '@/features/category/model'; import { useStackNavigation } from '@/hooks'; +import { theme } from '@/theme'; import { dialogService } from '@/utils/dialog'; import type { Time } from '@/utils/Time'; @@ -56,6 +57,7 @@ const CategoryItem = ({ id, title, goal, tags = [] }: CategoryItemProps) => { return ( + {title} @@ -86,12 +88,16 @@ const CategoryItem = ({ id, title, goal, tags = [] }: CategoryItemProps) => { StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: theme.spacing[200], + }, + content: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing[200], + }, + groupColorIndicator: { + backgroundColor: GROUP_COLORS[groupColorKey].medium, + width: 10, height: 10, + borderRadius: theme.radius['max'], + }, +}); + +export const GOAL_NAME_TEXT_STYLE: TextProps = { + font: 'b3', +}; + +export const GOAL_TIME_TEXT_STYLE: TextProps = { + font: 'b4', + color: theme.color.neutral[500], +}; + +export const TAG_CONTAINER_STYLE = (color: Color): ViewStyle => ({ + borderRadius: theme.radius['max'], + borderWidth: 1, + borderColor: color, + paddingVertical: theme.spacing[50], + paddingHorizontal: theme.spacing[100], +}); + +export const TAG_TEXT_STYLE = (color: Color): TextProps => ({ + font: 'b5', + color, +}); diff --git a/src/screens/category/ManageCategoryScreen/components/GroupCategoryItem/index.tsx b/src/screens/category/ManageCategoryScreen/components/GroupCategoryItem/index.tsx new file mode 100644 index 00000000..814be017 --- /dev/null +++ b/src/screens/category/ManageCategoryScreen/components/GroupCategoryItem/index.tsx @@ -0,0 +1,62 @@ +import { View } from 'react-native'; + +import { Shapes, Text, TText } from '@/components'; +import { type GroupColorKey } from '@/constants/groupColors'; +import type { CategoryTag } from '@/features/category/model'; +import type { Time } from '@/utils/Time'; + +import { + createStyles, + GOAL_NAME_TEXT_STYLE, + GOAL_TIME_TEXT_STYLE, + TAG_CONTAINER_STYLE, + TAG_TEXT_STYLE, +} from './index.style'; + +interface CategoryItemProps { + groupColorKey: GroupColorKey; + temporaryName: string | null; + title: string; + goal: Time | null; + tags?: CategoryTag[]; +} + +const GroupCategoryItem = ({ groupColorKey, title, goal, tags = [] }: CategoryItemProps) => { + const styles = createStyles({ groupColorKey }); + + return ( + + + + + {title} + + + {goal ? ( + + ) : ( + + )} + {tags.map((tag) => ( + + + {tag.name} + + + ))} + + + ); +}; + +export default GroupCategoryItem; diff --git a/src/screens/category/ManageCategoryScreen/index.style.ts b/src/screens/category/ManageCategoryScreen/index.style.ts index 1a73dcaf..98fad50a 100644 --- a/src/screens/category/ManageCategoryScreen/index.style.ts +++ b/src/screens/category/ManageCategoryScreen/index.style.ts @@ -12,7 +12,7 @@ export const styles = StyleSheet.create({ gap: theme.spacing[300], }, toggleButtonContainer: { - justifyContent: 'space-between', + justifyContent: 'space-around', marginHorizontal: theme.spacing[300], }, }); diff --git a/src/screens/category/ManageCategoryScreen/index.tsx b/src/screens/category/ManageCategoryScreen/index.tsx index 624eb118..14311972 100644 --- a/src/screens/category/ManageCategoryScreen/index.tsx +++ b/src/screens/category/ManageCategoryScreen/index.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { Panel, ToggleButtonGroup, TText } from '@/components'; +import type { GetCategoriesResponse } from '@/features/category/model'; import type { DAY_MASK_BY_UTC_CODE } from '@/features/category/model/types'; -import { useAvailableCategories } from '@/hooks/useCategoryData'; +import { useAvailableCategories, useSplitCategoriesByGroup } from '@/hooks/useCategoryData'; import { useTranslatedText } from '@/hooks/useTranslatedText'; import { StackableScreenLayout } from '@/layout'; import { getDayByUTCCode } from '@/utils/date'; @@ -10,6 +11,7 @@ import { typedEntries } from '@/utils/object'; import CategoryItem from './components/CategoryItem'; import EmptyFallback from './components/EmptyFallback'; +import GroupCategoryItem from './components/GroupCategoryItem'; import { GOAL_COUNT_TEXT_STYLE, styles, @@ -21,18 +23,23 @@ import { const ManageCategoryScreen = () => { const tManageMyGoals = useTranslatedText({ tKey: 'manageCategory.manage-my-goals' }); const tMyGoals = useTranslatedText({ tKey: 'manageCategory.my-goals' }); + const tGroupGoals = useTranslatedText({ tKey: 'manageCategory.group-goals' }); const [selectedDays, setSelectedDays] = useState[]>( [getDayByUTCCode(new Date().getDay() as keyof typeof DAY_MASK_BY_UTC_CODE)], ); const categories = useAvailableCategories(); - const filteredCategories = categories.filter(cat => { - for (const [k, v] of typedEntries(cat.dayBelong)) { + + const filterCategoryBySelectedDays = (category: GetCategoriesResponse['data'][number]) => { + for (const [k, v] of typedEntries(category.dayBelong)) { if (!v) continue; if (selectedDays.includes(k)) return true; } return false; - }); + }; + const filteredByDaysCategories = categories.filter(cat => filterCategoryBySelectedDays(cat)); + const { myCategories, groupCategories } = useSplitCategoriesByGroup(filteredByDaysCategories); + return ( { /> { - filteredCategories.length ? - filteredCategories.map(category => ( + myCategories.length ? + myCategories.map(category => ( { ) } + {!!groupCategories.length && ( + + + + {groupCategories.map(category => ( + + ))} + + )} ); diff --git a/src/screens/group/GroupChatScreen/components/MessageInput/index.tsx b/src/screens/group/GroupChatScreen/components/MessageInput/index.tsx index 038ba7c1..5954ed4b 100644 --- a/src/screens/group/GroupChatScreen/components/MessageInput/index.tsx +++ b/src/screens/group/GroupChatScreen/components/MessageInput/index.tsx @@ -39,7 +39,7 @@ const MessageInput = ({ ); diff --git a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx index b14e1ce4..996d3993 100644 --- a/src/screens/group/groupDetail/GroupDetailScreen/index.tsx +++ b/src/screens/group/groupDetail/GroupDetailScreen/index.tsx @@ -6,7 +6,7 @@ import { useCategoriesQuery } from '@/features/category/api/queries'; import { isDayInDayBelong } from '@/features/category/model/dayBelongHelper'; import { useChatCount } from '@/features/chat/sse/useChatCount'; import { useGroupDetailQuery } from '@/features/groups/api/queries'; -import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey'; +import { useGroupColorKeyMap } from '@/features/groups/hooks/useGroupColorKey'; import { useStackRoute } from '@/hooks'; import { StackableScreenLayout } from '@/layout'; import { studyDataUtils } from '@/store/studySlice'; @@ -17,7 +17,8 @@ import TodaysGoalPanel from '../components/TodaysGoalPanel'; const GroupDetailScreen = () => { const { data: categories } = useCategoriesQuery(); const { params } = useStackRoute<'GroupDetail'>(); - const { colorKey } = useGroupColorKey(params.groupId); + const { colorKeyMap } = useGroupColorKeyMap(); + const colorKey = colorKeyMap.get(params.groupId); const groupColor = colorKey ? GROUP_COLORS[colorKey] : null; const { data, isPending } = useGroupDetailQuery(params.groupId); const chatCount = useChatCount(params.chatRoomId); diff --git a/src/screens/group/groupDetail/components/GroupInfoPanel/DashBoardButtonGroup/index.tsx b/src/screens/group/groupDetail/components/GroupInfoPanel/DashBoardButtonGroup/index.tsx index 40dffe29..ca700e17 100644 --- a/src/screens/group/groupDetail/components/GroupInfoPanel/DashBoardButtonGroup/index.tsx +++ b/src/screens/group/groupDetail/components/GroupInfoPanel/DashBoardButtonGroup/index.tsx @@ -3,7 +3,7 @@ import { View } from 'react-native'; import { Icon, Toast } from '@/components'; import type { GroupColorKey } from '@/constants/groupColors'; import { GROUP_COLORS } from '@/constants/groupColors'; -import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey'; +import { useGroupColorKeyMap } from '@/features/groups/hooks/useGroupColorKey'; import type { GroupMember } from '@/features/groups/model'; import { useProfileQuery } from '@/features/user/api/queries'; import { useStackNavigation } from '@/hooks/useStackNavigation'; @@ -39,7 +39,7 @@ const DashBoardButtonGroup = ({ const styles = createStyles(groupColorKey, disabled); const buttonPrimaryColor = GROUP_COLORS[groupColorKey].medium; - const { updateColorKey } = useGroupColorKey(groupId); + const { updateColorKey } = useGroupColorKeyMap(); const { data: profileData } = useProfileQuery(); if (!profileData) return null; @@ -61,7 +61,7 @@ const DashBoardButtonGroup = ({ { - updateColorKey(selectedColorKey); + updateColorKey(groupId, selectedColorKey); }} />), }); diff --git a/src/types/icon.d.ts b/src/types/icon.d.ts index 09de208c..64421927 100644 --- a/src/types/icon.d.ts +++ b/src/types/icon.d.ts @@ -1,4 +1,5 @@ import type { FC, ReactElement, ReactNode } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; import type { SvgProps } from 'react-native-svg'; import type { Color } from './theme'; @@ -13,7 +14,7 @@ interface MultipleColorProps { } type MultiIconProps = Omit & MultipleColorProps; -type SingleIconProps = SvgProps; +type SingleIconProps = SvgProps & { containerStyle?: StyleProp }; export type IconProps = SingleIconProps & MultiIconProps; export type SingleIcon = FC;