Skip to content
3 changes: 3 additions & 0 deletions assets/icons/icon-palette.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 1 addition & 6 deletions src/StudyDataHydrator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,8 @@ const StudyDataHydrator = ({ children }: PropsWithChildren) => {
const todayRecord = todayStudyRecords.find(
record => record.categoryId === studyStateData.categoryId,
);
if (todayRecord == null) {
// eslint-disable-next-line no-console
console.error('Today study record must be defined when starting study session');
return;
}

const recordedStudyTime = todayRecord.time;
const recordedStudyTime = todayRecord?.time ?? Time.fromMilliseconds(0);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

"todayRecord"가 "null"일 경우 "console.error"를 호출하고 함수를 종료하는 대신, "?? Time.fromMilliseconds(0)"를 사용하여 기본값을 제공하도록 변경한 것은 더 견고한 오류 처리 방식입니다. 이는 애플리케이션의 안정성을 높이고 예기치 않은 종료를 방지합니다.

const progressiveStudyTime = Time.fromMilliseconds(
new Date().getTime() - studyStateData.startTime.getTime(),
);
Expand Down
37 changes: 20 additions & 17 deletions src/components/Icon/IconSet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import NotiOffOutline from '@icons/icon-noti-off-outline.svg'
import SendRight from '@icons/icon-send-right.svg'
import TrashBin from '@icons/icon-trash-bin.svg'
import Camera from '@icons/icon-camera.svg'
import Palette from '@icons/icon-palette.svg'

import type { Icon, MultiIcon } from '@/types/icon'
import { SvgProps } from 'react-native-svg'
Expand Down Expand Up @@ -86,6 +87,7 @@ type IconSetComponent = {
SendRight: Icon;
TrashBin: Icon;
Camera: Icon;
Palette: Icon;
}

const IconSet: IconSetComponent = {
Expand All @@ -106,23 +108,24 @@ const IconSet: IconSetComponent = {
DogFootprint: withDefaultProps(DogFootprint, { color: theme.color.red[100] }),
ArrowLeft: withDefaultProps(ArrowLeft, { color: COLOR_GRAY }),
Edit: withDefaultProps(Edit, { color: COLOR_GRAY }),
Study: withDefaultProps(Study, { color: theme.color.neutral['white'] }),
LockFilled: withDefaultProps(LockFilled, { color: theme.color.neutral[500] }),
LockOutline: withDefaultProps(LockOutline, { color: theme.color.neutral[500] }),
UnlockFilled: withDefaultProps(UnlockFilled, { color: theme.color.neutral[500] }),
Invite: withDefaultProps(Invite, { color: theme.color.neutral[500] }),
Graph: withDefaultProps(Graph, { color: theme.color.neutral[500] }),
Ranking: withDefaultProps(Ranking, { color: theme.color.neutral[500] }),
Chat: withDefaultProps(Chat, { color: theme.color.neutral[500] }),
More: withDefaultProps(More, { color: theme.color.neutral[500] }),
Person: withDefaultProps(Person, { color: theme.color.neutral[500] }),
DoorOpened: withDefaultProps(DoorOpened, { color: theme.color.neutral[500] }),
Setting: withDefaultProps(Setting, { color: theme.color.neutral[500] }),
NotiOnFilled: withDefaultProps(NotiOnFilled, { color: theme.color.neutral[500] }),
NotiOffOutline: withDefaultProps(NotiOffOutline, { color: theme.color.neutral[500] }),
SendRight: withDefaultProps(SendRight, { color: theme.color.neutral[500] }),
TrashBin: withDefaultProps(TrashBin, { color: theme.color.neutral[500] }),
Camera: withDefaultProps(Camera, { color: theme.color.neutral['white'] }),
Study: withDefaultProps(Study, { color: COLOR_WHITE }),
LockFilled: withDefaultProps(LockFilled, { color: COLOR_GRAY }),
LockOutline: withDefaultProps(LockOutline, { color: COLOR_GRAY }),
UnlockFilled: withDefaultProps(UnlockFilled, { color: COLOR_GRAY }),
Invite: withDefaultProps(Invite, { color: COLOR_GRAY }),
Graph: withDefaultProps(Graph, { color: COLOR_GRAY }),
Ranking: withDefaultProps(Ranking, { color: COLOR_GRAY }),
Chat: withDefaultProps(Chat, { color: COLOR_GRAY }),
More: withDefaultProps(More, { color: COLOR_GRAY }),
Person: withDefaultProps(Person, { color: COLOR_GRAY }),
DoorOpened: withDefaultProps(DoorOpened, { color: COLOR_GRAY }),
Setting: withDefaultProps(Setting, { color: COLOR_GRAY }),
NotiOnFilled: withDefaultProps(NotiOnFilled, { color: COLOR_GRAY }),
NotiOffOutline: withDefaultProps(NotiOffOutline, { color: COLOR_GRAY }),
SendRight: withDefaultProps(SendRight, { color: COLOR_GRAY }),
TrashBin: withDefaultProps(TrashBin, { color: COLOR_GRAY }),
Camera: withDefaultProps(Camera, { color: COLOR_WHITE }),
Palette: withDefaultProps(Palette, { color: COLOR_GRAY }),
};

export default IconSet;
19 changes: 19 additions & 0 deletions src/features/category/hooks/useCategoryColor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

import { type GroupColorKey } from '@/constants/groupColors';
import { useGroupsQuery } from '@/features/groups/api/queries';
import { useGroupColorKey } from '@/features/groups/hooks/useGroupColorKey';
import { theme } from '@/theme';

const DEFAULT_COLOR_KEY: GroupColorKey = theme.color.primary[500];

export const useCategoryColor = (categoryId: number) => {
const { data: groupsData } = useGroupsQuery();

const matchingGroupId =
groupsData?.find((g) => g.categoryIds.includes(categoryId))?.groupId ?? null;

const { colorKey: groupColor } = useGroupColorKey(matchingGroupId);
const colorKey = groupColor ?? DEFAULT_COLOR_KEY;

return { colorKey };
};
50 changes: 50 additions & 0 deletions src/features/groups/hooks/useGroupColorKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';

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';

const GROUP_COLOR_KEYS = Object.keys(GROUP_COLORS) as GroupColorKey[];

const pickRandomGroupColorKey = (): GroupColorKey =>
GROUP_COLOR_KEYS[Math.floor(Math.random() * GROUP_COLOR_KEYS.length)];

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;
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 storageKey = ASYNC_KEYS.groupColor(userId, groupId);
const stored = await asyncStorage.get(storageKey);

if (stored && isGroupColorKey(stored)) return stored;

const newKey = pickRandomGroupColorKey();
await asyncStorage.set(storageKey, newKey);
return newKey;
},
staleTime: Infinity,
gcTime: Infinity,
});

