diff --git a/api/analyses.ts b/api/analyses.ts index d88a861..3f5bf9b 100644 --- a/api/analyses.ts +++ b/api/analyses.ts @@ -1,5 +1,6 @@ import { authenticatedApiRequest, + publicApiRequest, type ApiRequestOptions, type ClerkTokenGetter, } from '@/api/api-client'; @@ -30,6 +31,8 @@ export type AnalysisResponse = { errorMessage?: string; }; +export type VerdictStatisticsResponse = Record; + type AnalysisRequestOptions = Pick; export function requestAnalysis( @@ -55,3 +58,10 @@ export function fetchAnalysis( options, ); } + +export function fetchVerdictStatistics(options: AnalysisRequestOptions = {}) { + return publicApiRequest( + '/api/v1/analyses/statistics', + options, + ); +} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index dbf427b..0c92fe0 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -7,6 +7,7 @@ import { View, } from 'react-native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; +import { useFocusEffect } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -16,6 +17,11 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; import { SectionHeader } from '@/components/ui/section-header'; import { TitleEditModal } from '@/components/ui/title-edit-modal'; import { Toast } from '@/components/ui/toast'; +import { + fetchVerdictStatistics, + type AnalysisVerdict, + type VerdictStatisticsResponse, +} from '@/api/analyses'; import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; @@ -24,6 +30,31 @@ import { showAlert } from '@/utils/guarded-alert'; const COMPACT_WIDTH = 380; +type SecurityStatusItem = { + verdict: AnalysisVerdict; + label: string; + summary: string; +}; + +type StatisticsViewStatus = 'loading' | 'error' | 'ready'; + +const SECURITY_STATUS_ITEMS: SecurityStatusItem[] = [ + { verdict: 'safe', label: '안전', summary: '문제 없음' }, + { verdict: 'caution', label: '주의', summary: '확인 필요' }, + { verdict: 'danger', label: '위험', summary: '접근 주의' }, +]; + +const STATISTICS_STATUS_LABELS: Record, string> = { + loading: '조회 중', + error: '불러오지 못했어요', +}; + +const EMPTY_STATISTICS: VerdictStatisticsResponse = { + safe: 0, + caution: 0, + danger: 0, +}; + export default function HomeScreen() { const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{ savedLinkToast?: string | string[]; @@ -37,10 +68,21 @@ export default function HomeScreen() { const [saveToastVisible, setSaveToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); + const [statistics, setStatistics] = useState(EMPTY_STATISTICS); + const [isStatisticsLoading, setIsStatisticsLoading] = useState(true); + const [hasStatisticsError, setHasStatisticsError] = useState(false); const lastToastParamRef = useRef(undefined); const deletingLinkIdsRef = useRef>(new Set()); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; const isCompactWidth = windowWidth < COMPACT_WIDTH; + const statisticsStatus = getStatisticsViewStatus( + isStatisticsLoading, + hasStatisticsError, + ); + const hasNoStatistics = SECURITY_STATUS_ITEMS.every( + (item) => statistics[item.verdict] === 0, + ); + const statisticsStatusLabel = getStatisticsStatusLabel(statisticsStatus, hasNoStatistics); // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -54,6 +96,39 @@ export default function HomeScreen() { setSaveToastVisible(true); }, [savedLinkToast]); + useFocusEffect( + useCallback(() => { + const abortController = new AbortController(); + + async function loadStatistics() { + setIsStatisticsLoading(true); + setHasStatisticsError(false); + + try { + const response = await fetchVerdictStatistics({ signal: abortController.signal }); + setStatistics(response); + } catch { + if (abortController.signal.aborted) { + return; + } + + setStatistics(EMPTY_STATISTICS); + setHasStatisticsError(true); + } finally { + if (!abortController.signal.aborted) { + setIsStatisticsLoading(false); + } + } + } + + void loadStatistics(); + + return () => { + abortController.abort(); + }; + }, []), + ); + const handleMore = (id: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId: id }); }; @@ -149,14 +224,50 @@ export default function HomeScreen() { {/* 보안 등급별 링크 현황 */} - - - - - - - - + {statisticsStatusLabel} + ) : undefined + } + /> + + {SECURITY_STATUS_ITEMS.map((item) => { + const colors = Colors.brand.verdict[item.verdict]; + const countLabel = getStatisticsCountLabel( + item.verdict, + statistics, + statisticsStatus, + ); + const summary = getStatisticsSummary(item); + + return ( + + + + {item.label} + + + {countLabel} + + + {summary} + + + ); + })} @@ -284,38 +395,91 @@ const styles = StyleSheet.create({ gap: 12, }, - statPlaceholder: { + sectionStatus: { + ...Typography.bold12, + color: Colors.brand.textHint, + }, + + statCardGroup: { backgroundColor: Colors.brand.surface, borderRadius: 16, borderWidth: 1, borderColor: Colors.brand.line, padding: 16, - }, - statPlaceholderCompact: { - padding: 14, - }, - statGrid: { flexDirection: 'row', - flexWrap: 'wrap', gap: 12, }, - statGridCompact: { + statCardGroupCompact: { + padding: 14, gap: 10, }, - statItem: { + statCard: { flex: 1, - minWidth: '45%', - height: 56, - borderRadius: 10, - borderWidth: 1.5, - borderColor: Colors.brand.line, - borderStyle: 'dashed', + minHeight: 112, + borderRadius: 14, + borderWidth: 1, + padding: 12, + justifyContent: 'space-between', + }, + statCardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + statIndicator: { + width: 8, + height: 8, + borderRadius: 4, + }, + statLabel: { + ...Typography.bold12, }, - statItemCompact: { - height: 48, + statCount: { + ...Typography.title, + lineHeight: 30, + }, + statSummary: { + ...Typography.regular12, }, linkList: { gap: 12, }, }); + +function getStatisticsViewStatus( + isLoading: boolean, + hasError: boolean, +): StatisticsViewStatus { + if (isLoading) { + return 'loading'; + } + + if (hasError) { + return 'error'; + } + + return 'ready'; +} + +function getStatisticsStatusLabel(status: StatisticsViewStatus, hasNoStatistics: boolean) { + if (status === 'ready') { + return hasNoStatistics ? '기록 없음' : ''; + } + + return STATISTICS_STATUS_LABELS[status]; +} + +function getStatisticsCountLabel( + verdict: AnalysisVerdict, + statistics: VerdictStatisticsResponse, + status: StatisticsViewStatus, +) { + return status === 'loading' || status === 'error' + ? '-' + : String(statistics[verdict]); +} + +function getStatisticsSummary(item: SecurityStatusItem) { + return item.summary; +}