From 6e78038e327ae13e94c97c34f40fa4f3728d0139 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Wed, 24 Jun 2026 21:39:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20=EC=95=A0=ED=94=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(auth)/_layout.tsx | 4 ++- app/(auth)/login.tsx | 32 +++++++++++++----- app/(tabs)/(folder)/folder-add-url.tsx | 35 ++++++++------------ app/(tabs)/(folder)/folder-name.tsx | 34 ++++++++----------- app/(tabs)/(folder)/folder-url-select.tsx | 35 ++++++++------------ app/(tabs)/(folder)/index.tsx | 5 ++- app/(tabs)/(home)/add-link.tsx | 34 ++++++++----------- app/(tabs)/_layout.tsx | 5 +-- app/_layout.tsx | 6 ++-- app/index.tsx | 4 ++- components/ui/app-loading-screen.tsx | 20 ++++++++++++ components/ui/card-link.tsx | 18 ++++++++-- components/ui/filter-chip.tsx | 4 +-- components/ui/folder-card.tsx | 23 ++++++++++--- components/ui/folder-context-menu.tsx | 40 +++++++++++++++++------ components/ui/screen-header.tsx | 34 +++++++++++++++++++ constants/theme.ts | 10 +++--- 17 files changed, 219 insertions(+), 124 deletions(-) create mode 100644 components/ui/app-loading-screen.tsx create mode 100644 components/ui/screen-header.tsx diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx index f14114c..1c29790 100644 --- a/app/(auth)/_layout.tsx +++ b/app/(auth)/_layout.tsx @@ -2,11 +2,13 @@ import { useAuth } from '@clerk/expo'; import { Redirect } from 'expo-router'; import { Stack } from 'expo-router/stack'; +import { AppLoadingScreen } from '@/components/ui/app-loading-screen'; + export default function AuthLayout() { const { isLoaded, isSignedIn } = useAuth(); if (!isLoaded) { - return null; + return ; } if (isSignedIn) { diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index 0e3a133..50cea0d 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -5,11 +5,11 @@ import { router, useLocalSearchParams } from 'expo-router'; import { useShareIntentContext } from 'expo-share-intent'; import * as WebBrowser from 'expo-web-browser'; import { useEffect, useRef, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ApiError } from '@/api/api-client'; -import { SocialLoginButton } from '@/components/ui/social-login-button'; +import { SocialLoginButton, type SocialLoginProvider } from '@/components/ui/social-login-button'; import { Colors, Typography } from '@/constants/theme'; import { syncAuthenticatedMember } from '@/services/auth-api'; import { showAlert } from '@/utils/guarded-alert'; @@ -21,6 +21,10 @@ const CLERK_REDIRECT_URL = AuthSession.makeRedirectUri({ path: 'sso-callback', }); const MEMBER_SYNC_RETRY_DELAYS_MS = [200, 500, 1000]; +const SSO_STRATEGY_BY_PROVIDER = { + google: 'oauth_google', + apple: 'oauth_apple', +} as const; WebBrowser.maybeCompleteAuthSession(); @@ -50,7 +54,9 @@ export default function LoginScreen() { const { hasShareIntent, resetShareIntent, shareIntent } = useShareIntentContext(); const { notice } = useLocalSearchParams<{ notice?: LoginNotice }>(); const shownNoticeRef = useRef(null); - const [isSigningIn, setIsSigningIn] = useState(false); + const [signingInProvider, setSigningInProvider] = useState(null); + const isSigningIn = signingInProvider !== null; + const shouldShowAppleLogin = Platform.OS === 'ios'; useEffect(() => { if (!notice || shownNoticeRef.current === notice) { @@ -66,17 +72,17 @@ export default function LoginScreen() { showAlert(alert.title, alert.message); }, [notice]); - const handleGoogleLogin = async () => { + const handleSocialLogin = async (provider: SocialLoginProvider) => { if (isSigningIn) { return; } - setIsSigningIn(true); + setSigningInProvider(provider); let sessionActivated = false; try { const { createdSessionId, setActive } = await startSSOFlow({ - strategy: 'oauth_google', + strategy: SSO_STRATEGY_BY_PROVIDER[provider], redirectUrl: CLERK_REDIRECT_URL, }); @@ -104,7 +110,7 @@ export default function LoginScreen() { '계정 연결 중 서버와 통신하지 못했습니다. 잠시 후 다시 시도해주세요.' ); } finally { - setIsSigningIn(false); + setSigningInProvider(null); } }; @@ -157,10 +163,18 @@ export default function LoginScreen() { handleSocialLogin('google')} disabled={isSigningIn} /> + {shouldShowAppleLogin && ( + handleSocialLogin('apple')} + disabled={isSigningIn} + /> + )} 계속 진행하면 서비스 이용약관 및 개인정보 처리방침에 동의하는 것으로 간주합니다. diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index a81062e..bfc5041 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -5,9 +5,11 @@ import { Text, View, } from 'react-native'; -import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { Button } from '@/components/ui/button'; +import { ScreenHeader } from '@/components/ui/screen-header'; import { SelectableLinkCard } from '@/components/ui/selectable-link-card'; import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; @@ -68,25 +70,17 @@ export default function FolderAddUrlScreen() { const selectedCount = selectedIds.size; const name = folderName ?? '폴더'; + const handleBack = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace({ pathname: '/(tabs)/(folder)/[id]', params: { id: folderId } }); + } + }; + return ( - <> - - - + + {/* ── 폴더명 표시 ── */} 추가할 폴더 @@ -138,8 +132,7 @@ export default function FolderAddUrlScreen() { /> - - + ); } diff --git a/app/(tabs)/(folder)/folder-name.tsx b/app/(tabs)/(folder)/folder-name.tsx index 5819a5c..fd9e692 100644 --- a/app/(tabs)/(folder)/folder-name.tsx +++ b/app/(tabs)/(folder)/folder-name.tsx @@ -9,9 +9,11 @@ import { TouchableOpacity, View, } from 'react-native'; -import { router, Stack } from 'expo-router'; +import { router } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { Button } from '@/components/ui/button'; +import { ScreenHeader } from '@/components/ui/screen-header'; import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; import { useGuardedPress } from '@/utils/press-guard'; @@ -58,24 +60,17 @@ export default function FolderNameScreen() { }; const guardedClearFolderName = useGuardedPress(() => setFolderName(''), { lockMs: 250 }); + const handleBack = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/(tabs)/(folder)'); + } + }; + return ( - <> - - + + - - + ); } diff --git a/app/(tabs)/(folder)/folder-url-select.tsx b/app/(tabs)/(folder)/folder-url-select.tsx index 9423262..942c27e 100644 --- a/app/(tabs)/(folder)/folder-url-select.tsx +++ b/app/(tabs)/(folder)/folder-url-select.tsx @@ -5,9 +5,11 @@ import { Text, View, } from 'react-native'; -import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { Button } from '@/components/ui/button'; +import { ScreenHeader } from '@/components/ui/screen-header'; import { SelectableLinkCard } from '@/components/ui/selectable-link-card'; import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; @@ -71,25 +73,17 @@ export default function FolderUrlSelectScreen() { const selectedCount = selectedIds.size; + const handleBack = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/(tabs)/(folder)'); + } + }; + return ( - <> - - - + + {/* ── 폴더명 표시 ── */} 현재 폴더명 @@ -141,8 +135,7 @@ export default function FolderUrlSelectScreen() { /> - - + ); } diff --git a/app/(tabs)/(folder)/index.tsx b/app/(tabs)/(folder)/index.tsx index 717f807..073610a 100644 --- a/app/(tabs)/(folder)/index.tsx +++ b/app/(tabs)/(folder)/index.tsx @@ -312,7 +312,6 @@ export default function FolderScreen() { 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)} /> @@ -436,11 +435,11 @@ const styles = StyleSheet.create({ content: { paddingHorizontal: CONTENT_HORIZONTAL_PADDING, paddingBottom: 32, - gap: 20, + gap: 12, }, header: { - paddingTop: 16, + paddingTop: 6, gap: 4, }, title: { diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 600c6ce..f96e1bd 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -12,11 +12,13 @@ import { useWindowDimensions, View, } from 'react-native'; -import { Stack, router, useLocalSearchParams } from 'expo-router'; +import { router, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { ApiError } from '@/api/api-client'; import { checkSavedLinkUrl } from '@/api/saved-links'; import { ScanButton } from '@/components/ui/scan-button'; +import { ScreenHeader } from '@/components/ui/screen-header'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; import { normalizeHttpUrlInput } from '@/utils/shared-url'; @@ -160,24 +162,17 @@ export default function AddLinkScreen() { setError(''); }, { lockMs: 250 }); + const handleBack = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/(tabs)/(home)'); + } + }; + return ( - <> - - + + - - + ); } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index d270c39..de94cf0 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -4,6 +4,7 @@ import { Redirect, router, Tabs } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; import { ShareIntentRouter } from '@/components/share-intent-router'; +import { AppLoadingScreen } from '@/components/ui/app-loading-screen'; import { BottomTabBar, type TabVariant } from '@/components/ui/bottom-tab-bar'; import { FoldersProvider } from '@/context/folders-context'; import { SavedLinksProvider } from '@/context/saved-links-context'; @@ -136,7 +137,7 @@ export default function TabLayout() { }, [isLoaded, isSignedIn, sessionId]); if (!isLoaded) { - return null; + return ; } if (!isSignedIn) { @@ -144,7 +145,7 @@ export default function TabLayout() { } if (!hasSyncedMember) { - return null; + return ; } return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index e88eeaf..c9340de 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -9,6 +9,7 @@ import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { PressBlocker } from '@/components/ui/press-blocker'; +import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; @@ -31,14 +32,14 @@ export default function RootLayout() { - + - + @@ -50,5 +51,6 @@ export default function RootLayout() { const styles = StyleSheet.create({ root: { flex: 1, + backgroundColor: Colors.brand.background, }, }); diff --git a/app/index.tsx b/app/index.tsx index 838c6f1..373441e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,11 +1,13 @@ import { useAuth } from '@clerk/expo'; import { Redirect } from 'expo-router'; +import { AppLoadingScreen } from '@/components/ui/app-loading-screen'; + export default function IndexScreen() { const { isLoaded, isSignedIn } = useAuth(); if (!isLoaded) { - return null; + return ; } return ; diff --git a/components/ui/app-loading-screen.tsx b/components/ui/app-loading-screen.tsx new file mode 100644 index 0000000..9f3b77e --- /dev/null +++ b/components/ui/app-loading-screen.tsx @@ -0,0 +1,20 @@ +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { Colors } from '@/constants/theme'; + +export function AppLoadingScreen() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.brand.background, + }, +}); \ No newline at end of file diff --git a/components/ui/card-link.tsx b/components/ui/card-link.tsx index 846075c..f869198 100644 --- a/components/ui/card-link.tsx +++ b/components/ui/card-link.tsx @@ -70,8 +70,22 @@ export function CardLink({ const handleMorePress = (event: GestureResponderEvent) => { event.stopPropagation(); - moreRef.current?.measure((_fx, _fy, width, height, px, py) => { - guardedMore?.({ x: px, y: py, width, height }); + + const { pageX, pageY } = event.nativeEvent; + const fallbackAnchor = { x: pageX, y: pageY, width: 1, height: 1 }; + + if (!moreRef.current) { + guardedMore?.(fallbackAnchor); + return; + } + + moreRef.current.measure((_fx, _fy, width, height, px, py) => { + const measuredAnchor = { x: px, y: py, width, height }; + const anchor = Number.isFinite(px) && Number.isFinite(py) && width > 0 && height > 0 + ? measuredAnchor + : fallbackAnchor; + + guardedMore?.(anchor); }); }; diff --git a/components/ui/filter-chip.tsx b/components/ui/filter-chip.tsx index ac45e29..e948615 100644 --- a/components/ui/filter-chip.tsx +++ b/components/ui/filter-chip.tsx @@ -1,6 +1,5 @@ import { useRef, useState } from 'react'; import { Modal, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; @@ -158,7 +157,6 @@ export function FilterChip({ const [isOpen, setIsOpen] = useState(false); const [chipBounds, setChipBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }); const chipRef = useRef(null); - const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); const currentItem = items.find((i) => i.value === selectedValue); @@ -172,7 +170,7 @@ export function FilterChip({ DROPDOWN_SCREEN_PADDING, Math.min(chipBounds.x, width - dropdownWidth - DROPDOWN_SCREEN_PADDING), ); - const dropdownTop = chipBounds.y + chipBounds.height + insets.top + 4; + const dropdownTop = chipBounds.y + chipBounds.height + 4; const dropdownMaxHeight = Math.max( DROPDOWN_MIN_HEIGHT, Math.min(height * DROPDOWN_MAX_HEIGHT_RATIO, height - dropdownTop - DROPDOWN_SCREEN_PADDING), diff --git a/components/ui/folder-card.tsx b/components/ui/folder-card.tsx index fc0520c..7961b0c 100644 --- a/components/ui/folder-card.tsx +++ b/components/ui/folder-card.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react'; -import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Pressable, StyleSheet, Text, TouchableOpacity, View, type GestureResponderEvent } from 'react-native'; import { Colors, ComponentTokens, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; @@ -54,9 +54,24 @@ export function FolderCard({ 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) => { - guardedOnMorePress?.({ x: px, y: py, width: measuredWidth, height: measuredHeight }); + const handleMorePress = (event: GestureResponderEvent) => { + event.stopPropagation(); + + const { pageX, pageY } = event.nativeEvent; + const fallbackAnchor = { x: pageX, y: pageY, width: 1, height: 1 }; + + if (!moreRef.current) { + guardedOnMorePress?.(fallbackAnchor); + return; + } + + moreRef.current.measure((_fx, _fy, measuredWidth, measuredHeight, px, py) => { + const measuredAnchor = { x: px, y: py, width: measuredWidth, height: measuredHeight }; + const anchor = Number.isFinite(px) && Number.isFinite(py) && measuredWidth > 0 && measuredHeight > 0 + ? measuredAnchor + : fallbackAnchor; + + guardedOnMorePress?.(anchor); }); }; diff --git a/components/ui/folder-context-menu.tsx b/components/ui/folder-context-menu.tsx index 017cfab..1ffa367 100644 --- a/components/ui/folder-context-menu.tsx +++ b/components/ui/folder-context-menu.tsx @@ -1,4 +1,5 @@ -import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useRef } from 'react'; +import { Modal, Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; import type { AnchorPosition } from './folder-card'; @@ -6,6 +7,17 @@ import type { AnchorPosition } from './folder-card'; const MENU_WIDTH = 140; const MENU_ITEM_HEIGHT = 48; const GAP = 4; +const SCREEN_PADDING = 8; + +function isValidAnchor(anchor?: AnchorPosition): anchor is AnchorPosition { + return Boolean( + anchor && + Number.isFinite(anchor.x) && + Number.isFinite(anchor.y) && + Number.isFinite(anchor.width) && + Number.isFinite(anchor.height), + ); +} export interface ContextMenuItem { label: string; @@ -31,7 +43,7 @@ export function FolderContextMenu({ onDelete, onDismiss, }: FolderContextMenuProps) { - const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const resolvedItems: ContextMenuItem[] = items ?? [ { label: '폴더명 수정', onPress: () => onEditName?.() }, @@ -44,16 +56,24 @@ export function FolderContextMenu({ }); const menuHeight = MENU_ITEM_HEIGHT * resolvedItems.length + (resolvedItems.length - 1); + const lastAnchorRef = useRef(undefined); + + if (isValidAnchor(anchor)) { + lastAnchorRef.current = anchor; + } + + const resolvedAnchor = isValidAnchor(anchor) ? anchor : lastAnchorRef.current; + const fallbackLeft = Math.max(SCREEN_PADDING, screenWidth - MENU_WIDTH - SCREEN_PADDING); - const menuTop = anchor - ? anchor.y + anchor.height + GAP + menuHeight > screenHeight - ? anchor.y - menuHeight - GAP - : anchor.y + anchor.height + GAP - : screenHeight / 2; + const menuTop = resolvedAnchor + ? resolvedAnchor.y + resolvedAnchor.height + GAP + menuHeight > screenHeight + ? resolvedAnchor.y - menuHeight - GAP + : resolvedAnchor.y + resolvedAnchor.height + GAP + : SCREEN_PADDING + GAP; - const menuLeft = anchor - ? Math.min(anchor.x, screenWidth - MENU_WIDTH - 8) - : (screenWidth - MENU_WIDTH) / 2; + const menuLeft = resolvedAnchor + ? Math.min(resolvedAnchor.x, screenWidth - MENU_WIDTH - SCREEN_PADDING) + : fallbackLeft; return ( void; +} + +export function ScreenHeader({ title, onBack }: ScreenHeaderProps) { + return ( + + + {title} + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 8, + }, + title: { + ...Typography.pageTitle, + color: Colors.brand.text, + flexShrink: 1, + }, +}); \ No newline at end of file diff --git a/constants/theme.ts b/constants/theme.ts index e2e965d..91effbb 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -172,11 +172,11 @@ export const Typography = { export const ComponentTokens = { folderScreen: { - contentHorizontalPadding: 24, - canvasPaddingVertical: 16, - canvasGap: 16, - gridGap: 12, - minTwoColumnCardWidth: 120, + contentHorizontalPadding: 20, + canvasPaddingVertical: 8, + canvasGap: 12, + gridGap: 10, + minTwoColumnCardWidth: 150, headerRowGap: 10, compactWidth: 380, subtitleRowOffsetTop: -2,