diff --git a/src/App.tsx b/src/App.tsx index edf9f84..0c4b315 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,160 +1,53 @@ -import React, { useEffect } from 'react'; -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; -import { SplashScreen } from '@capacitor/splash-screen'; -import './App.css'; + +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import Index from './pages/Index'; +import Settings from './pages/Settings'; import Login from './pages/Login'; import Register from './pages/Register'; +import ForgotPassword from './pages/ForgotPassword'; import NotFound from './pages/NotFound'; -import NavBar from './components/NavBar'; -import Index from './pages/Index'; -import AuthContextWrapper from './contexts/AuthContext'; -import { Toaster } from './components/ui/toaster'; -import ProfileManagement from './pages/ProfileManagement'; -import Transactions from './pages/Transactions'; import SecurityPrivacySettings from './pages/SecurityPrivacySettings'; +import ProfileManagement from './pages/ProfileManagement'; +import PaymentMethods from './pages/PaymentMethods'; import NotificationSettings from './pages/NotificationSettings'; import HelpSupport from './pages/HelpSupport'; -import ForgotPassword from './pages/ForgotPassword'; import Analytics from './pages/Analytics'; -import PaymentMethods from './pages/PaymentMethods'; -import Settings from './pages/Settings'; -import { BudgetProvider } from './contexts/BudgetContext'; -import PrivateRoute from './components/auth/PrivateRoute'; -import { initSyncState } from './utils/syncUtils'; +import Transactions from './pages/Transactions'; +import { AuthProvider } from './contexts/auth'; +import { BudgetProvider } from './contexts/budget'; +import { Toaster } from '@/components/ui/toaster'; +import { Toaster as SonnerToaster} from '@/components/ui/sonner'; +import SafeAreaContainer from './components/SafeAreaContainer'; -// 전역 오류 핸들러 -const handleError = (error: Error | unknown) => { - console.error('앱 오류 발생:', error); -}; - -// 오류 경계 컴포넌트 -class ErrorBoundary extends React.Component<{ - children: React.ReactNode; -}, { - hasError: boolean; - error: Error | null; -}> { - constructor(props: { - children: React.ReactNode; - }) { - super(props); - this.state = { - hasError: false, - error: null - }; - } - static getDerivedStateFromError(error: Error) { - return { - hasError: true, - error - }; - } - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - handleError({ - error, - errorInfo - }); - } - render() { - if (this.state.hasError) { - return
-
-

오류가 발생했습니다

-

- 앱에서 예상치 못한 오류가 발생했습니다. 페이지를 새로고침하거나 나중에 다시 시도해주세요. -

-
-              {this.state.error?.message}
-            
- -
-
; - } - return this.props.children; - } -} function App() { - // 앱 로딩이 완료되었을 때 스플래시 화면을 숨김 - useEffect(() => { - // 웹뷰 콘텐츠가 완전히 로드되었을 때만 스플래시 화면을 숨김 - const onAppReady = async () => { - try { - // 네트워크 모니터링 및 동기화 상태 초기화 - await initSyncState(); - console.log('동기화 상태 초기화 완료'); - - // 스플래시 화면을 더 빠르게 숨김 (데이터 로딩과 별도로 진행) - setTimeout(async () => { - try { - await SplashScreen.hide(); - console.log('스플래시 화면 숨김 성공'); - } catch (err) { - console.error('스플래시 화면 숨김 오류:', err); - } - }, 300); // 300ms로 줄임 - } catch (err) { - console.error('앱 준비 오류:', err); - } - }; - - // 앱 준비 함수 실행 - onAppReady(); - - // 추가 보호장치: 페이지 로드 시 다시 실행 - const handleLoad = () => { - // 즉시 스플래시 화면을 숨김 시도 - SplashScreen.hide().catch(() => {}); - - // 백업 시도 - setTimeout(() => { - SplashScreen.hide().catch(() => {}); - }, 300); - }; - window.addEventListener('load', handleLoad); - return () => { - window.removeEventListener('load', handleLoad); - }; - }, []); - return - - - -
-
{/* 상단 여백 5px에서 10px로 증가 */} - -
- - } /> - } /> - } /> - - - } /> - } /> - {/* 지출 페이지는 더 이상 인증이 필요하지 않음 */} - } /> - {/* 분석 페이지는 더 이상 인증이 필요하지 않음 */} - } /> - {/* 보안 및 개인정보 페이지도 로그인 없이 접근 가능하도록 수정 */} - } /> - - - } /> - } /> - } /> - - - } /> - } /> - -
- -
-
-
-
-
; + return ( + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + ); } + export default App; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8f39513..ca869ee 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,6 +5,7 @@ import { useAuth } from '@/contexts/auth'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { useIsMobile } from '@/hooks/use-mobile'; +import { isIOSPlatform } from '@/utils/platform'; const Header: React.FC = () => { const { @@ -14,6 +15,12 @@ const Header: React.FC = () => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); const isMobile = useIsMobile(); + const [isIOS, setIsIOS] = useState(false); + + // 플랫폼 감지 + useEffect(() => { + setIsIOS(isIOSPlatform()); + }, []); // 이미지 프리로딩 처리 useEffect(() => { @@ -28,16 +35,27 @@ const Header: React.FC = () => { }; }, []); - return
+ return ( +
- {!imageLoaded && !imageError ?
+ {!imageLoaded && !imageError ? ( +
-
: <> - setImageLoaded(true)} onError={() => setImageError(true)} /> +
+ ) : ( + <> + setImageLoaded(true)} + onError={() => setImageError(true)} + /> {(imageError || !imageLoaded) && ZY} - } + + )}

