diff --git a/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml b/.github/workflows/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml index 78892e4..eb8b36a 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 + # ============================================ # ๐Ÿ”ง ํ”„๋กœ์ ํŠธ๋ณ„ ์„ค์ • (์•„๋ž˜ ๊ฐ’๋“ค์„ ์ˆ˜์ •ํ•˜์„ธ์š”) # ============================================ diff --git a/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml b/.github/workflows/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml index 83f640f..d59b2f1 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 + # ============================================ # ๐Ÿ”ง ํ”„๋กœ์ ํŠธ๋ณ„ ์„ค์ • (์•„๋ž˜ ๊ฐ’๋“ค์„ ์ˆ˜์ •ํ•˜์„ธ์š”) # ============================================ diff --git a/.gitignore b/.gitignore index 839e51b..16c7970 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,13 @@ 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/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/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 7981576..dc345d7 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -3,59 +3,109 @@ 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..d2a83cc --- /dev/null +++ b/lib/core/network/auth_interceptor.dart @@ -0,0 +1,57 @@ +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..61efdfe --- /dev/null +++ b/lib/core/network/error_interceptor.dart @@ -0,0 +1,141 @@ +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..ee1edf0 --- /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..ac5a97d --- /dev/null +++ b/lib/core/network/token_storage.dart @@ -0,0 +1,148 @@ +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..347826a --- /dev/null +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -0,0 +1,89 @@ +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..a8dcd59 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,129 @@ +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..21804b1 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,8 @@ 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 +34,6 @@ class LoginPage extends ConsumerWidget { ? (authState.error as AuthException).message : '๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; - //TODO: ์Šค๋‚ต๋ฐ” ๋‚˜์ค‘์— ๋””์ž์ธ ๋งŒ๋“ค์–ด์ง€๋ฉด ๋ฐ”๋€Œ์–ด์•ผํ•จ. ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage, style: AppTextStyles.toast), @@ -40,17 +42,21 @@ 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 +68,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 +76,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..8a6a069 100644 --- a/lib/features/auth/presentation/pages/splash_page.dart +++ b/lib/features/auth/presentation/pages/splash_page.dart @@ -1,7 +1,11 @@ +import 'package:flutter/foundation.dart'; 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 +13,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..52ac270 --- /dev/null +++ b/lib/features/onboarding/data/datasources/onboarding_remote_datasource.dart @@ -0,0 +1,102 @@ +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..a41e9c6 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/birth_date_step_page.dart @@ -0,0 +1,276 @@ +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..e774df0 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/gender_step_page.dart @@ -0,0 +1,179 @@ +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..c3bbb8e --- /dev/null +++ b/lib/features/onboarding/presentation/pages/nickname_step_page.dart @@ -0,0 +1,298 @@ +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..c089466 --- /dev/null +++ b/lib/features/onboarding/presentation/pages/terms_step_page.dart @@ -0,0 +1,289 @@ +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..c8f3ed6 --- /dev/null +++ b/lib/features/onboarding/presentation/providers/onboarding_provider.dart @@ -0,0 +1,316 @@ +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..19bf4e9 --- /dev/null +++ b/lib/features/onboarding/presentation/widgets/onboarding_button.dart @@ -0,0 +1,84 @@ +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..b6782df --- /dev/null +++ b/lib/features/onboarding/presentation/widgets/step_indicator.dart @@ -0,0 +1,109 @@ +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"