diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml index 97c1db0..f471d7b 100644 --- a/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml @@ -178,12 +178,13 @@ jobs: echo "✅ .env 파일 생성됨 (크기: $(wc -c < .env) bytes)" # Release Keystore 설정 + # build.gradle.kts에서 rootProject.file()을 사용하므로 android/ 기준으로 경로 설정 - name: Setup Release Keystore run: | - mkdir -p android/app/keystore - echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore/key.jks + mkdir -p android/keystore + echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > android/keystore/key.jks echo "✅ Release Keystore 생성 완료" - ls -la android/app/keystore/ + ls -la android/keystore/ # key.properties 생성 (Release 서명 정보) - name: Create key.properties diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-SYNOLOGY-CICD.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-SYNOLOGY-CICD.yaml index d870453..9e8ac46 100644 --- a/.github/workflows/PROJECT-FLUTTER-ANDROID-SYNOLOGY-CICD.yaml +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-SYNOLOGY-CICD.yaml @@ -57,13 +57,14 @@ jobs: ls -la # Keystore와 key.properties 설정 + # build.gradle.kts에서 rootProject.file()을 사용하므로 android/ 기준으로 경로 설정 - name: Setup Keystore and key.properties run: | - mkdir -p android/app/keystore - echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > android/app/keystore/key.jks || echo "Base64 decoding failed" + mkdir -p android/keystore + echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > android/keystore/key.jks || echo "Base64 decoding failed" echo "Keystore created from DEBUG_KEYSTORE" - ls -la android/app/keystore - echo "storeFile=app/keystore/key.jks" > android/key.properties + ls -la android/keystore + echo "storeFile=keystore/key.jks" > android/key.properties echo "storePassword=android" >> android/key.properties echo "keyAlias=androiddebugkey" >> android/key.properties echo "keyPassword=android" >> android/key.properties diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml index 4efd31d..a5ceba7 100644 --- a/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +++ b/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml @@ -38,6 +38,11 @@ on: repository_dispatch: types: [build-android-app] +permissions: + contents: read + issues: write + pull-requests: write + # ============================================ # 🔧 프로젝트별 설정 (아래 값들을 수정하세요) # ============================================ @@ -335,42 +340,30 @@ jobs: - name: Pull latest changes run: git pull origin ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch_name || github.ref_name }} - # Debug Keystore 설정 (선택적) - - name: Setup Debug Keystore - run: | - mkdir -p ~/.android - if [ -n "${{ secrets.DEBUG_KEYSTORE }}" ]; then - echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > ~/.android/debug.keystore - echo "✅ Debug Keystore created from secrets" - else - echo "ℹ️ DEBUG_KEYSTORE secret not provided, using default" - fi - ls -la ~/.android/ || true - # .env 파일 생성 - name: Create .env file run: | - cat << 'EOF' > ${{ env.ENV_FILE_PATH }} - ${{ secrets.ENV_FILE || secrets.ENV }} - EOF + echo "${{ secrets.ENV_FILE || secrets.ENV }}" > ${{ env.ENV_FILE_PATH }} echo "✅ ${{ env.ENV_FILE_PATH }} file created" - # Keystore와 key.properties 설정 (선택적) - - name: Setup Keystore and key.properties + # Release Keystore 설정 (Play Store 워크플로우와 동일한 방식) + - name: Setup Release Keystore run: | - mkdir -p android/app/keystore - if [ -n "${{ secrets.DEBUG_KEYSTORE }}" ]; then - echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > android/app/keystore/key.jks - echo "✅ Keystore created from DEBUG_KEYSTORE" - echo "storeFile=app/keystore/key.jks" > android/key.properties - echo "storePassword=android" >> android/key.properties - echo "keyAlias=androiddebugkey" >> android/key.properties - echo "keyPassword=android" >> android/key.properties - echo "✅ key.properties created" - else - echo "ℹ️ DEBUG_KEYSTORE not provided, skipping keystore setup" - fi - ls -la android/ || true + mkdir -p android/keystore + echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > android/keystore/key.jks + echo "✅ Release Keystore 생성 완료" + ls -la android/keystore/ + + # key.properties 생성 (Release 서명 정보) + - name: Create key.properties + run: | + cat > android/key.properties << EOF + storeFile=keystore/key.jks + storePassword=${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + keyAlias=${{ secrets.RELEASE_KEY_ALIAS }} + keyPassword=${{ secrets.RELEASE_KEY_PASSWORD }} + EOF + echo "✅ key.properties 생성 완료" # google-services.json 생성 (선택적) - name: Create google-services.json @@ -412,8 +405,10 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle', '**/gradle-wrapper.properties') }} + ~/.gradle/buildOutputCleanup + key: ${{ runner.os }}-gradle-8.12-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }} restore-keys: | + ${{ runner.os }}-gradle-8.12- ${{ runner.os }}-gradle- # 프로젝트 의존성 설치 @@ -431,7 +426,7 @@ jobs: # Java 설정 - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: "temurin" java-version: ${{ env.JAVA_VERSION }} @@ -441,13 +436,6 @@ jobs: echo "✅ Java setup completed" java -version - # Android SDK 설정 - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Verify Android SDK setup - run: echo "✅ Android SDK setup completed" - # Ruby 설정 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml b/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml index 83f640f..5e269a7 100644 --- a/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +++ b/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml @@ -51,6 +51,11 @@ on: repository_dispatch: types: [build-ios-app] +permissions: + contents: read + issues: write + pull-requests: write + # ============================================ # 🔧 프로젝트별 설정 (아래 값들을 수정하세요) # ============================================ @@ -63,7 +68,7 @@ env: jobs: prepare-test-build: name: 테스트 빌드 준비 - runs-on: macos-15 + runs-on: macos-26 outputs: version: ${{ steps.test_version.outputs.version }} @@ -394,7 +399,7 @@ jobs: # ============================================ build-ios-test: name: iOS 테스트 빌드 - runs-on: macos-15 + runs-on: macos-26 needs: prepare-test-build outputs: @@ -469,31 +474,122 @@ jobs: p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - name: Install Provisioning Profiles (Main App + Share Extension) + - name: Install Provisioning Profiles + id: install_profile run: | - # 메인 앱 프로비저닝 프로파일 설치 - echo "${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile_main.mobileprovision mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - uuid_main=$(grep -A1 -a "UUID" profile_main.mobileprovision | grep string | sed -e "s///" -e "s/<\/string>//" -e "s/[[:space:]]//g") - cp profile_main.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$uuid_main.mobileprovision - echo "✅ 메인 앱 프로파일 설치 완료: ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}" - # Share Extension 프로비저닝 프로파일 설치 - echo "${{ secrets.APPLE_PROVISIONING_PROFILE_SHARE_BASE64 }}" | base64 --decode > profile_share.mobileprovision - uuid_share=$(grep -A1 -a "UUID" profile_share.mobileprovision | grep string | sed -e "s///" -e "s/<\/string>//" -e "s/[[:space:]]//g") - cp profile_share.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$uuid_share.mobileprovision - echo "✅ Share Extension 프로파일 설치 완료: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_NAME }}" + # 메인 앱 프로파일 설치 + echo "${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode > profile.mobileprovision + cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ + + # UUID 추출 + uuid=$(grep -A1 -a "UUID" profile.mobileprovision | grep string | sed -e "s///" -e "s/<\/string>//" -e "s/[[:space:]]//g") + cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$uuid.mobileprovision + + # 프로파일 이름 추출 (동적) + profile_name=$(security cms -D -i profile.mobileprovision | plutil -extract Name xml1 -o - - | sed -n 's/.*\(.*\)<\/string>.*/\1/p') + + echo "✅ Main App Provisioning Profile installed" + echo " UUID: $uuid" + echo " Name: $profile_name" + + # 출력 저장 + echo "profile_uuid=$uuid" >> $GITHUB_OUTPUT + echo "profile_name=$profile_name" >> $GITHUB_OUTPUT + + # Share Extension 프로파일 설치 (선택적) + if [ -n "${{ secrets.APPLE_PROVISIONING_PROFILE_SHARE_BASE64 }}" ]; then + echo "${{ secrets.APPLE_PROVISIONING_PROFILE_SHARE_BASE64 }}" | base64 --decode > share_extension_profile.mobileprovision + cp share_extension_profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - - name: Verify ExportOptions.plist + # Share Extension UUID 추출 + share_uuid=$(grep -A1 -a "UUID" share_extension_profile.mobileprovision | grep string | sed -e "s///" -e "s/<\/string>//" -e "s/[[:space:]]//g") + cp share_extension_profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$share_uuid.mobileprovision + + # Share Extension 프로파일 이름 추출 (동적) + share_profile_name=$(security cms -D -i share_extension_profile.mobileprovision | plutil -extract Name xml1 -o - - | sed -n 's/.*\(.*\)<\/string>.*/\1/p') + + echo "✅ Share Extension Provisioning Profile installed" + echo " UUID: $share_uuid" + echo " Name: $share_profile_name" + + # 출력 저장 + echo "share_profile_uuid=$share_uuid" >> $GITHUB_OUTPUT + echo "share_profile_name=$share_profile_name" >> $GITHUB_OUTPUT + fi + + - name: Create ExportOptions.plist run: | cd ios - if [ ! -f "ExportOptions.plist" ]; then - echo "❌ ios/ExportOptions.plist이 없습니다!" - echo "웹 마법사를 실행하여 설정 파일을 생성하세요:" - echo " 브라우저에서 .github/util/flutter/ios-testflight-setup-wizard/index.html 열기" - exit 1 + + # Share Extension 프로파일이 있는지 확인 + if [ -n "${{ steps.install_profile.outputs.share_profile_name }}" ]; then + # Share Extension 포함 + cat > ExportOptions.plist << EOF + + + + + method + app-store + teamID + ${{ secrets.APPLE_TEAM_ID }} + provisioningProfiles + + com.elipair.mapsy + ${{ steps.install_profile.outputs.profile_name }} + com.elipair.mapsy.share + ${{ steps.install_profile.outputs.share_profile_name }} + + signingStyle + manual + signingCertificate + Apple Distribution + stripSwiftSymbols + + uploadBitcode + + uploadSymbols + + + + EOF + echo "✅ ExportOptions.plist 생성 완료 (Share Extension 포함)" + echo " Main Profile: ${{ steps.install_profile.outputs.profile_name }}" + echo " Share Extension Profile: ${{ steps.install_profile.outputs.share_profile_name }}" + else + # 메인 앱만 + cat > ExportOptions.plist << EOF + + + + + method + app-store + teamID + ${{ secrets.APPLE_TEAM_ID }} + provisioningProfiles + + com.elipair.mapsy + ${{ steps.install_profile.outputs.profile_name }} + + signingStyle + manual + signingCertificate + Apple Distribution + stripSwiftSymbols + + uploadBitcode + + uploadSymbols + + + + EOF + echo "✅ ExportOptions.plist 생성 완료 (메인 앱만)" + echo " Main Profile: ${{ steps.install_profile.outputs.profile_name }}" fi - echo "✅ ExportOptions.plist 확인됨" - name: Flutter build (no codesign) run: | @@ -501,19 +597,63 @@ jobs: --build-name="0.0.0" \ --build-number="${{ needs.prepare-test-build.outputs.build_number }}" + - name: Configure Code Signing in Project + run: | + cd ios + + # xcodeproj gem 설치 + gem install xcodeproj + + # Ruby 스크립트로 프로젝트 설정 수정 + ruby < { + const body = c.body.toLowerCase(); + return body.includes('@suh-lab') && ( + body.includes('ios build') || + (body.includes('build') && body.includes('app')) + ); + }).length; + console.log(`📊 iOS 빌드 카운트: 'ios build' + 'build app' = ${buildCount}개`); } else if (buildType === 'apk') { - searchKeyword = 'APK 빌드 트리거 완료'; - } else if (buildType === 'ios') { - searchKeyword = 'iOS 빌드 트리거 완료'; + // Android 빌드: 'apk build' + 'build app' 모두 카운트 + buildCount = comments.data.filter(c => { + const body = c.body.toLowerCase(); + return body.includes('@suh-lab') && ( + body.includes('apk build') || + (body.includes('build') && body.includes('app')) + ); + }).length; + console.log(`📊 Android 빌드 카운트: 'apk build' + 'build app' = ${buildCount}개`); + } else if (buildType === 'app') { + // App 빌드 (iOS + Android 동시): 모든 빌드 명령어 통합 카운트 + buildCount = comments.data.filter(c => { + const body = c.body.toLowerCase(); + return body.includes('@suh-lab') && ( + body.includes('ios build') || + body.includes('apk build') || + (body.includes('build') && body.includes('app')) + ); + }).length; + console.log(`📊 App 빌드 카운트: 'build app' + 'ios build' + 'apk build' = ${buildCount}개`); } - const buildCount = comments.data.filter(c => - c.body.includes(searchKeyword) - ).length; - // 빌드 번호: 소스번호 + 2자리 카운트 (38800, 38801, 38802...) const buildNumber = `${sourceNumber}${String(buildCount).padStart(2, '0')}`; - console.log(`📊 카운트 키워드: "${searchKeyword}"`); console.log(`📊 ${sourceType} #${sourceNumber}: ${buildType} ${buildCount}번째 빌드`); console.log(`📦 생성된 빌드 번호: ${buildNumber}`); @@ -413,52 +438,3 @@ jobs: }); console.log('✅ iOS 빌드 워크플로우 트리거 완료'); - - - name: 트리거 완료 댓글 작성 - if: steps.source_info.outputs.found == 'true' && steps.check_branch.outputs.exists == 'true' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const sourceType = '${{ steps.source_info.outputs.sourceType }}'; - const sourceNumber = '${{ steps.source_info.outputs.sourceNumber }}'; - const branchName = '${{ steps.source_info.outputs.branchName }}'; - const buildNumber = '${{ steps.build_number_calc.outputs.buildNumber }}'; - const buildCount = '${{ steps.build_number_calc.outputs.buildCount }}'; - const buildType = '${{ steps.build_type.outputs.buildType }}'; - - // 빌드 타입에 따라 완료 메시지 결정 (플랫폼별 분리) - let triggerTypeText = ''; - let platformText = ''; - - if (buildType === 'app') { - triggerTypeText = '앱 빌드 트리거 완료'; - platformText = 'Android와 iOS'; - } else if (buildType === 'apk') { - triggerTypeText = 'APK 빌드 트리거 완료'; - platformText = 'Android'; - } else if (buildType === 'ios') { - triggerTypeText = 'iOS 빌드 트리거 완료'; - platformText = 'iOS'; - } - - const body = [ - `🚀 **${triggerTypeText}**`, - '', - `${platformText} 빌드 워크플로우를 시작했습니다.`, - '', - `- 앱 버전: \`0.0.0(${buildNumber})\``, - `- PR/ISSUE: **#${sourceNumber}** (${buildType} ${buildCount}번째 빌드)`, - `- 브랜치: \`${branchName}\``, - '', - '⏳ 빌드가 완료되면 자동으로 결과 댓글이 작성됩니다.' - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parseInt(sourceNumber), - body: body - }); - - console.log(`✅ ${triggerTypeText} 댓글 작성 완료`); diff --git a/.gitignore b/.gitignore index 1b1ffcf..16c7970 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,22 @@ android/app/keystore/ # Play Store CI/CD - 민감한 파일 (자동 생성됨) .env.*.local + +# iOS CI/CD - 민감한 파일 (자동 생성됨) +ios/*.mobileprovision +ios/fastlane/*.p8 +ios/fastlane/*.p12 + +# Android CI/CD - 민감한 파일 (자동 생성됨) +android/fastlane/service-account.json + +# macOS CocoaPods - 자동 생성 파일 +macos/Pods/ +macos/Podfile.lock +macos/.symlinks/ +macos/Flutter/ephemeral/ + +# iOS CocoaPods - 자동 생성 파일 +ios/Pods/ +ios/.symlinks/ +ios/Flutter/ephemeral/ diff --git a/README.md b/README.md index 1b6e778..1d8475f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,6 @@ samples, guidance on mobile development, and a full API reference. --- -## 최신 버전 : v1.0.16 (2026-01-20) +## 최신 버전 : v1.0.19 (2026-01-28) [전체 버전 기록 보기](CHANGELOG.md) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9ad3087..82ab00d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -11,79 +11,79 @@ PODS: - PromisesObjC (~> 2.4) - device_info_plus (0.0.1): - Flutter - - Firebase/Auth (12.6.0): + - Firebase/Auth (12.8.0): - Firebase/CoreOnly - - FirebaseAuth (~> 12.6.0) - - Firebase/CoreOnly (12.6.0): - - FirebaseCore (~> 12.6.0) - - Firebase/Crashlytics (12.6.0): + - FirebaseAuth (~> 12.8.0) + - Firebase/CoreOnly (12.8.0): + - FirebaseCore (~> 12.8.0) + - Firebase/Crashlytics (12.8.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.6.0) - - Firebase/Messaging (12.6.0): + - FirebaseCrashlytics (~> 12.8.0) + - Firebase/Messaging (12.8.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.6.0) - - firebase_auth (6.1.3): - - Firebase/Auth (= 12.6.0) + - FirebaseMessaging (~> 12.8.0) + - firebase_auth (6.1.4): + - Firebase/Auth (= 12.8.0) - firebase_core - Flutter - - firebase_core (4.3.0): - - Firebase/CoreOnly (= 12.6.0) + - firebase_core (4.4.0): + - Firebase/CoreOnly (= 12.8.0) - Flutter - - firebase_crashlytics (5.0.6): - - Firebase/Crashlytics (= 12.6.0) + - firebase_crashlytics (5.0.7): + - Firebase/Crashlytics (= 12.8.0) - firebase_core - Flutter - - firebase_messaging (16.1.0): - - Firebase/Messaging (= 12.6.0) + - firebase_messaging (16.1.1): + - Firebase/Messaging (= 12.8.0) - firebase_core - Flutter - - FirebaseAppCheckInterop (12.6.0) - - FirebaseAuth (12.6.0): - - FirebaseAppCheckInterop (~> 12.6.0) - - FirebaseAuthInterop (~> 12.6.0) - - FirebaseCore (~> 12.6.0) - - FirebaseCoreExtension (~> 12.6.0) + - FirebaseAppCheckInterop (12.8.0) + - FirebaseAuth (12.8.0): + - FirebaseAppCheckInterop (~> 12.8.0) + - FirebaseAuthInterop (~> 12.8.0) + - FirebaseCore (~> 12.8.0) + - FirebaseCoreExtension (~> 12.8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) - GTMSessionFetcher/Core (< 6.0, >= 3.4) - RecaptchaInterop (~> 101.0) - - FirebaseAuthInterop (12.6.0) - - FirebaseCore (12.6.0): - - FirebaseCoreInternal (~> 12.6.0) + - FirebaseAuthInterop (12.8.0) + - FirebaseCore (12.8.0): + - FirebaseCoreInternal (~> 12.8.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreInternal (12.6.0): + - FirebaseCoreExtension (12.8.0): + - FirebaseCore (~> 12.8.0) + - FirebaseCoreInternal (12.8.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) - - FirebaseRemoteConfigInterop (~> 12.6.0) - - FirebaseSessions (~> 12.6.0) + - FirebaseCrashlytics (12.8.0): + - FirebaseCore (~> 12.8.0) + - FirebaseInstallations (~> 12.8.0) + - FirebaseRemoteConfigInterop (~> 12.8.0) + - FirebaseSessions (~> 12.8.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (12.6.0): - - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (12.8.0): + - FirebaseCore (~> 12.8.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseMessaging (12.8.0): + - FirebaseCore (~> 12.8.0) + - FirebaseInstallations (~> 12.8.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Reachability (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - - FirebaseRemoteConfigInterop (12.6.0) - - FirebaseSessions (12.6.0): - - FirebaseCore (~> 12.6.0) - - FirebaseCoreExtension (~> 12.6.0) - - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (12.8.0) + - FirebaseSessions (12.8.0): + - FirebaseCore (~> 12.8.0) + - FirebaseCoreExtension (~> 12.8.0) + - FirebaseInstallations (~> 12.8.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) @@ -147,9 +147,6 @@ PODS: - nanopb/encode (3.30910.0) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) @@ -171,7 +168,6 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) @@ -222,8 +218,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_sign_in_ios/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sign_in_with_apple: @@ -232,40 +226,39 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d - Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 - firebase_auth: c90f1b17fe53a1e9a63222d0b2f33256879b1a92 - firebase_core: 7ca5e04fc97329a2245376eba53c5bed113ca21e - firebase_crashlytics: eb4e3214716bff15ccd3a554dd1cf7922115c430 - firebase_messaging: d9fd1aeeabefbbee02336521ccd19fc6e93c4625 - FirebaseAppCheckInterop: e2178171b4145013c7c1a3cc464d1d446d3a1896 - FirebaseAuth: 613c463cb43545a7fd2cd99ade09b78ac472c544 - FirebaseAuthInterop: db06756ef028006d034b6004dc0c37c24f7828d4 - FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 - FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f - FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e - FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e - FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad - FirebaseMessaging: a61bc42dcab3f7a346d94bbb54dab2c9435b18b2 - FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 - FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d + firebase_auth: e9031a1dbe04a90d98e8d11ff2302352a1c6d9e8 + firebase_core: ee30637e6744af8e0c12a6a1e8a9718506ec2398 + firebase_crashlytics: 28b8f39df8104131376393e6af658b8b77dd120f + firebase_messaging: 343de01a8d3e18b60df0c6d37f7174c44ae38e02 + FirebaseAppCheckInterop: ba3dc604a89815379e61ec2365101608d365cf7d + FirebaseAuth: 4c289b1a43f5955283244a55cf6bd616de344be5 + FirebaseAuthInterop: 95363fe96493cb4f106656666a0768b420cba090 + FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c + FirebaseCoreExtension: 6605938d51f765d8b18bfcafd2085276a252bee2 + FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21 + FirebaseCrashlytics: fb31c6907e5b52aa252668394d3f1ab326df1511 + FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 + FirebaseMessaging: 7f42cfd10ec64181db4e01b305a613791c8e782c + FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41 + FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - google_sign_in_ios: 7411fab6948df90490dc4620ecbcabdc3ca04017 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 PODFILE CHECKSUM: 85d318c08613be190fccc1abd43524ac3b83a41b diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 53f1136..2777e26 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -701,7 +701,7 @@ DEVELOPMENT_TEAM = 98QY4938R9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = com.elipair.mapsy.share/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = com.elipair.mapsy.share; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -746,7 +746,7 @@ DEVELOPMENT_TEAM = 98QY4938R9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = com.elipair.mapsy.share/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = com.elipair.mapsy.share; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -788,7 +788,7 @@ DEVELOPMENT_TEAM = 98QY4938R9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = com.elipair.mapsy.share/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = com.elipair.mapsy.share; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/ios/com.elipair.mapsy.share/Info.plist b/ios/com.elipair.mapsy.share/Info.plist index faa0de4..269a4c6 100644 --- a/ios/com.elipair.mapsy.share/Info.plist +++ b/ios/com.elipair.mapsy.share/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) + 1.0 CFBundleVersion - $(FLUTTER_BUILD_NUMBER) + 1 NSExtension NSExtensionAttributes diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 40ea92b..14bab22 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -51,18 +51,18 @@ platform :ios do UI.message("📦 IPA 경로: #{ipa_path}") - # 릴리즈 노트 설정 - release_notes = ENV["RELEASE_NOTES"] || "새로운 빌드가 업로드되었습니다." + # 릴리즈 노트 설정 (Ruby 3.4+ frozen string 호환을 위해 .dup 사용) + release_notes = (ENV["RELEASE_NOTES"] || "새로운 빌드가 업로드되었습니다.").dup UI.message("📝 릴리즈 노트: #{release_notes}") # TestFlight 업로드 (upload_to_testflight = pilot) upload_to_testflight( api_key: api_key, ipa: ipa_path, - changelog: release_notes, + changelog: release_notes.dup, localized_build_info: { - "ko" => { whats_new: release_notes }, - "en-US" => { whats_new: release_notes } + "ko" => { whats_new: release_notes.dup }, + "en-US" => { whats_new: release_notes.dup } }, skip_waiting_for_build_processing: ENV["SKIP_WAITING_FOR_BUILD_PROCESSING"] == "true", distribute_external: false, diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 7981576..71a5f38 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -3,59 +3,110 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; /// API 엔드포인트 중앙 관리 /// Centralized API Endpoint Management /// -/// 환경 변수(.env)를 통해 설정 관리 -/// Configuration managed via environment variables (.env) +/// 환경 변수(.env)를 통해 Base URL 설정 관리 +/// Base URL configuration managed via environment variables (.env) /// /// **사용 가능한 환경 변수**: -/// - `API_BASE_URL`: 백엔드 API Base URL +/// - `API_BASE_URL`: 백엔드 API Base URL (기본값: https://api.mapsy.suhsaechan.kr) /// - `WS_URL`: WebSocket 연결 URL /// - `USE_MOCK_API`: Mock API 사용 여부 (true/false) class ApiEndpoints { // Private 생성자 - 인스턴스화 방지 - // Private constructor to prevent instantiation ApiEndpoints._(); // ============================================ // Base URL (환경 변수에서 로드) - // Base URL (loaded from environment variables) // ============================================ - /// API Base URL (.env에서 로드) - /// API Base URL (loaded from .env) - /// - /// **기본값**: `http://localhost:8080` - /// **Default**: `http://localhost:8080` + /// API Base URL (.env에서 로드, 기본값: 개발 서버) static String get baseUrl => - dotenv.env['API_BASE_URL'] ?? 'http://localhost:8080'; + dotenv.env['API_BASE_URL'] ?? 'https://api.mapsy.suhsaechan.kr'; /// WebSocket URL (.env에서 로드) - /// WebSocket URL (loaded from .env) - /// - /// **기본값**: `ws://localhost:8080/ws` - /// **Default**: `ws://localhost:8080/ws` - static String get wsUrl => dotenv.env['WS_URL'] ?? 'ws://localhost:8080/ws'; + static String get wsUrl => + dotenv.env['WS_URL'] ?? 'wss://api.mapsy.suhsaechan.kr/ws'; /// Mock API 사용 여부 (.env에서 로드) - /// Whether to use Mock API (loaded from .env) - /// - /// **기본값**: `true` - /// **Default**: `true` static bool get useMockApi => dotenv.env['USE_MOCK_API']?.toLowerCase() == 'true'; // ============================================ - // 참고: 구체적인 API 엔드포인트 - // Note: Specific API Endpoints - // ============================================ - // - // 백엔드 API가 정의되면 아래와 같은 형태로 추가 예정: - // Will be added in the following format once backend API is defined: - // - // static const String googleLogin = '/auth/google'; - // static const String refreshToken = '/auth/refresh'; - // static const String createSession = '/api/sessions'; - // static String getSession(String sessionId) => '/api/sessions/$sessionId'; - // - // 현재는 백엔드 정의 전이므로 빈 상태로 유지 - // Currently kept empty until backend is defined + // Auth API Endpoints + // ============================================ + + /// 소셜 로그인 (Firebase ID Token → 백엔드 JWT) + static const String signIn = '/api/auth/sign-in'; + + /// 토큰 재발급 (Refresh Token → 새 Access Token) + static const String reissue = '/api/auth/reissue'; + + /// 로그아웃 + static const String logout = '/api/auth/logout'; + + /// 회원 탈퇴 + static const String withdraw = '/api/auth/withdraw'; + + // ============================================ + // Member API Endpoints + // ============================================ + + /// 회원 기본 경로 + static const String members = '/api/members'; + + /// 온보딩: 약관 동의 + static const String onboardingTerms = '/api/members/onboarding/terms'; + + /// 온보딩: 생년월일 + static const String onboardingBirthDate = + '/api/members/onboarding/birth-date'; + + /// 온보딩: 성별 + static const String onboardingGender = '/api/members/onboarding/gender'; + + /// 프로필 업데이트 (닉네임 등) + static const String memberProfile = '/api/members/profile'; + + /// 닉네임 중복 확인 + static const String checkName = '/api/members/check-name'; + + // ============================================ + // Place API Endpoints + // ============================================ + + /// 장소 기본 경로 + static const String place = '/api/place'; + + /// 장소 상세 조회 + static String placeDetail(String placeId) => '/api/place/$placeId'; + + /// 임시 저장 장소 목록 + static const String temporaryPlaces = '/api/place/temporary'; + + /// 저장된 장소 목록 + static const String savedPlaces = '/api/place/saved'; + + /// 장소 저장 + static String savePlace(String placeId) => '/api/place/$placeId/save'; + + // ============================================ + // Content API Endpoints (AI 추출) + // ============================================ + + /// AI 분석 요청 (SNS URL → 장소 추출) + static const String contentAnalyze = '/api/content/analyze'; + + /// 콘텐츠 기본 경로 + static const String content = '/api/content'; + + /// 콘텐츠 상세 조회 (폴링용) + static String contentDetail(String contentId) => '/api/content/$contentId'; + + /// 회원 콘텐츠 목록 + static const String memberContent = '/api/content/member'; + + /// 최근 콘텐츠 + static const String recentContent = '/api/content/recent'; + + /// 저장된 장소 (콘텐츠에서) + static const String contentSavedPlaces = '/api/content/place/saved'; } diff --git a/lib/core/constants/app_colors.dart b/lib/core/constants/app_colors.dart index 822032e..f05f595 100644 --- a/lib/core/constants/app_colors.dart +++ b/lib/core/constants/app_colors.dart @@ -34,6 +34,40 @@ class AppColors { /// Robber team color static const Color robberTeam = Color(0xFFE53935); + // ============================================ + // Gray 스케일 색상 (Gray Scale Colors) + // ============================================ + + /// Gray 50 - 가장 밝은 회색 + static const Color gray50 = Color(0xFFFAFAFA); + + /// Gray 100 + static const Color gray100 = Color(0xFFF5F5F5); + + /// Gray 200 + static const Color gray200 = Color(0xFFEEEEEE); + + /// Gray 300 + static const Color gray300 = Color(0xFFE0E0E0); + + /// Gray 400 + static const Color gray400 = Color(0xFFBDBDBD); + + /// Gray 500 + static const Color gray500 = Color(0xFF9E9E9E); + + /// Gray 600 + static const Color gray600 = Color(0xFF757575); + + /// Gray 700 + static const Color gray700 = Color(0xFF616161); + + /// Gray 800 + static const Color gray800 = Color(0xFF424242); + + /// Gray 900 - 가장 어두운 회색 + static const Color gray900 = Color(0xFF212121); + // ============================================ // 배경 색상 (Background Colors) // ============================================ diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart new file mode 100644 index 0000000..edcfc40 --- /dev/null +++ b/lib/core/network/api_client.dart @@ -0,0 +1,84 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../constants/api_endpoints.dart'; +import 'auth_interceptor.dart'; +import 'error_interceptor.dart'; +import 'token_refresh_interceptor.dart'; + +part 'api_client.g.dart'; + +/// Dio 인스턴스 Provider +/// +/// 앱 전체에서 사용하는 HTTP 클라이언트입니다. +/// 인증, 토큰 갱신, 에러 변환 인터셉터가 자동으로 적용됩니다. +/// +/// **사용법**: +/// ```dart +/// final dio = ref.watch(dioProvider); +/// final response = await dio.get('/api/some-endpoint'); +/// ``` +@riverpod +Dio dio(Ref ref) { + final dio = Dio( + BaseOptions( + baseUrl: ApiEndpoints.baseUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(minutes: 30), // LLM 처리 시간 고려 + sendTimeout: const Duration(minutes: 30), // LLM 처리 시간 고려 + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + // 인터셉터 추가 (순서 중요!) + // 1. 로깅 (개발 모드에서만) + if (kDebugMode) { + dio.interceptors.add( + LogInterceptor( + requestHeader: true, + requestBody: true, + responseHeader: false, + responseBody: true, + error: true, + logPrint: (obj) => debugPrint('📡 $obj'), + ), + ); + } + + // 2. 인증 (Access Token 주입) + dio.interceptors.add(AuthInterceptor(ref)); + + // 3. 토큰 갱신 (401 → Refresh → 재시도) + // refreshDio를 별도로 사용하여 인터셉터 순환 방지 + final refreshDio = ref.read(refreshDioProvider); + dio.interceptors.add(TokenRefreshInterceptor(ref, dio, refreshDio)); + + // 4. 에러 변환 (DioException → AppException) + dio.interceptors.add(ErrorInterceptor()); + + return dio; +} + +/// 토큰 갱신 전용 Dio 인스턴스 +/// +/// TokenRefreshInterceptor에서 사용하는 별도의 Dio 인스턴스입니다. +/// 인터셉터 순환을 방지하기 위해 최소한의 설정만 적용합니다. +@riverpod +Dio refreshDio(Ref ref) { + return Dio( + BaseOptions( + baseUrl: ApiEndpoints.baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); +} diff --git a/lib/core/network/api_client.g.dart b/lib/core/network/api_client.g.dart new file mode 100644 index 0000000..750d308 --- /dev/null +++ b/lib/core/network/api_client.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$dioHash() => r'59e14cd015af5ec5163321683e8232da6291ca1b'; + +/// Dio 인스턴스 Provider +/// +/// 앱 전체에서 사용하는 HTTP 클라이언트입니다. +/// 인증, 토큰 갱신, 에러 변환 인터셉터가 자동으로 적용됩니다. +/// +/// **사용법**: +/// ```dart +/// final dio = ref.watch(dioProvider); +/// final response = await dio.get('/api/some-endpoint'); +/// ``` +/// +/// Copied from [dio]. +@ProviderFor(dio) +final dioProvider = AutoDisposeProvider.internal( + dio, + name: r'dioProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$dioHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DioRef = AutoDisposeProviderRef; +String _$refreshDioHash() => r'4e4a3a496c4b824d722ceb8ea9ed618351f0e92c'; + +/// 토큰 갱신 전용 Dio 인스턴스 +/// +/// TokenRefreshInterceptor에서 사용하는 별도의 Dio 인스턴스입니다. +/// 인터셉터 순환을 방지하기 위해 최소한의 설정만 적용합니다. +/// +/// Copied from [refreshDio]. +@ProviderFor(refreshDio) +final refreshDioProvider = AutoDisposeProvider.internal( + refreshDio, + name: r'refreshDioProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$refreshDioHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef RefreshDioRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart new file mode 100644 index 0000000..9d7bdcb --- /dev/null +++ b/lib/core/network/auth_interceptor.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'token_storage.dart'; + +/// 인증 인터셉터 +/// +/// 모든 HTTP 요청에 Access Token을 자동으로 주입합니다. +/// +/// **동작**: +/// 1. 요청 전: Authorization 헤더에 Bearer Token 추가 +/// 2. 인증이 필요 없는 경로는 제외 (예: /auth/sign-in) +class AuthInterceptor extends Interceptor { + final Ref _ref; + + /// 인증이 필요 없는 경로 목록 + static const _publicPaths = ['/api/auth/sign-in', '/api/auth/reissue']; + + AuthInterceptor(this._ref); + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + // 인증이 필요 없는 경로는 토큰 주입 생략 + if (_isPublicPath(options.path)) { + debugPrint('🔓 Public path, skipping auth: ${options.path}'); + return handler.next(options); + } + + try { + final tokenStorage = _ref.read(tokenStorageProvider); + final accessToken = await tokenStorage.getAccessToken(); + + if (accessToken != null && accessToken.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $accessToken'; + debugPrint('🔐 Token injected for: ${options.path}'); + } else { + debugPrint('⚠️ No access token available for: ${options.path}'); + } + } catch (e) { + debugPrint('❌ Error reading token: $e'); + } + + return handler.next(options); + } + + /// 인증이 필요 없는 경로인지 확인 + bool _isPublicPath(String path) { + return _publicPaths.any((publicPath) => path.contains(publicPath)); + } +} diff --git a/lib/core/network/error_interceptor.dart b/lib/core/network/error_interceptor.dart new file mode 100644 index 0000000..63eea66 --- /dev/null +++ b/lib/core/network/error_interceptor.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../errors/app_exception.dart'; + +/// 에러 인터셉터 +/// +/// Dio 에러를 앱의 커스텀 예외(AppException)로 변환합니다. +/// +/// **변환 규칙**: +/// - 네트워크 연결 에러 → NetworkException +/// - 401 Unauthorized → AuthException +/// - 400 Bad Request → ValidationException +/// - 500+ 서버 에러 → ServerException +/// - 그 외 → NetworkException +class ErrorInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final exception = _convertToAppException(err); + + debugPrint('❌ API Error: ${exception.toString()}'); + debugPrint(' Path: ${err.requestOptions.path}'); + debugPrint(' Status: ${err.response?.statusCode}'); + + // DioException을 유지하되, error 필드에 AppException을 담아서 전달 + handler.next( + DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: exception, + message: exception.message, + ), + ); + } + + /// DioException을 AppException으로 변환 + AppException _convertToAppException(DioException err) { + // 네트워크 연결 에러 + if (err.type == DioExceptionType.connectionError || + err.type == DioExceptionType.connectionTimeout || + err.error is SocketException) { + return NetworkException( + message: '인터넷 연결을 확인해주세요.', + code: 'NETWORK_ERROR', + originalException: err, + ); + } + + // 요청 타임아웃 + if (err.type == DioExceptionType.sendTimeout || + err.type == DioExceptionType.receiveTimeout) { + return NetworkException( + message: '서버 응답이 너무 느립니다. 잠시 후 다시 시도해주세요.', + code: 'TIMEOUT', + originalException: err, + ); + } + + // HTTP 상태 코드 기반 에러 + final statusCode = err.response?.statusCode; + final responseData = err.response?.data; + + // 서버에서 전달한 에러 메시지 추출 + String? serverMessage; + String? serverCode; + if (responseData is Map) { + serverMessage = + responseData['message'] as String? ?? + responseData['error'] as String?; + serverCode = responseData['code'] as String?; + } + + switch (statusCode) { + case 400: + return ValidationException( + message: serverMessage ?? '잘못된 요청입니다.', + code: serverCode ?? 'BAD_REQUEST', + originalException: err, + ); + + case 401: + return AuthException( + message: serverMessage ?? '인증이 만료되었습니다. 다시 로그인해주세요.', + code: serverCode ?? 'UNAUTHORIZED', + originalException: err, + ); + + case 403: + return AuthException( + message: serverMessage ?? '접근 권한이 없습니다.', + code: serverCode ?? 'FORBIDDEN', + originalException: err, + ); + + case 404: + return ServerException( + message: serverMessage ?? '요청한 리소스를 찾을 수 없습니다.', + code: serverCode ?? 'NOT_FOUND', + originalException: err, + ); + + case 409: + return ValidationException( + message: serverMessage ?? '중복된 데이터가 있습니다.', + code: serverCode ?? 'CONFLICT', + originalException: err, + ); + + case 422: + return ValidationException( + message: serverMessage ?? '입력 데이터를 처리할 수 없습니다.', + code: serverCode ?? 'UNPROCESSABLE_ENTITY', + originalException: err, + ); + + case 429: + return NetworkException( + message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + code: 'TOO_MANY_REQUESTS', + originalException: err, + ); + + case 500: + case 502: + case 503: + case 504: + return ServerException( + message: serverMessage ?? '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + code: serverCode ?? 'SERVER_ERROR', + originalException: err, + ); + + default: + return NetworkException( + message: serverMessage ?? '알 수 없는 오류가 발생했습니다.', + code: serverCode ?? 'UNKNOWN_ERROR', + originalException: err, + ); + } + } +} diff --git a/lib/core/network/token_refresh_interceptor.dart b/lib/core/network/token_refresh_interceptor.dart new file mode 100644 index 0000000..9956078 --- /dev/null +++ b/lib/core/network/token_refresh_interceptor.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../constants/api_endpoints.dart'; +import 'token_storage.dart'; + +/// 토큰 갱신 인터셉터 +/// +/// 401 Unauthorized 응답 시 Refresh Token으로 Access Token을 갱신하고 +/// 원래 요청을 재시도합니다. +/// +/// **동작**: +/// 1. 401 에러 감지 +/// 2. Refresh Token으로 새 Access Token 요청 (refreshDio 사용) +/// 3. 새 토큰 저장 +/// 4. 원래 요청 재시도 +/// 5. Refresh Token도 만료된 경우 → 로그아웃 처리 +/// +/// **주의**: 토큰 갱신 요청 시 [_refreshDio]를 사용하여 +/// 인터셉터 순환 호출을 방지합니다. +class TokenRefreshInterceptor extends Interceptor { + final Ref _ref; + final Dio _mainDio; + final Dio _refreshDio; + + /// 토큰 갱신 중 여부 (중복 갱신 방지) + bool _isRefreshing = false; + + /// 갱신 완료 대기 중인 요청들 + final List<({RequestOptions options, ErrorInterceptorHandler handler})> + _pendingRequests = []; + + TokenRefreshInterceptor(this._ref, this._mainDio, this._refreshDio); + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // 401 에러가 아니면 그대로 전달 + if (err.response?.statusCode != 401) { + return handler.next(err); + } + + // 토큰 갱신 요청 자체가 실패한 경우 → 로그아웃 + if (err.requestOptions.path.contains(ApiEndpoints.reissue)) { + debugPrint('🔴 Refresh token expired, logging out...'); + await _handleLogout(); + return handler.next(err); + } + + debugPrint('🔄 401 detected, attempting token refresh...'); + + // 이미 갱신 중이면 대기열에 추가 + if (_isRefreshing) { + debugPrint('⏳ Token refresh in progress, queuing request...'); + _pendingRequests.add((options: err.requestOptions, handler: handler)); + return; + } + + _isRefreshing = true; + + try { + // 토큰 갱신 시도 + final success = await _refreshToken(); + + if (success) { + debugPrint('✅ Token refreshed successfully'); + + // 원래 요청 재시도 + final response = await _retryRequest(err.requestOptions); + handler.resolve(response); + + // 대기 중인 요청들도 재시도 + _retryPendingRequests(); + } else { + debugPrint('🔴 Token refresh failed, logging out...'); + await _handleLogout(); + handler.next(err); + _rejectPendingRequests(err); + } + } catch (e) { + debugPrint('❌ Token refresh error: $e'); + await _handleLogout(); + handler.next(err); + _rejectPendingRequests(err); + } finally { + _isRefreshing = false; + } + } + + /// Refresh Token으로 새 Access Token 요청 + Future _refreshToken() async { + try { + final tokenStorage = _ref.read(tokenStorageProvider); + final refreshToken = await tokenStorage.getRefreshToken(); + + if (refreshToken == null || refreshToken.isEmpty) { + debugPrint('⚠️ No refresh token available'); + return false; + } + + // 토큰 갱신 요청 (인터셉터 순환 방지를 위해 별도 Dio 인스턴스 사용) + final response = await _refreshDio.post( + ApiEndpoints.reissue, + data: {'refreshToken': refreshToken}, + ); + + if (response.statusCode == 200 && response.data != null) { + final newAccessToken = response.data['accessToken'] as String?; + final newRefreshToken = response.data['refreshToken'] as String?; + + if (newAccessToken != null && newRefreshToken != null) { + await tokenStorage.saveTokens( + accessToken: newAccessToken, + refreshToken: newRefreshToken, + ); + return true; + } + } + + return false; + } catch (e) { + debugPrint('❌ Refresh token request failed: $e'); + return false; + } + } + + /// 원래 요청 재시도 + Future _retryRequest(RequestOptions requestOptions) async { + final tokenStorage = _ref.read(tokenStorageProvider); + final newAccessToken = await tokenStorage.getAccessToken(); + + // 새 토큰으로 헤더 업데이트 + requestOptions.headers['Authorization'] = 'Bearer $newAccessToken'; + + return await _mainDio.fetch(requestOptions); + } + + /// 대기 중인 요청들 재시도 + void _retryPendingRequests() { + for (final pending in _pendingRequests) { + _retryRequest(pending.options).then( + (response) => pending.handler.resolve(response), + onError: (error) => pending.handler.reject(error as DioException), + ); + } + _pendingRequests.clear(); + } + + /// 대기 중인 요청들 거부 + void _rejectPendingRequests(DioException err) { + for (final pending in _pendingRequests) { + pending.handler.next(err); + } + _pendingRequests.clear(); + } + + /// 로그아웃 처리 + Future _handleLogout() async { + try { + final tokenStorage = _ref.read(tokenStorageProvider); + await tokenStorage.clearTokens(); + debugPrint('🚪 Tokens cleared, user logged out'); + + // TODO: 로그인 화면으로 리다이렉트 (GoRouter 사용) + // ref.read(routerProvider).go('/login'); + } catch (e) { + debugPrint('❌ Logout cleanup error: $e'); + } + } +} diff --git a/lib/core/network/token_storage.dart b/lib/core/network/token_storage.dart new file mode 100644 index 0000000..828a427 --- /dev/null +++ b/lib/core/network/token_storage.dart @@ -0,0 +1,151 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'token_storage.g.dart'; + +/// 토큰 저장소 Provider +@riverpod +TokenStorage tokenStorage(Ref ref) { + return TokenStorage(); +} + +/// 토큰 보안 저장소 +/// +/// flutter_secure_storage를 사용하여 Access Token과 Refresh Token을 +/// 안전하게 저장하고 관리합니다. +/// +/// **저장 키**: +/// - `access_token`: 백엔드 API 인증용 JWT Access Token +/// - `refresh_token`: Access Token 갱신용 Refresh Token +/// - `requires_onboarding`: 온보딩 필요 여부 (true/false) +/// - `onboarding_step`: 현재 온보딩 단계 (TERMS, BIRTH_DATE, GENDER, NICKNAME) +class TokenStorage { + static const _accessTokenKey = 'access_token'; + static const _refreshTokenKey = 'refresh_token'; + static const _requiresOnboardingKey = 'requires_onboarding'; + static const _onboardingStepKey = 'onboarding_step'; + + final FlutterSecureStorage _storage; + + TokenStorage({FlutterSecureStorage? storage}) + : _storage = + storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + // ============================================ + // Access Token + // ============================================ + + /// Access Token 저장 + Future saveAccessToken(String token) async { + await _storage.write(key: _accessTokenKey, value: token); + } + + /// Access Token 조회 + Future getAccessToken() async { + return await _storage.read(key: _accessTokenKey); + } + + /// Access Token 삭제 + Future deleteAccessToken() async { + await _storage.delete(key: _accessTokenKey); + } + + // ============================================ + // Refresh Token + // ============================================ + + /// Refresh Token 저장 + Future saveRefreshToken(String token) async { + await _storage.write(key: _refreshTokenKey, value: token); + } + + /// Refresh Token 조회 + Future getRefreshToken() async { + return await _storage.read(key: _refreshTokenKey); + } + + /// Refresh Token 삭제 + Future deleteRefreshToken() async { + await _storage.delete(key: _refreshTokenKey); + } + + // ============================================ + // Token Pair (Access + Refresh) + // ============================================ + + /// Access Token과 Refresh Token을 함께 저장 + Future saveTokens({ + required String accessToken, + required String refreshToken, + }) async { + await Future.wait([ + saveAccessToken(accessToken), + saveRefreshToken(refreshToken), + ]); + } + + /// 모든 토큰 삭제 (로그아웃 시) + Future clearTokens() async { + await Future.wait([ + deleteAccessToken(), + deleteRefreshToken(), + deleteOnboardingState(), + ]); + } + + /// 토큰 존재 여부 확인 + Future hasTokens() async { + final accessToken = await getAccessToken(); + return accessToken != null && accessToken.isNotEmpty; + } + + // ============================================ + // Onboarding State + // ============================================ + + /// 온보딩 상태 저장 + Future saveOnboardingState({ + required bool requiresOnboarding, + String? onboardingStep, + }) async { + await _storage.write( + key: _requiresOnboardingKey, + value: requiresOnboarding.toString(), + ); + if (onboardingStep != null) { + await _storage.write(key: _onboardingStepKey, value: onboardingStep); + } + } + + /// 온보딩 필요 여부 조회 + Future getRequiresOnboarding() async { + final value = await _storage.read(key: _requiresOnboardingKey); + return value?.toLowerCase() == 'true'; + } + + /// 현재 온보딩 단계 조회 + Future getOnboardingStep() async { + return await _storage.read(key: _onboardingStepKey); + } + + /// 온보딩 완료 처리 + Future completeOnboarding() async { + await _storage.write(key: _requiresOnboardingKey, value: 'false'); + await _storage.delete(key: _onboardingStepKey); + } + + /// 온보딩 상태 삭제 + Future deleteOnboardingState() async { + await Future.wait([ + _storage.delete(key: _requiresOnboardingKey), + _storage.delete(key: _onboardingStepKey), + ]); + } +} diff --git a/lib/core/network/token_storage.g.dart b/lib/core/network/token_storage.g.dart new file mode 100644 index 0000000..6c468ef --- /dev/null +++ b/lib/core/network/token_storage.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'token_storage.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tokenStorageHash() => r'd76686f5c7fe18426f0a40b20f703a6a382a2a30'; + +/// 토큰 저장소 Provider +/// +/// Copied from [tokenStorage]. +@ProviderFor(tokenStorage) +final tokenStorageProvider = AutoDisposeProvider.internal( + tokenStorage, + name: r'tokenStorageProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tokenStorageHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TokenStorageRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/services/device/device_info_service.dart b/lib/core/services/device/device_info_service.dart index c09afeb..31c3036 100644 --- a/lib/core/services/device/device_info_service.dart +++ b/lib/core/services/device/device_info_service.dart @@ -1,6 +1,40 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'device_id_manager.dart'; + +part 'device_info_service.g.dart'; + +/// DeviceInfoService Provider +@riverpod +DeviceInfoService deviceInfoService(Ref ref) { + return DeviceInfoService(); +} + +/// 기기 정보 데이터 클래스 +class DeviceInfo { + final String deviceName; + final String deviceType; + final String deviceId; + final String osVersion; + final bool isPhysicalDevice; + + const DeviceInfo({ + required this.deviceName, + required this.deviceType, + required this.deviceId, + required this.osVersion, + required this.isPhysicalDevice, + }); + + @override + String toString() { + return 'DeviceInfo(name: $deviceName, type: $deviceType, id: $deviceId, os: $osVersion, physical: $isPhysicalDevice)'; + } +} /// 기기 정보 수집 서비스 /// @@ -171,4 +205,24 @@ class DeviceInfoService { return false; } } + + /// 백엔드 로그인용 기기 정보를 수집합니다 + /// + /// 기기 이름, 타입, 고유 ID, OS 버전 등을 한 번에 수집합니다. + /// 로그인 시 FCM 토큰과 함께 백엔드에 전송됩니다. + Future getDeviceInfo() async { + final deviceName = await getDeviceName(); + final deviceType = getDeviceType(); + final deviceId = await DeviceIdManager.getOrCreateDeviceId(); + final osVersion = await getOSVersion(); + final physical = await isPhysicalDevice(); + + return DeviceInfo( + deviceName: deviceName, + deviceType: deviceType, + deviceId: deviceId, + osVersion: osVersion, + isPhysicalDevice: physical, + ); + } } diff --git a/lib/core/services/device/device_info_service.g.dart b/lib/core/services/device/device_info_service.g.dart new file mode 100644 index 0000000..7818875 --- /dev/null +++ b/lib/core/services/device/device_info_service.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'device_info_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$deviceInfoServiceHash() => r'694e64043cca19e34677981472b50d45bca72fc1'; + +/// DeviceInfoService Provider +/// +/// Copied from [deviceInfoService]. +@ProviderFor(deviceInfoService) +final deviceInfoServiceProvider = + AutoDisposeProvider.internal( + deviceInfoService, + name: r'deviceInfoServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$deviceInfoServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef DeviceInfoServiceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/core/services/fcm/firebase_messaging_service.dart b/lib/core/services/fcm/firebase_messaging_service.dart index 8792081..046963e 100644 --- a/lib/core/services/fcm/firebase_messaging_service.dart +++ b/lib/core/services/fcm/firebase_messaging_service.dart @@ -1,10 +1,21 @@ import 'dart:async'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + import 'local_notifications_service.dart'; import '../device/device_info_service.dart'; import '../device/device_id_manager.dart'; +part 'firebase_messaging_service.g.dart'; + +/// FirebaseMessagingService Provider +@riverpod +FirebaseMessagingService firebaseMessagingService(Ref ref) { + return FirebaseMessagingService.instance(); +} + /// Firebase Cloud Messaging 서비스 /// FCM 푸시 알림을 관리하고 메시지를 처리합니다 class FirebaseMessagingService { diff --git a/lib/core/services/fcm/firebase_messaging_service.g.dart b/lib/core/services/fcm/firebase_messaging_service.g.dart new file mode 100644 index 0000000..1072e9f --- /dev/null +++ b/lib/core/services/fcm/firebase_messaging_service.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'firebase_messaging_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$firebaseMessagingServiceHash() => + r'6b59f5611cd378acf599644d912cc77202a62abd'; + +/// FirebaseMessagingService Provider +/// +/// Copied from [firebaseMessagingService]. +@ProviderFor(firebaseMessagingService) +final firebaseMessagingServiceProvider = + AutoDisposeProvider.internal( + firebaseMessagingService, + name: r'firebaseMessagingServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$firebaseMessagingServiceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FirebaseMessagingServiceRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart new file mode 100644 index 0000000..c820206 --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -0,0 +1,91 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/network/api_client.dart'; +import '../models/sign_in_request.dart'; +import '../models/sign_in_response.dart'; +import '../models/reissue_request.dart'; +import '../models/reissue_response.dart'; + +part 'auth_remote_datasource.g.dart'; + +/// 인증 Remote DataSource Provider +@riverpod +AuthRemoteDataSource authRemoteDataSource(Ref ref) { + final dio = ref.watch(dioProvider); + return AuthRemoteDataSource(dio); +} + +/// 인증 관련 백엔드 API 호출 +/// +/// Firebase 인증 후 백엔드 서버와 통신하여 +/// JWT 토큰을 발급받고 관리합니다. +class AuthRemoteDataSource { + final Dio _dio; + + AuthRemoteDataSource(this._dio); + + /// 소셜 로그인 (Firebase ID Token → 백엔드 JWT) + /// + /// [request]에 Firebase ID Token과 기기 정보를 담아 전송합니다. + /// 성공 시 Access Token, Refresh Token, 온보딩 상태를 반환합니다. + /// + /// Throws: DioException (network errors), AppException (converted errors) + Future signIn(SignInRequest request) async { + debugPrint('🔐 Calling sign-in API...'); + debugPrint( + ' Firebase ID Token length: ${request.firebaseIdToken.length}', + ); + + final response = await _dio.post( + ApiEndpoints.signIn, + data: request.toJson(), + ); + + debugPrint('✅ Sign-in API response: ${response.statusCode}'); + + return SignInResponse.fromJson(response.data); + } + + /// 토큰 재발급 + /// + /// Refresh Token을 사용하여 새로운 Access Token을 발급받습니다. + /// 주로 TokenRefreshInterceptor에서 자동으로 호출됩니다. + Future reissue(ReissueRequest request) async { + debugPrint('🔄 Calling reissue API...'); + + final response = await _dio.post( + ApiEndpoints.reissue, + data: request.toJson(), + ); + + debugPrint('✅ Reissue API response: ${response.statusCode}'); + + return ReissueResponse.fromJson(response.data); + } + + /// 로그아웃 + /// + /// 서버에 로그아웃을 알리고 토큰을 무효화합니다. + Future logout() async { + debugPrint('🚪 Calling logout API...'); + + await _dio.post(ApiEndpoints.logout); + + debugPrint('✅ Logout API completed'); + } + + /// 회원 탈퇴 + /// + /// 회원 정보를 삭제합니다. (Soft Delete) + Future withdraw() async { + debugPrint('⚠️ Calling withdraw API...'); + + await _dio.delete(ApiEndpoints.withdraw); + + debugPrint('✅ Withdraw API completed'); + } +} diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.g.dart b/lib/features/auth/data/datasources/auth_remote_datasource.g.dart new file mode 100644 index 0000000..c975356 --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_remote_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authRemoteDataSourceHash() => + r'08fbc5fbf94c86964791b7f20b39f2504c0e8a74'; + +/// 인증 Remote DataSource Provider +/// +/// Copied from [authRemoteDataSource]. +@ProviderFor(authRemoteDataSource) +final authRemoteDataSourceProvider = + AutoDisposeProvider.internal( + authRemoteDataSource, + name: r'authRemoteDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$authRemoteDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AuthRemoteDataSourceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/auth/data/models/reissue_request.dart b/lib/features/auth/data/models/reissue_request.dart new file mode 100644 index 0000000..b71a943 --- /dev/null +++ b/lib/features/auth/data/models/reissue_request.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'reissue_request.freezed.dart'; +part 'reissue_request.g.dart'; + +/// 토큰 재발급 요청 DTO +@freezed +class ReissueRequest with _$ReissueRequest { + const factory ReissueRequest({ + /// Refresh Token + required String refreshToken, + }) = _ReissueRequest; + + factory ReissueRequest.fromJson(Map json) => + _$ReissueRequestFromJson(json); +} diff --git a/lib/features/auth/data/models/reissue_request.freezed.dart b/lib/features/auth/data/models/reissue_request.freezed.dart new file mode 100644 index 0000000..eb1719f --- /dev/null +++ b/lib/features/auth/data/models/reissue_request.freezed.dart @@ -0,0 +1,175 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'reissue_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ReissueRequest _$ReissueRequestFromJson(Map json) { + return _ReissueRequest.fromJson(json); +} + +/// @nodoc +mixin _$ReissueRequest { + /// Refresh Token + String get refreshToken => throw _privateConstructorUsedError; + + /// Serializes this ReissueRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ReissueRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ReissueRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ReissueRequestCopyWith<$Res> { + factory $ReissueRequestCopyWith( + ReissueRequest value, + $Res Function(ReissueRequest) then, + ) = _$ReissueRequestCopyWithImpl<$Res, ReissueRequest>; + @useResult + $Res call({String refreshToken}); +} + +/// @nodoc +class _$ReissueRequestCopyWithImpl<$Res, $Val extends ReissueRequest> + implements $ReissueRequestCopyWith<$Res> { + _$ReissueRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ReissueRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? refreshToken = null}) { + return _then( + _value.copyWith( + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ReissueRequestImplCopyWith<$Res> + implements $ReissueRequestCopyWith<$Res> { + factory _$$ReissueRequestImplCopyWith( + _$ReissueRequestImpl value, + $Res Function(_$ReissueRequestImpl) then, + ) = __$$ReissueRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String refreshToken}); +} + +/// @nodoc +class __$$ReissueRequestImplCopyWithImpl<$Res> + extends _$ReissueRequestCopyWithImpl<$Res, _$ReissueRequestImpl> + implements _$$ReissueRequestImplCopyWith<$Res> { + __$$ReissueRequestImplCopyWithImpl( + _$ReissueRequestImpl _value, + $Res Function(_$ReissueRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ReissueRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? refreshToken = null}) { + return _then( + _$ReissueRequestImpl( + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ReissueRequestImpl implements _ReissueRequest { + const _$ReissueRequestImpl({required this.refreshToken}); + + factory _$ReissueRequestImpl.fromJson(Map json) => + _$$ReissueRequestImplFromJson(json); + + /// Refresh Token + @override + final String refreshToken; + + @override + String toString() { + return 'ReissueRequest(refreshToken: $refreshToken)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ReissueRequestImpl && + (identical(other.refreshToken, refreshToken) || + other.refreshToken == refreshToken)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, refreshToken); + + /// Create a copy of ReissueRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ReissueRequestImplCopyWith<_$ReissueRequestImpl> get copyWith => + __$$ReissueRequestImplCopyWithImpl<_$ReissueRequestImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$ReissueRequestImplToJson(this); + } +} + +abstract class _ReissueRequest implements ReissueRequest { + const factory _ReissueRequest({required final String refreshToken}) = + _$ReissueRequestImpl; + + factory _ReissueRequest.fromJson(Map json) = + _$ReissueRequestImpl.fromJson; + + /// Refresh Token + @override + String get refreshToken; + + /// Create a copy of ReissueRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ReissueRequestImplCopyWith<_$ReissueRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/auth/data/models/reissue_request.g.dart b/lib/features/auth/data/models/reissue_request.g.dart new file mode 100644 index 0000000..9cdbe42 --- /dev/null +++ b/lib/features/auth/data/models/reissue_request.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reissue_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ReissueRequestImpl _$$ReissueRequestImplFromJson(Map json) => + _$ReissueRequestImpl(refreshToken: json['refreshToken'] as String); + +Map _$$ReissueRequestImplToJson( + _$ReissueRequestImpl instance, +) => {'refreshToken': instance.refreshToken}; diff --git a/lib/features/auth/data/models/reissue_response.dart b/lib/features/auth/data/models/reissue_response.dart new file mode 100644 index 0000000..c149269 --- /dev/null +++ b/lib/features/auth/data/models/reissue_response.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'reissue_response.freezed.dart'; +part 'reissue_response.g.dart'; + +/// 토큰 재발급 응답 DTO +@freezed +class ReissueResponse with _$ReissueResponse { + const factory ReissueResponse({ + /// 새로운 Access Token + required String accessToken, + + /// 새로운 Refresh Token + required String refreshToken, + }) = _ReissueResponse; + + factory ReissueResponse.fromJson(Map json) => + _$ReissueResponseFromJson(json); +} diff --git a/lib/features/auth/data/models/reissue_response.freezed.dart b/lib/features/auth/data/models/reissue_response.freezed.dart new file mode 100644 index 0000000..833d379 --- /dev/null +++ b/lib/features/auth/data/models/reissue_response.freezed.dart @@ -0,0 +1,201 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'reissue_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ReissueResponse _$ReissueResponseFromJson(Map json) { + return _ReissueResponse.fromJson(json); +} + +/// @nodoc +mixin _$ReissueResponse { + /// 새로운 Access Token + String get accessToken => throw _privateConstructorUsedError; + + /// 새로운 Refresh Token + String get refreshToken => throw _privateConstructorUsedError; + + /// Serializes this ReissueResponse to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ReissueResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ReissueResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ReissueResponseCopyWith<$Res> { + factory $ReissueResponseCopyWith( + ReissueResponse value, + $Res Function(ReissueResponse) then, + ) = _$ReissueResponseCopyWithImpl<$Res, ReissueResponse>; + @useResult + $Res call({String accessToken, String refreshToken}); +} + +/// @nodoc +class _$ReissueResponseCopyWithImpl<$Res, $Val extends ReissueResponse> + implements $ReissueResponseCopyWith<$Res> { + _$ReissueResponseCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ReissueResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? accessToken = null, Object? refreshToken = null}) { + return _then( + _value.copyWith( + accessToken: null == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ReissueResponseImplCopyWith<$Res> + implements $ReissueResponseCopyWith<$Res> { + factory _$$ReissueResponseImplCopyWith( + _$ReissueResponseImpl value, + $Res Function(_$ReissueResponseImpl) then, + ) = __$$ReissueResponseImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String accessToken, String refreshToken}); +} + +/// @nodoc +class __$$ReissueResponseImplCopyWithImpl<$Res> + extends _$ReissueResponseCopyWithImpl<$Res, _$ReissueResponseImpl> + implements _$$ReissueResponseImplCopyWith<$Res> { + __$$ReissueResponseImplCopyWithImpl( + _$ReissueResponseImpl _value, + $Res Function(_$ReissueResponseImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ReissueResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? accessToken = null, Object? refreshToken = null}) { + return _then( + _$ReissueResponseImpl( + accessToken: null == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ReissueResponseImpl implements _ReissueResponse { + const _$ReissueResponseImpl({ + required this.accessToken, + required this.refreshToken, + }); + + factory _$ReissueResponseImpl.fromJson(Map json) => + _$$ReissueResponseImplFromJson(json); + + /// 새로운 Access Token + @override + final String accessToken; + + /// 새로운 Refresh Token + @override + final String refreshToken; + + @override + String toString() { + return 'ReissueResponse(accessToken: $accessToken, refreshToken: $refreshToken)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ReissueResponseImpl && + (identical(other.accessToken, accessToken) || + other.accessToken == accessToken) && + (identical(other.refreshToken, refreshToken) || + other.refreshToken == refreshToken)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, accessToken, refreshToken); + + /// Create a copy of ReissueResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ReissueResponseImplCopyWith<_$ReissueResponseImpl> get copyWith => + __$$ReissueResponseImplCopyWithImpl<_$ReissueResponseImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$ReissueResponseImplToJson(this); + } +} + +abstract class _ReissueResponse implements ReissueResponse { + const factory _ReissueResponse({ + required final String accessToken, + required final String refreshToken, + }) = _$ReissueResponseImpl; + + factory _ReissueResponse.fromJson(Map json) = + _$ReissueResponseImpl.fromJson; + + /// 새로운 Access Token + @override + String get accessToken; + + /// 새로운 Refresh Token + @override + String get refreshToken; + + /// Create a copy of ReissueResponse + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ReissueResponseImplCopyWith<_$ReissueResponseImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/auth/data/models/reissue_response.g.dart b/lib/features/auth/data/models/reissue_response.g.dart new file mode 100644 index 0000000..155a694 --- /dev/null +++ b/lib/features/auth/data/models/reissue_response.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reissue_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ReissueResponseImpl _$$ReissueResponseImplFromJson( + Map json, +) => _$ReissueResponseImpl( + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, +); + +Map _$$ReissueResponseImplToJson( + _$ReissueResponseImpl instance, +) => { + 'accessToken': instance.accessToken, + 'refreshToken': instance.refreshToken, +}; diff --git a/lib/features/auth/data/models/sign_in_request.dart b/lib/features/auth/data/models/sign_in_request.dart new file mode 100644 index 0000000..98317d5 --- /dev/null +++ b/lib/features/auth/data/models/sign_in_request.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_in_request.freezed.dart'; +part 'sign_in_request.g.dart'; + +/// 로그인 요청 DTO +/// +/// Firebase 인증 후 백엔드에 전달할 로그인 정보입니다. +/// +/// **필수 필드**: +/// - [firebaseIdToken]: Firebase에서 발급받은 ID Token +/// +/// **선택 필드**: +/// - [fcmToken]: FCM 푸시 알림 토큰 +/// - [deviceType]: 디바이스 타입 (IOS, ANDROID) +/// - [deviceId]: 디바이스 고유 식별자 +@freezed +class SignInRequest with _$SignInRequest { + const factory SignInRequest({ + /// Firebase ID Token (필수) + required String firebaseIdToken, + + /// FCM 푸시 알림 토큰 (선택) + String? fcmToken, + + /// 디바이스 타입: IOS, ANDROID (선택) + String? deviceType, + + /// 디바이스 고유 식별자 UUID (선택) + String? deviceId, + }) = _SignInRequest; + + factory SignInRequest.fromJson(Map json) => + _$SignInRequestFromJson(json); +} diff --git a/lib/features/auth/data/models/sign_in_request.freezed.dart b/lib/features/auth/data/models/sign_in_request.freezed.dart new file mode 100644 index 0000000..ef9b5a0 --- /dev/null +++ b/lib/features/auth/data/models/sign_in_request.freezed.dart @@ -0,0 +1,265 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sign_in_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +SignInRequest _$SignInRequestFromJson(Map json) { + return _SignInRequest.fromJson(json); +} + +/// @nodoc +mixin _$SignInRequest { + /// Firebase ID Token (필수) + String get firebaseIdToken => throw _privateConstructorUsedError; + + /// FCM 푸시 알림 토큰 (선택) + String? get fcmToken => throw _privateConstructorUsedError; + + /// 디바이스 타입: IOS, ANDROID (선택) + String? get deviceType => throw _privateConstructorUsedError; + + /// 디바이스 고유 식별자 UUID (선택) + String? get deviceId => throw _privateConstructorUsedError; + + /// Serializes this SignInRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SignInRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SignInRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SignInRequestCopyWith<$Res> { + factory $SignInRequestCopyWith( + SignInRequest value, + $Res Function(SignInRequest) then, + ) = _$SignInRequestCopyWithImpl<$Res, SignInRequest>; + @useResult + $Res call({ + String firebaseIdToken, + String? fcmToken, + String? deviceType, + String? deviceId, + }); +} + +/// @nodoc +class _$SignInRequestCopyWithImpl<$Res, $Val extends SignInRequest> + implements $SignInRequestCopyWith<$Res> { + _$SignInRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SignInRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? firebaseIdToken = null, + Object? fcmToken = freezed, + Object? deviceType = freezed, + Object? deviceId = freezed, + }) { + return _then( + _value.copyWith( + firebaseIdToken: null == firebaseIdToken + ? _value.firebaseIdToken + : firebaseIdToken // ignore: cast_nullable_to_non_nullable + as String, + fcmToken: freezed == fcmToken + ? _value.fcmToken + : fcmToken // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + deviceId: freezed == deviceId + ? _value.deviceId + : deviceId // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$SignInRequestImplCopyWith<$Res> + implements $SignInRequestCopyWith<$Res> { + factory _$$SignInRequestImplCopyWith( + _$SignInRequestImpl value, + $Res Function(_$SignInRequestImpl) then, + ) = __$$SignInRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String firebaseIdToken, + String? fcmToken, + String? deviceType, + String? deviceId, + }); +} + +/// @nodoc +class __$$SignInRequestImplCopyWithImpl<$Res> + extends _$SignInRequestCopyWithImpl<$Res, _$SignInRequestImpl> + implements _$$SignInRequestImplCopyWith<$Res> { + __$$SignInRequestImplCopyWithImpl( + _$SignInRequestImpl _value, + $Res Function(_$SignInRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of SignInRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? firebaseIdToken = null, + Object? fcmToken = freezed, + Object? deviceType = freezed, + Object? deviceId = freezed, + }) { + return _then( + _$SignInRequestImpl( + firebaseIdToken: null == firebaseIdToken + ? _value.firebaseIdToken + : firebaseIdToken // ignore: cast_nullable_to_non_nullable + as String, + fcmToken: freezed == fcmToken + ? _value.fcmToken + : fcmToken // ignore: cast_nullable_to_non_nullable + as String?, + deviceType: freezed == deviceType + ? _value.deviceType + : deviceType // ignore: cast_nullable_to_non_nullable + as String?, + deviceId: freezed == deviceId + ? _value.deviceId + : deviceId // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$SignInRequestImpl implements _SignInRequest { + const _$SignInRequestImpl({ + required this.firebaseIdToken, + this.fcmToken, + this.deviceType, + this.deviceId, + }); + + factory _$SignInRequestImpl.fromJson(Map json) => + _$$SignInRequestImplFromJson(json); + + /// Firebase ID Token (필수) + @override + final String firebaseIdToken; + + /// FCM 푸시 알림 토큰 (선택) + @override + final String? fcmToken; + + /// 디바이스 타입: IOS, ANDROID (선택) + @override + final String? deviceType; + + /// 디바이스 고유 식별자 UUID (선택) + @override + final String? deviceId; + + @override + String toString() { + return 'SignInRequest(firebaseIdToken: $firebaseIdToken, fcmToken: $fcmToken, deviceType: $deviceType, deviceId: $deviceId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SignInRequestImpl && + (identical(other.firebaseIdToken, firebaseIdToken) || + other.firebaseIdToken == firebaseIdToken) && + (identical(other.fcmToken, fcmToken) || + other.fcmToken == fcmToken) && + (identical(other.deviceType, deviceType) || + other.deviceType == deviceType) && + (identical(other.deviceId, deviceId) || + other.deviceId == deviceId)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, firebaseIdToken, fcmToken, deviceType, deviceId); + + /// Create a copy of SignInRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SignInRequestImplCopyWith<_$SignInRequestImpl> get copyWith => + __$$SignInRequestImplCopyWithImpl<_$SignInRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SignInRequestImplToJson(this); + } +} + +abstract class _SignInRequest implements SignInRequest { + const factory _SignInRequest({ + required final String firebaseIdToken, + final String? fcmToken, + final String? deviceType, + final String? deviceId, + }) = _$SignInRequestImpl; + + factory _SignInRequest.fromJson(Map json) = + _$SignInRequestImpl.fromJson; + + /// Firebase ID Token (필수) + @override + String get firebaseIdToken; + + /// FCM 푸시 알림 토큰 (선택) + @override + String? get fcmToken; + + /// 디바이스 타입: IOS, ANDROID (선택) + @override + String? get deviceType; + + /// 디바이스 고유 식별자 UUID (선택) + @override + String? get deviceId; + + /// Create a copy of SignInRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SignInRequestImplCopyWith<_$SignInRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/auth/data/models/sign_in_request.g.dart b/lib/features/auth/data/models/sign_in_request.g.dart new file mode 100644 index 0000000..572b9aa --- /dev/null +++ b/lib/features/auth/data/models/sign_in_request.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sign_in_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SignInRequestImpl _$$SignInRequestImplFromJson(Map json) => + _$SignInRequestImpl( + firebaseIdToken: json['firebaseIdToken'] as String, + fcmToken: json['fcmToken'] as String?, + deviceType: json['deviceType'] as String?, + deviceId: json['deviceId'] as String?, + ); + +Map _$$SignInRequestImplToJson(_$SignInRequestImpl instance) => + { + 'firebaseIdToken': instance.firebaseIdToken, + 'fcmToken': instance.fcmToken, + 'deviceType': instance.deviceType, + 'deviceId': instance.deviceId, + }; diff --git a/lib/features/auth/data/models/sign_in_response.dart b/lib/features/auth/data/models/sign_in_response.dart new file mode 100644 index 0000000..afbcffe --- /dev/null +++ b/lib/features/auth/data/models/sign_in_response.dart @@ -0,0 +1,42 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +// OnboardingStep은 통합 enum 사용 +export '../../domain/entities/onboarding_step.dart'; + +part 'sign_in_response.freezed.dart'; +part 'sign_in_response.g.dart'; + +/// 로그인 응답 DTO +/// +/// 백엔드 로그인 성공 시 반환되는 정보입니다. +/// +/// **토큰 정보**: +/// - [accessToken]: API 인증용 JWT Access Token (약 1시간 유효) +/// - [refreshToken]: Access Token 갱신용 Refresh Token (약 7일 유효) +/// +/// **온보딩 정보**: +/// - [isFirstLogin]: 첫 로그인 여부 +/// - [requiresOnboarding]: 온보딩 완료 필요 여부 +/// - [onboardingStep]: 현재 온보딩 단계 (TERMS, BIRTH_DATE, GENDER, NICKNAME) +@freezed +class SignInResponse with _$SignInResponse { + const factory SignInResponse({ + /// API 인증용 JWT Access Token + required String accessToken, + + /// Access Token 갱신용 Refresh Token + required String refreshToken, + + /// 첫 로그인 여부 + @Default(false) bool isFirstLogin, + + /// 온보딩 완료 필요 여부 + @Default(false) bool requiresOnboarding, + + /// 현재 온보딩 단계: TERMS, BIRTH_DATE, GENDER, NICKNAME, COMPLETED + String? onboardingStep, + }) = _SignInResponse; + + factory SignInResponse.fromJson(Map json) => + _$SignInResponseFromJson(json); +} diff --git a/lib/features/auth/data/models/sign_in_response.freezed.dart b/lib/features/auth/data/models/sign_in_response.freezed.dart new file mode 100644 index 0000000..3ae50ae --- /dev/null +++ b/lib/features/auth/data/models/sign_in_response.freezed.dart @@ -0,0 +1,303 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sign_in_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +SignInResponse _$SignInResponseFromJson(Map json) { + return _SignInResponse.fromJson(json); +} + +/// @nodoc +mixin _$SignInResponse { + /// API 인증용 JWT Access Token + String get accessToken => throw _privateConstructorUsedError; + + /// Access Token 갱신용 Refresh Token + String get refreshToken => throw _privateConstructorUsedError; + + /// 첫 로그인 여부 + bool get isFirstLogin => throw _privateConstructorUsedError; + + /// 온보딩 완료 필요 여부 + bool get requiresOnboarding => throw _privateConstructorUsedError; + + /// 현재 온보딩 단계: TERMS, BIRTH_DATE, GENDER, NICKNAME, COMPLETED + String? get onboardingStep => throw _privateConstructorUsedError; + + /// Serializes this SignInResponse to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SignInResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SignInResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SignInResponseCopyWith<$Res> { + factory $SignInResponseCopyWith( + SignInResponse value, + $Res Function(SignInResponse) then, + ) = _$SignInResponseCopyWithImpl<$Res, SignInResponse>; + @useResult + $Res call({ + String accessToken, + String refreshToken, + bool isFirstLogin, + bool requiresOnboarding, + String? onboardingStep, + }); +} + +/// @nodoc +class _$SignInResponseCopyWithImpl<$Res, $Val extends SignInResponse> + implements $SignInResponseCopyWith<$Res> { + _$SignInResponseCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SignInResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accessToken = null, + Object? refreshToken = null, + Object? isFirstLogin = null, + Object? requiresOnboarding = null, + Object? onboardingStep = freezed, + }) { + return _then( + _value.copyWith( + accessToken: null == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + isFirstLogin: null == isFirstLogin + ? _value.isFirstLogin + : isFirstLogin // ignore: cast_nullable_to_non_nullable + as bool, + requiresOnboarding: null == requiresOnboarding + ? _value.requiresOnboarding + : requiresOnboarding // ignore: cast_nullable_to_non_nullable + as bool, + onboardingStep: freezed == onboardingStep + ? _value.onboardingStep + : onboardingStep // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$SignInResponseImplCopyWith<$Res> + implements $SignInResponseCopyWith<$Res> { + factory _$$SignInResponseImplCopyWith( + _$SignInResponseImpl value, + $Res Function(_$SignInResponseImpl) then, + ) = __$$SignInResponseImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + String accessToken, + String refreshToken, + bool isFirstLogin, + bool requiresOnboarding, + String? onboardingStep, + }); +} + +/// @nodoc +class __$$SignInResponseImplCopyWithImpl<$Res> + extends _$SignInResponseCopyWithImpl<$Res, _$SignInResponseImpl> + implements _$$SignInResponseImplCopyWith<$Res> { + __$$SignInResponseImplCopyWithImpl( + _$SignInResponseImpl _value, + $Res Function(_$SignInResponseImpl) _then, + ) : super(_value, _then); + + /// Create a copy of SignInResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? accessToken = null, + Object? refreshToken = null, + Object? isFirstLogin = null, + Object? requiresOnboarding = null, + Object? onboardingStep = freezed, + }) { + return _then( + _$SignInResponseImpl( + accessToken: null == accessToken + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: null == refreshToken + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + isFirstLogin: null == isFirstLogin + ? _value.isFirstLogin + : isFirstLogin // ignore: cast_nullable_to_non_nullable + as bool, + requiresOnboarding: null == requiresOnboarding + ? _value.requiresOnboarding + : requiresOnboarding // ignore: cast_nullable_to_non_nullable + as bool, + onboardingStep: freezed == onboardingStep + ? _value.onboardingStep + : onboardingStep // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$SignInResponseImpl implements _SignInResponse { + const _$SignInResponseImpl({ + required this.accessToken, + required this.refreshToken, + this.isFirstLogin = false, + this.requiresOnboarding = false, + this.onboardingStep, + }); + + factory _$SignInResponseImpl.fromJson(Map json) => + _$$SignInResponseImplFromJson(json); + + /// API 인증용 JWT Access Token + @override + final String accessToken; + + /// Access Token 갱신용 Refresh Token + @override + final String refreshToken; + + /// 첫 로그인 여부 + @override + @JsonKey() + final bool isFirstLogin; + + /// 온보딩 완료 필요 여부 + @override + @JsonKey() + final bool requiresOnboarding; + + /// 현재 온보딩 단계: TERMS, BIRTH_DATE, GENDER, NICKNAME, COMPLETED + @override + final String? onboardingStep; + + @override + String toString() { + return 'SignInResponse(accessToken: $accessToken, refreshToken: $refreshToken, isFirstLogin: $isFirstLogin, requiresOnboarding: $requiresOnboarding, onboardingStep: $onboardingStep)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SignInResponseImpl && + (identical(other.accessToken, accessToken) || + other.accessToken == accessToken) && + (identical(other.refreshToken, refreshToken) || + other.refreshToken == refreshToken) && + (identical(other.isFirstLogin, isFirstLogin) || + other.isFirstLogin == isFirstLogin) && + (identical(other.requiresOnboarding, requiresOnboarding) || + other.requiresOnboarding == requiresOnboarding) && + (identical(other.onboardingStep, onboardingStep) || + other.onboardingStep == onboardingStep)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + accessToken, + refreshToken, + isFirstLogin, + requiresOnboarding, + onboardingStep, + ); + + /// Create a copy of SignInResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SignInResponseImplCopyWith<_$SignInResponseImpl> get copyWith => + __$$SignInResponseImplCopyWithImpl<_$SignInResponseImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$SignInResponseImplToJson(this); + } +} + +abstract class _SignInResponse implements SignInResponse { + const factory _SignInResponse({ + required final String accessToken, + required final String refreshToken, + final bool isFirstLogin, + final bool requiresOnboarding, + final String? onboardingStep, + }) = _$SignInResponseImpl; + + factory _SignInResponse.fromJson(Map json) = + _$SignInResponseImpl.fromJson; + + /// API 인증용 JWT Access Token + @override + String get accessToken; + + /// Access Token 갱신용 Refresh Token + @override + String get refreshToken; + + /// 첫 로그인 여부 + @override + bool get isFirstLogin; + + /// 온보딩 완료 필요 여부 + @override + bool get requiresOnboarding; + + /// 현재 온보딩 단계: TERMS, BIRTH_DATE, GENDER, NICKNAME, COMPLETED + @override + String? get onboardingStep; + + /// Create a copy of SignInResponse + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SignInResponseImplCopyWith<_$SignInResponseImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/auth/data/models/sign_in_response.g.dart b/lib/features/auth/data/models/sign_in_response.g.dart new file mode 100644 index 0000000..a10ed3d --- /dev/null +++ b/lib/features/auth/data/models/sign_in_response.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sign_in_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SignInResponseImpl _$$SignInResponseImplFromJson(Map json) => + _$SignInResponseImpl( + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, + isFirstLogin: json['isFirstLogin'] as bool? ?? false, + requiresOnboarding: json['requiresOnboarding'] as bool? ?? false, + onboardingStep: json['onboardingStep'] as String?, + ); + +Map _$$SignInResponseImplToJson( + _$SignInResponseImpl instance, +) => { + 'accessToken': instance.accessToken, + 'refreshToken': instance.refreshToken, + 'isFirstLogin': instance.isFirstLogin, + 'requiresOnboarding': instance.requiresOnboarding, + 'onboardingStep': instance.onboardingStep, +}; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..297dbbb --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/network/token_storage.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../datasources/auth_remote_datasource.dart'; +import '../models/sign_in_request.dart'; +import '../models/sign_in_response.dart'; +import '../models/reissue_request.dart'; + +part 'auth_repository_impl.g.dart'; + +/// AuthRepository Provider +@riverpod +AuthRepository authRepository(Ref ref) { + final remoteDataSource = ref.watch(authRemoteDataSourceProvider); + final tokenStorage = ref.watch(tokenStorageProvider); + return AuthRepositoryImpl(remoteDataSource, tokenStorage); +} + +/// AuthRepository 구현체 +/// +/// 인증 관련 비즈니스 로직을 구현합니다. +/// Remote DataSource와 Token Storage를 조합하여 +/// 완전한 인증 플로우를 제공합니다. +class AuthRepositoryImpl implements AuthRepository { + final AuthRemoteDataSource _remoteDataSource; + final TokenStorage _tokenStorage; + + AuthRepositoryImpl(this._remoteDataSource, this._tokenStorage); + + @override + Future signIn({ + required String firebaseIdToken, + String? fcmToken, + String? deviceType, + String? deviceId, + }) async { + debugPrint('🔐 AuthRepository: Starting sign-in flow...'); + + // 1. 백엔드 로그인 API 호출 + final request = SignInRequest( + firebaseIdToken: firebaseIdToken, + fcmToken: fcmToken, + deviceType: deviceType, + deviceId: deviceId, + ); + + final response = await _remoteDataSource.signIn(request); + + // 2. 토큰 저장 + await _tokenStorage.saveTokens( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + ); + debugPrint('✅ Tokens saved to secure storage'); + + // 3. 온보딩 상태 저장 + await _tokenStorage.saveOnboardingState( + requiresOnboarding: response.requiresOnboarding, + onboardingStep: response.onboardingStep, + ); + debugPrint( + '✅ Onboarding state saved: requiresOnboarding=${response.requiresOnboarding}', + ); + + return response; + } + + @override + Future refreshTokens() async { + debugPrint('🔄 AuthRepository: Refreshing tokens...'); + + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken == null) { + throw Exception('No refresh token available'); + } + + final request = ReissueRequest(refreshToken: refreshToken); + final response = await _remoteDataSource.reissue(request); + + await _tokenStorage.saveTokens( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + ); + debugPrint('✅ Tokens refreshed and saved'); + } + + @override + Future logout() async { + debugPrint('🚪 AuthRepository: Logging out...'); + + try { + // 서버에 로그아웃 알림 + await _remoteDataSource.logout(); + } catch (e) { + // 서버 로그아웃 실패해도 로컬 토큰은 삭제 + debugPrint('⚠️ Server logout failed, clearing local tokens anyway: $e'); + } + + // 로컬 토큰 삭제 + await _tokenStorage.clearTokens(); + debugPrint('✅ Local tokens cleared'); + } + + @override + Future withdraw() async { + debugPrint('⚠️ AuthRepository: Withdrawing...'); + + await _remoteDataSource.withdraw(); + await _tokenStorage.clearTokens(); + debugPrint('✅ Account withdrawn and tokens cleared'); + } + + @override + Future hasValidTokens() async { + return await _tokenStorage.hasTokens(); + } + + @override + Future requiresOnboarding() async { + return await _tokenStorage.getRequiresOnboarding(); + } + + @override + Future getCurrentOnboardingStep() async { + final stepString = await _tokenStorage.getOnboardingStep(); + return OnboardingStep.fromString(stepString); + } +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.g.dart b/lib/features/auth/data/repositories/auth_repository_impl.g.dart new file mode 100644 index 0000000..c331a6e --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_repository_impl.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authRepositoryHash() => r'9c828bf1cc8b5f189768dbbb25dd06de52adb325'; + +/// AuthRepository Provider +/// +/// Copied from [authRepository]. +@ProviderFor(authRepository) +final authRepositoryProvider = AutoDisposeProvider.internal( + authRepository, + name: r'authRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$authRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AuthRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/auth/domain/entities/onboarding_step.dart b/lib/features/auth/domain/entities/onboarding_step.dart new file mode 100644 index 0000000..80ad2f5 --- /dev/null +++ b/lib/features/auth/domain/entities/onboarding_step.dart @@ -0,0 +1,123 @@ +/// 온보딩 단계 열거형 (통합) +/// +/// 인증 모듈과 온보딩 모듈에서 공통으로 사용합니다. +/// +/// **단계**: +/// - [terms]: 약관 동의 +/// - [birthDate]: 생년월일 입력 +/// - [gender]: 성별 선택 +/// - [nickname]: 닉네임 설정 +/// - [completed]: 온보딩 완료 +enum OnboardingStep { + terms, + birthDate, + gender, + nickname, + completed; + + /// 서버 응답 문자열을 OnboardingStep으로 변환 + /// + /// null 입력 시 기본값 [terms] 반환 + static OnboardingStep fromString(String? value) { + if (value == null) return OnboardingStep.terms; + + switch (value.toUpperCase()) { + case 'TERMS': + return OnboardingStep.terms; + case 'BIRTH_DATE': + return OnboardingStep.birthDate; + case 'GENDER': + return OnboardingStep.gender; + case 'NICKNAME': + return OnboardingStep.nickname; + case 'COMPLETED': + return OnboardingStep.completed; + default: + return OnboardingStep.terms; + } + } + + /// 서버 응답 문자열을 OnboardingStep으로 변환 (nullable) + static OnboardingStep? fromStringNullable(String? value) { + if (value == null) return null; + + switch (value.toUpperCase()) { + case 'TERMS': + return OnboardingStep.terms; + case 'BIRTH_DATE': + return OnboardingStep.birthDate; + case 'GENDER': + return OnboardingStep.gender; + case 'NICKNAME': + return OnboardingStep.nickname; + case 'COMPLETED': + return OnboardingStep.completed; + default: + return null; + } + } + + /// 라우트 경로로 변환 + String toRoutePath() { + switch (this) { + case OnboardingStep.terms: + return '/onboarding/terms'; + case OnboardingStep.birthDate: + return '/onboarding/birth-date'; + case OnboardingStep.gender: + return '/onboarding/gender'; + case OnboardingStep.nickname: + return '/onboarding/nickname'; + case OnboardingStep.completed: + return '/home'; + } + } + + /// 다음 단계 반환 + OnboardingStep? get next { + switch (this) { + case OnboardingStep.terms: + return OnboardingStep.birthDate; + case OnboardingStep.birthDate: + return OnboardingStep.gender; + case OnboardingStep.gender: + return OnboardingStep.nickname; + case OnboardingStep.nickname: + return OnboardingStep.completed; + case OnboardingStep.completed: + return null; + } + } + + /// 단계 인덱스 (0-4) + int get stepIndex { + switch (this) { + case OnboardingStep.terms: + return 0; + case OnboardingStep.birthDate: + return 1; + case OnboardingStep.gender: + return 2; + case OnboardingStep.nickname: + return 3; + case OnboardingStep.completed: + return 4; + } + } + + /// 서버 전송용 문자열로 변환 + String toServerString() { + switch (this) { + case OnboardingStep.terms: + return 'TERMS'; + case OnboardingStep.birthDate: + return 'BIRTH_DATE'; + case OnboardingStep.gender: + return 'GENDER'; + case OnboardingStep.nickname: + return 'NICKNAME'; + case OnboardingStep.completed: + return 'COMPLETED'; + } + } +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..a48a0f0 --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,50 @@ +import '../../../auth/data/models/sign_in_response.dart'; + +/// 인증 Repository 인터페이스 +/// +/// 인증 관련 비즈니스 로직의 계약을 정의합니다. +/// Clean Architecture의 Domain Layer에 위치합니다. +abstract class AuthRepository { + /// 소셜 로그인 + /// + /// Firebase 인증 후 백엔드에 로그인을 요청합니다. + /// + /// [firebaseIdToken]: Firebase에서 발급받은 ID Token + /// [fcmToken]: FCM 푸시 알림 토큰 (선택) + /// [deviceType]: 디바이스 타입 - IOS, ANDROID (선택) + /// [deviceId]: 디바이스 고유 식별자 (선택) + /// + /// Returns: 로그인 응답 (토큰 + 온보딩 상태) + Future signIn({ + required String firebaseIdToken, + String? fcmToken, + String? deviceType, + String? deviceId, + }); + + /// 토큰 재발급 + /// + /// Refresh Token을 사용하여 새로운 토큰 쌍을 발급받습니다. + Future refreshTokens(); + + /// 로그아웃 + /// + /// 서버에 로그아웃을 알리고 로컬 토큰을 삭제합니다. + Future logout(); + + /// 회원 탈퇴 + /// + /// 회원 정보를 삭제하고 로컬 토큰을 정리합니다. + Future withdraw(); + + /// 저장된 토큰 존재 여부 확인 + /// + /// 앱 시작 시 자동 로그인 여부 판단에 사용합니다. + Future hasValidTokens(); + + /// 온보딩 필요 여부 확인 + Future requiresOnboarding(); + + /// 현재 온보딩 단계 조회 + Future getCurrentOnboardingStep(); +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 40f24c2..7acf55b 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -2,10 +2,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/constants/spacing_and_radius.dart'; import '../../../../core/constants/text_styles.dart'; import '../../../../core/errors/app_exception.dart'; +import '../../../../router/route_paths.dart'; +import '../../data/models/sign_in_response.dart'; import '../providers/auth_provider.dart'; /// Google 로그인 화면 @@ -18,8 +21,10 @@ class LoginPage extends ConsumerWidget { /// /// Google 로그인을 수행하고 에러 발생 시 SnackBar를 표시합니다. Future _handleGoogleSignIn(BuildContext context, WidgetRef ref) async { - // AuthNotifier로 로그인 수행 - await ref.read(authNotifierProvider.notifier).signInWithGoogle(); + // AuthNotifier로 로그인 수행 (백엔드 응답 포함) + final signInResponse = await ref + .read(authNotifierProvider.notifier) + .signInWithGoogle(); // 에러 체크 및 SnackBar 표시 if (!context.mounted) return; @@ -31,7 +36,6 @@ class LoginPage extends ConsumerWidget { ? (authState.error as AuthException).message : '로그인 중 오류가 발생했습니다.'; - //TODO: 스낵바 나중에 디자인 만들어지면 바뀌어야함. ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage, style: AppTextStyles.toast), @@ -40,17 +44,23 @@ class LoginPage extends ConsumerWidget { duration: const Duration(seconds: 3), ), ); + return; } - // 성공 시 GoRouter가 자동으로 HomePage로 리다이렉트 + // 로그인 성공 시 온보딩 여부에 따라 리다이렉트 + if (signInResponse != null) { + _navigateAfterLogin(context, signInResponse); + } } /// Apple 로그인 버튼 핸들러 /// /// Apple 로그인을 수행하고 에러 발생 시 SnackBar를 표시합니다. Future _handleAppleSignIn(BuildContext context, WidgetRef ref) async { - // AuthNotifier로 로그인 수행 - await ref.read(authNotifierProvider.notifier).signInWithApple(); + // AuthNotifier로 로그인 수행 (백엔드 응답 포함) + final signInResponse = await ref + .read(authNotifierProvider.notifier) + .signInWithApple(); // 에러 체크 및 SnackBar 표시 if (!context.mounted) return; @@ -62,7 +72,6 @@ class LoginPage extends ConsumerWidget { ? (authState.error as AuthException).message : 'Apple 로그인 중 오류가 발생했습니다.'; - //TODO: 스낵바 나중에 디자인 만들어지면 바뀌어야함. ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage, style: AppTextStyles.toast), @@ -71,9 +80,43 @@ class LoginPage extends ConsumerWidget { duration: const Duration(seconds: 3), ), ); + return; + } + + // 로그인 성공 시 온보딩 여부에 따라 리다이렉트 + if (signInResponse != null) { + _navigateAfterLogin(context, signInResponse); } + } - // 성공 시 GoRouter가 자동으로 HomePage로 리다이렉트 + /// 로그인 성공 후 네비게이션 처리 + /// + /// 온보딩 필요 여부에 따라 적절한 화면으로 이동합니다. + void _navigateAfterLogin(BuildContext context, SignInResponse response) { + if (response.requiresOnboarding) { + // 온보딩이 필요한 경우 현재 단계로 이동 + final step = OnboardingStep.fromString(response.onboardingStep); + switch (step) { + case OnboardingStep.terms: + context.go(RoutePaths.onboardingTerms); + break; + case OnboardingStep.birthDate: + context.go(RoutePaths.onboardingBirthDate); + break; + case OnboardingStep.gender: + context.go(RoutePaths.onboardingGender); + break; + case OnboardingStep.nickname: + context.go(RoutePaths.onboardingNickname); + break; + case OnboardingStep.completed: + context.go(RoutePaths.home); + break; + } + } else { + // 온보딩이 완료된 경우 홈으로 이동 + context.go(RoutePaths.home); + } } @override diff --git a/lib/features/auth/presentation/pages/splash_page.dart b/lib/features/auth/presentation/pages/splash_page.dart index e0bb190..950facb 100644 --- a/lib/features/auth/presentation/pages/splash_page.dart +++ b/lib/features/auth/presentation/pages/splash_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../router/route_paths.dart'; +import '../../domain/entities/onboarding_step.dart'; +import '../providers/auth_provider.dart'; /// 앱 시작 시 초기 화면 /// @@ -9,34 +12,80 @@ import '../../../../router/route_paths.dart'; /// - 비인증: login 화면으로 이동 /// - 인증 + 온보딩 미완료: onboarding 화면으로 이동 /// - 인증 + 온보딩 완료: home 화면으로 이동 -class SplashPage extends StatefulWidget { +class SplashPage extends ConsumerStatefulWidget { const SplashPage({super.key}); @override - State createState() => _SplashPageState(); + ConsumerState createState() => _SplashPageState(); } -class _SplashPageState extends State { +class _SplashPageState extends ConsumerState { @override void initState() { super.initState(); _navigateToNextScreen(); } - /// 2초 후 다음 화면으로 자동 이동 + /// 인증 상태를 확인하고 적절한 화면으로 이동 Future _navigateToNextScreen() async { + // 최소 2초간 스플래시 표시 await Future.delayed(const Duration(seconds: 2)); // Widget이 여전히 마운트되어 있는지 확인 (메모리 누수 방지) if (!mounted) return; - // TODO: 인증 상태에 따라 분기 처리 - // 현재는 무조건 로그인 화면으로 이동 - // 향후 Auth Provider 구현 후 다음과 같이 분기: - // - 비인증 → login - // - 인증 + 온보딩 미완료 → onboarding - // - 인증 + 온보딩 완료 → home - context.go(RoutePaths.login); + try { + final authNotifier = ref.read(authNotifierProvider.notifier); + + // 1. 저장된 토큰으로 자동 로그인 시도 + final hasValidTokens = await authNotifier.tryAutoLogin(); + debugPrint('🔐 Auto-login check: hasValidTokens=$hasValidTokens'); + + if (!mounted) return; + + if (!hasValidTokens) { + // 토큰이 없거나 유효하지 않음 → 로그인 화면으로 + debugPrint('🔀 Navigating to login (no valid tokens)'); + context.go(RoutePaths.login); + return; + } + + // 2. 온보딩 필요 여부 확인 + final requiresOnboarding = await authNotifier.checkRequiresOnboarding(); + debugPrint('📋 Onboarding check: requiresOnboarding=$requiresOnboarding'); + + if (!mounted) return; + + if (!requiresOnboarding) { + // 온보딩 완료됨 → 홈 화면으로 + debugPrint('🔀 Navigating to home (onboarding completed)'); + context.go(RoutePaths.home); + return; + } + + // 3. 온보딩 미완료 → 현재 온보딩 단계로 이동 + final currentStep = await authNotifier.getCurrentOnboardingStep(); + debugPrint('📋 Current onboarding step: $currentStep'); + + if (!mounted) return; + + if (currentStep != null && currentStep != OnboardingStep.completed) { + // 해당 온보딩 단계로 이동 + final routePath = currentStep.toRoutePath(); + debugPrint('🔀 Navigating to onboarding step: $routePath'); + context.go(routePath); + } else { + // 단계를 알 수 없으면 약관 동의부터 시작 + debugPrint('🔀 Navigating to onboarding terms (default)'); + context.go(RoutePaths.onboardingTerms); + } + } catch (e) { + // 에러 발생 시 로그인 화면으로 이동 + debugPrint('❌ Splash navigation error: $e'); + if (mounted) { + context.go(RoutePaths.login); + } + } } @override diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index b653aab..3c9cdc2 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -4,7 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../../core/errors/app_exception.dart'; +import '../../../../core/network/token_storage.dart'; +import '../../../../core/services/device/device_info_service.dart'; +import '../../../../core/services/fcm/firebase_messaging_service.dart'; import '../../data/datasources/firebase_auth_datasource.dart'; +import '../../data/models/sign_in_response.dart'; +import '../../data/repositories/auth_repository_impl.dart'; import '../../domain/utils/firebase_auth_error_handler.dart'; part 'auth_provider.g.dart'; @@ -27,97 +32,138 @@ Stream authState(Ref ref) { /// 인증 상태를 관리하는 Notifier /// -/// Google 로그인, 로그아웃 등의 인증 작업을 수행하며 -/// 로딩/에러 상태를 관리합니다. +/// Google/Apple 로그인, 로그아웃 등의 인증 작업을 수행하며 +/// Firebase 인증 후 백엔드 로그인까지 완료합니다. @riverpod class AuthNotifier extends _$AuthNotifier { @override FutureOr build() { - // Firebase Auth의 현재 사용자를 반환 final dataSource = ref.watch(firebaseAuthDataSourceProvider); return dataSource.currentUser; } /// Google 로그인 수행 /// - /// 사용자가 취소하거나 네트워크 오류 발생 시 - /// [AuthException]으로 변환하여 에러 상태를 설정합니다. - /// - /// 로그인 실패 시 Firebase/Google 세션을 모두 정리합니다. - Future signInWithGoogle() async { + /// 1. Firebase Google 로그인 + /// 2. Firebase ID Token 획득 + /// 3. FCM 토큰 + 기기 정보 수집 + /// 4. 백엔드 로그인 API 호출 + /// 5. JWT 토큰 저장 + /// 6. 온보딩 상태 저장 + Future signInWithGoogle() async { state = const AsyncValue.loading(); try { final dataSource = ref.read(firebaseAuthDataSourceProvider); + + // 1. Firebase Google 로그인 final userCredential = await dataSource.signInWithGoogle(); + debugPrint('✅ Firebase Google login success'); + + // 2. Firebase ID Token 획득 + final idToken = await dataSource.getIdToken(); + debugPrint('✅ Firebase ID Token obtained'); - // Firebase ID Token 검증 (실패 시 내부에서 세션 정리 수행) - await _validateIdToken('google'); + // 3. 백엔드 로그인 완료 + final signInResponse = await _completeBackendLogin(idToken, 'google'); state = AsyncValue.data(userCredential.user); + return signInResponse; } on FirebaseAuthException catch (e) { - // 토큰 검증 실패는 이미 _validateIdToken에서 세션 정리됨 - // 그 외 Firebase 에러만 여기서 세션 정리 - if (e.code != 'token-validation-failed') { - await _cleanupSessionOnFailure('google'); - } - - // Firebase 에러를 사용자 친화적 메시지로 변환 + await _cleanupSessionOnFailure('google'); state = AsyncValue.error( FirebaseAuthErrorHandler.createAuthException(e, provider: 'Google'), StackTrace.current, ); + return null; } catch (e, stack) { - // 예상치 못한 에러 발생 시 세션 정리 await _cleanupSessionOnFailure('google'); - - // 기타 예외 처리 state = AsyncValue.error( AuthException(message: '알 수 없는 오류가 발생했습니다.', originalException: e), stack, ); + return null; } } /// Apple 로그인 수행 - /// - /// 사용자가 취소하거나 네트워크 오류 발생 시 - /// [AuthException]으로 변환하여 에러 상태를 설정합니다. - /// - /// 로그인 실패 시 Firebase 및 소셜 로그인 세션을 모두 정리합니다. - Future signInWithApple() async { + Future signInWithApple() async { state = const AsyncValue.loading(); try { final dataSource = ref.read(firebaseAuthDataSourceProvider); + + // 1. Firebase Apple 로그인 final userCredential = await dataSource.signInWithApple(); + debugPrint('✅ Firebase Apple login success'); + + // 2. Firebase ID Token 획득 + final idToken = await dataSource.getIdToken(); + debugPrint('✅ Firebase ID Token obtained'); - // Firebase ID Token 검증 (실패 시 내부에서 세션 정리 수행) - await _validateIdToken('apple'); + // 3. 백엔드 로그인 완료 + final signInResponse = await _completeBackendLogin(idToken, 'apple'); state = AsyncValue.data(userCredential.user); + return signInResponse; } on FirebaseAuthException catch (e) { - // 토큰 검증 실패는 이미 _validateIdToken에서 세션 정리됨 - // 그 외 Firebase 에러만 여기서 세션 정리 - if (e.code != 'token-validation-failed') { - await _cleanupSessionOnFailure('apple'); - } - - // Firebase 에러를 사용자 친화적 메시지로 변환 + await _cleanupSessionOnFailure('apple'); state = AsyncValue.error( FirebaseAuthErrorHandler.createAuthException(e, provider: 'Apple'), StackTrace.current, ); + return null; } catch (e, stack) { - // 예상치 못한 에러 발생 시 세션 정리 await _cleanupSessionOnFailure('apple'); - - // 기타 예외 처리 state = AsyncValue.error( AuthException(message: '알 수 없는 오류가 발생했습니다.', originalException: e), stack, ); + return null; + } + } + + /// 백엔드 로그인 완료 (공통 로직) + Future _completeBackendLogin( + String idToken, + String provider, + ) async { + // FCM 토큰 획득 + String? fcmToken; + try { + final fcmService = ref.read(firebaseMessagingServiceProvider); + fcmToken = await fcmService.getFcmToken(); + debugPrint('✅ FCM Token obtained'); + } catch (e) { + debugPrint('⚠️ FCM Token retrieval failed (continuing): $e'); + } + + // 기기 정보 수집 + String? deviceType; + String? deviceId; + try { + final deviceInfoService = ref.read(deviceInfoServiceProvider); + final deviceInfo = await deviceInfoService.getDeviceInfo(); + deviceType = deviceInfo.deviceType; + deviceId = deviceInfo.deviceId; + debugPrint('✅ Device info obtained: $deviceType, $deviceId'); + } catch (e) { + debugPrint('⚠️ Device info retrieval failed (continuing): $e'); } + + // 백엔드 로그인 API 호출 + final authRepository = ref.read(authRepositoryProvider); + final signInResponse = await authRepository.signIn( + firebaseIdToken: idToken, + fcmToken: fcmToken, + deviceType: deviceType, + deviceId: deviceId, + ); + debugPrint('✅ Backend login success'); + debugPrint(' requiresOnboarding: ${signInResponse.requiresOnboarding}'); + debugPrint(' onboardingStep: ${signInResponse.onboardingStep}'); + + return signInResponse; } /// 로그아웃 @@ -125,10 +171,23 @@ class AuthNotifier extends _$AuthNotifier { state = const AsyncValue.loading(); try { + // Firebase 로그아웃 final dataSource = ref.read(firebaseAuthDataSourceProvider); await dataSource.signOut(); + + // 백엔드 로그아웃 + 토큰 삭제 + final authRepository = ref.read(authRepositoryProvider); + await authRepository.logout(); + state = const AsyncValue.data(null); + debugPrint('✅ Sign out success'); } catch (e, stack) { + // 에러가 발생해도 로컬 토큰은 삭제 + try { + final tokenStorage = ref.read(tokenStorageProvider); + await tokenStorage.clearTokens(); + } catch (_) {} + state = AsyncValue.error( AuthException(message: '로그아웃에 실패했습니다.', originalException: e), stack, @@ -136,47 +195,82 @@ class AuthNotifier extends _$AuthNotifier { } } - /// 로그인 실패 시 세션 정리 - /// - /// Firebase 및 Google 세션을 모두 정리합니다. - /// 에러가 발생해도 무시하고 계속 진행합니다. - /// - /// [provider]: 로그인 제공자 이름 (로그 출력용) - Future _cleanupSessionOnFailure(String provider) async { + /// 회원 탈퇴 + Future withdraw() async { + state = const AsyncValue.loading(); + try { + // 백엔드 회원 탈퇴 + final authRepository = ref.read(authRepositoryProvider); + await authRepository.withdraw(); + + // Firebase 로그아웃 final dataSource = ref.read(firebaseAuthDataSourceProvider); await dataSource.signOut(); - debugPrint('🔄 로그인 실패 - Firebase/Google 세션 정리 완료 ($provider)'); - } catch (signOutError) { - debugPrint('⚠️ 로그아웃 중 에러 (무시): $signOutError'); + + state = const AsyncValue.data(null); + debugPrint('✅ Withdraw success'); + } catch (e, stack) { + state = AsyncValue.error( + AuthException(message: '회원 탈퇴에 실패했습니다.', originalException: e), + stack, + ); } } - /// 로그인 후 Firebase ID Token 검증 - /// - /// 토큰 발급 실패 시 세션을 정리하고 명시적인 FirebaseAuthException을 발생시킵니다. - /// 상위 호출자는 토큰 검증 실패(code: 'token-validation-failed')에 대한 - /// 세션 정리를 신경쓰지 않아도 됩니다. - /// - /// [provider]: 로그인 제공자 이름 (로그 출력용) - /// - /// Throws: [FirebaseAuthException] (code: 'token-validation-failed') 토큰 발급 실패 시 - Future _validateIdToken(String provider) async { + /// 저장된 토큰으로 자동 로그인 시도 + Future tryAutoLogin() async { + try { + final authRepository = ref.read(authRepositoryProvider); + final hasTokens = await authRepository.hasValidTokens(); + + if (hasTokens) { + debugPrint('✅ Valid tokens found, auto-login enabled'); + return true; + } + + debugPrint('⚠️ No valid tokens found'); + return false; + } catch (e) { + debugPrint('❌ Auto-login check failed: $e'); + return false; + } + } + + /// 온보딩 필요 여부 확인 + Future checkRequiresOnboarding() async { + try { + final authRepository = ref.read(authRepositoryProvider); + return await authRepository.requiresOnboarding(); + } catch (e) { + debugPrint('❌ Onboarding check failed: $e'); + return false; + } + } + + /// 현재 온보딩 단계 조회 + Future getCurrentOnboardingStep() async { + try { + final authRepository = ref.read(authRepositoryProvider); + return await authRepository.getCurrentOnboardingStep(); + } catch (e) { + debugPrint('❌ Get onboarding step failed: $e'); + return null; + } + } + + /// 로그인 실패 시 세션 정리 + Future _cleanupSessionOnFailure(String provider) async { try { final dataSource = ref.read(firebaseAuthDataSourceProvider); - await dataSource.getIdToken(); - } catch (tokenError) { - debugPrint('❌ Firebase ID Token 발급 실패 - 로그인 취소 및 로그아웃 처리 ($provider)'); - debugPrint('에러 타입: ${tokenError.runtimeType}'); - debugPrint('에러 상세: $tokenError'); - await _cleanupSessionOnFailure(provider); - - // 토큰 발급 실패를 명시적인 FirebaseAuthException으로 변환 - // 이를 통해 상위 호출자에서 일관된 에러 처리 가능 - throw FirebaseAuthException( - code: 'token-validation-failed', - message: 'Firebase ID Token 발급에 실패했습니다.', - ); + await dataSource.signOut(); + + final tokenStorage = ref.read(tokenStorageProvider); + await tokenStorage.clearTokens(); + + debugPrint('🔄 로그인 실패 - 세션 정리 완료 ($provider)'); + } catch (signOutError) { + debugPrint('⚠️ 로그아웃 중 에러 (무시): $signOutError'); } } } diff --git a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart new file mode 100644 index 0000000..50ae0be --- /dev/null +++ b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart @@ -0,0 +1,90 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/network/api_client.dart'; +import '../models/terms_request.dart'; +import '../models/birth_date_request.dart'; +import '../models/gender_request.dart'; +import '../models/profile_request.dart'; + +part 'onboarding_remote_datasource.g.dart'; + +/// OnboardingRemoteDataSource Provider +@riverpod +OnboardingRemoteDataSource onboardingRemoteDataSource(Ref ref) { + final dio = ref.watch(dioProvider); + return OnboardingRemoteDataSource(dio); +} + +/// 온보딩 Remote DataSource +/// +/// 백엔드 온보딩 API를 호출합니다. +class OnboardingRemoteDataSource { + final Dio _dio; + + OnboardingRemoteDataSource(this._dio); + + /// 약관 동의 제출 + /// + /// POST /api/members/onboarding/terms + Future submitTerms(TermsRequest request) async { + debugPrint('📤 OnboardingRemoteDataSource: Submitting terms agreement...'); + + await _dio.post(ApiEndpoints.onboardingTerms, data: request.toJson()); + + debugPrint('✅ Terms agreement submitted successfully'); + } + + /// 생년월일 제출 + /// + /// POST /api/members/onboarding/birth-date + Future submitBirthDate(BirthDateRequest request) async { + debugPrint('📤 OnboardingRemoteDataSource: Submitting birth date...'); + + await _dio.post(ApiEndpoints.onboardingBirthDate, data: request.toJson()); + + debugPrint('✅ Birth date submitted successfully'); + } + + /// 성별 제출 + /// + /// POST /api/members/onboarding/gender + Future submitGender(GenderRequest request) async { + debugPrint('📤 OnboardingRemoteDataSource: Submitting gender...'); + + await _dio.post(ApiEndpoints.onboardingGender, data: request.toJson()); + + debugPrint('✅ Gender submitted successfully'); + } + + /// 프로필(닉네임) 제출 + /// + /// POST /api/members/profile + Future submitProfile(ProfileRequest request) async { + debugPrint('📤 OnboardingRemoteDataSource: Submitting profile...'); + + await _dio.post(ApiEndpoints.memberProfile, data: request.toJson()); + + debugPrint('✅ Profile submitted successfully'); + } + + /// 닉네임 중복 확인 + /// + /// GET /api/members/check-name?name=xxx + Future checkName(String name) async { + debugPrint('📤 OnboardingRemoteDataSource: Checking name availability...'); + + final response = await _dio.get( + ApiEndpoints.checkName, + queryParameters: {'name': name}, + ); + + final checkNameResponse = CheckNameResponse.fromJson(response.data); + debugPrint('✅ Name check result: ${checkNameResponse.available}'); + + return checkNameResponse; + } +} diff --git a/lib/features/onboarding/data/datasources/onboarding_remote_datasource.g.dart b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.g.dart new file mode 100644 index 0000000..1678e2e --- /dev/null +++ b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding_remote_datasource.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onboardingRemoteDataSourceHash() => + r'93c34981392d0b5d46a33b6cc2ebc536130e46b4'; + +/// OnboardingRemoteDataSource Provider +/// +/// Copied from [onboardingRemoteDataSource]. +@ProviderFor(onboardingRemoteDataSource) +final onboardingRemoteDataSourceProvider = + AutoDisposeProvider.internal( + onboardingRemoteDataSource, + name: r'onboardingRemoteDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$onboardingRemoteDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef OnboardingRemoteDataSourceRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/onboarding/data/models/birth_date_request.dart b/lib/features/onboarding/data/models/birth_date_request.dart new file mode 100644 index 0000000..acdec05 --- /dev/null +++ b/lib/features/onboarding/data/models/birth_date_request.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'birth_date_request.freezed.dart'; +part 'birth_date_request.g.dart'; + +/// 생년월일 요청 모델 +/// +/// POST /api/members/onboarding/birth-date 요청 바디 +@freezed +class BirthDateRequest with _$BirthDateRequest { + const factory BirthDateRequest({ + /// 생년월일 (YYYY-MM-DD 형식) + required String birthDate, + }) = _BirthDateRequest; + + factory BirthDateRequest.fromJson(Map json) => + _$BirthDateRequestFromJson(json); +} diff --git a/lib/features/onboarding/data/models/birth_date_request.freezed.dart b/lib/features/onboarding/data/models/birth_date_request.freezed.dart new file mode 100644 index 0000000..74e664c --- /dev/null +++ b/lib/features/onboarding/data/models/birth_date_request.freezed.dart @@ -0,0 +1,175 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'birth_date_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +BirthDateRequest _$BirthDateRequestFromJson(Map json) { + return _BirthDateRequest.fromJson(json); +} + +/// @nodoc +mixin _$BirthDateRequest { + /// 생년월일 (YYYY-MM-DD 형식) + String get birthDate => throw _privateConstructorUsedError; + + /// Serializes this BirthDateRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of BirthDateRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $BirthDateRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $BirthDateRequestCopyWith<$Res> { + factory $BirthDateRequestCopyWith( + BirthDateRequest value, + $Res Function(BirthDateRequest) then, + ) = _$BirthDateRequestCopyWithImpl<$Res, BirthDateRequest>; + @useResult + $Res call({String birthDate}); +} + +/// @nodoc +class _$BirthDateRequestCopyWithImpl<$Res, $Val extends BirthDateRequest> + implements $BirthDateRequestCopyWith<$Res> { + _$BirthDateRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of BirthDateRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? birthDate = null}) { + return _then( + _value.copyWith( + birthDate: null == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$BirthDateRequestImplCopyWith<$Res> + implements $BirthDateRequestCopyWith<$Res> { + factory _$$BirthDateRequestImplCopyWith( + _$BirthDateRequestImpl value, + $Res Function(_$BirthDateRequestImpl) then, + ) = __$$BirthDateRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String birthDate}); +} + +/// @nodoc +class __$$BirthDateRequestImplCopyWithImpl<$Res> + extends _$BirthDateRequestCopyWithImpl<$Res, _$BirthDateRequestImpl> + implements _$$BirthDateRequestImplCopyWith<$Res> { + __$$BirthDateRequestImplCopyWithImpl( + _$BirthDateRequestImpl _value, + $Res Function(_$BirthDateRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of BirthDateRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? birthDate = null}) { + return _then( + _$BirthDateRequestImpl( + birthDate: null == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$BirthDateRequestImpl implements _BirthDateRequest { + const _$BirthDateRequestImpl({required this.birthDate}); + + factory _$BirthDateRequestImpl.fromJson(Map json) => + _$$BirthDateRequestImplFromJson(json); + + /// 생년월일 (YYYY-MM-DD 형식) + @override + final String birthDate; + + @override + String toString() { + return 'BirthDateRequest(birthDate: $birthDate)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$BirthDateRequestImpl && + (identical(other.birthDate, birthDate) || + other.birthDate == birthDate)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, birthDate); + + /// Create a copy of BirthDateRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$BirthDateRequestImplCopyWith<_$BirthDateRequestImpl> get copyWith => + __$$BirthDateRequestImplCopyWithImpl<_$BirthDateRequestImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$BirthDateRequestImplToJson(this); + } +} + +abstract class _BirthDateRequest implements BirthDateRequest { + const factory _BirthDateRequest({required final String birthDate}) = + _$BirthDateRequestImpl; + + factory _BirthDateRequest.fromJson(Map json) = + _$BirthDateRequestImpl.fromJson; + + /// 생년월일 (YYYY-MM-DD 형식) + @override + String get birthDate; + + /// Create a copy of BirthDateRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$BirthDateRequestImplCopyWith<_$BirthDateRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/data/models/birth_date_request.g.dart b/lib/features/onboarding/data/models/birth_date_request.g.dart new file mode 100644 index 0000000..2c846f5 --- /dev/null +++ b/lib/features/onboarding/data/models/birth_date_request.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'birth_date_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$BirthDateRequestImpl _$$BirthDateRequestImplFromJson( + Map json, +) => _$BirthDateRequestImpl(birthDate: json['birthDate'] as String); + +Map _$$BirthDateRequestImplToJson( + _$BirthDateRequestImpl instance, +) => {'birthDate': instance.birthDate}; diff --git a/lib/features/onboarding/data/models/gender_request.dart b/lib/features/onboarding/data/models/gender_request.dart new file mode 100644 index 0000000..1223278 --- /dev/null +++ b/lib/features/onboarding/data/models/gender_request.dart @@ -0,0 +1,28 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'gender_request.freezed.dart'; +part 'gender_request.g.dart'; + +/// 성별 유형 +enum Gender { + @JsonValue('MALE') + male, + @JsonValue('FEMALE') + female, + @JsonValue('OTHER') + other, +} + +/// 성별 요청 모델 +/// +/// POST /api/members/onboarding/gender 요청 바디 +@freezed +class GenderRequest with _$GenderRequest { + const factory GenderRequest({ + /// 성별 (MALE, FEMALE, OTHER) + required Gender gender, + }) = _GenderRequest; + + factory GenderRequest.fromJson(Map json) => + _$GenderRequestFromJson(json); +} diff --git a/lib/features/onboarding/data/models/gender_request.freezed.dart b/lib/features/onboarding/data/models/gender_request.freezed.dart new file mode 100644 index 0000000..bbcbc97 --- /dev/null +++ b/lib/features/onboarding/data/models/gender_request.freezed.dart @@ -0,0 +1,171 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'gender_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +GenderRequest _$GenderRequestFromJson(Map json) { + return _GenderRequest.fromJson(json); +} + +/// @nodoc +mixin _$GenderRequest { + /// 성별 (MALE, FEMALE, OTHER) + Gender get gender => throw _privateConstructorUsedError; + + /// Serializes this GenderRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of GenderRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GenderRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GenderRequestCopyWith<$Res> { + factory $GenderRequestCopyWith( + GenderRequest value, + $Res Function(GenderRequest) then, + ) = _$GenderRequestCopyWithImpl<$Res, GenderRequest>; + @useResult + $Res call({Gender gender}); +} + +/// @nodoc +class _$GenderRequestCopyWithImpl<$Res, $Val extends GenderRequest> + implements $GenderRequestCopyWith<$Res> { + _$GenderRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GenderRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? gender = null}) { + return _then( + _value.copyWith( + gender: null == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$GenderRequestImplCopyWith<$Res> + implements $GenderRequestCopyWith<$Res> { + factory _$$GenderRequestImplCopyWith( + _$GenderRequestImpl value, + $Res Function(_$GenderRequestImpl) then, + ) = __$$GenderRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({Gender gender}); +} + +/// @nodoc +class __$$GenderRequestImplCopyWithImpl<$Res> + extends _$GenderRequestCopyWithImpl<$Res, _$GenderRequestImpl> + implements _$$GenderRequestImplCopyWith<$Res> { + __$$GenderRequestImplCopyWithImpl( + _$GenderRequestImpl _value, + $Res Function(_$GenderRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of GenderRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? gender = null}) { + return _then( + _$GenderRequestImpl( + gender: null == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$GenderRequestImpl implements _GenderRequest { + const _$GenderRequestImpl({required this.gender}); + + factory _$GenderRequestImpl.fromJson(Map json) => + _$$GenderRequestImplFromJson(json); + + /// 성별 (MALE, FEMALE, OTHER) + @override + final Gender gender; + + @override + String toString() { + return 'GenderRequest(gender: $gender)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GenderRequestImpl && + (identical(other.gender, gender) || other.gender == gender)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, gender); + + /// Create a copy of GenderRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GenderRequestImplCopyWith<_$GenderRequestImpl> get copyWith => + __$$GenderRequestImplCopyWithImpl<_$GenderRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$GenderRequestImplToJson(this); + } +} + +abstract class _GenderRequest implements GenderRequest { + const factory _GenderRequest({required final Gender gender}) = + _$GenderRequestImpl; + + factory _GenderRequest.fromJson(Map json) = + _$GenderRequestImpl.fromJson; + + /// 성별 (MALE, FEMALE, OTHER) + @override + Gender get gender; + + /// Create a copy of GenderRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GenderRequestImplCopyWith<_$GenderRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/data/models/gender_request.g.dart b/lib/features/onboarding/data/models/gender_request.g.dart new file mode 100644 index 0000000..36caf86 --- /dev/null +++ b/lib/features/onboarding/data/models/gender_request.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gender_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$GenderRequestImpl _$$GenderRequestImplFromJson(Map json) => + _$GenderRequestImpl(gender: $enumDecode(_$GenderEnumMap, json['gender'])); + +Map _$$GenderRequestImplToJson(_$GenderRequestImpl instance) => + {'gender': _$GenderEnumMap[instance.gender]!}; + +const _$GenderEnumMap = { + Gender.male: 'MALE', + Gender.female: 'FEMALE', + Gender.other: 'OTHER', +}; diff --git a/lib/features/onboarding/data/models/profile_request.dart b/lib/features/onboarding/data/models/profile_request.dart new file mode 100644 index 0000000..b3256bc --- /dev/null +++ b/lib/features/onboarding/data/models/profile_request.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_request.freezed.dart'; +part 'profile_request.g.dart'; + +/// 프로필(닉네임) 요청 모델 +/// +/// POST /api/members/profile 요청 바디 +@freezed +class ProfileRequest with _$ProfileRequest { + const factory ProfileRequest({ + /// 닉네임 + required String name, + }) = _ProfileRequest; + + factory ProfileRequest.fromJson(Map json) => + _$ProfileRequestFromJson(json); +} + +/// 닉네임 중복 확인 응답 모델 +/// +/// GET /api/members/check-name?name=xxx +@freezed +class CheckNameResponse with _$CheckNameResponse { + const factory CheckNameResponse({ + /// 사용 가능 여부 + required bool available, + + /// 메시지 (사용 불가 시 이유) + String? message, + }) = _CheckNameResponse; + + factory CheckNameResponse.fromJson(Map json) => + _$CheckNameResponseFromJson(json); +} diff --git a/lib/features/onboarding/data/models/profile_request.freezed.dart b/lib/features/onboarding/data/models/profile_request.freezed.dart new file mode 100644 index 0000000..948aced --- /dev/null +++ b/lib/features/onboarding/data/models/profile_request.freezed.dart @@ -0,0 +1,355 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'profile_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +ProfileRequest _$ProfileRequestFromJson(Map json) { + return _ProfileRequest.fromJson(json); +} + +/// @nodoc +mixin _$ProfileRequest { + /// 닉네임 + String get name => throw _privateConstructorUsedError; + + /// Serializes this ProfileRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ProfileRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProfileRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProfileRequestCopyWith<$Res> { + factory $ProfileRequestCopyWith( + ProfileRequest value, + $Res Function(ProfileRequest) then, + ) = _$ProfileRequestCopyWithImpl<$Res, ProfileRequest>; + @useResult + $Res call({String name}); +} + +/// @nodoc +class _$ProfileRequestCopyWithImpl<$Res, $Val extends ProfileRequest> + implements $ProfileRequestCopyWith<$Res> { + _$ProfileRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProfileRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? name = null}) { + return _then( + _value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$ProfileRequestImplCopyWith<$Res> + implements $ProfileRequestCopyWith<$Res> { + factory _$$ProfileRequestImplCopyWith( + _$ProfileRequestImpl value, + $Res Function(_$ProfileRequestImpl) then, + ) = __$$ProfileRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name}); +} + +/// @nodoc +class __$$ProfileRequestImplCopyWithImpl<$Res> + extends _$ProfileRequestCopyWithImpl<$Res, _$ProfileRequestImpl> + implements _$$ProfileRequestImplCopyWith<$Res> { + __$$ProfileRequestImplCopyWithImpl( + _$ProfileRequestImpl _value, + $Res Function(_$ProfileRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of ProfileRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? name = null}) { + return _then( + _$ProfileRequestImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$ProfileRequestImpl implements _ProfileRequest { + const _$ProfileRequestImpl({required this.name}); + + factory _$ProfileRequestImpl.fromJson(Map json) => + _$$ProfileRequestImplFromJson(json); + + /// 닉네임 + @override + final String name; + + @override + String toString() { + return 'ProfileRequest(name: $name)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProfileRequestImpl && + (identical(other.name, name) || other.name == name)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, name); + + /// Create a copy of ProfileRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ProfileRequestImplCopyWith<_$ProfileRequestImpl> get copyWith => + __$$ProfileRequestImplCopyWithImpl<_$ProfileRequestImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$ProfileRequestImplToJson(this); + } +} + +abstract class _ProfileRequest implements ProfileRequest { + const factory _ProfileRequest({required final String name}) = + _$ProfileRequestImpl; + + factory _ProfileRequest.fromJson(Map json) = + _$ProfileRequestImpl.fromJson; + + /// 닉네임 + @override + String get name; + + /// Create a copy of ProfileRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ProfileRequestImplCopyWith<_$ProfileRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} + +CheckNameResponse _$CheckNameResponseFromJson(Map json) { + return _CheckNameResponse.fromJson(json); +} + +/// @nodoc +mixin _$CheckNameResponse { + /// 사용 가능 여부 + bool get available => throw _privateConstructorUsedError; + + /// 메시지 (사용 불가 시 이유) + String? get message => throw _privateConstructorUsedError; + + /// Serializes this CheckNameResponse to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of CheckNameResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CheckNameResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CheckNameResponseCopyWith<$Res> { + factory $CheckNameResponseCopyWith( + CheckNameResponse value, + $Res Function(CheckNameResponse) then, + ) = _$CheckNameResponseCopyWithImpl<$Res, CheckNameResponse>; + @useResult + $Res call({bool available, String? message}); +} + +/// @nodoc +class _$CheckNameResponseCopyWithImpl<$Res, $Val extends CheckNameResponse> + implements $CheckNameResponseCopyWith<$Res> { + _$CheckNameResponseCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CheckNameResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? available = null, Object? message = freezed}) { + return _then( + _value.copyWith( + available: null == available + ? _value.available + : available // ignore: cast_nullable_to_non_nullable + as bool, + message: freezed == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$CheckNameResponseImplCopyWith<$Res> + implements $CheckNameResponseCopyWith<$Res> { + factory _$$CheckNameResponseImplCopyWith( + _$CheckNameResponseImpl value, + $Res Function(_$CheckNameResponseImpl) then, + ) = __$$CheckNameResponseImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool available, String? message}); +} + +/// @nodoc +class __$$CheckNameResponseImplCopyWithImpl<$Res> + extends _$CheckNameResponseCopyWithImpl<$Res, _$CheckNameResponseImpl> + implements _$$CheckNameResponseImplCopyWith<$Res> { + __$$CheckNameResponseImplCopyWithImpl( + _$CheckNameResponseImpl _value, + $Res Function(_$CheckNameResponseImpl) _then, + ) : super(_value, _then); + + /// Create a copy of CheckNameResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? available = null, Object? message = freezed}) { + return _then( + _$CheckNameResponseImpl( + available: null == available + ? _value.available + : available // ignore: cast_nullable_to_non_nullable + as bool, + message: freezed == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$CheckNameResponseImpl implements _CheckNameResponse { + const _$CheckNameResponseImpl({required this.available, this.message}); + + factory _$CheckNameResponseImpl.fromJson(Map json) => + _$$CheckNameResponseImplFromJson(json); + + /// 사용 가능 여부 + @override + final bool available; + + /// 메시지 (사용 불가 시 이유) + @override + final String? message; + + @override + String toString() { + return 'CheckNameResponse(available: $available, message: $message)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CheckNameResponseImpl && + (identical(other.available, available) || + other.available == available) && + (identical(other.message, message) || other.message == message)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, available, message); + + /// Create a copy of CheckNameResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CheckNameResponseImplCopyWith<_$CheckNameResponseImpl> get copyWith => + __$$CheckNameResponseImplCopyWithImpl<_$CheckNameResponseImpl>( + this, + _$identity, + ); + + @override + Map toJson() { + return _$$CheckNameResponseImplToJson(this); + } +} + +abstract class _CheckNameResponse implements CheckNameResponse { + const factory _CheckNameResponse({ + required final bool available, + final String? message, + }) = _$CheckNameResponseImpl; + + factory _CheckNameResponse.fromJson(Map json) = + _$CheckNameResponseImpl.fromJson; + + /// 사용 가능 여부 + @override + bool get available; + + /// 메시지 (사용 불가 시 이유) + @override + String? get message; + + /// Create a copy of CheckNameResponse + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CheckNameResponseImplCopyWith<_$CheckNameResponseImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/data/models/profile_request.g.dart b/lib/features/onboarding/data/models/profile_request.g.dart new file mode 100644 index 0000000..81eb341 --- /dev/null +++ b/lib/features/onboarding/data/models/profile_request.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'profile_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ProfileRequestImpl _$$ProfileRequestImplFromJson(Map json) => + _$ProfileRequestImpl(name: json['name'] as String); + +Map _$$ProfileRequestImplToJson( + _$ProfileRequestImpl instance, +) => {'name': instance.name}; + +_$CheckNameResponseImpl _$$CheckNameResponseImplFromJson( + Map json, +) => _$CheckNameResponseImpl( + available: json['available'] as bool, + message: json['message'] as String?, +); + +Map _$$CheckNameResponseImplToJson( + _$CheckNameResponseImpl instance, +) => { + 'available': instance.available, + 'message': instance.message, +}; diff --git a/lib/features/onboarding/data/models/terms_request.dart b/lib/features/onboarding/data/models/terms_request.dart new file mode 100644 index 0000000..71c7bec --- /dev/null +++ b/lib/features/onboarding/data/models/terms_request.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'terms_request.freezed.dart'; +part 'terms_request.g.dart'; + +/// 약관 동의 요청 모델 +/// +/// POST /api/members/onboarding/terms 요청 바디 +@freezed +class TermsRequest with _$TermsRequest { + const factory TermsRequest({ + /// 서비스 이용약관 동의 여부 + required bool serviceAgreement, + + /// 개인정보 처리방침 동의 여부 + required bool privacyAgreement, + + /// 마케팅 정보 수신 동의 여부 (선택) + @Default(false) bool marketingAgreement, + }) = _TermsRequest; + + factory TermsRequest.fromJson(Map json) => + _$TermsRequestFromJson(json); +} diff --git a/lib/features/onboarding/data/models/terms_request.freezed.dart b/lib/features/onboarding/data/models/terms_request.freezed.dart new file mode 100644 index 0000000..ff0afc6 --- /dev/null +++ b/lib/features/onboarding/data/models/terms_request.freezed.dart @@ -0,0 +1,243 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'terms_request.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +TermsRequest _$TermsRequestFromJson(Map json) { + return _TermsRequest.fromJson(json); +} + +/// @nodoc +mixin _$TermsRequest { + /// 서비스 이용약관 동의 여부 + bool get serviceAgreement => throw _privateConstructorUsedError; + + /// 개인정보 처리방침 동의 여부 + bool get privacyAgreement => throw _privateConstructorUsedError; + + /// 마케팅 정보 수신 동의 여부 (선택) + bool get marketingAgreement => throw _privateConstructorUsedError; + + /// Serializes this TermsRequest to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of TermsRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $TermsRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $TermsRequestCopyWith<$Res> { + factory $TermsRequestCopyWith( + TermsRequest value, + $Res Function(TermsRequest) then, + ) = _$TermsRequestCopyWithImpl<$Res, TermsRequest>; + @useResult + $Res call({ + bool serviceAgreement, + bool privacyAgreement, + bool marketingAgreement, + }); +} + +/// @nodoc +class _$TermsRequestCopyWithImpl<$Res, $Val extends TermsRequest> + implements $TermsRequestCopyWith<$Res> { + _$TermsRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of TermsRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serviceAgreement = null, + Object? privacyAgreement = null, + Object? marketingAgreement = null, + }) { + return _then( + _value.copyWith( + serviceAgreement: null == serviceAgreement + ? _value.serviceAgreement + : serviceAgreement // ignore: cast_nullable_to_non_nullable + as bool, + privacyAgreement: null == privacyAgreement + ? _value.privacyAgreement + : privacyAgreement // ignore: cast_nullable_to_non_nullable + as bool, + marketingAgreement: null == marketingAgreement + ? _value.marketingAgreement + : marketingAgreement // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$TermsRequestImplCopyWith<$Res> + implements $TermsRequestCopyWith<$Res> { + factory _$$TermsRequestImplCopyWith( + _$TermsRequestImpl value, + $Res Function(_$TermsRequestImpl) then, + ) = __$$TermsRequestImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + bool serviceAgreement, + bool privacyAgreement, + bool marketingAgreement, + }); +} + +/// @nodoc +class __$$TermsRequestImplCopyWithImpl<$Res> + extends _$TermsRequestCopyWithImpl<$Res, _$TermsRequestImpl> + implements _$$TermsRequestImplCopyWith<$Res> { + __$$TermsRequestImplCopyWithImpl( + _$TermsRequestImpl _value, + $Res Function(_$TermsRequestImpl) _then, + ) : super(_value, _then); + + /// Create a copy of TermsRequest + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serviceAgreement = null, + Object? privacyAgreement = null, + Object? marketingAgreement = null, + }) { + return _then( + _$TermsRequestImpl( + serviceAgreement: null == serviceAgreement + ? _value.serviceAgreement + : serviceAgreement // ignore: cast_nullable_to_non_nullable + as bool, + privacyAgreement: null == privacyAgreement + ? _value.privacyAgreement + : privacyAgreement // ignore: cast_nullable_to_non_nullable + as bool, + marketingAgreement: null == marketingAgreement + ? _value.marketingAgreement + : marketingAgreement // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$TermsRequestImpl implements _TermsRequest { + const _$TermsRequestImpl({ + required this.serviceAgreement, + required this.privacyAgreement, + this.marketingAgreement = false, + }); + + factory _$TermsRequestImpl.fromJson(Map json) => + _$$TermsRequestImplFromJson(json); + + /// 서비스 이용약관 동의 여부 + @override + final bool serviceAgreement; + + /// 개인정보 처리방침 동의 여부 + @override + final bool privacyAgreement; + + /// 마케팅 정보 수신 동의 여부 (선택) + @override + @JsonKey() + final bool marketingAgreement; + + @override + String toString() { + return 'TermsRequest(serviceAgreement: $serviceAgreement, privacyAgreement: $privacyAgreement, marketingAgreement: $marketingAgreement)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$TermsRequestImpl && + (identical(other.serviceAgreement, serviceAgreement) || + other.serviceAgreement == serviceAgreement) && + (identical(other.privacyAgreement, privacyAgreement) || + other.privacyAgreement == privacyAgreement) && + (identical(other.marketingAgreement, marketingAgreement) || + other.marketingAgreement == marketingAgreement)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + serviceAgreement, + privacyAgreement, + marketingAgreement, + ); + + /// Create a copy of TermsRequest + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$TermsRequestImplCopyWith<_$TermsRequestImpl> get copyWith => + __$$TermsRequestImplCopyWithImpl<_$TermsRequestImpl>(this, _$identity); + + @override + Map toJson() { + return _$$TermsRequestImplToJson(this); + } +} + +abstract class _TermsRequest implements TermsRequest { + const factory _TermsRequest({ + required final bool serviceAgreement, + required final bool privacyAgreement, + final bool marketingAgreement, + }) = _$TermsRequestImpl; + + factory _TermsRequest.fromJson(Map json) = + _$TermsRequestImpl.fromJson; + + /// 서비스 이용약관 동의 여부 + @override + bool get serviceAgreement; + + /// 개인정보 처리방침 동의 여부 + @override + bool get privacyAgreement; + + /// 마케팅 정보 수신 동의 여부 (선택) + @override + bool get marketingAgreement; + + /// Create a copy of TermsRequest + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$TermsRequestImplCopyWith<_$TermsRequestImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/data/models/terms_request.g.dart b/lib/features/onboarding/data/models/terms_request.g.dart new file mode 100644 index 0000000..cd29b91 --- /dev/null +++ b/lib/features/onboarding/data/models/terms_request.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'terms_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$TermsRequestImpl _$$TermsRequestImplFromJson(Map json) => + _$TermsRequestImpl( + serviceAgreement: json['serviceAgreement'] as bool, + privacyAgreement: json['privacyAgreement'] as bool, + marketingAgreement: json['marketingAgreement'] as bool? ?? false, + ); + +Map _$$TermsRequestImplToJson(_$TermsRequestImpl instance) => + { + 'serviceAgreement': instance.serviceAgreement, + 'privacyAgreement': instance.privacyAgreement, + 'marketingAgreement': instance.marketingAgreement, + }; diff --git a/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart b/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart new file mode 100644 index 0000000..dbe8043 --- /dev/null +++ b/lib/features/onboarding/data/repositories/onboarding_repository_impl.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/network/token_storage.dart'; +import '../../domain/repositories/onboarding_repository.dart'; +import '../datasources/onboarding_remote_datasource.dart'; +import '../models/terms_request.dart'; +import '../models/birth_date_request.dart'; +import '../models/gender_request.dart'; +import '../models/profile_request.dart'; + +part 'onboarding_repository_impl.g.dart'; + +/// OnboardingRepository Provider +@riverpod +OnboardingRepository onboardingRepository(Ref ref) { + final remoteDataSource = ref.watch(onboardingRemoteDataSourceProvider); + final tokenStorage = ref.watch(tokenStorageProvider); + return OnboardingRepositoryImpl(remoteDataSource, tokenStorage); +} + +/// 온보딩 Repository 구현체 +class OnboardingRepositoryImpl implements OnboardingRepository { + final OnboardingRemoteDataSource _remoteDataSource; + final TokenStorage _tokenStorage; + + OnboardingRepositoryImpl(this._remoteDataSource, this._tokenStorage); + + @override + Future submitTerms({ + required bool serviceAgreement, + required bool privacyAgreement, + bool marketingAgreement = false, + }) async { + debugPrint('📝 OnboardingRepository: Submitting terms...'); + + final request = TermsRequest( + serviceAgreement: serviceAgreement, + privacyAgreement: privacyAgreement, + marketingAgreement: marketingAgreement, + ); + + await _remoteDataSource.submitTerms(request); + + // 온보딩 단계 업데이트: TERMS → BIRTH_DATE + await _tokenStorage.saveOnboardingState( + requiresOnboarding: true, + onboardingStep: 'BIRTH_DATE', + ); + + debugPrint('✅ Terms submitted, next step: BIRTH_DATE'); + } + + @override + Future submitBirthDate(DateTime birthDate) async { + debugPrint('📝 OnboardingRepository: Submitting birth date...'); + + // DateTime을 YYYY-MM-DD 형식으로 변환 + final year = birthDate.year.toString(); + final month = birthDate.month.toString().padLeft(2, '0'); + final day = birthDate.day.toString().padLeft(2, '0'); + final formattedDate = '$year-$month-$day'; + + final request = BirthDateRequest(birthDate: formattedDate); + await _remoteDataSource.submitBirthDate(request); + + // 온보딩 단계 업데이트: BIRTH_DATE → GENDER + await _tokenStorage.saveOnboardingState( + requiresOnboarding: true, + onboardingStep: 'GENDER', + ); + + debugPrint('✅ Birth date submitted, next step: GENDER'); + } + + @override + Future submitGender(Gender gender) async { + debugPrint('📝 OnboardingRepository: Submitting gender...'); + + final request = GenderRequest(gender: gender); + await _remoteDataSource.submitGender(request); + + // 온보딩 단계 업데이트: GENDER → NICKNAME + await _tokenStorage.saveOnboardingState( + requiresOnboarding: true, + onboardingStep: 'NICKNAME', + ); + + debugPrint('✅ Gender submitted, next step: NICKNAME'); + } + + @override + Future submitProfile(String name) async { + debugPrint('📝 OnboardingRepository: Submitting profile...'); + + final request = ProfileRequest(name: name); + await _remoteDataSource.submitProfile(request); + + // 온보딩 완료 + await completeOnboarding(); + + debugPrint('✅ Profile submitted, onboarding completed'); + } + + @override + Future checkName(String name) async { + return await _remoteDataSource.checkName(name); + } + + @override + Future completeOnboarding() async { + debugPrint('🎉 OnboardingRepository: Completing onboarding...'); + + await _tokenStorage.saveOnboardingState( + requiresOnboarding: false, + onboardingStep: 'COMPLETED', + ); + + debugPrint('✅ Onboarding completed'); + } +} diff --git a/lib/features/onboarding/data/repositories/onboarding_repository_impl.g.dart b/lib/features/onboarding/data/repositories/onboarding_repository_impl.g.dart new file mode 100644 index 0000000..e5c6525 --- /dev/null +++ b/lib/features/onboarding/data/repositories/onboarding_repository_impl.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding_repository_impl.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onboardingRepositoryHash() => + r'f783674177423cd59773cf45726df55d0e0c4e67'; + +/// OnboardingRepository Provider +/// +/// Copied from [onboardingRepository]. +@ProviderFor(onboardingRepository) +final onboardingRepositoryProvider = + AutoDisposeProvider.internal( + onboardingRepository, + name: r'onboardingRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$onboardingRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef OnboardingRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/onboarding/domain/repositories/onboarding_repository.dart b/lib/features/onboarding/domain/repositories/onboarding_repository.dart new file mode 100644 index 0000000..8d03845 --- /dev/null +++ b/lib/features/onboarding/domain/repositories/onboarding_repository.dart @@ -0,0 +1,41 @@ +import '../../data/models/gender_request.dart'; +import '../../data/models/profile_request.dart'; + +/// 온보딩 Repository 인터페이스 +abstract class OnboardingRepository { + /// 약관 동의 제출 + /// + /// [serviceAgreement]: 서비스 이용약관 동의 + /// [privacyAgreement]: 개인정보 처리방침 동의 + /// [marketingAgreement]: 마케팅 정보 수신 동의 (선택) + Future submitTerms({ + required bool serviceAgreement, + required bool privacyAgreement, + bool marketingAgreement = false, + }); + + /// 생년월일 제출 + /// + /// [birthDate]: 생년월일 (DateTime) + Future submitBirthDate(DateTime birthDate); + + /// 성별 제출 + /// + /// [gender]: 성별 (Gender enum) + Future submitGender(Gender gender); + + /// 프로필(닉네임) 제출 + /// + /// [name]: 닉네임 + Future submitProfile(String name); + + /// 닉네임 중복 확인 + /// + /// Returns: 사용 가능 여부와 메시지 + Future checkName(String name); + + /// 온보딩 완료 처리 + /// + /// 로컬 저장소의 온보딩 상태를 완료로 변경 + Future completeOnboarding(); +} diff --git a/lib/features/onboarding/presentation/pages/birth_date_step_page.dart b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart new file mode 100644 index 0000000..aade8b2 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart @@ -0,0 +1,262 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/app_colors.dart'; +import '../../../../router/route_paths.dart'; +import '../providers/onboarding_provider.dart'; +import '../widgets/onboarding_button.dart'; +import '../widgets/step_indicator.dart'; + +/// 생년월일 입력 화면 +class BirthDateStepPage extends ConsumerStatefulWidget { + const BirthDateStepPage({super.key}); + + @override + ConsumerState createState() => _BirthDateStepPageState(); +} + +class _BirthDateStepPageState extends ConsumerState { + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + // 기본값: 20년 전 + _selectedDate = DateTime.now().subtract(const Duration(days: 365 * 20)); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(onboardingNotifierProvider); + final notifier = ref.read(onboardingNotifierProvider.notifier); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingTerms), + ), + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 진행 표시기 + const StepIndicator(currentStep: OnboardingStep.birthDate), + SizedBox(height: 32.h), + + // 타이틀 + Text( + '생년월일을\n알려주세요', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: AppColors.gray900, + height: 1.3, + ), + ), + SizedBox(height: 8.h), + Text( + '맞춤 콘텐츠 추천을 위해 사용됩니다.', + style: TextStyle(fontSize: 14.sp, color: AppColors.gray600), + ), + SizedBox(height: 48.h), + + // 날짜 선택 표시 + _DateDisplay( + date: state.birthDate ?? _selectedDate, + onTap: () => _showDatePicker(context, notifier), + ), + SizedBox(height: 24.h), + + // 날짜 선택 버튼 + OutlinedButton.icon( + onPressed: () => _showDatePicker(context, notifier), + icon: const Icon(Icons.calendar_today), + label: const Text('날짜 선택하기'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: EdgeInsets.symmetric( + horizontal: 24.w, + vertical: 12.h, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + + const Spacer(), + + // 에러 메시지 + if (state.errorMessage != null) ...[ + Text( + state.errorMessage!, + style: TextStyle(fontSize: 14.sp, color: AppColors.error), + ), + SizedBox(height: 8.h), + ], + + // 다음 버튼 + OnboardingButton( + text: '다음', + isLoading: state.isLoading, + onPressed: state.birthDate != null + ? () async { + final success = await notifier.submitBirthDate(); + if (success && context.mounted) { + context.go(RoutePaths.onboardingGender); + } + } + : null, + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ); + } + + void _showDatePicker(BuildContext context, OnboardingNotifier notifier) { + final now = DateTime.now(); + final minDate = DateTime(now.year - 100); + final maxDate = DateTime(now.year - 10); // 최소 10세 이상 + + showCupertinoModalPopup( + context: context, + builder: (context) => Container( + height: 300.h, + color: Colors.white, + child: Column( + children: [ + Container( + height: 50.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: const BoxDecoration( + color: AppColors.gray100, + border: Border(bottom: BorderSide(color: AppColors.gray200)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + child: Text( + '취소', + style: TextStyle( + fontSize: 16.sp, + color: AppColors.gray600, + ), + ), + onPressed: () => Navigator.pop(context), + ), + CupertinoButton( + padding: EdgeInsets.zero, + child: Text( + '확인', + style: TextStyle( + fontSize: 16.sp, + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + onPressed: () { + notifier.setBirthDate(_selectedDate); + Navigator.pop(context); + }, + ), + ], + ), + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: _selectedDate, + minimumDate: minDate, + maximumDate: maxDate, + onDateTimeChanged: (date) { + setState(() { + _selectedDate = date; + }); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// 날짜 표시 위젯 +class _DateDisplay extends StatelessWidget { + final DateTime date; + final VoidCallback onTap; + + const _DateDisplay({required this.date, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12.r), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(20.w), + decoration: BoxDecoration( + color: AppColors.gray100, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: AppColors.gray200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _DateBox(value: date.year.toString(), label: '년'), + SizedBox(width: 16.w), + _DateBox(value: date.month.toString().padLeft(2, '0'), label: '월'), + SizedBox(width: 16.w), + _DateBox(value: date.day.toString().padLeft(2, '0'), label: '일'), + ], + ), + ), + ); + } +} + +class _DateBox extends StatelessWidget { + final String value; + final String label; + + const _DateBox({required this.value, required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w700, + color: AppColors.gray900, + ), + ), + SizedBox(height: 4.h), + Text( + label, + style: TextStyle(fontSize: 14.sp, color: AppColors.gray500), + ), + ], + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/gender_step_page.dart b/lib/features/onboarding/presentation/pages/gender_step_page.dart new file mode 100644 index 0000000..c8f4030 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/gender_step_page.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/app_colors.dart'; +import '../../../../router/route_paths.dart'; +import '../../data/models/gender_request.dart'; +import '../providers/onboarding_provider.dart'; +import '../widgets/onboarding_button.dart'; +import '../widgets/step_indicator.dart'; + +/// 성별 선택 화면 +class GenderStepPage extends ConsumerWidget { + const GenderStepPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(onboardingNotifierProvider); + final notifier = ref.read(onboardingNotifierProvider.notifier); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingBirthDate), + ), + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 진행 표시기 + const StepIndicator(currentStep: OnboardingStep.gender), + SizedBox(height: 32.h), + + // 타이틀 + Text( + '성별을\n알려주세요', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: AppColors.gray900, + height: 1.3, + ), + ), + SizedBox(height: 8.h), + Text( + '맞춤 콘텐츠 추천을 위해 사용됩니다.', + style: TextStyle(fontSize: 14.sp, color: AppColors.gray600), + ), + SizedBox(height: 48.h), + + // 성별 선택 버튼들 + Row( + children: [ + Expanded( + child: _GenderButton( + label: '남성', + icon: Icons.male, + isSelected: state.gender == Gender.male, + onTap: () => notifier.setGender(Gender.male), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _GenderButton( + label: '여성', + icon: Icons.female, + isSelected: state.gender == Gender.female, + onTap: () => notifier.setGender(Gender.female), + ), + ), + ], + ), + SizedBox(height: 12.h), + _GenderButton( + label: '기타', + icon: Icons.person_outline, + isSelected: state.gender == Gender.other, + onTap: () => notifier.setGender(Gender.other), + ), + + const Spacer(), + + // 에러 메시지 + if (state.errorMessage != null) ...[ + Text( + state.errorMessage!, + style: TextStyle(fontSize: 14.sp, color: AppColors.error), + ), + SizedBox(height: 8.h), + ], + + // 다음 버튼 + OnboardingButton( + text: '다음', + isLoading: state.isLoading, + onPressed: state.gender != null + ? () async { + final success = await notifier.submitGender(); + if (success && context.mounted) { + context.go(RoutePaths.onboardingNickname); + } + } + : null, + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ); + } +} + +/// 성별 선택 버튼 +class _GenderButton extends StatelessWidget { + final String label; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + + const _GenderButton({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12.r), + child: Container( + padding: EdgeInsets.symmetric(vertical: 24.h), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withValues(alpha: 0.1) + : AppColors.gray100, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.gray200, + width: isSelected ? 2 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 48.w, + color: isSelected ? AppColors.primary : AppColors.gray500, + ), + SizedBox(height: 12.h), + Text( + label, + style: TextStyle( + fontSize: 16.sp, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? AppColors.primary : AppColors.gray700, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/nickname_step_page.dart b/lib/features/onboarding/presentation/pages/nickname_step_page.dart new file mode 100644 index 0000000..b09f129 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/nickname_step_page.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/app_colors.dart'; +import '../../../../router/route_paths.dart'; +import '../providers/onboarding_provider.dart'; +import '../widgets/onboarding_button.dart'; +import '../widgets/step_indicator.dart'; + +/// 닉네임 입력 화면 +class NicknameStepPage extends ConsumerStatefulWidget { + const NicknameStepPage({super.key}); + + @override + ConsumerState createState() => _NicknameStepPageState(); +} + +class _NicknameStepPageState extends ConsumerState { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + Timer? _debounce; + + @override + void initState() { + super.initState(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onNicknameChanged(String value, OnboardingNotifier notifier) { + notifier.setNickname(value); + + // 디바운스: 입력 후 500ms 후에 중복 확인 + _debounce?.cancel(); + if (value.length >= 2) { + _debounce = Timer(const Duration(milliseconds: 500), () { + notifier.checkNickname(); + }); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(onboardingNotifierProvider); + final notifier = ref.read(onboardingNotifierProvider.notifier); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.gray700), + onPressed: () => context.go(RoutePaths.onboardingGender), + ), + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 진행 표시기 + const StepIndicator(currentStep: OnboardingStep.nickname), + SizedBox(height: 32.h), + + // 타이틀 + Text( + '닉네임을\n설정해주세요', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: AppColors.gray900, + height: 1.3, + ), + ), + SizedBox(height: 8.h), + Text( + '다른 사용자에게 표시되는 이름입니다.', + style: TextStyle(fontSize: 14.sp, color: AppColors.gray600), + ), + SizedBox(height: 48.h), + + // 닉네임 입력 필드 + TextField( + controller: _controller, + focusNode: _focusNode, + onChanged: (value) => _onNicknameChanged(value, notifier), + maxLength: 20, + style: TextStyle(fontSize: 16.sp, color: AppColors.gray900), + decoration: InputDecoration( + hintText: '닉네임 입력 (2-20자)', + hintStyle: TextStyle( + color: AppColors.gray400, + fontSize: 16.sp, + ), + filled: true, + fillColor: AppColors.gray100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: _getBorderColor(state)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide( + color: _getBorderColor(state), + width: 2, + ), + ), + contentPadding: EdgeInsets.all(16.w), + counterText: '', + suffixIcon: _buildSuffixIcon(state), + ), + ), + SizedBox(height: 8.h), + + // 상태 메시지 + _buildStatusMessage(state), + + SizedBox(height: 16.h), + + // 닉네임 규칙 안내 + _NicknameRules(), + + const Spacer(), + + // 완료 버튼 + OnboardingButton( + text: '완료', + isLoading: state.isLoading, + onPressed: notifier.canSubmitNickname + ? () async { + final success = await notifier.submitProfile(); + if (success && context.mounted) { + context.go(RoutePaths.home); + } + } + : null, + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ); + } + + Color _getBorderColor(OnboardingState state) { + if (state.nicknameAvailable == true) { + return AppColors.success; + } else if (state.nicknameAvailable == false) { + return AppColors.error; + } + return AppColors.gray200; + } + + Widget? _buildSuffixIcon(OnboardingState state) { + if (state.isLoading) { + return Padding( + padding: EdgeInsets.all(12.w), + child: SizedBox( + width: 20.w, + height: 20.h, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ); + } + + if (state.nicknameAvailable == true) { + return Icon(Icons.check_circle, color: AppColors.success, size: 24.w); + } + + if (state.nicknameAvailable == false) { + return Icon(Icons.error, color: AppColors.error, size: 24.w); + } + + return null; + } + + Widget _buildStatusMessage(OnboardingState state) { + if (state.nicknameAvailable == true) { + return Text( + '사용 가능한 닉네임입니다.', + style: TextStyle(fontSize: 13.sp, color: AppColors.success), + ); + } + + if (state.errorMessage != null) { + return Text( + state.errorMessage!, + style: TextStyle(fontSize: 13.sp, color: AppColors.error), + ); + } + + return const SizedBox.shrink(); + } +} + +/// 닉네임 규칙 안내 +class _NicknameRules extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: AppColors.gray50, + borderRadius: BorderRadius.circular(8.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '닉네임 규칙', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: AppColors.gray700, + ), + ), + SizedBox(height: 8.h), + _RuleItem(text: '2~20자 이내'), + _RuleItem(text: '한글, 영문, 숫자 사용 가능'), + _RuleItem(text: '특수문자 사용 불가'), + _RuleItem(text: '공백 사용 불가'), + ], + ), + ); + } +} + +class _RuleItem extends StatelessWidget { + final String text; + + const _RuleItem({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: 4.h), + child: Row( + children: [ + Icon(Icons.check, size: 14.w, color: AppColors.gray500), + SizedBox(width: 6.w), + Text( + text, + style: TextStyle(fontSize: 13.sp, color: AppColors.gray600), + ), + ], + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/pages/terms_step_page.dart b/lib/features/onboarding/presentation/pages/terms_step_page.dart new file mode 100644 index 0000000..c75f704 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/terms_step_page.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/app_colors.dart'; +import '../../../../router/route_paths.dart'; +import '../providers/onboarding_provider.dart'; +import '../widgets/onboarding_button.dart'; +import '../widgets/step_indicator.dart'; + +/// 약관 동의 화면 +class TermsStepPage extends ConsumerWidget { + const TermsStepPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(onboardingNotifierProvider); + final notifier = ref.read(onboardingNotifierProvider.notifier); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 진행 표시기 + const StepIndicator(currentStep: OnboardingStep.terms), + SizedBox(height: 32.h), + + // 타이틀 + Text( + '서비스 이용을 위해\n약관에 동의해주세요', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: AppColors.gray900, + height: 1.3, + ), + ), + SizedBox(height: 8.h), + Text( + '원활한 서비스 이용을 위해 필수 약관에 동의가 필요합니다.', + style: TextStyle(fontSize: 14.sp, color: AppColors.gray600), + ), + SizedBox(height: 32.h), + + // 전체 동의 + _AllAgreeItem( + isChecked: + state.serviceAgreed && + state.privacyAgreed && + state.marketingAgreed, + onTap: notifier.agreeAll, + ), + SizedBox(height: 16.h), + const Divider(color: AppColors.gray200), + SizedBox(height: 16.h), + + // 개별 약관 + _AgreementItem( + title: '[필수] 서비스 이용약관', + isChecked: state.serviceAgreed, + onTap: notifier.toggleServiceAgreement, + onDetailTap: () => _showTermsDetail(context, '서비스 이용약관'), + ), + SizedBox(height: 12.h), + _AgreementItem( + title: '[필수] 개인정보 처리방침', + isChecked: state.privacyAgreed, + onTap: notifier.togglePrivacyAgreement, + onDetailTap: () => _showTermsDetail(context, '개인정보 처리방침'), + ), + SizedBox(height: 12.h), + _AgreementItem( + title: '[선택] 마케팅 정보 수신 동의', + isChecked: state.marketingAgreed, + onTap: notifier.toggleMarketingAgreement, + onDetailTap: () => _showTermsDetail(context, '마케팅 정보 수신'), + ), + + const Spacer(), + + // 에러 메시지 + if (state.errorMessage != null) ...[ + Text( + state.errorMessage!, + style: TextStyle(fontSize: 14.sp, color: AppColors.error), + ), + SizedBox(height: 8.h), + ], + + // 다음 버튼 + OnboardingButton( + text: '동의하고 계속하기', + isLoading: state.isLoading, + onPressed: notifier.canSubmitTerms + ? () async { + final success = await notifier.submitTerms(); + if (success && context.mounted) { + context.go(RoutePaths.onboardingBirthDate); + } + } + : null, + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ); + } + + void _showTermsDetail(BuildContext context, String title) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) => Padding( + padding: EdgeInsets.all(24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.w700), + ), + SizedBox(height: 16.h), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + child: Text( + _getTermsContent(title), + style: TextStyle( + fontSize: 14.sp, + color: AppColors.gray700, + height: 1.6, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + String _getTermsContent(String title) { + // TODO: 실제 약관 내용으로 교체 + return ''' +$title + +제1조 (목적) +이 약관은 MapSy(이하 "회사")가 제공하는 서비스의 이용조건 및 절차, 회사와 회원 간의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다. + +제2조 (정의) +1. "서비스"라 함은 회사가 제공하는 모든 서비스를 의미합니다. +2. "회원"이라 함은 회사와 서비스 이용계약을 체결하고 회사가 제공하는 서비스를 이용하는 자를 말합니다. + +제3조 (약관의 효력 및 변경) +1. 이 약관은 서비스 화면에 게시하거나 기타의 방법으로 회원에게 공지함으로써 효력이 발생합니다. +2. 회사는 합리적인 사유가 발생한 경우 관련 법령에 위배되지 않는 범위 내에서 약관을 변경할 수 있습니다. + +(이하 생략...) +'''; + } +} + +/// 전체 동의 아이템 +class _AllAgreeItem extends StatelessWidget { + final bool isChecked; + final VoidCallback onTap; + + const _AllAgreeItem({required this.isChecked, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12.r), + child: Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isChecked + ? AppColors.primary.withValues(alpha: 0.1) + : AppColors.gray100, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isChecked ? AppColors.primary : AppColors.gray200, + ), + ), + child: Row( + children: [ + Icon( + isChecked ? Icons.check_circle : Icons.check_circle_outline, + color: isChecked ? AppColors.primary : AppColors.gray400, + size: 24.w, + ), + SizedBox(width: 12.w), + Text( + '전체 동의하기', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: AppColors.gray900, + ), + ), + ], + ), + ), + ); + } +} + +/// 개별 약관 아이템 +class _AgreementItem extends StatelessWidget { + final String title; + final bool isChecked; + final VoidCallback onTap; + final VoidCallback onDetailTap; + + const _AgreementItem({ + required this.title, + required this.isChecked, + required this.onTap, + required this.onDetailTap, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4.r), + child: Row( + children: [ + Icon( + isChecked ? Icons.check_circle : Icons.check_circle_outline, + color: isChecked ? AppColors.primary : AppColors.gray400, + size: 22.w, + ), + SizedBox(width: 8.w), + Text( + title, + style: TextStyle(fontSize: 15.sp, color: AppColors.gray700), + ), + ], + ), + ), + const Spacer(), + InkWell( + onTap: onDetailTap, + child: Icon( + Icons.chevron_right, + color: AppColors.gray400, + size: 20.w, + ), + ), + ], + ); + } +} diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.dart b/lib/features/onboarding/presentation/providers/onboarding_provider.dart new file mode 100644 index 0000000..5a4421e --- /dev/null +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/errors/app_exception.dart'; +import '../../../auth/domain/entities/onboarding_step.dart'; +import '../../data/models/gender_request.dart'; +import '../../data/repositories/onboarding_repository_impl.dart'; +import '../../domain/repositories/onboarding_repository.dart'; + +// OnboardingStep re-export for convenience +export '../../../auth/domain/entities/onboarding_step.dart'; + +part 'onboarding_provider.freezed.dart'; +part 'onboarding_provider.g.dart'; + +/// 온보딩 상태 +@freezed +class OnboardingState with _$OnboardingState { + const factory OnboardingState({ + /// 현재 단계 + @Default(OnboardingStep.terms) OnboardingStep currentStep, + + /// 약관 동의 상태 + @Default(false) bool serviceAgreed, + @Default(false) bool privacyAgreed, + @Default(false) bool marketingAgreed, + + /// 생년월일 + DateTime? birthDate, + + /// 성별 + Gender? gender, + + /// 닉네임 + String? nickname, + + /// 닉네임 사용 가능 여부 (null = 확인 안함) + bool? nicknameAvailable, + + /// 로딩 상태 + @Default(false) bool isLoading, + + /// 에러 메시지 + String? errorMessage, + }) = _OnboardingState; +} + +/// 온보딩 Notifier +@riverpod +class OnboardingNotifier extends _$OnboardingNotifier { + @override + OnboardingState build() { + return const OnboardingState(); + } + + OnboardingRepository get _repository => + ref.read(onboardingRepositoryProvider); + + /// 현재 단계 설정 (라우터에서 호출) + void setCurrentStep(OnboardingStep step) { + state = state.copyWith(currentStep: step); + } + + /// 서비스 약관 동의 토글 + void toggleServiceAgreement() { + state = state.copyWith(serviceAgreed: !state.serviceAgreed); + } + + /// 개인정보 처리방침 동의 토글 + void togglePrivacyAgreement() { + state = state.copyWith(privacyAgreed: !state.privacyAgreed); + } + + /// 마케팅 수신 동의 토글 + void toggleMarketingAgreement() { + state = state.copyWith(marketingAgreed: !state.marketingAgreed); + } + + /// 전체 동의 (필수 약관만) + void agreeAll() { + state = state.copyWith( + serviceAgreed: true, + privacyAgreed: true, + marketingAgreed: true, + ); + } + + /// 약관 제출 가능 여부 + bool get canSubmitTerms => state.serviceAgreed && state.privacyAgreed; + + /// 약관 동의 제출 + Future submitTerms() async { + if (!canSubmitTerms) return false; + + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + await _repository.submitTerms( + serviceAgreement: state.serviceAgreed, + privacyAgreement: state.privacyAgreed, + marketingAgreement: state.marketingAgreed, + ); + + state = state.copyWith( + isLoading: false, + currentStep: OnboardingStep.birthDate, + ); + + return true; + } catch (e) { + state = state.copyWith( + isLoading: false, + errorMessage: _getErrorMessage(e), + ); + return false; + } + } + + /// 생년월일 설정 + void setBirthDate(DateTime date) { + state = state.copyWith(birthDate: date); + } + + /// 생년월일 제출 가능 여부 + bool get canSubmitBirthDate => state.birthDate != null; + + /// 생년월일 제출 + Future submitBirthDate() async { + if (!canSubmitBirthDate || state.birthDate == null) return false; + + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + await _repository.submitBirthDate(state.birthDate!); + + state = state.copyWith( + isLoading: false, + currentStep: OnboardingStep.gender, + ); + + return true; + } catch (e) { + state = state.copyWith( + isLoading: false, + errorMessage: _getErrorMessage(e), + ); + return false; + } + } + + /// 성별 설정 + void setGender(Gender gender) { + state = state.copyWith(gender: gender); + } + + /// 성별 제출 가능 여부 + bool get canSubmitGender => state.gender != null; + + /// 성별 제출 + Future submitGender() async { + if (!canSubmitGender || state.gender == null) return false; + + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + await _repository.submitGender(state.gender!); + + state = state.copyWith( + isLoading: false, + currentStep: OnboardingStep.nickname, + ); + + return true; + } catch (e) { + state = state.copyWith( + isLoading: false, + errorMessage: _getErrorMessage(e), + ); + return false; + } + } + + // ============================================ + // 닉네임 관련 상수 + // ============================================ + static const int _minNicknameLength = 2; + static const int _maxNicknameLength = 20; + + /// 닉네임 유효성 검증 정규식 (한글, 영문, 숫자만 허용) + static final RegExp _nicknameRegex = RegExp(r'^[가-힣a-zA-Z0-9]+$'); + + /// 닉네임 설정 + void setNickname(String nickname) { + state = state.copyWith( + nickname: nickname, + nicknameAvailable: null, // 닉네임 변경 시 확인 상태 초기화 + errorMessage: null, + ); + } + + /// 닉네임 유효성 검증 + /// + /// **규칙**: + /// - 2~20자 이내 + /// - 한글, 영문, 숫자만 사용 가능 + /// - 특수문자 및 공백 불가 + /// + /// Returns: 유효하면 null, 유효하지 않으면 에러 메시지 + String? validateNickname(String? nickname) { + if (nickname == null || nickname.isEmpty) { + return '닉네임을 입력해주세요.'; + } + if (nickname.length < _minNicknameLength) { + return '닉네임은 $_minNicknameLength자 이상이어야 합니다.'; + } + if (nickname.length > _maxNicknameLength) { + return '닉네임은 $_maxNicknameLength자 이하여야 합니다.'; + } + if (nickname.contains(' ')) { + return '닉네임에 공백을 사용할 수 없습니다.'; + } + if (!_nicknameRegex.hasMatch(nickname)) { + return '닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.'; + } + return null; // 유효함 + } + + /// 닉네임이 유효한지 확인 (불린 반환) + bool isNicknameFormatValid(String? nickname) { + return validateNickname(nickname) == null; + } + + /// 닉네임 중복 확인 + /// + /// 닉네임 형식이 유효한 경우에만 서버에 중복 확인 요청 + Future checkNickname() async { + final nickname = state.nickname; + + // 형식 유효성 검증 먼저 수행 + final validationError = validateNickname(nickname); + if (validationError != null) { + state = state.copyWith( + nicknameAvailable: false, + errorMessage: validationError, + ); + return false; + } + + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + final response = await _repository.checkName(nickname!); + + state = state.copyWith( + isLoading: false, + nicknameAvailable: response.available, + errorMessage: response.available + ? null + : response.message ?? '이미 사용 중인 닉네임입니다.', + ); + + return response.available; + } catch (e) { + state = state.copyWith( + isLoading: false, + nicknameAvailable: false, + errorMessage: _getErrorMessage(e), + ); + return false; + } + } + + /// 닉네임 제출 가능 여부 + bool get canSubmitNickname => + state.nickname != null && + state.nickname!.isNotEmpty && + isNicknameFormatValid(state.nickname) && + state.nicknameAvailable == true; + + /// 프로필(닉네임) 제출 + Future submitProfile() async { + if (!canSubmitNickname || state.nickname == null) return false; + + state = state.copyWith(isLoading: true, errorMessage: null); + + try { + await _repository.submitProfile(state.nickname!); + + state = state.copyWith( + isLoading: false, + currentStep: OnboardingStep.completed, + ); + + return true; + } catch (e) { + state = state.copyWith( + isLoading: false, + errorMessage: _getErrorMessage(e), + ); + return false; + } + } + + /// 에러 메시지 초기화 + void clearError() { + state = state.copyWith(errorMessage: null); + } + + /// 에러 메시지 추출 + String _getErrorMessage(Object error) { + if (error is AppException) { + return error.message; + } + debugPrint('❌ OnboardingNotifier error: $error'); + return '오류가 발생했습니다. 다시 시도해주세요.'; + } +} diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.freezed.dart b/lib/features/onboarding/presentation/providers/onboarding_provider.freezed.dart new file mode 100644 index 0000000..e7bbef5 --- /dev/null +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.freezed.dart @@ -0,0 +1,433 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'onboarding_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$OnboardingState { + /// 현재 단계 + OnboardingStep get currentStep => throw _privateConstructorUsedError; + + /// 약관 동의 상태 + bool get serviceAgreed => throw _privateConstructorUsedError; + bool get privacyAgreed => throw _privateConstructorUsedError; + bool get marketingAgreed => throw _privateConstructorUsedError; + + /// 생년월일 + DateTime? get birthDate => throw _privateConstructorUsedError; + + /// 성별 + Gender? get gender => throw _privateConstructorUsedError; + + /// 닉네임 + String? get nickname => throw _privateConstructorUsedError; + + /// 닉네임 사용 가능 여부 (null = 확인 안함) + bool? get nicknameAvailable => throw _privateConstructorUsedError; + + /// 로딩 상태 + bool get isLoading => throw _privateConstructorUsedError; + + /// 에러 메시지 + String? get errorMessage => throw _privateConstructorUsedError; + + /// Create a copy of OnboardingState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $OnboardingStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $OnboardingStateCopyWith<$Res> { + factory $OnboardingStateCopyWith( + OnboardingState value, + $Res Function(OnboardingState) then, + ) = _$OnboardingStateCopyWithImpl<$Res, OnboardingState>; + @useResult + $Res call({ + OnboardingStep currentStep, + bool serviceAgreed, + bool privacyAgreed, + bool marketingAgreed, + DateTime? birthDate, + Gender? gender, + String? nickname, + bool? nicknameAvailable, + bool isLoading, + String? errorMessage, + }); +} + +/// @nodoc +class _$OnboardingStateCopyWithImpl<$Res, $Val extends OnboardingState> + implements $OnboardingStateCopyWith<$Res> { + _$OnboardingStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of OnboardingState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? currentStep = null, + Object? serviceAgreed = null, + Object? privacyAgreed = null, + Object? marketingAgreed = null, + Object? birthDate = freezed, + Object? gender = freezed, + Object? nickname = freezed, + Object? nicknameAvailable = freezed, + Object? isLoading = null, + Object? errorMessage = freezed, + }) { + return _then( + _value.copyWith( + currentStep: null == currentStep + ? _value.currentStep + : currentStep // ignore: cast_nullable_to_non_nullable + as OnboardingStep, + serviceAgreed: null == serviceAgreed + ? _value.serviceAgreed + : serviceAgreed // ignore: cast_nullable_to_non_nullable + as bool, + privacyAgreed: null == privacyAgreed + ? _value.privacyAgreed + : privacyAgreed // ignore: cast_nullable_to_non_nullable + as bool, + marketingAgreed: null == marketingAgreed + ? _value.marketingAgreed + : marketingAgreed // ignore: cast_nullable_to_non_nullable + as bool, + birthDate: freezed == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as DateTime?, + gender: freezed == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender?, + nickname: freezed == nickname + ? _value.nickname + : nickname // ignore: cast_nullable_to_non_nullable + as String?, + nicknameAvailable: freezed == nicknameAvailable + ? _value.nicknameAvailable + : nicknameAvailable // ignore: cast_nullable_to_non_nullable + as bool?, + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$OnboardingStateImplCopyWith<$Res> + implements $OnboardingStateCopyWith<$Res> { + factory _$$OnboardingStateImplCopyWith( + _$OnboardingStateImpl value, + $Res Function(_$OnboardingStateImpl) then, + ) = __$$OnboardingStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + OnboardingStep currentStep, + bool serviceAgreed, + bool privacyAgreed, + bool marketingAgreed, + DateTime? birthDate, + Gender? gender, + String? nickname, + bool? nicknameAvailable, + bool isLoading, + String? errorMessage, + }); +} + +/// @nodoc +class __$$OnboardingStateImplCopyWithImpl<$Res> + extends _$OnboardingStateCopyWithImpl<$Res, _$OnboardingStateImpl> + implements _$$OnboardingStateImplCopyWith<$Res> { + __$$OnboardingStateImplCopyWithImpl( + _$OnboardingStateImpl _value, + $Res Function(_$OnboardingStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of OnboardingState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? currentStep = null, + Object? serviceAgreed = null, + Object? privacyAgreed = null, + Object? marketingAgreed = null, + Object? birthDate = freezed, + Object? gender = freezed, + Object? nickname = freezed, + Object? nicknameAvailable = freezed, + Object? isLoading = null, + Object? errorMessage = freezed, + }) { + return _then( + _$OnboardingStateImpl( + currentStep: null == currentStep + ? _value.currentStep + : currentStep // ignore: cast_nullable_to_non_nullable + as OnboardingStep, + serviceAgreed: null == serviceAgreed + ? _value.serviceAgreed + : serviceAgreed // ignore: cast_nullable_to_non_nullable + as bool, + privacyAgreed: null == privacyAgreed + ? _value.privacyAgreed + : privacyAgreed // ignore: cast_nullable_to_non_nullable + as bool, + marketingAgreed: null == marketingAgreed + ? _value.marketingAgreed + : marketingAgreed // ignore: cast_nullable_to_non_nullable + as bool, + birthDate: freezed == birthDate + ? _value.birthDate + : birthDate // ignore: cast_nullable_to_non_nullable + as DateTime?, + gender: freezed == gender + ? _value.gender + : gender // ignore: cast_nullable_to_non_nullable + as Gender?, + nickname: freezed == nickname + ? _value.nickname + : nickname // ignore: cast_nullable_to_non_nullable + as String?, + nicknameAvailable: freezed == nicknameAvailable + ? _value.nicknameAvailable + : nicknameAvailable // ignore: cast_nullable_to_non_nullable + as bool?, + isLoading: null == isLoading + ? _value.isLoading + : isLoading // ignore: cast_nullable_to_non_nullable + as bool, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$OnboardingStateImpl + with DiagnosticableTreeMixin + implements _OnboardingState { + const _$OnboardingStateImpl({ + this.currentStep = OnboardingStep.terms, + this.serviceAgreed = false, + this.privacyAgreed = false, + this.marketingAgreed = false, + this.birthDate, + this.gender, + this.nickname, + this.nicknameAvailable, + this.isLoading = false, + this.errorMessage, + }); + + /// 현재 단계 + @override + @JsonKey() + final OnboardingStep currentStep; + + /// 약관 동의 상태 + @override + @JsonKey() + final bool serviceAgreed; + @override + @JsonKey() + final bool privacyAgreed; + @override + @JsonKey() + final bool marketingAgreed; + + /// 생년월일 + @override + final DateTime? birthDate; + + /// 성별 + @override + final Gender? gender; + + /// 닉네임 + @override + final String? nickname; + + /// 닉네임 사용 가능 여부 (null = 확인 안함) + @override + final bool? nicknameAvailable; + + /// 로딩 상태 + @override + @JsonKey() + final bool isLoading; + + /// 에러 메시지 + @override + final String? errorMessage; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'OnboardingState(currentStep: $currentStep, serviceAgreed: $serviceAgreed, privacyAgreed: $privacyAgreed, marketingAgreed: $marketingAgreed, birthDate: $birthDate, gender: $gender, nickname: $nickname, nicknameAvailable: $nicknameAvailable, isLoading: $isLoading, errorMessage: $errorMessage)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'OnboardingState')) + ..add(DiagnosticsProperty('currentStep', currentStep)) + ..add(DiagnosticsProperty('serviceAgreed', serviceAgreed)) + ..add(DiagnosticsProperty('privacyAgreed', privacyAgreed)) + ..add(DiagnosticsProperty('marketingAgreed', marketingAgreed)) + ..add(DiagnosticsProperty('birthDate', birthDate)) + ..add(DiagnosticsProperty('gender', gender)) + ..add(DiagnosticsProperty('nickname', nickname)) + ..add(DiagnosticsProperty('nicknameAvailable', nicknameAvailable)) + ..add(DiagnosticsProperty('isLoading', isLoading)) + ..add(DiagnosticsProperty('errorMessage', errorMessage)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$OnboardingStateImpl && + (identical(other.currentStep, currentStep) || + other.currentStep == currentStep) && + (identical(other.serviceAgreed, serviceAgreed) || + other.serviceAgreed == serviceAgreed) && + (identical(other.privacyAgreed, privacyAgreed) || + other.privacyAgreed == privacyAgreed) && + (identical(other.marketingAgreed, marketingAgreed) || + other.marketingAgreed == marketingAgreed) && + (identical(other.birthDate, birthDate) || + other.birthDate == birthDate) && + (identical(other.gender, gender) || other.gender == gender) && + (identical(other.nickname, nickname) || + other.nickname == nickname) && + (identical(other.nicknameAvailable, nicknameAvailable) || + other.nicknameAvailable == nicknameAvailable) && + (identical(other.isLoading, isLoading) || + other.isLoading == isLoading) && + (identical(other.errorMessage, errorMessage) || + other.errorMessage == errorMessage)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + currentStep, + serviceAgreed, + privacyAgreed, + marketingAgreed, + birthDate, + gender, + nickname, + nicknameAvailable, + isLoading, + errorMessage, + ); + + /// Create a copy of OnboardingState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$OnboardingStateImplCopyWith<_$OnboardingStateImpl> get copyWith => + __$$OnboardingStateImplCopyWithImpl<_$OnboardingStateImpl>( + this, + _$identity, + ); +} + +abstract class _OnboardingState implements OnboardingState { + const factory _OnboardingState({ + final OnboardingStep currentStep, + final bool serviceAgreed, + final bool privacyAgreed, + final bool marketingAgreed, + final DateTime? birthDate, + final Gender? gender, + final String? nickname, + final bool? nicknameAvailable, + final bool isLoading, + final String? errorMessage, + }) = _$OnboardingStateImpl; + + /// 현재 단계 + @override + OnboardingStep get currentStep; + + /// 약관 동의 상태 + @override + bool get serviceAgreed; + @override + bool get privacyAgreed; + @override + bool get marketingAgreed; + + /// 생년월일 + @override + DateTime? get birthDate; + + /// 성별 + @override + Gender? get gender; + + /// 닉네임 + @override + String? get nickname; + + /// 닉네임 사용 가능 여부 (null = 확인 안함) + @override + bool? get nicknameAvailable; + + /// 로딩 상태 + @override + bool get isLoading; + + /// 에러 메시지 + @override + String? get errorMessage; + + /// Create a copy of OnboardingState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$OnboardingStateImplCopyWith<_$OnboardingStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart b/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart new file mode 100644 index 0000000..dad3598 --- /dev/null +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'onboarding_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$onboardingNotifierHash() => + r'4782c882b4809345d6b62241c9809feccdc9beab'; + +/// 온보딩 Notifier +/// +/// Copied from [OnboardingNotifier]. +@ProviderFor(OnboardingNotifier) +final onboardingNotifierProvider = + AutoDisposeNotifierProvider.internal( + OnboardingNotifier.new, + name: r'onboardingNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$onboardingNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$OnboardingNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/onboarding/presentation/widgets/onboarding_button.dart b/lib/features/onboarding/presentation/widgets/onboarding_button.dart new file mode 100644 index 0000000..c32a476 --- /dev/null +++ b/lib/features/onboarding/presentation/widgets/onboarding_button.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../core/constants/app_colors.dart'; + +/// 온보딩 공통 버튼 +class OnboardingButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + + const OnboardingButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final isEnabled = onPressed != null && !isLoading; + + return SizedBox( + width: double.infinity, + height: 56.h, + child: ElevatedButton( + onPressed: isEnabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: isEnabled ? AppColors.primary : AppColors.gray300, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.gray300, + disabledForegroundColor: AppColors.gray500, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 0, + ), + child: isLoading + ? SizedBox( + width: 24.w, + height: 24.h, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + text, + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), + ), + ), + ); + } +} + +/// 온보딩 텍스트 버튼 (건너뛰기 등) +class OnboardingTextButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + + const OnboardingTextButton({super.key, required this.text, this.onPressed}); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: Text( + text, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: AppColors.gray600, + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/widgets/step_indicator.dart b/lib/features/onboarding/presentation/widgets/step_indicator.dart new file mode 100644 index 0000000..7237bd2 --- /dev/null +++ b/lib/features/onboarding/presentation/widgets/step_indicator.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../../core/constants/app_colors.dart'; +import '../providers/onboarding_provider.dart'; + +/// 온보딩 진행 표시기 +class StepIndicator extends StatelessWidget { + final OnboardingStep currentStep; + final int totalSteps; + + const StepIndicator({ + super.key, + required this.currentStep, + this.totalSteps = 4, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(totalSteps, (index) => _buildDot(index)), + ); + } + + Widget _buildDot(int index) { + final isActive = index == currentStep.index; + final isCompleted = index < currentStep.index; + + return Container( + margin: EdgeInsets.symmetric(horizontal: 4.w), + width: isActive ? 24.w : 8.w, + height: 8.h, + decoration: BoxDecoration( + color: isActive || isCompleted ? AppColors.primary : AppColors.gray300, + borderRadius: BorderRadius.circular(4.r), + ), + ); + } +} + +/// 온보딩 진행률 바 +class StepProgressBar extends StatelessWidget { + final OnboardingStep currentStep; + final int totalSteps; + + const StepProgressBar({ + super.key, + required this.currentStep, + this.totalSteps = 4, + }); + + @override + Widget build(BuildContext context) { + final progress = (currentStep.index + 1) / totalSteps; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _getStepText(), + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: AppColors.gray600, + ), + ), + Text( + '${currentStep.index + 1}/$totalSteps', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 8.h), + LinearProgressIndicator( + value: progress, + backgroundColor: AppColors.gray200, + valueColor: const AlwaysStoppedAnimation(AppColors.primary), + minHeight: 4.h, + borderRadius: BorderRadius.circular(2.r), + ), + ], + ); + } + + String _getStepText() { + switch (currentStep) { + case OnboardingStep.terms: + return '약관 동의'; + case OnboardingStep.birthDate: + return '생년월일'; + case OnboardingStep.gender: + return '성별'; + case OnboardingStep.nickname: + return '닉네임'; + case OnboardingStep.completed: + return '완료'; + } + } +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 96e9f7b..36e3651 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -15,6 +15,12 @@ import '../features/auth/presentation/pages/login_page.dart'; import '../features/auth/presentation/pages/onboarding_page.dart'; import '../features/home/presentation/pages/home_page.dart'; +// Onboarding Step Pages +import '../features/onboarding/presentation/pages/terms_step_page.dart'; +import '../features/onboarding/presentation/pages/birth_date_step_page.dart'; +import '../features/onboarding/presentation/pages/gender_step_page.dart'; +import '../features/onboarding/presentation/pages/nickname_step_page.dart'; + /// GoRouter 인스턴스를 제공하는 Riverpod Provider /// /// 앱 전체의 네비게이션을 관리하며, 인증 상태에 따라 @@ -69,6 +75,15 @@ final routerProvider = Provider((ref) { // 인증이 불필요한 공개 경로 (Splash, Login) final publicPaths = [RoutePaths.splash, RoutePaths.login]; + // 온보딩 경로들 + final onboardingPaths = [ + RoutePaths.onboarding, + RoutePaths.onboardingTerms, + RoutePaths.onboardingBirthDate, + RoutePaths.onboardingGender, + RoutePaths.onboardingNickname, + ]; + // ==================================================================== // 1. 인증 체크 - 로그인 필요한 페이지 보호 // ==================================================================== @@ -82,21 +97,21 @@ final routerProvider = Provider((ref) { } // ==================================================================== - // 2. 인증된 사용자가 로그인 페이지 접근 시 + // 2. 인증된 사용자가 로그인 페이지 접근 시 → 홈 또는 온보딩으로 // ==================================================================== if (currentPath == RoutePaths.login) { - // 로그인 완료 시 홈으로 + // 온보딩 필요 여부 확인 (동기적으로 확인 불가하므로 일단 홈으로) + // 실제 온보딩 체크는 로그인 후 SignInResponse에서 처리 return RoutePaths.home; } // ==================================================================== - // 3. 온보딩 체크 (향후 구현) + // 3. 온보딩 중인 사용자가 홈에 접근 시 → 온보딩으로 리다이렉트 // ==================================================================== - // TODO: 온보딩 완료 여부 확인 - // SharedPreferences나 Firestore에서 온보딩 완료 상태 확인 - // if (!onboardingCompleted && currentPath != RoutePaths.onboarding) { - // return RoutePaths.onboarding; - // } + // 온보딩 경로에 있으면 허용 + if (onboardingPaths.contains(currentPath)) { + return null; + } return null; // 리다이렉트 불필요 }, @@ -123,6 +138,33 @@ final routerProvider = Provider((ref) { builder: (context, state) => const OnboardingPage(), ), + // ==================================================================== + // Onboarding Step Routes + // ==================================================================== + GoRoute( + path: RoutePaths.onboardingTerms, + name: RoutePaths.onboardingTermsName, + builder: (context, state) => const TermsStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingBirthDate, + name: RoutePaths.onboardingBirthDateName, + builder: (context, state) => const BirthDateStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingGender, + name: RoutePaths.onboardingGenderName, + builder: (context, state) => const GenderStepPage(), + ), + + GoRoute( + path: RoutePaths.onboardingNickname, + name: RoutePaths.onboardingNicknameName, + builder: (context, state) => const NicknameStepPage(), + ), + // ==================================================================== // Home & Main Navigation // ==================================================================== diff --git a/lib/router/route_paths.dart b/lib/router/route_paths.dart index 14bc0ce..ef87a72 100644 --- a/lib/router/route_paths.dart +++ b/lib/router/route_paths.dart @@ -41,6 +41,18 @@ class RoutePaths { /// 온보딩 화면 (첫 로그인 시: 이용약관 동의, 닉네임 설정 등) static const String onboarding = '/onboarding'; + /// 온보딩 - 약관 동의 + static const String onboardingTerms = '/onboarding/terms'; + + /// 온보딩 - 생년월일 + static const String onboardingBirthDate = '/onboarding/birth-date'; + + /// 온보딩 - 성별 + static const String onboardingGender = '/onboarding/gender'; + + /// 온보딩 - 닉네임 + static const String onboardingNickname = '/onboarding/nickname'; + /// 홈 화면 (인증 필수, 메인 기능 진입점) static const String home = '/home'; @@ -52,5 +64,9 @@ class RoutePaths { static const String splashName = 'splash'; static const String loginName = 'login'; static const String onboardingName = 'onboarding'; + static const String onboardingTermsName = 'onboarding-terms'; + static const String onboardingBirthDateName = 'onboarding-birth-date'; + static const String onboardingGenderName = 'onboarding-gender'; + static const String onboardingNicknameName = 'onboarding-nickname'; static const String homeName = 'home'; } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 926e8ef..a2c4087 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 12BD5999885CD2782FFFAD69 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4137A3D35378E7ED9A986E6 /* Pods_RunnerTests.framework */; }; 1B4D4B9641B35979114E19F9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2905CC2DC438CDE4F7700928 /* GoogleService-Info.plist */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; @@ -28,6 +29,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + FCB8C50EADD81F00E8642AF8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 092444A6C4387385F2B73CD9 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,7 +63,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 092444A6C4387385F2B73CD9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1C392A8B03AFD7E60BD0949A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 20A4A0C95DBEAAB57FA65D07 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 2905CC2DC438CDE4F7700928 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 300488F43CF7B11F802CEC95 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -78,8 +84,12 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3A1C108895255F12D7CD5DA7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 5726A8F0DB2D85C96A30EA41 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A4137A3D35378E7ED9A986E6 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C157EEB9BE94B633651908B7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 12BD5999885CD2782FFFAD69 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -94,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FCB8C50EADD81F00E8642AF8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -128,6 +140,7 @@ 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 2905CC2DC438CDE4F7700928 /* GoogleService-Info.plist */, + 96921C4AE5670A5E25CA234E /* Pods */, ); sourceTree = ""; }; @@ -175,9 +188,25 @@ path = Runner; sourceTree = ""; }; + 96921C4AE5670A5E25CA234E /* Pods */ = { + isa = PBXGroup; + children = ( + 3A1C108895255F12D7CD5DA7 /* Pods-Runner.debug.xcconfig */, + 5726A8F0DB2D85C96A30EA41 /* Pods-Runner.release.xcconfig */, + 1C392A8B03AFD7E60BD0949A /* Pods-Runner.profile.xcconfig */, + 300488F43CF7B11F802CEC95 /* Pods-RunnerTests.debug.xcconfig */, + 20A4A0C95DBEAAB57FA65D07 /* Pods-RunnerTests.release.xcconfig */, + C157EEB9BE94B633651908B7 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 092444A6C4387385F2B73CD9 /* Pods_Runner.framework */, + A4137A3D35378E7ED9A986E6 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -189,6 +218,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 9A4B57CF7FC060BA107CC516 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -207,12 +237,15 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 704003F8267A84DA97BF5147 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 45631ED06968130C2A63C1B7 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */, + 0365A5E78D57A78ED64FEA43 /* [CP] Embed Pods Frameworks */, + 3F5F0DD694506B7ECBF23E3B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -296,6 +329,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0365A5E78D57A78ED64FEA43 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -334,6 +384,23 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 3F5F0DD694506B7ECBF23E3B /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 45631ED06968130C2A63C1B7 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -352,6 +419,50 @@ shellPath = /bin/sh; shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n\nif [ -z \"$PODS_ROOT\" ] || [ ! -d \"$PODS_ROOT/FirebaseCrashlytics\" ]; then\n # Cannot use \"BUILD_DIR%/Build/*\" as per Firebase documentation, it points to \"flutter-project/build/ios/*\" path which doesn't have run script\n DERIVED_DATA_PATH=$(echo \"$BUILD_ROOT\" | sed -E 's|(.*DerivedData/[^/]+).*|\\1|')\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"${DERIVED_DATA_PATH}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nelse\n PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT=\"$PODS_ROOT/FirebaseCrashlytics/run\"\nfi\n\n# Command to upload symbols script used to upload symbols to Firebase server\nflutterfire upload-crashlytics-symbols --upload-symbols-script-path=\"$PATH_TO_CRASHLYTICS_UPLOAD_SCRIPT\" --platform=macos --apple-project-path=\"${SRCROOT}\" --env-platform-name=\"${PLATFORM_NAME}\" --env-configuration=\"${CONFIGURATION}\" --env-project-dir=\"${PROJECT_DIR}\" --env-built-products-dir=\"${BUILT_PRODUCTS_DIR}\" --env-dwarf-dsym-folder-path=\"${DWARF_DSYM_FOLDER_PATH}\" --env-dwarf-dsym-file-name=\"${DWARF_DSYM_FILE_NAME}\" --env-infoplist-path=\"${INFOPLIST_PATH}\" --default-config=default\n"; }; + 704003F8267A84DA97BF5147 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9A4B57CF7FC060BA107CC516 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -403,6 +514,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 300488F43CF7B11F802CEC95 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -417,6 +529,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 20A4A0C95DBEAAB57FA65D07 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -431,6 +544,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C157EEB9BE94B633651908B7 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 2fa6380..5f6657f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: e4a1b612fd2955908e26116075b3a4baf10c353418ca645b4deae231c82bf144 + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 url: "https://pub.dev" source: hosted - version: "1.3.65" + version: "1.3.66" analyzer: dependency: transitive description: @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -261,10 +269,10 @@ packages: dependency: "direct main" description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.1" dio_web_adapter: dependency: transitive description: @@ -301,34 +309,34 @@ packages: dependency: "direct main" description: name: firebase_auth - sha256: "060a9bfc9877538dfdc69f2fdc1e0bf068439a7e9f22da6513f7b9bde308bb93" + sha256: b20d1540460814c5984474c1e9dd833bdbcff6ecd8d6ad86cc9da8cfd581c172 url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.1.4" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface - sha256: e91c9aadf9944e8319855fa752fd6c664bade2bbd6e7697a5142113c319a4288 + sha256: fd0225320b6bbc92460c86352d16b60aea15f9ef88292774cca97b0522ea9f72 url: "https://pub.dev" source: hosted - version: "8.1.5" + version: "8.1.6" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: "155145fd84f311e50eb2bfeb892f74b0d1b30936f40765a051b70110270cb6d1" + sha256: be7dccb263b89fbda2a564de9d8193118196e8481ffb937222a025cdfdf82c40 url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "6.1.2" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "29cfa93c771d8105484acac340b5ea0835be371672c91405a300303986f4eba9" + sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.0" firebase_core_platform_interface: dependency: transitive description: @@ -341,50 +349,50 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: a631bbfbfa26963d68046aed949df80b228964020e9155b086eff94f462bbf1f + sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "8d52022ee6fdd224e92c042f297d1fd0ec277195c49f39fa61b8cc500a639f00" + sha256: a6e6cb8b2ea1214533a54e4c1b11b19c40f6a29333f3ab0854a479fdc3237c5b url: "https://pub.dev" source: hosted - version: "5.0.6" + version: "5.0.7" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: "97c6a97b35e3d3dafe38fb053a65086a1efb125022d292161405848527cc25a4" + sha256: fc6837c4c64c48fa94cab8a872a632b9194fa9208ca76a822f424b3da945584d url: "https://pub.dev" source: hosted - version: "3.8.16" + version: "3.8.17" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "1ad663fbb6758acec09d7e84a2e6478265f0a517f40ef77c573efd5e0089f400" + sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" url: "https://pub.dev" source: hosted - version: "16.1.0" + version: "16.1.1" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: ea620e841fbcec62a96984295fc628f53ef5a8da4f53238159719ed0af7db834 + sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" url: "https://pub.dev" source: hosted - version: "4.7.5" + version: "4.7.6" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "7d0fb6256202515bba8489a3d69c6bc9d52d69a4999bad789053b486c8e7323e" + sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" fixnum: dependency: transitive description: @@ -632,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" http: dependency: transitive description: @@ -756,10 +772,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -768,6 +784,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537" + url: "https://pub.dev" + source: hosted + version: "9.2.4" package_config: dependency: transitive description: @@ -828,10 +860,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -896,14 +928,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e - url: "https://pub.dev" - source: hosted - version: "4.2.0" pub_semver: dependency: transitive description: @@ -928,14 +952,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.2" - retrofit_generator: - dependency: "direct dev" - description: - name: retrofit_generator - sha256: "9abcf21acb95bf7040546eafff87f60cf0aee20b05101d71f99876fc4df1f522" - url: "https://pub.dev" - source: hosted - version: "9.7.0" riverpod: dependency: transitive description: @@ -980,10 +996,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -1169,10 +1185,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: transitive description: @@ -1233,10 +1249,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_math: dependency: transitive description: @@ -1326,5 +1342,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 45b1397..6c07938 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mapsy description: "MapSy - Flutter Application" publish_to: "none" -version: 1.0.19+19 +version: 1.0.29+29 environment: sdk: ^3.9.2 dependencies: diff --git a/version.yml b/version.yml index b285483..185aabb 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "1.0.19" -version_code: 20 # app build number +version: "1.0.29" +version_code: 30 # app build number project_type: "flutter" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-27 06:36:04" + last_updated: "2026-02-04 09:38:20" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"