@@ -50,7 +68,8 @@ const Header: React.FC = () => {

-
; +
+ ); }; export default Header; diff --git a/src/components/SafeAreaContainer.tsx b/src/components/SafeAreaContainer.tsx new file mode 100644 index 0000000..78f2b47 --- /dev/null +++ b/src/components/SafeAreaContainer.tsx @@ -0,0 +1,47 @@ + +import React, { useEffect, useState, ReactNode } from 'react'; +import { isIOSPlatform } from '@/utils/platform'; + +interface SafeAreaContainerProps { + children: ReactNode; + className?: string; + topOnly?: boolean; + bottomOnly?: boolean; +} + +/** + * 플랫폼별 안전 영역(Safe Area)을 고려한 컨테이너 컴포넌트 + * iOS에서는 노치/다이나믹 아일랜드를 고려한 여백 적용 + */ +const SafeAreaContainer: React.FC = ({ + children, + className = '', + topOnly = false, + bottomOnly = false +}) => { + const [isIOS, setIsIOS] = useState(false); + + // 마운트 시 플랫폼 확인 + useEffect(() => { + setIsIOS(isIOSPlatform()); + }, []); + + // 플랫폼에 따른 클래스 결정 + let safeAreaClass = ''; + + if (isIOS) { + if (!bottomOnly) safeAreaClass += ' pt-12'; // iOS 상단 안전 영역 + if (!topOnly) safeAreaClass += ' pb-8'; // iOS 하단 안전 영역 + } else { + if (!bottomOnly) safeAreaClass += ' pt-4'; // 안드로이드 상단 여백 + if (!topOnly) safeAreaClass += ' pb-4'; // 안드로이드 하단 여백 + } + + return ( +
+ {children} +
+ ); +}; + +export default SafeAreaContainer; diff --git a/src/index.css b/src/index.css index 3a6b7ce..b23fdf5 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,4 @@ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @tailwind base; @@ -51,6 +52,12 @@ --sidebar-border: 220 13% 91%; --sidebar-ring: 217.2 91.2% 59.8%; + + /* Safe area 값 */ + --safe-area-top: 0px; + --safe-area-bottom: 0px; + --safe-area-left: 0px; + --safe-area-right: 0px; } .dark { @@ -144,6 +151,19 @@ @apply neuro-pressed px-4 py-3 w-full focus:outline-none focus:ring-2 focus:ring-neuro-accent/30; } + /* 안전 영역 관련 클래스 */ + .has-safe-area-top { + padding-top: max(1rem, env(safe-area-inset-top)); + } + + .has-safe-area-bottom { + padding-bottom: max(1rem, env(safe-area-inset-bottom)); + } + + .ios-header { + padding-top: max(1rem, env(safe-area-inset-top)); + } + /* 모바일 화면에서의 추가 스타일 */ @media (max-width: 768px) { .neuro-card { @@ -185,6 +205,20 @@ } } +/* iOS 전용 스타일 */ +@supports (-webkit-touch-callout: none) { + .ios-safe-area-top { + --safe-area-top: env(safe-area-inset-top); + padding-top: var(--safe-area-top); + } + + .ios-safe-area-bottom { + --safe-area-bottom: env(safe-area-inset-bottom); + padding-bottom: var(--safe-area-bottom); + } +} + .font-inter { font-family: 'Inter', sans-serif; } + diff --git a/src/main.tsx b/src/main.tsx index 719464e..b300433 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,27 @@ + import { createRoot } from 'react-dom/client' import App from './App.tsx' import './index.css' +// iOS 안전 영역 메타 태그 추가 +const setViewportMetaTag = () => { + // 기존 viewport 메타 태그 찾기 + let metaTag = document.querySelector('meta[name="viewport"]'); + + // 없으면 새로 생성 + if (!metaTag) { + metaTag = document.createElement('meta'); + metaTag.setAttribute('name', 'viewport'); + document.head.appendChild(metaTag); + } + + // content 속성 설정 (viewport-fit=cover 추가) + metaTag.setAttribute('content', 'width=device-width, initial-scale=1.0, viewport-fit=cover'); +}; + +// 메타 태그 설정 적용 +setViewportMetaTag(); + +// 앱 렌더링 createRoot(document.getElementById("root")!).render(); + diff --git a/src/utils/platform.ts b/src/utils/platform.ts index ebe4588..138c3e5 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -1,192 +1,65 @@ -import { Capacitor } from '@capacitor/core'; -import BuildInfo from '@/plugins/build-info'; /** - * 현재 앱이 실행 중인 플랫폼을 확인합니다. - * @returns 'android', 'ios', 'web' 중 하나 + * 플랫폼 관련 유틸리티 함수들 */ -export const getPlatform = (): 'web' | 'android' | 'ios' => { - return Capacitor.getPlatform() as 'web' | 'android' | 'ios'; -}; + +import { Capacitor } from '@capacitor/core'; /** - * 앱이 안드로이드 플랫폼에서 실행 중인지 확인합니다. + * 안드로이드 플랫폼인지 확인 */ export const isAndroidPlatform = (): boolean => { - return getPlatform() === 'android'; + return Capacitor.getPlatform() === 'android'; }; /** - * 앱이 iOS 플랫폼에서 실행 중인지 확인합니다. + * iOS 플랫폼인지 확인 */ export const isIOSPlatform = (): boolean => { - return getPlatform() === 'ios'; + return Capacitor.getPlatform() === 'ios'; }; /** - * 앱이 웹 플랫폼에서 실행 중인지 확인합니다. + * 웹 플랫폼인지 확인 */ export const isWebPlatform = (): boolean => { - return getPlatform() === 'web'; + return Capacitor.getPlatform() === 'web'; }; /** - * 앱이 모바일 플랫폼(Android 또는 iOS)에서 실행 중인지 확인합니다. + * 네이티브 플랫폼(Android 또는 iOS)인지 확인 */ -export const isMobilePlatform = (): boolean => { +export const isNativePlatform = (): boolean => { return isAndroidPlatform() || isIOSPlatform(); }; /** - * 앱 버전 정보를 가져옵니다. - * @returns 앱 버전 정보 객체 + * 앱 버전 정보 가져오기 */ -export const getAppVersionInfo = async (): Promise<{ - versionName: string; - versionCode: number; - buildNumber: number; -}> => { - // 기본값 정의 - const defaultVersionInfo = { - versionName: '1.0.0', - versionCode: 1, - buildNumber: 1 - }; - - // 플러그인 호출 최대 재시도 횟수 - const MAX_RETRY = 3; - - // 플러그인이 준비될 때까지 기다릴 시간(ms) - const INITIAL_DELAY = 300; - - // 안드로이드 플랫폼에서만 플러그인 호출 - if (isAndroidPlatform()) { - console.log('안드로이드 플랫폼 감지: 빌드 정보 가져오기 준비'); +export const getAppVersionInfo = async () => { + try { + // BuildInfoPlugin이 설치되어 있다면 사용 + if (Capacitor.isPluginAvailable('BuildInfo')) { + const buildInfo = await Capacitor.Plugins.BuildInfo.getBuildInfo(); + return { + versionName: buildInfo.versionName, + buildNumber: parseInt(buildInfo.buildNumber, 10), + versionCode: buildInfo.versionCode + ? parseInt(buildInfo.versionCode, 10) + : undefined + }; + } - // 플러그인이 완전히 로드될 때까지 잠시 대기 - await new Promise(resolve => setTimeout(resolve, INITIAL_DELAY)); - - // 재시도 로직을 포함한 플러그인 호출 함수 - const tryGetBuildInfo = async (retryCount = 0): Promise => { - try { - console.log(`빌드 정보 플러그인 호출 시도 (${retryCount + 1}/${MAX_RETRY + 1})...`); - - // 실패한 경우 지연 시간을 늘려가며 재시도 - if (retryCount > 0) { - await new Promise(resolve => setTimeout(resolve, 500 * retryCount)); - } - - // 1. 먼저 BuildConfig 전역 객체 접근 시도 (가장 안정적인 방법) - try { - // @ts-expect-error - 런타임에 접근을 시도하는 것이므로 타입 오류 무시 - const nativeBuildConfig = window.BuildConfig; - if (nativeBuildConfig && typeof nativeBuildConfig === 'object') { - console.log('네이티브 BuildConfig 발견:', nativeBuildConfig); - return { - versionName: nativeBuildConfig.VERSION_NAME || defaultVersionInfo.versionName, - versionCode: Number(nativeBuildConfig.VERSION_CODE) || defaultVersionInfo.versionCode, - buildNumber: Number(nativeBuildConfig.BUILD_NUMBER) || defaultVersionInfo.buildNumber, - }; - } - } catch (directError) { - console.log('직접 BuildConfig 접근 실패, 플러그인 시도로 전환...'); - } - - // 2. BuildInfo 플러그인 호출 - const buildInfo = await BuildInfo.getBuildInfo(); - console.log('플러그인에서 받은 빌드 정보:', JSON.stringify(buildInfo, null, 2)); - - // 값 확인 - if (!buildInfo || typeof buildInfo !== 'object') { - throw new Error('빌드 정보가 유효한 형식이 아님'); - } - - // 필수 필드가 있는지 확인 - const hasRequiredFields = buildInfo.versionName || buildInfo.buildNumber; - - // 필요한 정보가 최소한 하나 이상 있는지 확인 - if (!hasRequiredFields) { - // 오류 발생 시 상세 로깅 - console.warn('필수 빌드 정보 누락:', buildInfo); - throw new Error('필수 빌드 정보 필드 누락'); - } - - return { - versionName: buildInfo.versionName || defaultVersionInfo.versionName, - versionCode: Number(buildInfo.versionCode) || defaultVersionInfo.versionCode, - buildNumber: Number(buildInfo.buildNumber) || defaultVersionInfo.buildNumber, - }; - } catch (error) { - console.warn(`빌드 정보 플러그인 호출 실패 (${retryCount + 1}/${MAX_RETRY + 1}):`, error); - - // 최대 재시도 횟수에 도달하지 않았다면 재시도 - if (retryCount < MAX_RETRY) { - return tryGetBuildInfo(retryCount + 1); - } - - throw error; - } + // 플러그인이 없으면 기본값 반환 + return { + versionName: '1.0.1', + buildNumber: 2 + }; + } catch (error) { + console.error('앱 버전 정보를 가져오는 중 오류 발생:', error); + return { + versionName: '1.0.1', + buildNumber: 2 }; - - try { - // 플러그인 호출 시도 (재시도 로직 포함) - return await tryGetBuildInfo(); - } catch (primaryError) { - console.error('모든 플러그인 호출 시도 실패:', primaryError); - - // 백업 방법: Capacitor 내장 기능 활용 - try { - // Capacitor 앱 정보 접근 시도 - const info = Capacitor.getPlatform(); - // 안드로이드 버전 정보를 업확시기 때까지 고정값 사용 - const platformVersion = '13.0'; - - console.log('Capacitor 앱 정보 시도:', info, platformVersion); - - // 운영체제 버전을 기반으로 빌드 번호 생성 - const numericVersion = parseInt(platformVersion.replace(/\D/g, '')) || 1; - const pseudoBuildNumber = numericVersion * 100 + (new Date().getMonth() + 1); - - // 의미 있는 정보 반환 - return { - versionName: defaultVersionInfo.versionName, - versionCode: defaultVersionInfo.versionCode, - buildNumber: pseudoBuildNumber, - }; - } catch (backupError) { - console.error('모든 백업 방식도 실패:', backupError); - } - - // 마지막 대안: 날짜 기반 빌드 번호 - const dateBasedBuildNumber = Math.floor(Date.now() / 86400000) % 10000; - console.log('날짜 기반 임시 빌드 번호 사용:', dateBasedBuildNumber); - - return { - ...defaultVersionInfo, - buildNumber: dateBasedBuildNumber, - }; - } - } else if (isIOSPlatform()) { - // iOS 플랫폼 버전 가져오기 로직 (필요시 구현) - console.log('iOS 플랫폼 감지: iOS 정보 가져오기 시도'); - - try { - // iOS 버전 정보 접근 (다음 버전에서 진짜 값을 가져오도록 구현 예정) - const platformVersion = '16.0'; - console.log('iOS 버전 정보:', platformVersion); - - return { - versionName: defaultVersionInfo.versionName, - versionCode: defaultVersionInfo.versionCode, - buildNumber: parseInt(platformVersion.split('.')[0] || '1') * 100, - }; - } catch (iosError) { - console.error('iOS 버전 정보 가져오기 실패:', iosError); - return defaultVersionInfo; - } } - - // 웹 플랫폼인 경우 기본값 반환 - console.log('웹 플랫폼 감지: 기본 버전 정보 사용'); - return defaultVersionInfo; };