diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index e511c93..690aed0 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -14,6 +14,7 @@ import { View, type KeyboardEvent, } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; @@ -34,6 +35,11 @@ type MenuState = { folderId?: number; }; +const CONTENT_HORIZONTAL_PADDING = 24; +const CANVAS_PADDING = 16; +const FOLDER_GRID_GAP = 12; +const DEFAULT_FOLDER_CARD_WIDTH = 144; +const MIN_TWO_COLUMN_CARD_WIDTH = 120; const RENAME_MODAL_BOTTOM_GAP = 16; const RENAME_KEYBOARD_TOP_GAP = 8; @@ -59,6 +65,7 @@ const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { export default function FolderScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); + const tabBarHeight = useBottomTabBarHeight(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; }>(); @@ -86,6 +93,7 @@ export default function FolderScreen() { const [createToastVisible, setCreateToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [renameToastVisible, setRenameToastVisible] = useState(false); + const [gridWidth, setGridWidth] = useState(0); const lastCreatedToastRef = useRef(undefined); const isMutatingRef = useRef(false); const renameInputRef = useRef(null); @@ -96,6 +104,27 @@ export default function FolderScreen() { () => rawFolders.map((folder) => ({ ...folder })), [rawFolders], ); + const folderCardWidth = useMemo(() => { + const availableWidth = gridWidth; + + if (availableWidth <= 0) { + return DEFAULT_FOLDER_CARD_WIDTH; + } + + const defaultTwoColumnWidth = DEFAULT_FOLDER_CARD_WIDTH * 2 + FOLDER_GRID_GAP; + + if (availableWidth >= defaultTwoColumnWidth) { + return DEFAULT_FOLDER_CARD_WIDTH; + } + + const compactTwoColumnWidth = Math.floor((availableWidth - FOLDER_GRID_GAP) / 2); + + if (compactTwoColumnWidth >= MIN_TWO_COLUMN_CARD_WIDTH) { + return compactTwoColumnWidth; + } + + return availableWidth; + }, [gridWidth]); const handleMorePress = (folderId: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, folderId }); @@ -249,7 +278,7 @@ export default function FolderScreen() { {/* 상단 헤더 */} @@ -276,12 +305,16 @@ export default function FolderScreen() { ) : folders.length > 0 ? ( - + setGridWidth(event.nativeEvent.layout.width)} + > {folders.map((folder) => ( router.push({ pathname: '/(tabs)/(folder)/[id]' as any, params: { id: folder.id } })} onMorePress={(anchor) => handleMorePress(folder.id, anchor)} /> @@ -403,7 +436,7 @@ const styles = StyleSheet.create({ flex: 1, }, content: { - paddingHorizontal: 24, + paddingHorizontal: CONTENT_HORIZONTAL_PADDING, paddingBottom: 32, gap: 20, }, @@ -426,13 +459,14 @@ const styles = StyleSheet.create({ borderRadius: 20, borderWidth: 1, borderColor: Colors.brand.line, - padding: 16, + padding: CANVAS_PADDING, gap: 16, }, grid: { + width: '100%', flexDirection: 'row', flexWrap: 'wrap', - gap: 12, + gap: FOLDER_GRID_GAP, }, errorBox: { borderRadius: 12, diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index fd97936..b8250f1 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -7,6 +7,7 @@ import { Text, TextInput, TouchableOpacity, + useWindowDimensions, View, } from 'react-native'; import { Stack, router, useLocalSearchParams } from 'expo-router'; @@ -16,6 +17,9 @@ import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; import { normalizeHttpUrlInput } from '@/utils/shared-url'; +const COMPACT_WIDTH = 380; +const SHORT_SCREEN_HEIGHT = 760; + function getSharedUrlParam(value: string | string[] | undefined): string { if (typeof value !== 'string') { return ''; @@ -26,9 +30,11 @@ function getSharedUrlParam(value: string | string[] | undefined): string { export default function AddLinkScreen() { const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const initialSharedUrl = getSharedUrlParam(sharedUrl); const [url, setUrl] = useState(initialSharedUrl); const [error, setError] = useState(''); + const isCompact = windowWidth < COMPACT_WIDTH || windowHeight <= SHORT_SCREEN_HEIGHT; const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); @@ -110,19 +116,21 @@ export default function AddLinkScreen() { style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - + {/* 타이틀 */} - - 링크를 - 입력해주세요 + + 링크를 + 입력해주세요 {/* 부제목 */} - 보안검사 후 저장할 수 있습니다. + + 보안검사 후 저장할 수 있습니다. + {/* URL 입력 + 검사 버튼 */} - - + + {/* 에러 메시지 */} - {hasError && {error}} + {hasError && {error}} {/* 안내 박스 */} - + 안내 검사 후 안전한 사이트이라면, 저장하실 수 있습니다. @@ -178,6 +186,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingTop: 16, }, + containerCompact: { + paddingHorizontal: 20, + paddingTop: 8, + }, // 타이틀 titleRow: { @@ -185,6 +197,9 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', marginBottom: 12, }, + titleRowCompact: { + marginBottom: 8, + }, titleGreen: { ...Typography.display, color: Colors.brand.primaryDeep, @@ -193,6 +208,9 @@ const styles = StyleSheet.create({ ...Typography.display, color: Colors.brand.text, }, + titleCompact: { + ...Typography.pageTitle, + }, // 부제목 subtitle: { @@ -200,6 +218,10 @@ const styles = StyleSheet.create({ color: Colors.brand.textSecondary, marginBottom: 32, }, + subtitleCompact: { + ...Typography.summary, + marginBottom: 24, + }, // 입력 행 inputRow: { @@ -208,6 +230,9 @@ const styles = StyleSheet.create({ gap: 8, marginBottom: 8, }, + inputRowCompact: { + gap: 6, + }, inputWrapper: { flex: 1, flexDirection: 'row', @@ -219,6 +244,10 @@ const styles = StyleSheet.create({ backgroundColor: Colors.brand.surface, paddingHorizontal: 16, }, + inputWrapperCompact: { + height: 56, + paddingHorizontal: 14, + }, inputError: { borderColor: Colors.brand.textWarning, }, @@ -247,6 +276,10 @@ const styles = StyleSheet.create({ color: Colors.brand.textWarning, marginBottom: 16, }, + errorTextCompact: { + ...Typography.summary, + marginBottom: 12, + }, // 안내 박스 infoBox: { @@ -256,6 +289,10 @@ const styles = StyleSheet.create({ marginTop: 16, gap: 6, }, + infoBoxCompact: { + padding: 14, + marginTop: 12, + }, infoLabel: { ...Typography.caption, color: Colors.brand.primaryDeep, diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 3a7901c..dbf427b 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -3,8 +3,10 @@ import { ScrollView, StyleSheet, Text, + useWindowDimensions, View, } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -20,10 +22,14 @@ import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/conte import type { AnchorPosition } from '@/components/ui/folder-card'; import { showAlert } from '@/utils/guarded-alert'; +const COMPACT_WIDTH = 380; + export default function HomeScreen() { const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{ savedLinkToast?: string | string[]; }>(); + const { width: windowWidth } = useWindowDimensions(); + const tabBarHeight = useBottomTabBarHeight(); const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks(); const { refreshFolders } = useFolders(); const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false }); @@ -34,6 +40,7 @@ export default function HomeScreen() { const lastToastParamRef = useRef(undefined); const deletingLinkIdsRef = useRef>(new Set()); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; + const isCompactWidth = windowWidth < COMPACT_WIDTH; // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -117,7 +124,11 @@ export default function HomeScreen() { {/* 상단 헤더 */} @@ -128,23 +139,23 @@ export default function HomeScreen() { size={30} color={Colors.brand.primary} /> - LinClean + LinClean router.push('/(tabs)/(home)/settings')} /> {/* 서브타이틀 */} - 오늘도 안전하게 정리해요 + 오늘도 안전하게 정리해요 {/* 보안 등급별 링크 현황 */} - - - - - - - + + + + + + + @@ -154,6 +165,7 @@ export default function HomeScreen() { router.push('/saved-links')} + compact={isCompactWidth} /> {recentLinks.map((link) => ( @@ -235,6 +247,10 @@ const styles = StyleSheet.create({ paddingBottom: 32, gap: 24, }, + contentCompact: { + paddingHorizontal: 20, + gap: 18, + }, header: { flexDirection: 'row', @@ -251,12 +267,18 @@ const styles = StyleSheet.create({ ...Typography.displayMedium, color: Colors.brand.primary, }, + brandTextCompact: { + ...Typography.pageTitle, + }, subtitle: { ...Typography.caption, color: Colors.brand.textSecondary, marginTop: -16, }, + subtitleCompact: { + marginTop: -10, + }, section: { gap: 12, @@ -269,11 +291,17 @@ const styles = StyleSheet.create({ borderColor: Colors.brand.line, padding: 16, }, + statPlaceholderCompact: { + padding: 14, + }, statGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, }, + statGridCompact: { + gap: 10, + }, statItem: { flex: 1, minWidth: '45%', @@ -283,6 +311,9 @@ const styles = StyleSheet.create({ borderColor: Colors.brand.line, borderStyle: 'dashed', }, + statItemCompact: { + height: 48, + }, linkList: { gap: 12, diff --git a/app/(tabs)/(home)/scan-result-block.tsx b/app/(tabs)/(home)/scan-result-block.tsx index 7e3a8f2..3c3250a 100644 --- a/app/(tabs)/(home)/scan-result-block.tsx +++ b/app/(tabs)/(home)/scan-result-block.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; @@ -14,11 +15,16 @@ import { } from '@/utils/analysis-result-display'; import { useGuardedPress } from '@/utils/press-guard'; +const COMPACT_RESULT_HEIGHT = 760; +const VERY_COMPACT_RESULT_HEIGHT = 700; + export default function ScanResultBlockScreen() { const { analysisId: analysisIdParam, url: urlParam, } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); + const { height: windowHeight } = useWindowDimensions(); + const tabBarHeight = useBottomTabBarHeight(); const analysisId = getRouteParam(analysisIdParam); const url = getRouteParam(urlParam); const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); @@ -26,6 +32,8 @@ export default function ScanResultBlockScreen() { const reason = getAnalysisReasonText(analysis, getMockScanResultReason('danger')); const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'danger'); const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading); + const isCompactResult = windowHeight <= COMPACT_RESULT_HEIGHT; + const isVeryCompactResult = windowHeight <= VERY_COMPACT_RESULT_HEIGHT; const guardedDismissAll = useGuardedPress(() => router.dismissAll()); useEffect(() => { @@ -79,24 +87,30 @@ export default function ScanResultBlockScreen() { /> {/* 차단 배지 */} - - + + {/* 결과 텍스트 */} - 차단된 위험 링크입니다. + + 차단된 위험 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -106,7 +120,7 @@ export default function ScanResultBlockScreen() { {/* 확인 버튼 */} @@ -130,11 +144,17 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, resultTitle: { ...Typography.displayMedium, @@ -142,9 +162,16 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 10, }, + resultTitleCompact: { + ...Typography.pageTitle, + marginBottom: 8, + }, reasonCard: { marginBottom: 24, }, + reasonCardCompact: { + marginBottom: 16, + }, statusText: { ...Typography.caption, color: Colors.brand.textSecondary, @@ -172,6 +199,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -192,6 +223,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + buttonVeryCompact: { + height: 52, + borderRadius: 26, + }, confirmButtonText: { ...Typography.section, color: Colors.brand.onPrimary, diff --git a/app/(tabs)/(home)/scan-result-caution.tsx b/app/(tabs)/(home)/scan-result-caution.tsx index 08e5d0b..0c0e96c 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; @@ -18,12 +19,17 @@ import { import { showAlert } from '@/utils/guarded-alert'; import { useGuardedPress } from '@/utils/press-guard'; +const COMPACT_RESULT_HEIGHT = 760; +const VERY_COMPACT_RESULT_HEIGHT = 700; + export default function ScanResultCautionScreen() { const { analysisId: analysisIdParam, url: urlParam, } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); const { addLink } = useSavedLinks(); + const { height: windowHeight } = useWindowDimensions(); + const tabBarHeight = useBottomTabBarHeight(); const analysisId = getRouteParam(analysisIdParam); const url = getRouteParam(urlParam); const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); @@ -42,6 +48,8 @@ export default function ScanResultCautionScreen() { !isLoading && !shouldRedirectToVerdict && !isSaving; + const isCompactResult = windowHeight <= COMPACT_RESULT_HEIGHT; + const isVeryCompactResult = windowHeight <= VERY_COMPACT_RESULT_HEIGHT; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'caution') { @@ -139,24 +147,30 @@ export default function ScanResultCautionScreen() { /> {/* 주의 배지 */} - - + + {/* 결과 텍스트 */} - 주의가 필요한 링크입니다. + + 주의가 필요한 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -164,9 +178,13 @@ export default function ScanResultCautionScreen() { {/* 버튼 영역 */} - + 주의 후 저장 - + 즉시 URL 접속 @@ -207,11 +229,17 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, resultTitle: { ...Typography.displayMedium, @@ -219,9 +247,16 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 10, }, + resultTitleCompact: { + ...Typography.pageTitle, + marginBottom: 8, + }, reasonCard: { marginBottom: 24, }, + reasonCardCompact: { + marginBottom: 16, + }, statusText: { ...Typography.caption, color: Colors.brand.textSecondary, @@ -249,6 +284,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -262,6 +301,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, cautionButton: { width: '100%', height: 56, @@ -270,6 +312,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + buttonVeryCompact: { + height: 52, + borderRadius: 26, + }, cautionButtonText: { ...Typography.section, color: Colors.brand.text, diff --git a/app/(tabs)/(home)/scan-result.tsx b/app/(tabs)/(home)/scan-result.tsx index 313e315..6f6dbf1 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { ResultStatusIcon } from '@/components/ui/result-status-icon'; import { ScanResultReason } from '@/components/ui/scan-result-reason'; import { getMockScanResultReason } from '@/constants/scan-result-reasons'; @@ -18,12 +19,17 @@ import { import { showAlert } from '@/utils/guarded-alert'; import { useGuardedPress } from '@/utils/press-guard'; +const COMPACT_RESULT_HEIGHT = 760; +const VERY_COMPACT_RESULT_HEIGHT = 700; + export default function ScanResultScreen() { const { analysisId: analysisIdParam, url: urlParam, } = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>(); const { addLink } = useSavedLinks(); + const { height: windowHeight } = useWindowDimensions(); + const tabBarHeight = useBottomTabBarHeight(); const analysisId = getRouteParam(analysisIdParam); const url = getRouteParam(urlParam); const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId); @@ -42,6 +48,8 @@ export default function ScanResultScreen() { !isLoading && !shouldRedirectToVerdict && !isSaving; + const isCompactResult = windowHeight <= COMPACT_RESULT_HEIGHT; + const isVeryCompactResult = windowHeight <= VERY_COMPACT_RESULT_HEIGHT; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'safe') { @@ -139,24 +147,30 @@ export default function ScanResultScreen() { /> {/* 안전 배지 영역 */} - - + + {/* 결과 텍스트 */} - 안전한 웹사이트입니다. + + 안전한 웹사이트입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -164,9 +178,13 @@ export default function ScanResultScreen() { {/* 버튼 영역 */} - + 저장 - + 즉시 URL 접속 @@ -207,12 +229,18 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, // 배지 영역 badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, // 결과 텍스트 resultTitle: { @@ -221,9 +249,16 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 10, }, + resultTitleCompact: { + ...Typography.pageTitle, + marginBottom: 8, + }, reasonCard: { marginBottom: 24, }, + reasonCardCompact: { + marginBottom: 16, + }, statusText: { ...Typography.caption, color: Colors.brand.textSecondary, @@ -252,6 +287,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -266,6 +305,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, primaryButton: { width: '100%', height: 56, @@ -274,6 +316,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + buttonVeryCompact: { + height: 52, + borderRadius: 26, + }, primaryButtonText: { ...Typography.section, color: Colors.brand.onPrimary, diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index 497598b..d8566c4 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -2,7 +2,8 @@ import { useAuth } from '@clerk/expo'; import LottieView from 'lottie-react-native'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { fetchAnalysis, requestAnalysis, type AnalysisResponse, type AnalysisVerdict } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; @@ -11,14 +12,28 @@ import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; const MAX_POLLING_MS = 30_000; +const SHORT_SCREEN_HEIGHT = 760; +const VERY_SHORT_SCREEN_HEIGHT = 700; +const DEFAULT_ANIMATION_SIZE = 280; +const SHORT_ANIMATION_SIZE = 216; +const VERY_SHORT_ANIMATION_SIZE = 188; export default function ScanningScreen() { const { getToken, isLoaded, isSignedIn } = useAuth(); const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>(); const url = getUrlParam(urlParam); + const { height: windowHeight } = useWindowDimensions(); + const tabBarHeight = useBottomTabBarHeight(); const [errorMessage, setErrorMessage] = useState(''); const [retryKey, setRetryKey] = useState(0); const getTokenRef = useRef(getToken); + const isShortScreen = windowHeight <= SHORT_SCREEN_HEIGHT; + const isVeryShortScreen = windowHeight <= VERY_SHORT_SCREEN_HEIGHT; + const animationSize = isVeryShortScreen + ? VERY_SHORT_ANIMATION_SIZE + : isShortScreen + ? SHORT_ANIMATION_SIZE + : DEFAULT_ANIMATION_SIZE; useEffect(() => { getTokenRef.current = getToken; @@ -105,14 +120,28 @@ export default function ScanningScreen() { headerShadowVisible: false, }} /> - + {/* Lottie 애니메이션 + 가운데 점 */} - + @@ -122,15 +151,15 @@ export default function ScanningScreen() { {/* 텍스트 */} - + {hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'} - + {hasError ? errorMessage : '약 5-10초 정도 소요돼요'} {/* 검사 대상 카드 */} - + 검사 대상 {url} @@ -138,16 +167,16 @@ export default function ScanningScreen() { {hasError && ( - + 다시 검사 @@ -301,6 +330,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingTop: 40, }, + containerCompact: { + paddingTop: 20, + }, + containerVeryCompact: { + paddingTop: 12, + }, animationWrapper: { width: 280, height: 280, @@ -308,6 +343,12 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginBottom: 32, }, + animationWrapperCompact: { + marginBottom: 20, + }, + animationWrapperVeryCompact: { + marginBottom: 14, + }, animation: { width: 280, height: 280, @@ -331,12 +372,18 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: 8, }, + titleCompact: { + ...Typography.pageTitle, + }, subtitle: { ...Typography.body, color: Colors.brand.textSecondary, textAlign: 'center', marginBottom: 40, }, + subtitleCompact: { + marginBottom: 24, + }, card: { width: '100%', backgroundColor: Colors.brand.surface, @@ -345,6 +392,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 24, }, + cardCompact: { + padding: 14, + marginBottom: 18, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -361,6 +412,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, primaryButton: { width: '100%', height: 56, @@ -369,6 +423,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + buttonCompact: { + height: 52, + borderRadius: 26, + }, primaryButtonText: { ...Typography.section, color: Colors.brand.onPrimary, diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx index 347cfa9..81ffacc 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -5,6 +5,8 @@ import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; import { AppIcon } from './app-icon'; +const DEFAULT_CARD_WIDTH = 144; + export interface AnchorPosition { x: number; y: number; @@ -15,6 +17,7 @@ export interface AnchorPosition { export interface FolderCardProps { folderName: string; urlCount: number; + width?: number; onPress?: () => void; onMorePress?: (anchor: AnchorPosition) => void; disabled?: boolean; @@ -23,23 +26,25 @@ export interface FolderCardProps { export function FolderCard({ folderName, urlCount, + width, onPress, onMorePress, disabled = false, }: FolderCardProps) { const moreRef = useRef(null); + const cardWidth = width ?? DEFAULT_CARD_WIDTH; const guardedOnPress = useGuardedPress(onPress, { disabled }); const guardedOnMorePress = useGuardedPress(onMorePress, { disabled }); const handleMorePress = () => { - moreRef.current?.measure((_fx, _fy, width, height, px, py) => { - guardedOnMorePress?.({ x: px, y: py, width, height }); + moreRef.current?.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => { + guardedOnMorePress?.({ x: px, y: py, width: measuredWidth, height: measuredHeight }); }); }; return ( [pressed && !disabled && styles.pressed]} + style={({ pressed }) => [styles.root, { width: cardWidth }, pressed && !disabled && styles.pressed]} onPress={guardedOnPress} accessibilityRole="button" accessibilityLabel={`${folderName} 폴더, ${urlCount}개`} @@ -47,7 +52,7 @@ export function FolderCard({ > {disabled ? ( - + {urlCount}개 @@ -70,7 +75,7 @@ export function FolderCard({ colors={[Colors.brand.folderGradientStart, Colors.brand.folderGradientEnd]} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} - style={styles.card} + style={[styles.card, { width: cardWidth }]} > {urlCount}개 @@ -93,6 +98,9 @@ export function FolderCard({ } const styles = StyleSheet.create({ + root: { + flexShrink: 0, + }, shadow: { borderRadius: 16, shadowColor: Colors.brand.shadow, diff --git a/components/ui/result-status-icon.tsx b/components/ui/result-status-icon.tsx index ef4cb6f..8e50a40 100644 --- a/components/ui/result-status-icon.tsx +++ b/components/ui/result-status-icon.tsx @@ -10,6 +10,7 @@ interface ResultStatusIconProps { icon?: boolean; disabled?: boolean; size?: 'default' | 'large'; + compact?: boolean; } const VARIANT_CONFIG = { @@ -49,23 +50,53 @@ export function ResultStatusIcon({ icon = true, disabled = false, size = 'default', + compact = false, }: ResultStatusIconProps) { const config = VARIANT_CONFIG[variant]; const isLarge = size === 'large'; + const isCompactLarge = isLarge && compact; return ( - + {/* 외부 글로우 레이어 */} - + {/* 내부 글로우 레이어 */} - + {/* 방패 아이콘 뱃지 */} - + {icon && ( )} @@ -73,8 +104,24 @@ export function ResultStatusIcon({ {/* 하단 상태 칩 */} {label && ( - - {label} + + + {label} + )} @@ -87,6 +134,8 @@ const GLOW_OUTER_SIZE = CONTAINER_SIZE; const GLOW_INNER_SIZE = 80; const LARGE_CONTAINER_SIZE = 216; const LARGE_BADGE_SIZE = 136; +const COMPACT_LARGE_CONTAINER_SIZE = 168; +const COMPACT_LARGE_BADGE_SIZE = 104; const styles = StyleSheet.create({ container: { @@ -99,6 +148,10 @@ const styles = StyleSheet.create({ width: LARGE_CONTAINER_SIZE, height: LARGE_CONTAINER_SIZE, }, + containerLargeCompact: { + width: COMPACT_LARGE_CONTAINER_SIZE, + height: COMPACT_LARGE_CONTAINER_SIZE, + }, disabled: { opacity: 0.5, }, @@ -113,6 +166,11 @@ const styles = StyleSheet.create({ height: LARGE_CONTAINER_SIZE, borderRadius: LARGE_CONTAINER_SIZE / 2, }, + glowOuterLargeCompact: { + width: COMPACT_LARGE_CONTAINER_SIZE, + height: COMPACT_LARGE_CONTAINER_SIZE, + borderRadius: COMPACT_LARGE_CONTAINER_SIZE / 2, + }, glowInner: { position: 'absolute', width: GLOW_INNER_SIZE, @@ -124,6 +182,11 @@ const styles = StyleSheet.create({ height: LARGE_BADGE_SIZE, borderRadius: LARGE_BADGE_SIZE / 2, }, + glowInnerLargeCompact: { + width: COMPACT_LARGE_BADGE_SIZE, + height: COMPACT_LARGE_BADGE_SIZE, + borderRadius: COMPACT_LARGE_BADGE_SIZE / 2, + }, badge: { width: BADGE_SIZE, height: BADGE_SIZE, @@ -138,6 +201,11 @@ const styles = StyleSheet.create({ height: LARGE_BADGE_SIZE, borderRadius: LARGE_BADGE_SIZE / 2, }, + badgeLargeCompact: { + width: COMPACT_LARGE_BADGE_SIZE, + height: COMPACT_LARGE_BADGE_SIZE, + borderRadius: COMPACT_LARGE_BADGE_SIZE / 2, + }, chip: { position: 'absolute', bottom: 0, @@ -149,6 +217,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, paddingVertical: 6, }, + chipLargeCompact: { + paddingHorizontal: 16, + paddingVertical: 5, + }, chipLabel: { ...Typography.caption, }, @@ -156,4 +228,7 @@ const styles = StyleSheet.create({ ...Typography.summary, fontWeight: '700', }, + chipLabelLargeCompact: { + ...Typography.bold12, + }, }); diff --git a/components/ui/section-header.tsx b/components/ui/section-header.tsx index 81f5498..5f70950 100644 --- a/components/ui/section-header.tsx +++ b/components/ui/section-header.tsx @@ -1,21 +1,29 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; interface SectionHeaderProps { label: string; + compact?: boolean; onViewAll?: () => void; viewAllLabel?: string; rightSlot?: ReactNode; } -export function SectionHeader({ label, onViewAll, viewAllLabel = '전체보기', rightSlot }: SectionHeaderProps) { +export function SectionHeader({ + label, + compact = false, + onViewAll, + viewAllLabel = '전체보기', + rightSlot, +}: SectionHeaderProps) { const guardedOnViewAll = useGuardedPress(onViewAll); return ( - {label} + {label} {rightSlot ?? (onViewAll && ( {viewAllLabel} @@ -35,6 +43,9 @@ const styles = StyleSheet.create({ ...Typography.sectionTitle, color: Colors.brand.text, }, + labelCompact: { + ...Typography.section, + }, viewAll: { ...Typography.caption, color: Colors.brand.primaryDeep, diff --git a/eas.json b/eas.json index 7e2e4ae..b8f47cc 100644 --- a/eas.json +++ b/eas.json @@ -9,7 +9,14 @@ "distribution": "internal" }, "preview": { - "distribution": "internal" + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_BASE_URL": "http://10.0.2.2:8080" + } + }, + "preview-device": { + "distribution": "internal", + "environment": "preview" }, "production": { "autoIncrement": true