const updateColorKey = async (next: GroupColorKey) => {
if (userId == null || groupId == null) return;

qc.setQueryData(queryKey, next);
await asyncStorage.set(ASYNC_KEYS.groupColor(userId, groupId), next);
};

return { colorKey: colorKey ?? null, updateColorKey };
};
9 changes: 2 additions & 7 deletions src/features/study/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const useStartStudyMutation = () => {
studyStatByDateQueryOptions(new Date()),
);
// TODO: 24시간이 넘어가는 공부시간에 대해 어떻게 처리할 것인지?
// TODO: StudyDataHydrator와 중복되는 로직 리팩토링
const category = studyDataUtils.identifyCategory({
categories: categories.data,
categoryId: response.data.categoryId,
Expand All @@ -35,13 +36,7 @@ export const useStartStudyMutation = () => {
const todayRecord = todayStudyRecords.find(
record => record.categoryId === response.data.categoryId,
);
if (todayRecord == null) {
// eslint-disable-next-line no-console
console.error('Today study record must be defined when starting study session');
return;
}

const recordedStudyTime = todayRecord.time;
const recordedStudyTime = todayRecord?.time ?? Time.fromMilliseconds(0);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

"StudyDataHydrator.tsx"와 마찬가지로 "todayRecord"가 "null"일 때 "console.error" 대신 "?? Time.fromMilliseconds(0)"를 사용하여 기본값을 할당하는 것은 일관성 있고 안정적인 오류 처리 방식입니다.

const progressiveStudyTime = Time.fromMilliseconds(
new Date().getTime() - response.data.startTime.getTime(),
);
Expand Down
2 changes: 1 addition & 1 deletion src/layout/StackableScreenLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const StackableScreenLayout = ({
headerElementColor = theme.color.primary[500],
header,
children,
safeAreas,
safeAreas = 'top',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

"safeAreas" prop에 "top"을 기본값으로 설정하여 컴포넌트 사용 시 명시적으로 지정하지 않아도 안전 영역 처리가 되도록 개선했습니다. 이는 컴포넌트의 사용 편의성을 높입니다.

...screenContainerProps
}: StackableScreenProps) => {
const navigation = useStackNavigation();
Expand Down
5 changes: 3 additions & 2 deletions src/locales/ko/groupDetail.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"public-group": "공개 그룹",
"private-group": "비공개 그룹",
"ranking": "랭킹",
"statistics": "통계",
"color": "색상",
"chat": "채팅",
"setting": "설정",
"member-list": "멤버 목록",
Expand All @@ -21,5 +21,6 @@
"cannot-join-your-group": "본인이 속한 그룹에는 가입할 수 없어요.",
"group-join-success": "그룹 가입이 완료되었습니다.",
"group-join-request-sent": "그룹 참여 요청이 전송되었습니다.",
"failed-to-join-group": "그룹 가입에 실패했습니다. 다시 시도해주세요."
"failed-to-join-group": "그룹 가입에 실패했습니다. 다시 시도해주세요.",
"group-color-picker-title": "그룹 테마 색상 선택"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ 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 type { GetGroupsResponse } from '@/features/groups/model/groups';
import { useStackNavigation } from '@/hooks';
import { useTranslatedText } from '@/hooks/useTranslatedText';
import { theme } from '@/theme';
Expand All @@ -14,34 +16,14 @@ const JoinedGroupPanel = () => {
const tCreateNewGroup = useTranslatedText({ tKey: 'groups.create-new-group' });
const navigation = useStackNavigation();
const { data, isPending } = useGroupsQuery();

if (!data || isPending) return null;

return (
<Panel.View gap={theme.spacing[250]}>
<Panel.View.Header title={tMyParticipatedGroups} />
{data.length ?
data.map(group => {
const leaderImageUrl =
group.profiles.find(profile => profile.role === 'LEADER')?.imageUrl ?? null;
const otherImageUrls = group.profiles
.filter(profile => profile.role !== 'LEADER')
.map(profile => profile.imageUrl);
return (
<GroupButton
achievedDailyGoalCount={5}
chatRoomId={group.chatRoomId}
// TODO: 그룹 색상 관리 방식 논의 후 수정 필요
groupColorKey={theme.color.blue[300]}
groupId={group.groupId}
groupName={group.groupName}
isPublic={group.isOpen}
key={group.groupId}
participantImageUrls={[leaderImageUrl, ...otherImageUrls]}
totalDailyGoalCount={10}
/>
);
})
data.map(group => <GroupListItem group={group} key={group.groupId} />)
:
Comment on lines +26 to 27

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

그룹 목록 렌더링 로직을 "GroupListItem"이라는 별도의 컴포넌트로 분리한 것은 코드의 가독성과 유지보수성을 크게 향상시킵니다. 또한, 새로 추가된 "useGroupColorKey" 훅을 사용하여 그룹별 색상을 동적으로 할당하는 방식은 기능 구현에 적합합니다.

<EmptyFallback />}
<Button.Basic
Expand All @@ -55,4 +37,29 @@ const JoinedGroupPanel = () => {
);
};

const GroupListItem = ({ group }: { group: GetGroupsResponse['data'][number] }) => {
const { colorKey: groupColorKey } = useGroupColorKey(group.groupId);
if (!groupColorKey) return null;
Comment on lines 40 to 42
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook returns null when groupColorKey is null, causing the GroupListItem component to return null and not render. This creates an inconsistent user experience where groups may temporarily not appear in the list while their color is being loaded from async storage. Consider providing a default color or loading state instead, or ensure that the color is loaded before rendering the group list.

Copilot uses AI. Check for mistakes.

const leaderImageUrl =
group.profiles.find(p => p.role === 'LEADER')?.imageUrl ?? null;

const otherImageUrls = group.profiles
.filter(p => p.role !== 'LEADER')
.map(p => p.imageUrl);

return (
<GroupButton
achievedDailyGoalCount={5}
chatRoomId={group.chatRoomId}
groupColorKey={groupColorKey}
groupId={group.groupId}
groupName={group.groupName}
isPublic={group.isOpen}
participantImageUrls={[leaderImageUrl, ...otherImageUrls]}
totalDailyGoalCount={10}
/>
);
};

export default JoinedGroupPanel;
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { startOfDay } from 'date-fns';

import type { GroupColorKey } from '@/constants/groupColors';
import { useCategoriesQuery } from '@/features/category/api/queries';
import { useCategoryColor } from '@/features/category/hooks/useCategoryColor';
import type { GetCategoriesResponse } from '@/features/category/model';
import type { DAY_MASK_BY_UTC_CODE } from '@/features/category/model/types';
import { CategoryProgressItem } from '@/features/category/ui';
import { useStartStudyMutation } from '@/features/study/api/mutations';
import { useStudyStatByDateQuery } from '@/features/study/api/queries';
import type { GetStudyStatByDateResponse } from '@/features/study/model';
import { formatDateToHMS, getDayByUTCCode } from '@/utils/date';
import { Time } from '@/utils/Time';

Expand All @@ -22,8 +24,6 @@ const GoalItemList = ({
}: GoalItemListProps) => {
const { data: categoryData, isPending: isCategoryDataPending } = useCategoriesQuery();
const { data: studyData, isPending: isStudyDataPending } = useStudyStatByDateQuery(selectedDate);

const { startStudyMutate } = useStartStudyMutation();

if (!categoryData || isCategoryDataPending) return null;
if (!studyData || isStudyDataPending) return null;
Expand All @@ -37,38 +37,61 @@ const GoalItemList = ({
getDayByUTCCode(selectedDate.getDay() as keyof typeof DAY_MASK_BY_UTC_CODE)
];
})
.map(category => {
const onStudyButtonPress = () => {
startStudyMutate({
categoryId: category.id,
// TODO: 임시 카테고리 기능 구현 후 수정 필요
temporaryName: null,
startTime: formatDateToHMS(new Date()),
}, {
onSuccess: () => {
setBottomSheetEnabled(false);
},
});
};
const studyTime =
studyData.find(stat => stat.categoryId === category.id)?.time ?? Time.fromSeconds(0);
.map(category =>
<GoalItemListElement
category={category}
key={category.id}
selectedDate={selectedDate}
setBottomSheetEnabled={setBottomSheetEnabled}
studyData={studyData}
/>);

return (
categoryNodes.length ? categoryNodes : <EmptyFallback />
);
};

return (
<CategoryProgressItem
cumulativeTime={studyTime}
goalTime={category.goal}
groupColorKey={category.color as GroupColorKey}
isCompleted={false}
key={category.id}
onStudyButtonPress={onStudyButtonPress}
studyButtonVisible={selectedDate.toDateString() === new Date().toDateString()}
title={category.name}
/>
);
const GoalItemListElement = ({
category,
studyData,
setBottomSheetEnabled,
selectedDate,
}: {
category: GetCategoriesResponse['data'][number];
studyData: GetStudyStatByDateResponse['data'];
setBottomSheetEnabled: (enabled: boolean) => void;
selectedDate: Date;
}) => {
const { startStudyMutate } = useStartStudyMutation();
const { colorKey: categoryColor } = useCategoryColor(category.id);

const onStudyButtonPress = () => {
startStudyMutate({
categoryId: category.id,
// TODO: 임시 카테고리 기능 구현 후 수정 필요
temporaryName: null,
startTime: formatDateToHMS(new Date()),
}, {
onSuccess: () => {
setBottomSheetEnabled(false);
},
});
};
const studyTime = studyData.find(
stat => stat.categoryId === category.id,
)?.time ?? Time.fromSeconds(0);

return (
categoryNodes.length ? categoryNodes : <EmptyFallback />
<CategoryProgressItem
cumulativeTime={studyTime}
goalTime={category.goal}
groupColorKey={categoryColor}
isCompleted={false}
key={category.id}
onStudyButtonPress={onStudyButtonPress}
studyButtonVisible={selectedDate.toDateString() === new Date().toDateString()}
title={category.name}
/>
);
};

Expand Down
Loading