From f1a55ad26b2b6866cdd2e2e8d885c53453a9d3ae Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Mon, 25 May 2026 18:24:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?design:=20=EC=9E=91=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/index.tsx | 45 ++++++++++-- app/(tabs)/(home)/add-link.tsx | 55 +++++++++++--- app/(tabs)/(home)/index.tsx | 51 ++++++++++--- app/(tabs)/(home)/scan-result-block.tsx | 51 +++++++++++-- app/(tabs)/(home)/scan-result-caution.tsx | 66 ++++++++++++++--- app/(tabs)/(home)/scan-result.tsx | 66 ++++++++++++++--- app/(tabs)/(home)/scanning.tsx | 78 +++++++++++++++++--- components/ui/folder-card.tsx | 14 +++- components/ui/result-status-icon.tsx | 89 +++++++++++++++++++++-- components/ui/section-header.tsx | 14 +++- eas.json | 11 ++- 11 files changed, 465 insertions(+), 75 deletions(-) diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index c2093bc..afee180 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -13,6 +13,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; @@ -31,8 +32,15 @@ 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; + export default function FolderScreen() { const router = useRouter(); + const tabBarHeight = useBottomTabBarHeight(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; }>(); @@ -59,6 +67,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 renameInputRef = useRef(null); const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; @@ -67,6 +76,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 }); @@ -154,7 +184,7 @@ export default function FolderScreen() { {/* 상단 헤더 */} @@ -181,12 +211,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)} /> @@ -300,7 +334,7 @@ const styles = StyleSheet.create({ flex: 1, }, content: { - paddingHorizontal: 24, + paddingHorizontal: CONTENT_HORIZONTAL_PADDING, paddingBottom: 32, gap: 20, }, @@ -323,13 +357,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 64d42d4..96e1064 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -6,6 +6,7 @@ import { Text, TextInput, TouchableOpacity, + useWindowDimensions, View, } from 'react-native'; import { Stack, router, useLocalSearchParams } from 'expo-router'; @@ -14,6 +15,9 @@ import { ScanButton } from '@/components/ui/scan-button'; import { Colors, Typography } from '@/constants/theme'; 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 ''; @@ -24,9 +28,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; useEffect(() => { const nextSharedUrl = getSharedUrlParam(sharedUrl); @@ -89,19 +95,21 @@ export default function AddLinkScreen() { style={styles.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - + {/* 타이틀 */} - - 링크를 - 입력해주세요 + + 링크를 + 입력해주세요 {/* 부제목 */} - 보안검사 후 저장할 수 있습니다. + + 보안검사 후 저장할 수 있습니다. + {/* URL 입력 + 검사 버튼 */} - - + + {/* 에러 메시지 */} - {hasError && {error}} + {hasError && {error}} {/* 안내 박스 */} - + 안내 검사 후 안전한 사이트이라면, 저장하실 수 있습니다. @@ -157,6 +165,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingTop: 16, }, + containerCompact: { + paddingHorizontal: 20, + paddingTop: 8, + }, // 타이틀 titleRow: { @@ -164,6 +176,9 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', marginBottom: 12, }, + titleRowCompact: { + marginBottom: 8, + }, titleGreen: { ...Typography.display, color: Colors.brand.primaryDeep, @@ -172,6 +187,9 @@ const styles = StyleSheet.create({ ...Typography.display, color: Colors.brand.text, }, + titleCompact: { + ...Typography.pageTitle, + }, // 부제목 subtitle: { @@ -179,6 +197,10 @@ const styles = StyleSheet.create({ color: Colors.brand.textSecondary, marginBottom: 32, }, + subtitleCompact: { + ...Typography.summary, + marginBottom: 24, + }, // 입력 행 inputRow: { @@ -187,6 +209,9 @@ const styles = StyleSheet.create({ gap: 8, marginBottom: 8, }, + inputRowCompact: { + gap: 6, + }, inputWrapper: { flex: 1, flexDirection: 'row', @@ -198,6 +223,10 @@ const styles = StyleSheet.create({ backgroundColor: Colors.brand.surface, paddingHorizontal: 16, }, + inputWrapperCompact: { + height: 56, + paddingHorizontal: 14, + }, inputError: { borderColor: Colors.brand.textWarning, }, @@ -226,6 +255,10 @@ const styles = StyleSheet.create({ color: Colors.brand.textWarning, marginBottom: 16, }, + errorTextCompact: { + ...Typography.summary, + marginBottom: 12, + }, // 안내 박스 infoBox: { @@ -235,6 +268,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 cec21b0..e146bbf 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -4,8 +4,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 { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; +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 }); @@ -33,6 +39,7 @@ export default function HomeScreen() { const [titleToastVisible, setTitleToastVisible] = useState(false); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; + const isCompactWidth = windowWidth < COMPACT_WIDTH; // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -104,7 +111,11 @@ export default function HomeScreen() { {/* 상단 헤더 */} @@ -115,23 +126,23 @@ export default function HomeScreen() { size={30} color={Colors.brand.primary} /> - LinClean + LinClean router.push('/(tabs)/(home)/settings')} /> {/* 서브타이틀 */} - 오늘도 안전하게 정리해요 + 오늘도 안전하게 정리해요 {/* 보안 등급별 링크 현황 */} - - - - - - - + + + + + + + @@ -141,6 +152,7 @@ export default function HomeScreen() { router.push('/saved-links')} + compact={isCompactWidth} /> {recentLinks.map((link) => ( @@ -222,6 +234,10 @@ const styles = StyleSheet.create({ paddingBottom: 32, gap: 24, }, + contentCompact: { + paddingHorizontal: 20, + gap: 18, + }, header: { flexDirection: 'row', @@ -238,12 +254,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, @@ -256,11 +278,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%', @@ -270,6 +298,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 3fd1be5..1afd058 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'; @@ -13,11 +14,16 @@ import { getRouteParam, } from '@/utils/analysis-result-display'; +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); @@ -25,6 +31,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; useEffect(() => { if (!analysis?.verdict || analysis.verdict === 'danger') { @@ -77,24 +85,30 @@ export default function ScanResultBlockScreen() { /> {/* 차단 배지 */} - - + + {/* 결과 텍스트 */} - 차단된 위험 링크입니다. + + 차단된 위험 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -104,7 +118,7 @@ export default function ScanResultBlockScreen() { {/* 확인 버튼 */} router.dismissAll()} activeOpacity={0.8} > @@ -128,11 +142,17 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, resultTitle: { ...Typography.displayMedium, @@ -140,9 +160,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, @@ -170,6 +197,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -190,6 +221,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 6f96c8b..24b8f05 100644 --- a/app/(tabs)/(home)/scan-result-caution.tsx +++ b/app/(tabs)/(home)/scan-result-caution.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, 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'; @@ -16,12 +17,17 @@ import { getRouteParam, } from '@/utils/analysis-result-display'; +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); @@ -39,6 +45,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') { @@ -130,24 +138,30 @@ export default function ScanResultCautionScreen() { /> {/* 주의 배지 */} - - + + {/* 결과 텍스트 */} - 주의가 필요한 링크입니다. + + 주의가 필요한 링크입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -155,9 +169,13 @@ export default function ScanResultCautionScreen() { {/* 버튼 영역 */} - + setSaveModalVisible(true)} activeOpacity={0.8} disabled={!canSave} @@ -165,7 +183,11 @@ export default function ScanResultCautionScreen() { 주의 후 저장 - + 즉시 URL 접속 @@ -198,11 +220,17 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, resultTitle: { ...Typography.displayMedium, @@ -210,9 +238,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, @@ -240,6 +275,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -253,6 +292,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, cautionButton: { width: '100%', height: 56, @@ -261,6 +303,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 889a50b..ea27dae 100644 --- a/app/(tabs)/(home)/scan-result.tsx +++ b/app/(tabs)/(home)/scan-result.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { Alert, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, 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'; @@ -16,12 +17,17 @@ import { getRouteParam, } from '@/utils/analysis-result-display'; +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); @@ -39,6 +45,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') { @@ -130,24 +138,30 @@ export default function ScanResultScreen() { /> {/* 안전 배지 영역 */} - - + + {/* 결과 텍스트 */} - 안전한 웹사이트입니다. + + 안전한 웹사이트입니다. + {isLoading && 분석 결과를 불러오는 중입니다.} {errorMessage ? {errorMessage} : null} - + {/* 검사 대상 카드 */} - + 검사 대상 {displayUrl} @@ -155,9 +169,13 @@ export default function ScanResultScreen() { {/* 버튼 영역 */} - + setSaveModalVisible(true)} activeOpacity={0.8} disabled={!canSave} @@ -165,7 +183,11 @@ export default function ScanResultScreen() { 저장 - + 즉시 URL 접속 @@ -198,12 +220,18 @@ const styles = StyleSheet.create({ paddingBottom: 32, alignItems: 'center', }, + containerCompact: { + paddingTop: 0, + }, // 배지 영역 badgeArea: { alignItems: 'center', marginBottom: 20, }, + badgeAreaCompact: { + marginBottom: 12, + }, // 결과 텍스트 resultTitle: { @@ -212,9 +240,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, @@ -243,6 +278,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 28, }, + cardCompact: { + padding: 14, + marginBottom: 20, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -257,6 +296,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, primaryButton: { width: '100%', height: 56, @@ -265,6 +307,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 a258e41..f878089 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'; @@ -10,14 +11,28 @@ import { Colors, Typography } from '@/constants/theme'; 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; @@ -102,14 +117,28 @@ export default function ScanningScreen() { headerShadowVisible: false, }} /> - + {/* Lottie 애니메이션 + 가운데 점 */} - + @@ -119,15 +148,15 @@ export default function ScanningScreen() { {/* 텍스트 */} - + {hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'} - + {hasError ? errorMessage : '약 5-10초 정도 소요돼요'} {/* 검사 대상 카드 */} - + 검사 대상 {url} @@ -135,16 +164,16 @@ export default function ScanningScreen() { {hasError && ( - + setRetryKey((key) => key + 1)} activeOpacity={0.8} > 다시 검사 router.back()} activeOpacity={0.8} > @@ -298,6 +327,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, paddingTop: 40, }, + containerCompact: { + paddingTop: 20, + }, + containerVeryCompact: { + paddingTop: 12, + }, animationWrapper: { width: 280, height: 280, @@ -305,6 +340,12 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginBottom: 32, }, + animationWrapperCompact: { + marginBottom: 20, + }, + animationWrapperVeryCompact: { + marginBottom: 14, + }, animation: { width: 280, height: 280, @@ -328,12 +369,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, @@ -342,6 +389,10 @@ const styles = StyleSheet.create({ gap: 6, marginBottom: 24, }, + cardCompact: { + padding: 14, + marginBottom: 18, + }, cardLabel: { ...Typography.caption, color: Colors.brand.textHint, @@ -358,6 +409,9 @@ const styles = StyleSheet.create({ width: '100%', gap: 12, }, + buttonAreaCompact: { + gap: 10, + }, primaryButton: { width: '100%', height: 56, @@ -366,6 +420,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 21c2bb2..549b463 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -4,6 +4,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Colors, Typography } from '@/constants/theme'; import { AppIcon } from './app-icon'; +const DEFAULT_CARD_WIDTH = 144; + export interface AnchorPosition { x: number; y: number; @@ -14,6 +16,7 @@ export interface AnchorPosition { export interface FolderCardProps { folderName: string; urlCount: number; + width?: number; onPress?: () => void; onMorePress?: (anchor: AnchorPosition) => void; disabled?: boolean; @@ -22,11 +25,13 @@ 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 handleMorePress = () => { moreRef.current?.measure((_fx, _fy, width, height, px, py) => { @@ -36,7 +41,7 @@ export function FolderCard({ return ( [pressed && !disabled && styles.pressed]} + style={({ pressed }) => [styles.root, { width: cardWidth }, pressed && !disabled && styles.pressed]} onPress={disabled ? undefined : onPress} accessibilityRole="button" accessibilityLabel={`${folderName} 폴더, ${urlCount}개`} @@ -44,7 +49,7 @@ export function FolderCard({ > {disabled ? ( - + {urlCount}개 @@ -67,7 +72,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}개 @@ -90,6 +95,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 daabe63..cb4db34 100644 --- a/components/ui/section-header.tsx +++ b/components/ui/section-header.tsx @@ -4,15 +4,22 @@ import { Colors, Typography } from '@/constants/theme'; 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) { return ( - {label} + {label} {rightSlot ?? (onViewAll && ( {viewAllLabel} @@ -32,6 +39,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..464c403 100644 --- a/eas.json +++ b/eas.json @@ -9,7 +9,16 @@ "distribution": "internal" }, "preview": { - "distribution": "internal" + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_BASE_URL": "http://10.0.2.2:8080" + } + }, + "preview-device": { + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_BASE_URL": "http://192.168.0.12:8080" + } }, "production": { "autoIncrement": true From f00b393fc0b2b31a1a432f4e1ae8a7ce180dab24 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 15:49:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B8=A1=EC=A0=95=20=EB=B3=80=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/folder-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx index 549b463..c60ad66 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -34,8 +34,8 @@ export function FolderCard({ const cardWidth = width ?? DEFAULT_CARD_WIDTH; const handleMorePress = () => { - moreRef.current?.measure((_fx, _fy, width, height, px, py) => { - onMorePress?.({ x: px, y: py, width, height }); + moreRef.current?.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => { + onMorePress?.({ x: px, y: py, width: measuredWidth, height: measuredHeight }); }); }; From 174a842400bc1d556a6e6a89799f7d61179f3d6d Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 16:15:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20preview-device=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eas.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/eas.json b/eas.json index 464c403..b8f47cc 100644 --- a/eas.json +++ b/eas.json @@ -16,9 +16,7 @@ }, "preview-device": { "distribution": "internal", - "env": { - "EXPO_PUBLIC_API_BASE_URL": "http://192.168.0.12:8080" - } + "environment": "preview" }, "production": { "autoIncrement": true