diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 690aed0..717f807 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -11,6 +11,7 @@ import { Text, TextInput, TouchableOpacity, + useWindowDimensions, View, type KeyboardEvent, } from 'react-native'; @@ -20,9 +21,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { AddFolderButton } from '@/components/ui/add-folder-button'; import { FolderCard } from '@/components/ui/folder-card'; import { FolderContextMenu } from '@/components/ui/folder-context-menu'; -import { SectionHeader } from '@/components/ui/section-header'; import { Toast } from '@/components/ui/toast'; -import { Colors, Typography } from '@/constants/theme'; +import { Colors, ComponentTokens, Typography } from '@/constants/theme'; import type { AnchorPosition } from '@/components/ui/folder-card'; import { useSavedLinks } from '@/context/saved-links-context'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; @@ -35,11 +35,13 @@ 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 FOLDER_SCREEN = ComponentTokens.folderScreen; +const FOLDER_CARD = ComponentTokens.folderCard; +const CONTENT_HORIZONTAL_PADDING = FOLDER_SCREEN.contentHorizontalPadding; +const CANVAS_PADDING_VERTICAL = FOLDER_SCREEN.canvasPaddingVertical; +const FOLDER_GRID_GAP = FOLDER_SCREEN.gridGap; +const DEFAULT_FOLDER_CARD_WIDTH = FOLDER_CARD.defaultWidth; +const MIN_TWO_COLUMN_CARD_WIDTH = FOLDER_SCREEN.minTwoColumnCardWidth; const RENAME_MODAL_BOTTOM_GAP = 16; const RENAME_KEYBOARD_TOP_GAP = 8; @@ -65,6 +67,7 @@ const syncKeyboardLayoutAnimation = (event: KeyboardEvent) => { export default function FolderScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); + const { width: windowWidth } = useWindowDimensions(); const tabBarHeight = useBottomTabBarHeight(); const { folderCreated: folderCreatedParam } = useLocalSearchParams<{ folderCreated?: string | string[]; @@ -99,6 +102,7 @@ export default function FolderScreen() { const renameInputRef = useRef(null); const renameFocusTimerRef = useRef | null>(null); const folderCreated = typeof folderCreatedParam === 'string' ? folderCreatedParam : undefined; + const isCompactWidth = windowWidth < FOLDER_SCREEN.compactWidth; const folders = useMemo( () => rawFolders.map((folder) => ({ ...folder })), @@ -111,16 +115,10 @@ export default function FolderScreen() { return DEFAULT_FOLDER_CARD_WIDTH; } - const defaultTwoColumnWidth = DEFAULT_FOLDER_CARD_WIDTH * 2 + FOLDER_GRID_GAP; + const twoColumnCardWidth = Math.floor((availableWidth - FOLDER_GRID_GAP) / 2); - 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; + if (twoColumnCardWidth >= MIN_TWO_COLUMN_CARD_WIDTH) { + return twoColumnCardWidth; } return availableWidth; @@ -283,17 +281,15 @@ export default function FolderScreen() { > {/* 상단 헤더 */} - 폴더 - 저장한 링크를 폴더별로 정리해요 + 폴더 + + 저장한 링크를 폴더별로 정리해요 + + {/* 폴더 캔버스 */} - } - /> - {errorMessage ? ( {errorMessage} @@ -315,6 +311,8 @@ export default function FolderScreen() { folderName={folder.name} urlCount={folder.linkCount} width={folderCardWidth} + variant="plain" + compactFolderName={isCompactWidth} onPress={() => router.push({ pathname: '/(tabs)/(folder)/[id]' as any, params: { id: folder.id } })} onMorePress={(anchor) => handleMorePress(folder.id, anchor)} /> @@ -449,18 +447,26 @@ const styles = StyleSheet.create({ ...Typography.pageTitle, color: Colors.brand.text, }, + titleCompact: { + ...Typography.title, + }, subtitle: { + flex: 1, + flexShrink: 1, ...Typography.caption, color: Colors.brand.textSecondary, }, + subtitleRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + gap: FOLDER_SCREEN.headerRowGap, + marginTop: FOLDER_SCREEN.subtitleRowOffsetTop, + }, canvas: { - backgroundColor: Colors.brand.surface, - borderRadius: 20, - borderWidth: 1, - borderColor: Colors.brand.line, - padding: CANVAS_PADDING, - gap: 16, + paddingVertical: CANVAS_PADDING_VERTICAL, + gap: FOLDER_SCREEN.canvasGap, }, grid: { width: '100%', diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx index 81ffacc..fc0520c 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -1,11 +1,13 @@ import { useRef } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Colors, Typography } from '@/constants/theme'; +import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { Colors, ComponentTokens, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; -import { AppIcon } from './app-icon'; +import { IconSymbol } from './icon-symbol'; + +type FolderCardVariant = 'tabbed' | 'plain'; -const DEFAULT_CARD_WIDTH = 144; +const FOLDER_CARD = ComponentTokens.folderCard; export interface AnchorPosition { x: number; @@ -21,6 +23,9 @@ export interface FolderCardProps { onPress?: () => void; onMorePress?: (anchor: AnchorPosition) => void; disabled?: boolean; + icon?: boolean; + variant?: FolderCardVariant; + compactFolderName?: boolean; } export function FolderCard({ @@ -30,11 +35,24 @@ export function FolderCard({ onPress, onMorePress, disabled = false, + icon = true, + variant = 'tabbed', + compactFolderName, }: FolderCardProps) { const moreRef = useRef(null); - const cardWidth = width ?? DEFAULT_CARD_WIDTH; + const cardWidth = width ?? FOLDER_CARD.defaultWidth; const guardedOnPress = useGuardedPress(onPress, { disabled }); const guardedOnMorePress = useGuardedPress(onMorePress, { disabled }); + const isTabbed = variant === 'tabbed'; + const isCompact = cardWidth <= FOLDER_CARD.defaultWidth; + const shouldUseCompactFolderName = compactFolderName ?? isCompact; + const cardHeight = isCompact ? FOLDER_CARD.compact.height : FOLDER_CARD.height; + const bodyTop = isCompact ? FOLDER_CARD.compact.bodyTop : FOLDER_CARD.bodyTop; + const bodyMinHeight = isCompact ? FOLDER_CARD.compact.bodyMinHeight : FOLDER_CARD.bodyMinHeight; + const tabWidth = isCompact ? FOLDER_CARD.compact.tabWidth : FOLDER_CARD.tabWidth; + const horizontalPadding = isCompact ? FOLDER_CARD.compact.horizontalPadding : FOLDER_CARD.horizontalPadding; + const verticalPadding = isCompact ? FOLDER_CARD.compact.verticalPadding : FOLDER_CARD.verticalPadding; + const menuSize = isCompact ? FOLDER_CARD.compact.menuSize : FOLDER_CARD.menuSize; const handleMorePress = () => { moreRef.current?.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => { @@ -44,55 +62,80 @@ export function FolderCard({ return ( [styles.root, { width: cardWidth }, pressed && !disabled && styles.pressed]} + style={({ pressed }) => [ + styles.root, + { width: cardWidth }, + isTabbed + ? [styles.rootTabbed, { height: cardHeight, paddingTop: bodyTop }] + : [styles.rootPlain, { minHeight: bodyMinHeight }], + pressed && !disabled && styles.pressed, + ]} onPress={guardedOnPress} accessibilityRole="button" accessibilityLabel={`${folderName} 폴더, ${urlCount}개`} accessibilityState={{ disabled }} > - {disabled ? ( - - - - {urlCount}개 - - + ) : null} + + + {urlCount}개 + {icon ? ( + + + - + - - {folderName} - - + ) : null} - ) : ( - - - - {urlCount}개 - - - - - - {folderName} - - - - )} + + {folderName} + + ); } @@ -101,56 +144,72 @@ const styles = StyleSheet.create({ root: { flexShrink: 0, }, - shadow: { - borderRadius: 16, - shadowColor: Colors.brand.shadow, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 4, - }, - shadowActive: { - backgroundColor: Colors.brand.folderGradientEnd, + rootTabbed: {}, + rootPlain: {}, + tab: { + position: 'absolute', + top: FOLDER_CARD.origin, + left: FOLDER_CARD.tabLeft, + borderTopLeftRadius: FOLDER_CARD.tabRadius, + borderTopRightRadius: FOLDER_CARD.tabRadius, + borderWidth: FOLDER_CARD.borderWidth, + borderBottomWidth: FOLDER_CARD.hiddenBorderWidth, + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.softMint, }, - shadowDisabled: { + tabDisabled: { backgroundColor: Colors.brand.softMint, + borderColor: Colors.brand.line, }, - card: { - borderRadius: 16, - paddingHorizontal: 12, - paddingVertical: 12, - width: 144, - minHeight: 96, + body: { + flex: 1, justifyContent: 'space-between', + borderRadius: FOLDER_CARD.radius, + borderWidth: FOLDER_CARD.borderWidth, + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.folderCard.body, + shadowColor: Colors.brand.text, + shadowOffset: FOLDER_CARD.shadowOffset, + shadowOpacity: FOLDER_CARD.shadowOpacity, + shadowRadius: FOLDER_CARD.shadowRadius, + elevation: FOLDER_CARD.elevation, }, - pressed: { - opacity: 0.8, + bodyPlain: { + flex: 0, }, - cardDisabled: { - borderRadius: 16, - paddingHorizontal: 12, - paddingVertical: 12, - width: 144, - minHeight: 96, - justifyContent: 'space-between', - overflow: 'hidden', + bodyDisabled: { + borderColor: Colors.brand.line, + backgroundColor: Colors.brand.folderCard.body, + }, + pressed: { + transform: [{ scale: FOLDER_CARD.pressedScale }], }, header: { flexDirection: 'row', justifyContent: 'space-between', - alignItems: 'flex-start', + alignItems: 'center', + gap: FOLDER_CARD.headerGap, }, count: { - ...Typography.regular12, + ...Typography.body, color: Colors.brand.textSecondary, }, countDisabled: { color: Colors.brand.textHint, }, + moreButton: { + width: FOLDER_CARD.menuSize, + height: FOLDER_CARD.menuSize, + alignItems: 'center', + justifyContent: 'center', + }, folderName: { - ...Typography.section, + ...Typography.folderName, color: Colors.brand.text, }, + folderNameCompact: { + ...Typography.folderNameCompact, + }, folderNameDisabled: { color: Colors.brand.textHint, }, diff --git a/constants/theme.ts b/constants/theme.ts index 07c0dc9..e2e965d 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -25,10 +25,10 @@ const dangerBackgroundColor = '#F5C8C8'; const selectedOverlayColor = 'rgba(0,0,0,0.08)'; const modalBackdropColor = 'rgba(0,0,0,0.4)'; const inverseSubtleOverlayColor = 'rgba(255,255,255,0.08)'; +const folderCardBodyColor = surfaceColor; const socialButtonBackgroundColor = '#FFFFFF'; const socialButtonBorderColor = lineColor; -const googleIconColor = '#4285F4'; const appleButtonBackgroundColor = '#111111'; const appleButtonTextColor = '#FFFFFF'; @@ -88,13 +88,13 @@ export const Colors = { accent: textWarningColor, }, }, - folderGradientStart: '#8FE2C6', - folderGradientEnd: '#499B80', + folderCard: { + body: folderCardBodyColor, + }, }, social: { buttonBackground: socialButtonBackgroundColor, buttonBorder: socialButtonBorderColor, - googleIcon: googleIconColor, appleButtonBackground: appleButtonBackgroundColor, appleButtonText: appleButtonTextColor, }, @@ -140,6 +140,8 @@ const fontSizeSectionTitle = 20; const fontSizeSection = 18; const fontSizeProfile = 17; const fontSizeBody = 16; +const fontSizeFolderName = 20; +const fontSizeFolderNameCompact = 18; const fontSizeSummary = 14; const fontSizeCaption = 13; const fontSizeUrl = 12; @@ -159,9 +161,57 @@ export const Typography = { section: { fontSize: fontSizeSection, fontWeight: fontWeightBold }, profile: { fontSize: fontSizeProfile, fontWeight: fontWeightBold }, body: { fontSize: fontSizeBody, fontWeight: fontWeightRegular }, + folderName: { fontSize: fontSizeFolderName, fontWeight: fontWeightBold }, + folderNameCompact: { fontSize: fontSizeFolderNameCompact, fontWeight: fontWeightBold }, summary: { fontSize: fontSizeSummary, fontWeight: fontWeightRegular }, caption: { fontSize: fontSizeCaption, fontWeight: fontWeightBold }, url: { fontSize: fontSizeUrl, fontWeight: fontWeightRegular, textDecorationLine: 'underline' as const }, bold12: { fontSize: fontSizeBold12, fontWeight: fontWeightBold }, regular12: { fontSize: fontSizeRegular12, fontWeight: fontWeightRegular }, }; + +export const ComponentTokens = { + folderScreen: { + contentHorizontalPadding: 24, + canvasPaddingVertical: 16, + canvasGap: 16, + gridGap: 12, + minTwoColumnCardWidth: 120, + headerRowGap: 10, + compactWidth: 380, + subtitleRowOffsetTop: -2, + }, + folderCard: { + defaultWidth: 144, + height: 172, + bodyTop: 16, + bodyMinHeight: 156, + tabWidth: 72, + tabLeft: 10, + horizontalPadding: 18, + verticalPadding: 20, + menuSize: 22, + radius: 12, + tabRadius: 8, + touchHitSlop: 8, + pressedScale: 0.98, + borderWidth: 1, + hiddenBorderWidth: 0, + origin: 0, + headerGap: 8, + folderNameLines: 2, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 5, + elevation: 1, + compact: { + height: 116, + bodyTop: 14, + bodyMinHeight: 102, + tabWidth: 64, + horizontalPadding: 12, + verticalPadding: 14, + menuSize: 20, + }, + }, +} as const;