diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx
new file mode 100644
index 0000000..e04efbb
--- /dev/null
+++ b/app/(tabs)/(home)/add-link.tsx
@@ -0,0 +1,269 @@
+import { useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Linking,
+ Platform,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { Stack, router } from 'expo-router';
+
+import { ScanButton } from '@/components/ui/scan-button';
+import { Colors, Typography } from '@/constants/theme';
+
+function isValidUrlFormat(value: string): boolean {
+ const trimmed = value.trim();
+ if (!/^https?:\/\//i.test(trimmed)) return false;
+ try {
+ const parsed = new URL(trimmed);
+ const parts = parsed.hostname.split('.');
+ const tld = parts[parts.length - 1];
+ return parts.length >= 2 && tld.length >= 2;
+ } catch {
+ return false;
+ }
+}
+
+export default function AddLinkScreen() {
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState('');
+ const [isChecking, setIsChecking] = useState(false);
+
+ const handleScan = async () => {
+ const trimmed = url.trim();
+
+ if (!trimmed) {
+ setError('URL을 입력해주세요.');
+ return;
+ }
+
+ // 1단계: new URL()로 형식 검증
+ if (!isValidUrlFormat(trimmed)) {
+ setError('올바르지 않은 URL 입력입니다.');
+ return;
+ }
+
+ // 2단계: Linking.canOpenURL()로 실제 열기 가능 여부 확인
+ setIsChecking(true);
+ try {
+ const canOpen = await Linking.canOpenURL(trimmed);
+ if (!canOpen) {
+ setError('올바르지 않은 URL 입력입니다.');
+ return;
+ }
+ setError('');
+ router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: trimmed } });
+ } catch {
+ setError('URL 확인 중 오류가 발생했습니다.');
+ } finally {
+ setIsChecking(false);
+ }
+ };
+
+ const handleChangeUrl = (value: string) => {
+ setUrl(value);
+ if (error) setError('');
+ };
+
+ const hasError = error.length > 0;
+ const scanDisabled = !url.trim() || isChecking;
+
+ return (
+ <>
+
+
+
+
+ {/* 타이틀 */}
+
+ 링크를
+ 입력해주세요
+
+
+ {/* 부제목 */}
+ 보안검사 후 저장할 수 있습니다.
+
+ {/* URL 입력 + 검사 버튼 */}
+
+
+
+ {url.length > 0 && !isChecking && (
+ { setUrl(''); setError(''); }}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ style={styles.clearButton}
+ >
+ −
+
+ )}
+
+ {isChecking ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* 에러 메시지 */}
+ {hasError && {error}}
+
+ {/* 안내 박스 */}
+
+ 안내
+
+ 검사 후 안전한 사이트이라면, 저장하실 수 있습니다.
+
+
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ flex: {
+ flex: 1,
+ },
+ container: {
+ flex: 1,
+ paddingHorizontal: 24,
+ paddingTop: 16,
+ },
+
+ // 타이틀
+ titleRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginBottom: 12,
+ },
+ titleGreen: {
+ ...Typography.display,
+ color: Colors.brand.primaryDeep,
+ },
+ titleDark: {
+ ...Typography.display,
+ color: Colors.brand.text,
+ },
+
+ // 부제목
+ subtitle: {
+ ...Typography.body,
+ color: Colors.brand.textSecondary,
+ marginBottom: 32,
+ },
+
+ // 입력 행
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 8,
+ },
+ inputWrapper: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: 60,
+ borderRadius: 16,
+ borderWidth: 1.5,
+ borderColor: Colors.brand.line,
+ backgroundColor: Colors.light.background,
+ paddingHorizontal: 16,
+ },
+ inputError: {
+ borderColor: Colors.brand.textWarning,
+ },
+ input: {
+ flex: 1,
+ ...Typography.body,
+ color: Colors.brand.text,
+ },
+ clearButton: {
+ marginLeft: 8,
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: Colors.brand.softMint,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ clearButtonText: {
+ ...Typography.caption,
+ color: Colors.brand.primary,
+ lineHeight: 16,
+ },
+ loadingBox: {
+ width: 60,
+ height: 60,
+ borderRadius: 16,
+ borderWidth: 1.5,
+ borderColor: Colors.brand.line,
+ backgroundColor: Colors.brand.background,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
+ // 에러
+ errorText: {
+ ...Typography.body,
+ color: Colors.brand.textWarning,
+ marginBottom: 16,
+ },
+
+ // 안내 박스
+ infoBox: {
+ backgroundColor: Colors.brand.softMint,
+ borderRadius: 12,
+ padding: 16,
+ marginTop: 16,
+ gap: 6,
+ },
+ infoLabel: {
+ ...Typography.caption,
+ color: Colors.brand.primaryDeep,
+ },
+ infoBody: {
+ ...Typography.summary,
+ color: Colors.brand.textSecondary,
+ },
+});
diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx
index 6e257a2..003a7a1 100644
--- a/app/(tabs)/(home)/index.tsx
+++ b/app/(tabs)/(home)/index.tsx
@@ -1,98 +1,171 @@
-import { Image } from 'expo-image';
-import { StyleSheet } from 'react-native';
-
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+import { useState } from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { router } from 'expo-router';
+import { AppIcon } from '@/components/ui/app-icon';
+import { CardLink } from '@/components/ui/card-link';
+import { FolderContextMenu } from '@/components/ui/folder-context-menu';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { SectionHeader } from '@/components/ui/section-header';
+import { Colors, Typography } from '@/constants/theme';
+import { useSavedLinks } from '@/context/saved-links-context';
+import type { AnchorPosition } from '@/components/ui/folder-card';
export default function HomeScreen() {
- const devShortcut =
- process.env.EXPO_OS === 'ios'
- ? 'cmd + d'
- : process.env.EXPO_OS === 'android'
- ? 'cmd + m'
- : 'F12';
+ const { links, toggleBookmark, deleteLink } = useSavedLinks();
+ const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false });
+
+ // 최근 저장한 링크 — createdAt 내림차순 상위 3개
+ const recentLinks = links.slice(0, 3);
+
+ const handleMore = (id: number, anchor: AnchorPosition) => {
+ setMenuState({ visible: true, anchor, linkId: id });
+ };
+
+ const selectedLink = links.find((l) => l.id === menuState.linkId);
return (
-
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
- {devShortcut}
- {' '}to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
+
+
+ {/* 상단 헤더 */}
+
+
+
-
- alert('Delete pressed')}
+ LinClean
+
+ router.push('/(tabs)/(home)/settings')} />
+
+
+ {/* 서브타이틀 */}
+ 오늘도 안전하게 정리해요
+
+ {/* 보안 등급별 링크 현황 */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 최근 저장한 링크 */}
+
+ router.push('/saved-links')}
+ />
+
+ {recentLinks.map((link) => (
+ toggleBookmark(link.id)}
+ onMore={(anchor) => handleMore(link.id, anchor)}
/>
-
-
-
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
+ ))}
+
+
+
+
+ menuState.linkId != null && toggleBookmark(menuState.linkId),
+ },
+ {
+ label: '링크 삭제',
+ onPress: () => menuState.linkId != null && deleteLink(menuState.linkId),
+ destructive: true,
+ },
+ ]}
+ onDismiss={() => setMenuState({ visible: false })}
+ />
+
);
}
const styles = StyleSheet.create({
- titleContainer: {
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 25,
+ paddingBottom: 32,
+ gap: 24,
+ },
+
+ header: {
flexDirection: 'row',
alignItems: 'center',
- gap: 8,
+ justifyContent: 'space-between',
+ paddingTop: 16,
},
- stepContainer: {
- gap: 8,
- marginBottom: 8,
+ wordmark: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ brandText: {
+ ...Typography.title,
+ color: Colors.brand.primary,
+ },
+
+ subtitle: {
+ ...Typography.caption,
+ color: Colors.brand.textSecondary,
+ marginTop: -16,
+ },
+
+ section: {
+ gap: 12,
},
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
- position: 'absolute',
+
+ statPlaceholder: {
+ backgroundColor: '#fff',
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: Colors.brand.line,
+ padding: 16,
+ },
+ statGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ },
+ statItem: {
+ flex: 1,
+ minWidth: '45%',
+ height: 56,
+ borderRadius: 10,
+ borderWidth: 1.5,
+ borderColor: Colors.brand.line,
+ borderStyle: 'dashed',
+ },
+
+ linkList: {
+ gap: 12,
},
});
diff --git a/app/(tabs)/(home)/saved-links.tsx b/app/(tabs)/(home)/saved-links.tsx
new file mode 100644
index 0000000..e4b556e
--- /dev/null
+++ b/app/(tabs)/(home)/saved-links.tsx
@@ -0,0 +1,153 @@
+import { useState, useMemo, useCallback } from 'react';
+import { FlatList, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { router } from 'expo-router';
+import { AppIcon } from '@/components/ui/app-icon';
+import { BookmarkChip } from '@/components/ui/bookmark-chip';
+import { CardLink } from '@/components/ui/card-link';
+import { FilterChip, type FilterChipItem } from '@/components/ui/filter-chip';
+import { FolderContextMenu, type ContextMenuItem } from '@/components/ui/folder-context-menu';
+import type { AnchorPosition } from '@/components/ui/folder-card';
+import { Colors, Typography } from '@/constants/theme';
+import { useSavedLinks, type SavedLink } from '@/context/saved-links-context';
+
+// ─── 폴더 필터 칩 아이템 ──────────────────────────────────────────────────────
+// 실제 구현 시 GET /api/v1/categories 응답으로 교체
+
+// 폴더명은 folder/index.tsx MOCK_FOLDERS와 동일하게 유지 (API 연동 시 GET /categories로 교체)
+const FOLDER_ITEMS: FilterChipItem[] = [
+ { label: '전체', value: 'all' },
+ { label: '맛집 정보', value: '1' },
+ { label: '취업', value: '2' },
+ { label: '취미', value: '3' },
+];
+
+// ─── Screen ───────────────────────────────────────────────────────────────────
+
+export default function SavedLinksScreen() {
+ const { links, toggleBookmark, deleteLink } = useSavedLinks();
+
+ const [selectedFolder, setSelectedFolder] = useState('all');
+ const [bookmarkFilter, setBookmarkFilter] = useState(false);
+
+ const [menuVisible, setMenuVisible] = useState(false);
+ const [menuAnchor, setMenuAnchor] = useState();
+ const [menuItems, setMenuItems] = useState([]);
+
+ const displayLinks = useMemo(() => {
+ const folderFiltered =
+ selectedFolder === 'all'
+ ? links
+ : links.filter((link) => String(link.categoryId) === selectedFolder);
+
+ if (!bookmarkFilter) return folderFiltered;
+
+ return [...folderFiltered].sort((a, b) => Number(b.isBookmarked) - Number(a.isBookmarked));
+ }, [links, selectedFolder, bookmarkFilter]);
+
+ const openMoreMenu = useCallback(
+ (link: SavedLink, anchor: AnchorPosition) => {
+ setMenuAnchor(anchor);
+ setMenuItems([
+ {
+ label: link.isBookmarked ? '북마크 제거' : '북마크 추가',
+ onPress: () => toggleBookmark(link.id),
+ },
+ {
+ label: '링크 삭제',
+ onPress: () => deleteLink(link.id),
+ destructive: true,
+ },
+ ]);
+ setMenuVisible(true);
+ },
+ [toggleBookmark, deleteLink]
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+ router.back()} />
+ 저장한 링크
+
+
+ {/* 필터 칩 */}
+
+
+ setBookmarkFilter((prev) => !prev)}
+ />
+
+
+ {/* 링크 목록 */}
+ String(item.id)}
+ contentContainerStyle={styles.listContent}
+ showsVerticalScrollIndicator={false}
+ renderItem={({ item }) => (
+ toggleBookmark(item.id)}
+ onMore={(anchor) => openMoreMenu(item, anchor)}
+ />
+ )}
+ ItemSeparatorComponent={() => }
+ />
+
+ {/* 링크 컨텍스트 메뉴 */}
+ setMenuVisible(false)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: 8,
+ },
+ title: {
+ ...Typography.title,
+ color: Colors.brand.text,
+ },
+ chipRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ zIndex: 10,
+ },
+ listContent: {
+ paddingHorizontal: 20,
+ paddingTop: 4,
+ paddingBottom: 32,
+ },
+ separator: {
+ height: 12,
+ },
+});
diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx
new file mode 100644
index 0000000..ec306bb
--- /dev/null
+++ b/app/(tabs)/(home)/scan-result-block.tsx
@@ -0,0 +1,126 @@
+import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { Colors, Typography } from '@/constants/theme';
+import { ResultStatusIcon } from '@/components/ui/result-status-icon';
+
+export default function ScanResultBlockScreen() {
+ const { url } = useLocalSearchParams<{ url: string }>();
+
+ return (
+ <>
+
+
+ {/* 차단 배지 */}
+
+
+
+
+ {/* 결과 텍스트 */}
+ 차단된 위험 링크입니다.
+
+ {'고위험 신호가 감지되었습니다.\n해당 링크는 저장할 수 없습니다.'}
+
+
+ {/* 검사 대상 카드 */}
+
+ 검사 대상
+
+ {url}
+
+
+
+ {/* 확인 버튼 */}
+
+ router.dismissAll()}
+ activeOpacity={0.8}
+ >
+ 확인
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ container: {
+ flexGrow: 1,
+ paddingHorizontal: 24,
+ paddingTop: 40,
+ paddingBottom: 40,
+ alignItems: 'center',
+ },
+
+ badgeArea: {
+ alignItems: 'center',
+ marginBottom: 32,
+ },
+
+ resultTitle: {
+ ...Typography.display,
+ color: Colors.brand.text,
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ resultSubtitle: {
+ ...Typography.body,
+ color: Colors.brand.textSecondary,
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: 40,
+ },
+
+ card: {
+ width: '100%',
+ backgroundColor: Colors.light.background,
+ borderRadius: 16,
+ padding: 16,
+ gap: 6,
+ marginBottom: 40,
+ },
+ cardLabel: {
+ ...Typography.caption,
+ color: Colors.brand.textHint,
+ },
+ cardUrl: {
+ ...Typography.body,
+ fontWeight: '700',
+ color: Colors.brand.text,
+ },
+
+ buttonArea: {
+ width: '100%',
+ },
+ confirmButton: {
+ width: '100%',
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: Colors.brand.textWarning,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ confirmButtonText: {
+ ...Typography.section,
+ color: '#FFFFFF',
+ },
+});
diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx
new file mode 100644
index 0000000..971c24b
--- /dev/null
+++ b/app/(tabs)/(home)/scan-result-caution.tsx
@@ -0,0 +1,167 @@
+import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { Colors, Typography } from '@/constants/theme';
+import { ResultStatusIcon } from '@/components/ui/result-status-icon';
+import { useSavedLinks } from '@/context/saved-links-context';
+
+export default function ScanResultCautionScreen() {
+ const { url } = useLocalSearchParams<{ url: string }>();
+ const { addLink } = useSavedLinks();
+
+ const handleSave = () => {
+ // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체
+ addLink({
+ id: Date.now(),
+ analysisId: `mock-${Date.now()}`,
+ categoryId: null,
+ originalUrl: url ?? '',
+ finalUrl: url ?? null,
+ title: url ?? '제목 없음',
+ description: '저장된 링크입니다.',
+ siteName: (() => {
+ try { return new URL(url ?? '').hostname; } catch { return '알 수 없음'; }
+ })(),
+ verdict: 'caution',
+ isBookmarked: false,
+ createdAt: new Date().toISOString(),
+ });
+ router.dismissAll();
+ };
+
+ const handleOpenUrl = () => {
+ if (url) Linking.openURL(url);
+ };
+
+ return (
+ <>
+
+
+ {/* 주의 배지 */}
+
+
+
+
+ {/* 결과 텍스트 */}
+ 주의가 필요한 링크입니다.
+
+ {'의심 신호가 일부 감지됐어요.\n계속 진행할지 한번 더 확인하세요.'}
+
+
+ {/* 검사 대상 카드 */}
+
+ 검사 대상
+
+ {url}
+
+
+
+ {/* 버튼 영역 */}
+
+
+ 주의 후 저장
+
+
+
+ 즉시 URL 접속
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ container: {
+ flexGrow: 1,
+ paddingHorizontal: 24,
+ paddingTop: 40,
+ paddingBottom: 40,
+ alignItems: 'center',
+ },
+
+ badgeArea: {
+ alignItems: 'center',
+ marginBottom: 32,
+ },
+
+ resultTitle: {
+ ...Typography.display,
+ color: Colors.brand.text,
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ resultSubtitle: {
+ ...Typography.body,
+ color: Colors.brand.textSecondary,
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: 40,
+ },
+
+ card: {
+ width: '100%',
+ backgroundColor: Colors.light.background,
+ borderRadius: 16,
+ padding: 16,
+ gap: 6,
+ marginBottom: 40,
+ },
+ cardLabel: {
+ ...Typography.caption,
+ color: Colors.brand.textHint,
+ },
+ cardUrl: {
+ ...Typography.body,
+ fontWeight: '700',
+ color: Colors.brand.text,
+ },
+
+ buttonArea: {
+ width: '100%',
+ gap: 12,
+ },
+ cautionButton: {
+ width: '100%',
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: Colors.brand.textCaution,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ cautionButtonText: {
+ ...Typography.section,
+ color: Colors.brand.text,
+ },
+ secondaryButton: {
+ width: '100%',
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: Colors.light.background,
+ borderWidth: 1.5,
+ borderColor: Colors.brand.line,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ secondaryButtonText: {
+ ...Typography.section,
+ color: Colors.brand.text,
+ },
+});
diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx
new file mode 100644
index 0000000..d727477
--- /dev/null
+++ b/app/(tabs)/(home)/scan-result.tsx
@@ -0,0 +1,217 @@
+import { Ionicons } from '@expo/vector-icons';
+import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { Colors, Typography } from '@/constants/theme';
+import { useSavedLinks } from '@/context/saved-links-context';
+
+// TODO: 백엔드 연동 시 아래 흐름으로 교체
+// 1. scanning.tsx에서 POST /api/v1/analyses → analysisId 수신 후 params로 전달
+// 2. 여기서 POST /api/v1/saved-links { analysisId } 호출
+// 3. 응답(id, title, siteName 등)을 addLink에 전달
+// ERD: SAVED_LINK.analysis_id → ANALYSIS.analysis_id (FK)
+// API 명세: Draft of the specification.md > 4.1 링크 저장 참고
+
+export default function ScanResultScreen() {
+ const { url } = useLocalSearchParams<{ url: string }>();
+ const { addLink } = useSavedLinks();
+
+ const handleSave = () => {
+ // TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체
+ // 현재는 URL 기반 mock 데이터로 즉시 추가
+ addLink({
+ id: Date.now(),
+ analysisId: `mock-${Date.now()}`,
+ categoryId: null,
+ originalUrl: url ?? '',
+ finalUrl: url ?? null,
+ title: url ?? '제목 없음',
+ description: '저장된 링크입니다.',
+ siteName: (() => {
+ try { return new URL(url ?? '').hostname; } catch { return '알 수 없음'; }
+ })(),
+ verdict: 'safe',
+ isBookmarked: false,
+ createdAt: new Date().toISOString(),
+ });
+ router.dismissAll();
+ };
+
+ const handleOpenUrl = () => {
+ if (url) {
+ Linking.openURL(url);
+ }
+ };
+
+ return (
+ <>
+
+
+ {/* 안전 배지 영역 */}
+
+
+
+
+
+
+
+ {/* 안전 칩 */}
+
+ 안전
+
+
+
+ {/* 결과 텍스트 */}
+ 안전한 웹사이트입니다.
+ 저장 후 바로 접속하거나,{'\n'}즉시 URL로 이동할 수 있어요.
+
+ {/* 검사 대상 카드 */}
+
+ 검사 대상
+
+ {url}
+
+
+
+ {/* 버튼 영역 */}
+
+
+ 저장
+
+
+
+ 즉시 URL 접속
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ },
+ container: {
+ flexGrow: 1,
+ paddingHorizontal: 24,
+ paddingTop: 40,
+ paddingBottom: 40,
+ alignItems: 'center',
+ },
+
+ // 배지 영역
+ badgeArea: {
+ alignItems: 'center',
+ marginBottom: 32,
+ },
+ orbOuter: {
+ width: 160,
+ height: 160,
+ borderRadius: 80,
+ backgroundColor: Colors.brand.softMint,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ },
+ orbInner: {
+ width: 100,
+ height: 100,
+ borderRadius: 50,
+ backgroundColor: Colors.brand.background,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ chip: {
+ backgroundColor: Colors.light.background,
+ borderRadius: 20,
+ paddingVertical: 6,
+ paddingHorizontal: 20,
+ borderWidth: 1,
+ borderColor: Colors.brand.line,
+ },
+ chipText: {
+ ...Typography.summary,
+ color: Colors.brand.text,
+ },
+
+ // 결과 텍스트
+ resultTitle: {
+ ...Typography.display,
+ color: Colors.brand.text,
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ resultSubtitle: {
+ ...Typography.body,
+ color: Colors.brand.textSecondary,
+ textAlign: 'center',
+ lineHeight: 24,
+ marginBottom: 40,
+ },
+
+ // 검사 대상 카드
+ card: {
+ width: '100%',
+ backgroundColor: Colors.light.background,
+ borderRadius: 16,
+ padding: 16,
+ gap: 6,
+ marginBottom: 40,
+ },
+ cardLabel: {
+ ...Typography.caption,
+ color: Colors.brand.textHint,
+ },
+ cardUrl: {
+ ...Typography.body,
+ fontWeight: '700',
+ color: Colors.brand.text,
+ },
+
+ // 버튼 영역
+ buttonArea: {
+ width: '100%',
+ gap: 12,
+ },
+ primaryButton: {
+ width: '100%',
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: Colors.brand.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ primaryButtonText: {
+ ...Typography.section,
+ color: Colors.light.background,
+ },
+ secondaryButton: {
+ width: '100%',
+ height: 56,
+ borderRadius: 28,
+ backgroundColor: Colors.light.background,
+ borderWidth: 1.5,
+ borderColor: Colors.brand.line,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ secondaryButtonText: {
+ ...Typography.section,
+ color: Colors.brand.text,
+ },
+});
diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx
new file mode 100644
index 0000000..de634de
--- /dev/null
+++ b/app/(tabs)/(home)/scanning.tsx
@@ -0,0 +1,133 @@
+import LottieView from 'lottie-react-native';
+import { router, Stack, useLocalSearchParams } from 'expo-router';
+import { useEffect } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { Colors, Typography } from '@/constants/theme';
+
+// TODO: 백엔드 연동 시 POST /api/v1/analyses 호출 후 폴링으로 결과 확인
+// Request: { original_url }
+// Response: { analysis_id, status, verdict, score, summary }
+// verdict: 'safe' → scan-result(allowed), 'caution'/'danger' → 별도 결과 화면
+// API 명세: Draft of the specification.md > POST /analyses, GET /analyses/{analysisId} 참고
+
+export default function ScanningScreen() {
+ const { url } = useLocalSearchParams<{ url: string }>();
+
+ useEffect(() => {
+ // TODO: 실제 백엔드 분석 요청으로 교체
+ // verdict에 따라 화면 분기:
+ // safe → '/(tabs)/(home)/scan-result'
+ // caution → '/(tabs)/(home)/scan-result-caution'
+ // block → '/(tabs)/(home)/scan-result-block'
+ const timer = setTimeout(() => {
+ router.replace({ pathname: '/(tabs)/(home)/scan-result', params: { url } });
+ }, 3000);
+ return () => clearTimeout(timer);
+ }, [url]);
+
+ return (
+ <>
+
+
+ {/* Lottie 애니메이션 + 가운데 점 */}
+
+
+
+
+
+
+
+
+
+ {/* 텍스트 */}
+ 보안 검사 중입니다
+ 약 5–10초 정도 소요돼요
+
+ {/* 검사 대상 카드 */}
+
+ 검사 대상
+
+ {url}
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.brand.background,
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ paddingTop: 40,
+ },
+ animationWrapper: {
+ width: 280,
+ height: 280,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 32,
+ },
+ animation: {
+ width: 280,
+ height: 280,
+ position: 'absolute',
+ },
+ dotsOverlay: {
+ flexDirection: 'row',
+ gap: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: Colors.brand.textHint,
+ },
+ title: {
+ ...Typography.display,
+ color: Colors.brand.text,
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ ...Typography.body,
+ color: Colors.brand.textSecondary,
+ textAlign: 'center',
+ marginBottom: 40,
+ },
+ card: {
+ width: '100%',
+ backgroundColor: Colors.light.background,
+ borderRadius: 16,
+ padding: 16,
+ gap: 6,
+ },
+ cardLabel: {
+ ...Typography.caption,
+ color: Colors.brand.textHint,
+ },
+ cardUrl: {
+ ...Typography.body,
+ fontWeight: '700',
+ color: Colors.brand.text,
+ },
+});
diff --git a/assets/animations/scanning.json b/assets/animations/scanning.json
new file mode 100644
index 0000000..f25be22
--- /dev/null
+++ b/assets/animations/scanning.json
@@ -0,0 +1,150 @@
+{
+ "v": "5.7.4",
+ "fr": 60,
+ "ip": 0,
+ "op": 120,
+ "w": 300,
+ "h": 300,
+ "nm": "scanning",
+ "ddd": 0,
+ "assets": [],
+ "layers": [
+ {
+ "ddd": 0,
+ "ind": 1,
+ "ty": 4,
+ "nm": "Spinning Arc",
+ "sr": 1,
+ "ks": {
+ "o": { "a": 0, "k": 100 },
+ "r": {
+ "a": 1,
+ "k": [
+ { "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] }, "t": 0, "s": [0] },
+ { "t": 120, "s": [360] }
+ ]
+ },
+ "p": { "a": 0, "k": [150, 150, 0] },
+ "a": { "a": 0, "k": [0, 0, 0] },
+ "s": { "a": 0, "k": [100, 100, 100] }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "nm": "Arc Group",
+ "it": [
+ {
+ "ty": "el",
+ "nm": "Ellipse Path",
+ "p": { "a": 0, "k": [0, 0] },
+ "s": { "a": 0, "k": [220, 220] }
+ },
+ {
+ "ty": "tm",
+ "nm": "Trim Paths",
+ "s": { "a": 0, "k": 0 },
+ "e": { "a": 0, "k": 75 },
+ "o": { "a": 0, "k": 0 },
+ "m": 1
+ },
+ {
+ "ty": "st",
+ "nm": "Stroke",
+ "c": { "a": 0, "k": [0.443, 0.659, 0.588, 1] },
+ "o": { "a": 0, "k": 100 },
+ "w": { "a": 0, "k": 14 },
+ "lc": 2,
+ "lj": 2,
+ "ml": 4
+ }
+ ]
+ }
+ ],
+ "ip": 0,
+ "op": 120,
+ "st": 0,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 2,
+ "ty": 4,
+ "nm": "White Inner Circle",
+ "sr": 1,
+ "ks": {
+ "o": { "a": 0, "k": 100 },
+ "r": { "a": 0, "k": 0 },
+ "p": { "a": 0, "k": [150, 150, 0] },
+ "a": { "a": 0, "k": [0, 0, 0] },
+ "s": { "a": 0, "k": [100, 100, 100] }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "nm": "Inner Circle",
+ "it": [
+ {
+ "ty": "el",
+ "nm": "Ellipse",
+ "p": { "a": 0, "k": [0, 0] },
+ "s": { "a": 0, "k": [190, 190] }
+ },
+ {
+ "ty": "fl",
+ "nm": "Fill",
+ "c": { "a": 0, "k": [1, 1, 1, 1] },
+ "o": { "a": 0, "k": 100 },
+ "r": 1
+ }
+ ]
+ }
+ ],
+ "ip": 0,
+ "op": 120,
+ "st": 0,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 3,
+ "ty": 4,
+ "nm": "Gray Outer Circle",
+ "sr": 1,
+ "ks": {
+ "o": { "a": 0, "k": 100 },
+ "r": { "a": 0, "k": 0 },
+ "p": { "a": 0, "k": [150, 150, 0] },
+ "a": { "a": 0, "k": [0, 0, 0] },
+ "s": { "a": 0, "k": [100, 100, 100] }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "nm": "Outer Circle",
+ "it": [
+ {
+ "ty": "el",
+ "nm": "Ellipse",
+ "p": { "a": 0, "k": [0, 0] },
+ "s": { "a": 0, "k": [262, 262] }
+ },
+ {
+ "ty": "fl",
+ "nm": "Fill",
+ "c": { "a": 0, "k": [0.867, 0.914, 0.894, 1] },
+ "o": { "a": 0, "k": 100 },
+ "r": 1
+ }
+ ]
+ }
+ ],
+ "ip": 0,
+ "op": 120,
+ "st": 0,
+ "bm": 0
+ }
+ ]
+}
diff --git a/context/saved-links-context.tsx b/context/saved-links-context.tsx
new file mode 100644
index 0000000..29644c2
--- /dev/null
+++ b/context/saved-links-context.tsx
@@ -0,0 +1,184 @@
+import { createContext, useCallback, useContext, useState } from 'react';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+// ERD: SAVED_LINK JOIN ANALYSIS / API 명세 4.2 응답 구조 기반
+
+export interface SavedLink {
+ id: number;
+ analysisId: string;
+ categoryId: number | null;
+ originalUrl: string;
+ finalUrl: string | null;
+ title: string;
+ description: string;
+ siteName: string;
+ verdict: 'safe' | 'caution' | 'danger';
+ isBookmarked: boolean;
+ createdAt: string;
+}
+
+interface SavedLinksContextValue {
+ links: SavedLink[];
+ addLink: (link: SavedLink) => void;
+ toggleBookmark: (id: number) => void;
+ deleteLink: (id: number) => void;
+ assignCategory: (ids: number[], categoryId: number) => void;
+}
+
+// ─── Mock Data ────────────────────────────────────────────────────────────────
+// 실제 구현 시 GET /api/v1/saved-links 응답으로 교체
+
+const MOCK_LINKS: SavedLink[] = [
+ {
+ id: 1,
+ analysisId: 'a1b2c3d4-0001-0001-0001-000000000001',
+ categoryId: 1,
+ originalUrl: 'https://blog.naver.com/food1',
+ finalUrl: 'https://blog.naver.com/food1',
+ title: '용인시장 맛집 Top 100',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'NAVER',
+ verdict: 'safe',
+ isBookmarked: false,
+ createdAt: '2026-04-24T10:00:00Z',
+ },
+ {
+ id: 2,
+ analysisId: 'a1b2c3d4-0002-0002-0002-000000000002',
+ categoryId: 1,
+ originalUrl: 'https://blog.naver.com/food2',
+ finalUrl: 'https://blog.naver.com/food2',
+ title: '명지대학교 맛집',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'NAVER',
+ verdict: 'safe',
+ isBookmarked: true,
+ createdAt: '2026-04-24T09:00:00Z',
+ },
+ {
+ id: 3,
+ analysisId: 'a1b2c3d4-0003-0003-0003-000000000003',
+ categoryId: 2,
+ originalUrl: 'https://blog.naver.com/study1',
+ finalUrl: 'https://blog.naver.com/study1',
+ title: '알고리즘 이론',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'NAVER',
+ verdict: 'safe',
+ isBookmarked: false,
+ createdAt: '2026-04-24T08:00:00Z',
+ },
+ {
+ id: 4,
+ analysisId: 'a1b2c3d4-0004-0004-0004-000000000004',
+ categoryId: 2,
+ originalUrl: 'https://velog.io/typescript',
+ finalUrl: 'https://velog.io/typescript',
+ title: 'TypeScript 완전 정복',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'Velog',
+ verdict: 'safe',
+ isBookmarked: true,
+ createdAt: '2026-04-23T10:00:00Z',
+ },
+ {
+ id: 5,
+ analysisId: 'a1b2c3d4-0005-0005-0005-000000000005',
+ categoryId: null,
+ originalUrl: 'https://www.notion.so/LinClean',
+ finalUrl: 'https://www.notion.so/LinClean',
+ title: 'LinClean 기획서',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'Notion',
+ verdict: 'caution',
+ isBookmarked: true,
+ createdAt: '2026-04-23T08:00:00Z',
+ },
+ {
+ id: 6,
+ analysisId: 'a1b2c3d4-0006-0006-0006-000000000006',
+ categoryId: 3,
+ originalUrl: 'https://blog.naver.com/invest1',
+ finalUrl: 'https://blog.naver.com/invest1',
+ title: '주식 투자 꿀팁 모음',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'NAVER',
+ verdict: 'safe',
+ isBookmarked: false,
+ createdAt: '2026-04-22T10:00:00Z',
+ },
+ {
+ id: 7,
+ analysisId: 'a1b2c3d4-0007-0007-0007-000000000007',
+ categoryId: 2,
+ originalUrl: 'https://youtube.com/rn',
+ finalUrl: 'https://youtube.com/rn',
+ title: 'React Native 강의',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'YouTube',
+ verdict: 'safe',
+ isBookmarked: true,
+ createdAt: '2026-04-22T08:00:00Z',
+ },
+ {
+ id: 8,
+ analysisId: 'a1b2c3d4-0008-0008-0008-000000000008',
+ categoryId: null,
+ originalUrl: 'https://github.com/expo-router',
+ finalUrl: 'https://github.com/expo-router',
+ title: 'Expo Router 공식 문서',
+ description: '요약된 한줄짜리 글~',
+ siteName: 'GitHub',
+ verdict: 'safe',
+ isBookmarked: false,
+ createdAt: '2026-04-21T10:00:00Z',
+ },
+];
+
+// ─── Context ──────────────────────────────────────────────────────────────────
+
+const SavedLinksContext = createContext(null);
+
+export function SavedLinksProvider({ children }: { children: React.ReactNode }) {
+ const [links, setLinks] = useState(MOCK_LINKS);
+
+ const addLink = useCallback((link: SavedLink) => {
+ // TODO: 백엔드 연동 시 POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체
+ setLinks((prev) => [link, ...prev]);
+ }, []);
+
+ const toggleBookmark = useCallback((id: number) => {
+ // 실제 구현 시 PATCH /api/v1/saved-links/{id}/bookmark 호출
+ setLinks((prev) =>
+ prev.map((link) =>
+ link.id === id ? { ...link, isBookmarked: !link.isBookmarked } : link
+ )
+ );
+ }, []);
+
+ const deleteLink = useCallback((id: number) => {
+ // 실제 구현 시 DELETE /api/v1/saved-links/{id} 호출
+ setLinks((prev) => prev.filter((link) => link.id !== id));
+ }, []);
+
+ const assignCategory = useCallback((ids: number[], categoryId: number) => {
+ // 실제 구현 시 ids.forEach → PATCH /api/v1/saved-links/{id} { categoryId } 호출
+ setLinks((prev) =>
+ prev.map((link) =>
+ ids.includes(link.id) ? { ...link, categoryId } : link
+ )
+ );
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSavedLinks(): SavedLinksContextValue {
+ const ctx = useContext(SavedLinksContext);
+ if (!ctx) throw new Error('useSavedLinks must be used within SavedLinksProvider');
+ return ctx;
+}