diff --git a/.github/workflows/1. poppang-build.yml b/.github/workflows/1. poppang-build.yml index aa8e5beb..32555158 100644 --- a/.github/workflows/1. poppang-build.yml +++ b/.github/workflows/1. poppang-build.yml @@ -57,6 +57,11 @@ jobs: ADMOB_NATIVE_AD_UNIT_ID = ca-app-pub-3940256099942544/3986624511 EOF + - name: Download React Native release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/download-rn-release.sh v0.1.0 + - name: Generate Tuist workspace run: | "$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install diff --git a/.github/workflows/2. poppang-test.yml b/.github/workflows/2. poppang-test.yml index 9e528f30..3f8b6c99 100644 --- a/.github/workflows/2. poppang-test.yml +++ b/.github/workflows/2. poppang-test.yml @@ -57,6 +57,11 @@ jobs: ADMOB_NATIVE_AD_UNIT_ID = ca-app-pub-3940256099942544/3986624511 EOF + - name: Download React Native release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/download-rn-release.sh v0.1.0 + - name: Generate Tuist workspace run: | "$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install diff --git a/.github/workflows/3. poppang-build-and-test.yml b/.github/workflows/3. poppang-build-and-test.yml index 06153b3d..cecd20ef 100644 --- a/.github/workflows/3. poppang-build-and-test.yml +++ b/.github/workflows/3. poppang-build-and-test.yml @@ -120,6 +120,11 @@ jobs: ADMOB_APP_ID = cidummy EOF + - name: Download React Native release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/download-rn-release.sh v0.1.0 + - name: Generate Tuist workspace run: | "$HOME/.local/bin/mise" x tuist@$(cat .tuist-version) -- tuist install diff --git a/.gitignore b/.gitignore index 4bf2e819..c89e89ee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ GoogleService-Info.plist # codex .codex/logs/ +# React Native release artifacts +.rn-release-temp/ +Vendor/PrebuiltReactNativeFrameworks/ +Projects/App/Resources/ReactNative/ + # macOS .DS_Store ._* diff --git a/Docs/images/module-dependency-graph-legacy.png b/Docs/images/module-dependency-graph-legacy.png new file mode 100644 index 00000000..940e734e Binary files /dev/null and b/Docs/images/module-dependency-graph-legacy.png differ diff --git a/Docs/images/module-dependency-graph.png b/Docs/images/module-dependency-graph.png index 940e734e..54e339d6 100644 Binary files a/Docs/images/module-dependency-graph.png and b/Docs/images/module-dependency-graph.png differ diff --git a/Docs/poppang-architecture.md b/Docs/poppang-architecture.md index 504fc187..2d29ec37 100644 --- a/Docs/poppang-architecture.md +++ b/Docs/poppang-architecture.md @@ -44,9 +44,8 @@ Projects │ ├── HomeFeature │ ├── MapFeature │ ├── OnboardingFeature +│ ├── PopPangRNFeature │ ├── PopupDetailFeature -│ ├── PopupRequestFeature -│ ├── PopupRequestManagementFeature │ ├── ProfileFeature │ ├── ReviewFeature │ ├── MainTabFeature @@ -101,13 +100,9 @@ Projects - 현재 로그인 사용자는 `AppFeature.session.user`로 표현한다. - active main flow는 `MainTabFeature`가 TCA `StackState`와 단일 `@Presents` destination으로 소유한다. - `AppFeature`는 `MainTabFeature.State?`를 저장하고, 메인 진입 시 shared `session`을 전달해 한 번 생성한다. -- TCA-ready feature는 필요한 경우 parent가 내려주는 shared `session`을 직접 읽는다. -- 현재 `CalendarFeature`도 shared `session`을 직접 읽고, 캘린더 로컬 상태를 feature state가 소유한다. -- 현재 `FavoritesFeature`도 shared `session`을 직접 읽고, 찜 로컬 상태를 feature state가 소유한다. -- 현재 `HomeFeature`는 shared `session`을 직접 읽고, 홈 로컬 상태만 feature state가 소유한다. -- 현재 `MapFeature`도 shared `session`을 직접 읽고, 지도 로컬 상태를 feature state가 소유한다. -- 현재 `AlertFeature`도 shared `session`을 직접 읽고, 알림 로컬 상태를 feature state가 소유한다. -- 현재 `ProfileFeature`도 shared `session`을 직접 읽고, 프로필 로컬 상태와 프로필 설정 path는 TCA reducer가 소유한다. +- `MainTabFeature`는 shared `session`을 root session 동기화에만 사용하고, child feature에는 로그인 사용자 snapshot만 전달한다. +- 현재 `HomeFeature`, `CalendarFeature`, `FavoritesFeature`, `MapFeature`, `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 shared `session`을 직접 읽지 않는다. +- 각 feature state는 필요한 사용자 primitive와 화면 로컬 상태를 함께 소유한다. - legacy feature는 당분간 session-derived primitive 값을 view init으로 주입한다. - `MainTabFeature.Action`은 탭 child action, `path`, `destination`, parent delegate 중심으로 유지한다. - 탭 내부의 로컬 sheet/bottom sheet/selected item 상태는 각 feature가 소유한다. @@ -138,14 +133,10 @@ Projects #### Home / Calendar / Map / Favorites / Profile / Alert - `AppFeature`가 shared `session` source of truth를 소유 -- `MainTabFeature`가 shared `session`을 `HomeFeature`, `CalendarFeature`, `MapFeature`, `FavoritesFeature`, `AlertFeature`, `ProfileFeature`, `ProfileSettingFeature`에 전달 -- `HomeFeature.State`는 shared `session`을 읽고, `bestPopups`, `filter` 같은 홈 로컬 상태를 직접 소유 -- `CalendarFeature.State`는 shared `session`을 읽고, `selectedDate`, `calendarPopups`, `popupEventCounts` 같은 캘린더 로컬 상태를 직접 소유 -- `MapFeature.State`는 shared `session`을 읽고, 지도 팝업 목록/시트 상태/위치 상태를 직접 소유한다. -- `FavoritesFeature.State`는 shared `session`을 읽고, `favoritePopups`, `selectedDate`, `popupEventCounts` 같은 찜 로컬 상태를 직접 소유한다. -- `AlertFeature.State`는 shared `session`을 읽고, alert popup/keyword/recent keyword/편집 상태를 직접 소유한다. -- `ProfileFeature.State`는 shared `session`을 읽고, `localIsAlerted`, `errorMessage` 같은 프로필 로컬 상태를 직접 소유 -- `ProfileSettingFeature.State`는 shared `session`을 읽고, 닉네임 변경/회원탈퇴용 로컬 상태를 직접 소유한다. +- `MainTabFeature.CoreState`가 로그인 `User` snapshot을 소유하고 각 child state를 생성한다. +- `HomeFeature.State`는 `userUuid`, `nickname`, `isAdmin`을, `CalendarFeature`, `FavoritesFeature`, `MapFeature`는 `userUuid`을 직접 소유한다. +- `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 화면에 필요한 사용자 snapshot과 로컬 상태를 직접 소유한다. +- 프로필 이름과 알림 상태 변경은 delegate로 `MainTabFeature`에 전달하고, MainTab이 child snapshot과 `AppFeature.session`을 함께 동기화한다. - `HomeFeature`가 `@Dependencies.Dependency(\.homePopupClient)`로 feature-scoped dependency 사용 - `CalendarFeature`는 `@Dependencies.Dependency(\.calendarFeatureClient)`로 feature-scoped dependency를 사용한다. - `MapFeature`는 `@Dependencies.Dependency(\.mapFeatureClient)`로 feature-scoped dependency를 사용한다. @@ -153,7 +144,7 @@ Projects - `AlertFeature`는 `@Dependencies.Dependency(\.alertFeatureClient)`로 feature-scoped dependency를 사용한다. - `ProfileFeature`와 `ProfileSettingFeature`는 `@Dependencies.Dependency(\.profileFeatureClient)`로 feature-scoped dependency를 사용한다. - `MainTabFeature`가 홈에서 시작하는 fullScreen presentation을 소유 - - 현재 검색과 팝업 제보는 `MainTabFeature.destination`에서 관리 + - 현재 검색과 RN 팝업 제보는 `MainTabFeature.destination`에서 관리 - `CalendarFeature`는 지역/정렬 시트를 view local state로 열고, 실제 필터/날짜/좋아요 상태는 reducer가 소유한다. - `FavoritesFeature`는 segmented selection을 view local state로 두고, 찜 목록/찜 캘린더/좋아요/에러 상태는 reducer가 소유한다. - 홈에서 시작하는 연속 drill-down push(`오픈예정팝업 리스트 -> 팝업 상세`)는 `MainTabFeature.path`가 소유 @@ -162,7 +153,7 @@ Projects - 알림 화면에서 선택한 팝업 상세도 `MainTabFeature.path`가 소유하고, child feature는 delegate action으로 popup selection intent만 올린다. - 프로필 설정 push도 `MainTabFeature.path`가 소유하고, child feature는 delegate action으로 dismiss/logout intent만 올린다. - 여러 탭과 공통으로 이어질 수 있는 push 흐름은 `MainTabFeature.path`가 소유 - - 현재 팝업 상세, 리뷰 상세, 관리자 팝업 제보 관리 흐름이 여기에 해당 + - 현재 팝업 상세, 리뷰 상세, RN 관리자 팝업 제보 관리 흐름이 여기에 해당 ```swift HomeFeatureView(store: store.scope(state: \.core.home, action: \.home)) @@ -193,7 +184,7 @@ ProfileFeatureView(store: store.scope(state: \.core.profile, action: \.profile)) 주의: - feature가 다른 feature를 직접 import하는 구조는 기본 전략이 아니다. -- 예외로 `PopupRequestFeature`, `PopupRequestManagementFeature`, `PopupSubmissionFormFeature`는 제거 예정 임시 흐름이라 서로 직접 참조를 허용한다. +- `PopPangRNFeature`는 RN host wrapper 전용 모듈이며, 화면 전환은 계속 `MainTabFeature`가 소유한다. - active main flow에서 feature 간 이동은 `MainTabFeature.Path` 또는 `MainTabFeature.Destination` state로 조립한다. - 화면 로컬 상태는 feature reducer local state 또는 feature view local state에 둔다. - 전역 이동, 탭 바깥 push, full screen 전환은 상위 TCA parent reducer로 올린다. @@ -419,6 +410,7 @@ DI 변경 시 함께 확인할 파일: 원칙: - feature target은 기본적으로 `.staticFramework` 성격의 앱 내부 leaf feature로 본다. +- `PopPangRNFeature`는 React Native prebuilt binary의 링크 설정을 보존하기 위해 `.framework`를 사용한다. - `Coordinator`는 legacy 공유 경계이며 제거 대상이다. - `Domain`, `Data`, `Core`, `DSKit`, `ThirdParty`는 현재 공유 경계로 본다. - 장기적으로 `Shared.Models`, `Shared.Clients`, `Shared.Caches`, `SharedFeature` 분리를 검토한다. diff --git a/Docs/static-dynamic-linking.md b/Docs/static-dynamic-linking.md index 633e04f1..57514c70 100644 --- a/Docs/static-dynamic-linking.md +++ b/Docs/static-dynamic-linking.md @@ -135,9 +135,12 @@ Data sources | `Core` | `.framework` | 네트워크, 저장소, formatter 등 여러 레이어가 공유하는 기반 모듈이다. | | `DSKit` | `.framework` | Feature들이 공유하는 UI resource/API 경계다. | | `ThirdParty` | `.framework` | 외부 SDK product를 한 곳에서 링크하는 허브다. | +| `PopPangRNFeature` | `.framework` | RN prebuilt binary를 하나의 동적 경계에서 링크하고 host 심볼을 최종 앱에 한 번만 전달한다. | | demo app | `.app` | 독립 실행 샘플 앱이다. | | App | `.app` | 최종 앱 산출물이다. | +RN prebuilt 산출물은 저장소에 커밋하지 않고 `scripts/download-rn-release.sh`로 `Vendor/PrebuiltReactNativeFrameworks`와 `Projects/App/Resources/ReactNative`에 내려받는다. + ### feature는 왜 static인가 PopPang feature는 아래 성격이다. diff --git a/Docs/tca-navigation-guidelines.md b/Docs/tca-navigation-guidelines.md index ae295b6b..7e7807f4 100644 --- a/Docs/tca-navigation-guidelines.md +++ b/Docs/tca-navigation-guidelines.md @@ -12,17 +12,13 @@ - 새 화면 전환은 TCA state/action/reducer로 모델링한다. - tree-based navigation과 stack-based navigation을 함께 사용한다. - feature는 다른 feature를 직접 조립하지 않고 delegate action으로 intent만 올린다. -- 임시 예외로 제거 예정인 `PopupRequestFeature`, `PopupRequestManagementFeature`, `PopupSubmissionFormFeature` 조합만 직접 조립을 허용한다. +- RN 팝업 제보 화면은 `PopPangRNFeature` wrapper로 감싸고, navigation owner는 계속 `MainTabFeature`가 가진다. - 전역 세션 상태의 source of truth는 `AppFeature.session`이다. - 현재 로그인 사용자는 `AppFeature.session.user`로 표현한다. - `AppFeature`는 root/auth/register 전환을 소유하고, `MainTabFeature` 모듈은 로그인 이후 main flow navigation owner로 동작한다. -- `MainTabFeature`는 shared `session`을 child feature에 전달하고, 탭 로컬 navigation state를 소유한다. -- direct scope가 가능한 feature는 shared `session`을 직접 읽거나 필요한 값을 projection해서 reducer/state에 주입한다. -- 현재 `CalendarFeature`도 shared `session`을 직접 읽고 캘린더 로컬 상태를 feature state가 소유한다. -- 현재 `FavoritesFeature`도 shared `session`을 직접 읽고 찜 로컬 상태를 feature state가 소유한다. -- 현재 `HomeFeature`는 shared `session`을 직접 읽고 홈 로컬 상태를 feature state가 소유한다. -- 현재 `MapFeature`도 shared `session`을 직접 읽고 지도 로컬 상태를 feature state가 소유한다. -- 현재 `ProfileFeature`와 `ProfileSettingFeature`도 shared `session`을 직접 읽고 프로필 로컬 상태를 feature state가 소유한다. +- `MainTabFeature`는 shared `session`을 root session 동기화에만 사용하고, child feature에는 사용자 snapshot을 전달하며 탭 로컬 navigation state를 소유한다. +- direct scope가 가능한 feature는 필요한 사용자 primitive를 state에 주입하고 shared `session`을 직접 읽지 않는다. +- 현재 `HomeFeature`, `CalendarFeature`, `FavoritesFeature`, `MapFeature`, `ProfileFeature`, `ProfileSettingFeature`, `AlertFeature`는 사용자 snapshot과 화면 로컬 상태를 feature state가 소유한다. - legacy feature는 당분간 session-derived primitive 값을 view init으로 주입한다. ## 용어 @@ -141,7 +137,7 @@ PopPang에서 stack-based navigation을 쓰는 경우: - coming popup list에서 popup detail로 이어지는 drill-down - review detail - alert에서 popup detail 진입 -- popup request management detail +- RN popup request management - profile setting처럼 탭 root 위로 push되는 화면 ### Path 규칙 @@ -254,11 +250,11 @@ PopPang ## Session Injection Strategy -세션 source of truth는 항상 `AppFeature.session`이다. 하위 feature는 필요 시 parent가 explicit shared state로 내려준 `session`을 읽는다. +세션 source of truth는 항상 `AppFeature.session`이다. `MainTabFeature`는 session 동기화가 필요한 parent 작업에만 shared state를 사용하고, 하위 feature에는 사용자 snapshot을 전달한다. ### 1. TCA-ready feature는 state projection + direct scope -`HomeFeature`가 현재 기준선이다. +`HomeFeature`를 포함한 TCA-ready 탭이 현재 기준선이다. ```swift @ObservableState @@ -269,8 +265,9 @@ struct AppFeature.State: Equatable { ``` ```swift -init(session: Shared) { - self.home = .init(session: session) +init(user: User) { + self.home = .init(user: user) + self.calendar = .init(userUuid: user.userUuid) } ``` @@ -278,7 +275,7 @@ init(session: Shared) { HomeFeatureView(store: store.scope(state: \.core.home, action: \.home)) ``` -이 방식에서는 view가 `userUuid`, `nickname`, `isAdmin` 같은 session-derived primitive를 따로 받지 않는다. feature state는 shared `session`을 통해 최신 값을 읽고, reducer는 feature-scoped dependency를 직접 사용한다. +이 방식에서는 view가 별도 사용자 closure를 받지 않는다. feature state가 `userUuid`, `nickname`, `isAdmin` 같은 필요한 snapshot을 직접 소유하고, reducer는 feature-scoped dependency를 직접 사용한다. 변경 가능한 사용자 값은 child delegate를 통해 MainTab이 session 원본과 각 snapshot에 반영한다. ```swift @Reducer @@ -475,7 +472,7 @@ case .destination: - `popupRequestManagement`처럼 메인 공통 흐름으로 이어지는 push는 `MainTabFeature.path`가 소유 - `CalendarFeature`: direct scope 완료, region/sort sheet는 view local state로 두고 상세/알림 이동은 `MainTabFeature.path`가 소유 - `FavoritesFeature`: direct scope 완료, segmented selection은 view local state로 두고 상세/알림 이동과 홈 탭 복귀 intent만 parent로 올림 -- `Map`: bridge reducer를 유지하며 session primitive만 주입 +- `MapFeature`: direct scope로 동작하며 `userUuid` snapshot만 주입 - 각 탭이 TCA reducer entry를 갖추면 `*LegacyBridgeFeature`를 제거하고 direct scope로 전환 ## Do Not diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index b684be4b..13792f9b 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -2,6 +2,9 @@ import ProjectDescription let project = Project( name: "App", + packages: [ + .local(path: "../../Vendor/PrebuiltReactNativeFrameworks"), + ], targets: [ .target( name: "PopPangApp", @@ -50,6 +53,7 @@ let project = Project( "NSLocationAlwaysAndWhenInUseUsageDescription": "주변 팝업스토어를 지도에서 보여주고 거리 순으로 제공하기 위해 위치 정보가 필요합니다.", "NSLocationAlwaysUsageDescription": "팝업스토어 정보 제공을 위해 사용자의 위치를 받습니다.", "NSLocationWhenInUseUsageDescription": "주변 팝업스토어를 지도에서 보여주고 거리 순으로 제공하기 위해 위치 정보가 필요합니다.", + "NSPhotoLibraryUsageDescription": "팝업 제보에 사용할 사진을 선택하기 위해 사진 보관함에 접근합니다.", "NSAppTransportSecurity": [ "NSAllowsArbitraryLoads": false, ], @@ -120,7 +124,11 @@ let project = Project( ] ), sources: ["Sources/**"], - resources: ["Resources/**"], + resources: [ + "Resources/Assets.xcassets", + "Resources/GoogleService-Info.plist", + .folderReference(path: "Resources/ReactNative"), + ], entitlements: "PopPangApp.entitlements", dependencies: [ .project(target: "ADKit", path: "../Shared/ADKit"), @@ -132,6 +140,7 @@ let project = Project( .project(target: "ThirdParty", path: "../Shared/ThirdParty"), .project(target: "Core", path: "../Shared/Core"), .project(target: "DSKit", path: "../Shared/DSKit"), + .package(product: "PrebuiltReactNativeFrameworks"), ], settings: .settings( base: [ @@ -141,8 +150,8 @@ let project = Project( "CODE_SIGN_STYLE": "Manual", "DEVELOPMENT_TEAM": "", "DEVELOPMENT_TEAM[sdk=iphoneos*]": "LGX4B4WC66", - "CURRENT_PROJECT_VERSION": "6", - "MARKETING_VERSION": "1.1.5", + "CURRENT_PROJECT_VERSION": "1", + "MARKETING_VERSION": "1.1.6", "PROVISIONING_PROFILE_SPECIFIER": "", "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "match Development kr.co.poppang.PopPang", ], diff --git a/Projects/App/Sources/AppCore/AppSDKInitializer.swift b/Projects/App/Sources/AppCore/0. AppSDKInitializer.swift similarity index 94% rename from Projects/App/Sources/AppCore/AppSDKInitializer.swift rename to Projects/App/Sources/AppCore/0. AppSDKInitializer.swift index 562d5313..5be49af0 100644 --- a/Projects/App/Sources/AppCore/AppSDKInitializer.swift +++ b/Projects/App/Sources/AppCore/0. AppSDKInitializer.swift @@ -31,6 +31,8 @@ enum AppSDKInitializer { // 지도 오토 레이아웃 경고 제거 UserDefaults.standard.set(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable") + + Logger.d("1. 라이브러리 configure 완료") } } @@ -38,6 +40,5 @@ private enum FirebaseCoreBootstrap { static func configureIfNeeded() { FirebaseConfiguration.shared.setLoggerLevel(.error) FirebaseApp.configure() - Logger.d("FirebaseApp.configure 완료") } } diff --git a/Projects/App/Sources/AppCore/AppDependencyRegistry.swift b/Projects/App/Sources/AppCore/1. AppDependencyRegistry.swift similarity index 87% rename from Projects/App/Sources/AppCore/AppDependencyRegistry.swift rename to Projects/App/Sources/AppCore/1. AppDependencyRegistry.swift index e8d1e592..b52922db 100644 --- a/Projects/App/Sources/AppCore/AppDependencyRegistry.swift +++ b/Projects/App/Sources/AppCore/1. AppDependencyRegistry.swift @@ -2,13 +2,13 @@ import Data import Domain import Foundation +// repository struct AppRepositoryRegistry { let adminRepository: AdminRepositoryProtocol let appleAuthRepository: AppleAuthRepositoryProtocol let googleAuthRepository: GoogleAuthRepositoryProtocol let kakaoAuthRepository: KakaoAuthRepositoryProtocol let popupRepository: PopupRepositoryProtocol - let popupSubmissionRepository: PopupSubmissionRepositoryProtocol let userRepository: UserRepositoryProtocol static func live() -> AppRepositoryRegistry { @@ -18,19 +18,18 @@ struct AppRepositoryRegistry { googleAuthRepository: GoogleAuthRepositoryImpl(), kakaoAuthRepository: KakaoAuthRepositoryImpl(), popupRepository: PopupRepositoryImpl(), - popupSubmissionRepository: PopupSubmissionRepositoryImpl(), userRepository: UserRepositoryImpl() ) } } +// usecase struct AppUsecaseRegistry { let adminUsecase: AdminUsecaseProtocol let appleAuthUsecase: AppleAuthUsecaseProtocol let googleAuthUsecase: GoogleAuthUsecaseProtocol let kakaoAuthUsecase: KakaoAuthUsecaseProtocol let popupUsecase: PopupUsecaseProtocol - let popupSubmissionUsecase: PopupSubmissionUsecaseProtocol let userUsecase: UserUsecaseProtocol init(repositories: AppRepositoryRegistry) { @@ -39,11 +38,11 @@ struct AppUsecaseRegistry { self.googleAuthUsecase = GoogleAuthUsecaseImpl(googleAuthRepository: repositories.googleAuthRepository) self.kakaoAuthUsecase = KakaoAuthUsecaseImpl(kakaoAuthRepository: repositories.kakaoAuthRepository) self.popupUsecase = PopupUsecaseImpl(popupRepository: repositories.popupRepository) - self.popupSubmissionUsecase = PopupSubmissionUsecaseImpl(repository: repositories.popupSubmissionRepository) self.userUsecase = UserUsecaseImpl(userRepository: repositories.userRepository) } } +// usecase dependency struct AppDependencyRegistry { let repositories: AppRepositoryRegistry let usecases: AppUsecaseRegistry diff --git a/Projects/App/Sources/AppCore/AppBootstrap.swift b/Projects/App/Sources/AppCore/2. AppBootstrap.swift similarity index 68% rename from Projects/App/Sources/AppCore/AppBootstrap.swift rename to Projects/App/Sources/AppCore/2. AppBootstrap.swift index bfb684c7..32288eb8 100644 --- a/Projects/App/Sources/AppCore/AppBootstrap.swift +++ b/Projects/App/Sources/AppCore/2. AppBootstrap.swift @@ -10,24 +10,37 @@ struct AppBootstrap { let mainTabFeatureDependencies: MainTabFeatureDependencies let launchStateResolver: AppLaunchStateResolver let dependencies: AppDependencyRegistry - + + /// 실제 앱 실행 환경에서 사용할 의존성 그래프를 생성합니다. + /// 동일한 `store`와 UseCase 인스턴스를 여러 객체에 전달하여 + /// 앱 내부에서 같은 저장소와 비즈니스 로직 인스턴스를 공유하도록 합니다. static func live( store: KeyValueStoring = UserDefaultsStore() ) -> AppBootstrap { + // 로컬 세션 저장소 let sessionStorage = LocalSessionStorage(store: store) + + // Repository와 UseCase를 포함한 앱의 실제 의존성 그래프 let dependencies = AppDependencyRegistry.live() + + /// TCA Feature용 LocalSessionClient를 생성 let localSessionClient = LocalSessionClient.live( sessionStorage: sessionStorage, userUsecase: dependencies.usecases.userUsecase ) + + // MainTab과 하위 Feature들이 사용할 실제 의존성들을 구성 let mainTabFeatureDependencies = MainTabFeatureDependencies( store: store, adminUsecase: dependencies.usecases.adminUsecase, popupUsecase: dependencies.usecases.popupUsecase, - popupSubmissionUsecase: dependencies.usecases.popupSubmissionUsecase, userUsecase: dependencies.usecases.userUsecase ) + + // 푸시 토큰을 로컬에 저장하기 위한 저장소를 생성 let pushTokenStorage = PushTokenStorage(store: store) + + // 알림 처리 객체에도 동일한 세션 저장소와 UserUsecase를 전달 AppNotificationManager.shared.configure( sessionStorage: sessionStorage, pushTokenStorage: pushTokenStorage, @@ -43,6 +56,10 @@ struct AppBootstrap { ) } + // MARK: - 의존성을 만드는 시점과 TCA Store에 연결하는 시점이 다르기 때문 + /// 앱의 최상위 TCA Store를 생성합니다. + /// `AppBootstrap.live()`에서 미리 조립한 실제 Client와 UseCase 기반 구현을 + /// `withDependencies`를 통해 AppFeature와 모든 하위 Reducer에 전달합니다. func makeAppStore() -> StoreOf { Store(initialState: AppFeature.State()) { AppFeature( diff --git a/Projects/App/Sources/AppCore/3. AppDeepLinkHandler.swift b/Projects/App/Sources/AppCore/3. AppDeepLinkHandler.swift new file mode 100644 index 00000000..4e1e1bbe --- /dev/null +++ b/Projects/App/Sources/AppCore/3. AppDeepLinkHandler.swift @@ -0,0 +1,69 @@ +import Core +import Foundation +import GoogleSignIn +import KakaoSDKAuth + +/// 앱으로 전달된 URL을 분석하여 소셜 로그인 콜백과 딥링크를 처리합니다. +/// +/// Google 로그인, Kakao 로그인, Kakao Link, Universal Link를 순서대로 확인하고, +/// 팝업 상세 화면으로 이동해야 하는 경우 팝업 ID를 저장합니다. +@MainActor +struct AppDeepLinkHandler { + private let storage: DeepLinkStorage + + /// 딥링크 정보를 저장할 저장소를 생성합니다. + /// + /// 기본적으로 `UserDefaultsStore`를 사용하며, + /// 테스트 시 별도의 `KeyValueStoring` 구현체를 주입할 수 있습니다. + init(store: KeyValueStoring = UserDefaultsStore()) { + self.storage = DeepLinkStorage(store: store) + } + + /// 앱으로 전달된 URL을 분석하고 적절한 동작을 수행합니다. + /// + /// 처리 가능한 URL이면 `true`, 처리하지 못한 URL이면 `false`를 반환합니다. + /// + /// - Parameter url: 앱에 전달된 로그인 콜백 또는 딥링크 URL + /// - Returns: URL을 성공적으로 처리했으면 `true`, 그렇지 않으면 `false` + @discardableResult + func handleIncomingURL(_ url: URL) -> Bool { + // Google 로그인 인증 콜백 URL을 처리합니다. + if GIDSignIn.sharedInstance.handle(url) { + return true + } + + // Kakao 로그인 인증 콜백 URL인지 확인하고 처리합니다. + if AuthApi.isKakaoTalkLoginUrl(url) { + return AuthController.handleOpenUrl(url: url) + } + + // Kakao Link를 통해 전달된 팝업 상세 딥링크를 처리합니다. + if url.scheme?.hasPrefix("kakao") == true, url.host == "kakaolink" { + // 쿼리 파라미터에서 팝업 ID를 추출합니다. + guard let popupID = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "popupId" })? + .value + else { + return false + } + + // 앱에서 팝업 상세 화면으로 이동할 수 있도록 팝업 ID를 저장합니다. + storage.savePopupID(popupID) + return true + } + + // 웹의 Universal Link를 통해 전달된 팝업 상세 딥링크를 처리합니다. + if url.absoluteString.hasPrefix( + ExternalLinkConfig.popupUniversalLinkBaseURLString + ) { + // URL의 마지막 경로 값을 팝업 ID로 저장합니다. + storage.savePopupID(url.lastPathComponent) + return true + } + + // 지원하지 않는 URL은 처리하지 않습니다. + return false + } +} + diff --git a/Projects/App/Sources/AppCore/AppDeepLinkHandler.swift b/Projects/App/Sources/AppCore/AppDeepLinkHandler.swift deleted file mode 100644 index 3668678f..00000000 --- a/Projects/App/Sources/AppCore/AppDeepLinkHandler.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Core -import Foundation -import GoogleSignIn -import KakaoSDKAuth - -@MainActor -struct AppDeepLinkHandler { - private let storage: DeepLinkStorage - - init(store: KeyValueStoring = UserDefaultsStore()) { - self.storage = DeepLinkStorage(store: store) - } - - @discardableResult - func handleIncomingURL(_ url: URL) -> Bool { - if GIDSignIn.sharedInstance.handle(url) { - return true - } - - if AuthApi.isKakaoTalkLoginUrl(url) { - return AuthController.handleOpenUrl(url: url) - } - - if url.scheme?.hasPrefix("kakao") == true, url.host == "kakaolink" { - guard let popupID = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "popupId" })? - .value - else { - return false - } - - storage.savePopupID(popupID) - return true - } - - if url.absoluteString.hasPrefix(ExternalLinkConfig.popupUniversalLinkBaseURLString) { - storage.savePopupID(url.lastPathComponent) - return true - } - - return false - } -} diff --git a/Projects/App/Sources/AppCore/Navigation/AppRootFlowView.swift b/Projects/App/Sources/AppCore/Navigation/7. AppRootFlowView.swift similarity index 90% rename from Projects/App/Sources/AppCore/Navigation/AppRootFlowView.swift rename to Projects/App/Sources/AppCore/Navigation/7. AppRootFlowView.swift index 8150c1dc..8b52adb6 100644 --- a/Projects/App/Sources/AppCore/Navigation/AppRootFlowView.swift +++ b/Projects/App/Sources/AppCore/Navigation/7. AppRootFlowView.swift @@ -27,6 +27,9 @@ struct AppRootFlowView: View { } } } + .onDisappear { + store.send(.onboardingRootDidDisappear) + } case .auth: AuthFeatureView(store: store.scope(state: \.auth, action: \.auth)) @@ -41,6 +44,9 @@ struct AppRootFlowView: View { case .main: if let mainTabStore = store.scope(state: \.mainTab, action: \.mainTab) { MainTabFeatureView(store: mainTabStore) + .onDisappear { + store.send(.mainTabViewDidDisappear) + } } else { EmptyView() } diff --git a/Projects/App/Sources/AppCore/Navigation/8. AppFeature.swift b/Projects/App/Sources/AppCore/Navigation/8. AppFeature.swift new file mode 100644 index 00000000..94323b23 --- /dev/null +++ b/Projects/App/Sources/AppCore/Navigation/8. AppFeature.swift @@ -0,0 +1,357 @@ +import ComposableArchitecture +import AuthFeature +import Core +import Domain +import OnboardingFeature +import MainTabFeature + +/// 앱 최상위에서 표시할 화면 +enum AppRootDestination: Equatable, Sendable { + case launch /// 시작 + case onboarding /// 온보딩 + case auth /// 로그인 + case register /// 회원정보 입력 화면 + case main /// 로그인 완료 후 메인 화면 +} + +/// 앱 실행 시 저장된 세션을 확인한 결과를 나타낸다 +enum AppLaunchResolution { + /// 사용자 정보 없이 특정 화면으로 이동 + case destination(AppRootDestination) + + /// 인증과 회원가입이 모두 완료된 사용자 + case authenticated(User) + + /// 인증은 완료됐지만 추가 회원정보 입력이 필요한 사용자 + case registrationRequired(User) +} + +extension AppFeature.OnboardingPath.State: Equatable {} + +@Reducer +struct AppFeature { + @ObservableState + struct State: Equatable { + /// 여러 하위 Feature가 공유하는 사용자 세션 + @Shared var session: UserSession + + /// 현재 앱 루트에 표시할 화면 + var destination: AppRootDestination = .launch + + /// 기본 로그인 화면의 상태 + var auth = AuthFeature.State() + + /// 추가 회원정보 입력이 필요할 때 생성되는 상태 + var registerFlow: RegisterFlowFeature.State? + + /// 온보딩 화면의 상태 + var onboarding = OnboardingFeature.State() + + /// /// 온보딩 내부 NavigationStack 경로 + var onboardingPath = StackState() + + /// 메인 화면에 진입했을 때 생성되는 상태 + var mainTab: MainTabFeature.State? + + /// SwiftUI가 MainTab view tree를 해제하는 동안의 로그아웃 전환 여부 + var isLoggingOut = false + + /// 초기 사용자 세션을 전달받아 공유 상태로 생성 + init(session: UserSession = .init()) { + self._session = Shared(value: session) + } + } + + enum Action { + /// 앱 실행 시 저장된 세션과 초기 화면을 확인 + case launchTask + + /// 앱 실행 상태 확인이 완료됐을 때 전달 + case launchResolved(UserSession, AppRootDestination) + + /// Actions + case auth(AuthFeature.Action) + case registerFlow(RegisterFlowFeature.Action) + case onboarding(OnboardingFeature.Action) + case onboardingPath(StackActionOf) + + /// 소셜 인증이 완료됐을 때 사용자 정보를 전달 + case authCompleted(User) + + /// 추가 회원정보 등록이 완료됐을 때 사용자 정보를 전달 + case registerCompleted(User) + + /// 메인 탭 루트 view가 화면에서 사라졌음을 전달 + case mainTabViewDidDisappear + + /// MainTab view tree 해제 이후 state를 제거 + case logoutTeardownCompleted + + /// 온보딩 NavigationStack이 화면에서 사라졌음을 전달 + case onboardingRootDidDisappear + + /// 온보딩 view tree 해제 이후 navigation state를 제거 + case onboardingTeardownCompleted + + /// 메인 탭 Feature의 액션 + case mainTab(MainTabFeature.Action) + } + + /// 온보딩 화면에서 이동할 수 있는 경로 + @Reducer + enum OnboardingPath { + case auth(AuthFeature) + } + + /// 사용자 세션을 불러오고 저장하거나 제거하는 TCA 의존성 + @Dependency(\.localSessionClient) private var localSessionClient: LocalSessionClient + + /// 온보딩 완료 여부 등의 로컬 값을 동기적으로 관리 + private let sessionStorage: LocalSessionStorage + + /// 저장된 정보와 현재 세션을 바탕으로 초기 화면을 결정 + private let launchStateResolver: AppLaunchStateResolver + + init( + sessionStorage: LocalSessionStorage, + launchStateResolver: AppLaunchStateResolver + ) { + self.sessionStorage = sessionStorage + self.launchStateResolver = launchStateResolver + } + + var body: some ReducerOf { + // 기본 로그인 화면을 AppFeature에 연결 + Scope(state: \.auth, action: \.auth) { + AuthFeature() + } + + // 추가 회원정보 입력 상태가 존재할 때만 RegisterFlowFeature를 실행 + EmptyReducer() + .ifLet(\.registerFlow, action: \.registerFlow) { + RegisterFlowFeature() + } + + // 온보딩 화면을 AppFeature에 연결 + Scope(state: \.onboarding, action: \.onboarding) { + OnboardingFeature() + } + + // Child logout actions must be reduced before this parent removes mainTab state. + EmptyReducer() + .ifLet(\.mainTab, action: \.mainTab) { + MainTabFeature() + } + + Reduce { state, action in + switch action { + case .launchTask: + // 초기 화면 확인이 중복 실행되지 않도록 launch 상태에서만 처리 + guard state.destination == .launch else { return .none } + return resolveLaunch() + + case .launchResolved(let session, let destination): + // 확인한 세션과 목적지를 앱 루트 상태에 반영 + applyLaunchState( + session: session, + destination: destination, + state: &state + ) + return .none + + case .onboarding(.delegate(.authRequested)): + // 사용자가 온보딩에서 로그인을 선택했으므로 온보딩 완료 여부를 저장 + sessionStorage.setOnboardingCompleted(true) + + // 로그인 화면이 중복으로 push되지 않도록 경로가 비어 있을 때만 추가 + if state.onboardingPath.isEmpty { + state.onboardingPath.append(.auth(.init())) + } + return .none + + case let .auth(.delegate(.authenticated(user))), + let .onboardingPath(.element(_, .auth(.delegate(.authenticated(user))))): + // 루트 로그인과 온보딩 내부 로그인의 성공 결과를 하나의 공통 액션으로 통합 + return .send(.authCompleted(user)) + + case let .registerFlow(.delegate(.completed(user))): + // 추가 회원정보 등록 완료 결과를 공통 액션으로 전달 + return .send(.registerCompleted(user)) + + case .authCompleted(let user), + .registerCompleted(let user): + // 인증 또는 회원가입 완료 사용자 정보를 앱 상태에 반영 + applyAuthenticatedUser(user, state: &state) + + // 최신 사용자 세션을 로컬에 저장 + return .run { _ in + await localSessionClient.saveUser(user) + } + + case .mainTab(.delegate(.logout)): + // Keep mainTab alive until SwiftUI completes its pending lifecycle callbacks. + guard !state.isLoggingOut else { return .none } + beginLoggedOutTransition(state: &state) + return .run { [localSessionClient] _ in + await localSessionClient.clear() + } + + case .mainTabViewDidDisappear: + // Child views can finish their lifecycle callbacks before optional state is removed. + guard state.isLoggingOut else { return .none } + return .run { send in + await Task.yield() + await send(.logoutTeardownCompleted) + } + + case .logoutTeardownCompleted: + guard state.isLoggingOut else { return .none } + finishLoggedOutTransition(state: &state) + return .none + + case .onboardingRootDidDisappear: + // Keep the bound path alive until SwiftUI finishes its pending write-back. + guard state.destination != .onboarding else { return .none } + return .run { send in + await Task.yield() + await send(.onboardingTeardownCompleted) + } + + case .onboardingTeardownCompleted: + guard state.destination != .onboarding else { return .none } + state.onboarding = .init() + state.onboardingPath = StackState() + return .none + + case .auth: + return .none + + case .registerFlow: + return .none + + case .onboardingPath: + return .none + + case .onboarding: + return .none + + case .mainTab: + return .none + } + } + // 온보딩 NavigationStack에 포함된 각 화면의 Reducer를 연결 + .forEach(\.onboardingPath, action: \.onboardingPath) + } +} + +private extension AppFeature { + func beginLoggedOutTransition(state: inout State) { + state.isLoggingOut = true + state.destination = .onboarding + state.auth = .init() + state.registerFlow = nil + state.onboarding = .init() + state.onboardingPath = StackState() + } + + func finishLoggedOutTransition(state: inout State) { + state.mainTab = nil + state.$session.withLock { $0 = UserSession() } + state.isLoggingOut = false + } + + /// 저장된 세션을 불러오고 앱 시작 화면을 결정 + func resolveLaunch() -> Effect { + let sessionStorage = sessionStorage + let launchStateResolver = launchStateResolver + + return .run { send in + // 온보딩 완료 여부 등 동기 저장 정보 확인 + let latestSnapshot = sessionStorage.loadSnapshot() + + // 저장된 사용자 세션을 비동기로 불러옴 + let session = await localSessionClient.load() + + // 저장 정보와 세션을 조합해 앱 최초 화면을 결정 + let destination = launchStateResolver.resolve( + snapshot: latestSnapshot, + session: session + ) + + await send(.launchResolved(session, destination)) + } + } + + /// 앱 실행 시 불러온 세션과 목적지를 최상위 상태에 반영 + func applyLaunchState( + session: UserSession, + destination: AppRootDestination, + state: inout State + ) { + state.isLoggingOut = false + + // 공유 세션을 최신 값으로 교체 + state.$session.withLock { $0 = session } + state.destination = destination + + // 이전 인증 및 화면 전환 상태를 초기화 + state.auth = .init() + state.registerFlow = nil + state.onboarding = .init() + state.onboardingPath = StackState() + + if destination == .main, session.user != nil { + // 인증과 회원정보 등록이 완료됐다면 메인 탭 상태를 생성 + state.mainTab = state.mainTab ?? .init(session: state.$session) + } else if destination == .register, let user = session.user { + // 인증은 완료됐지만 닉네임 등 추가 정보가 필요하면 회원정보 입력 흐름을 생성 + state.registerFlow = .init(user: user) + state.mainTab = nil + } else { + // 그 외 화면에서는 메인 탭 상태를 유지하지 않는다 + state.mainTab = nil + } + } + + /// 인증 또는 회원가입이 완료된 사용자를 앱 상태에 반영 + func applyAuthenticatedUser(_ user: User, state: inout State) { + // A new authentication result must not reuse a MainTab state retained for logout teardown. + state.isLoggingOut = false + state.mainTab = nil + let shouldDeferOnboardingTeardown = state.destination == .onboarding + + // 온보딩 완료 여부와 푸시 토큰 동기화를 처리 + configureAuthenticatedSession(user) + + // 모든 하위 Feature가 공유하는 세션에 사용자를 저장 + state.$session.withLock { $0.user = user } + + // 인증 관련 임시 상태를 초기화 + state.auth = .init() + if !shouldDeferOnboardingTeardown { + state.onboarding = .init() + state.onboardingPath = StackState() + } + + if user.nickname == nil { + // 닉네임이 없으면 추가 회원정보 입력 화면으로 이동 + state.mainTab = nil + state.registerFlow = .init(user: user) + state.destination = .register + } else { + // 필요한 회원정보가 모두 있으면 메인 화면으로 이동 + state.registerFlow = nil + state.mainTab = state.mainTab ?? .init(session: state.$session) + state.destination = .main + } + } + + /// 인증 완료 후 필요한 앱 외부 상태를 설정 + func configureAuthenticatedSession(_ user: User) { + // 인증까지 완료된 사용자는 온보딩을 완료한 것으로 저장 + sessionStorage.setOnboardingCompleted(true) + + // 로그인 전 저장됐던 푸시 토큰을 현재 사용자 계정과 동기화 + AppNotificationManager.shared.syncStoredToken(userUuid: user.userUuid) + } +} diff --git a/Projects/App/Sources/AppCore/Navigation/AppFeature.swift b/Projects/App/Sources/AppCore/Navigation/AppFeature.swift deleted file mode 100644 index 0f8be6fd..00000000 --- a/Projects/App/Sources/AppCore/Navigation/AppFeature.swift +++ /dev/null @@ -1,226 +0,0 @@ -import ComposableArchitecture -import AuthFeature -import Core -import Domain -import OnboardingFeature -import MainTabFeature - -enum AppRootDestination: Equatable, Sendable { - case launch - case onboarding - case auth - case register - case main -} - -enum AppLaunchResolution { - case destination(AppRootDestination) - case authenticated(User) - case registrationRequired(User) -} - -@Reducer -struct AppFeature { - @ObservableState - struct State: Equatable { - var destination: AppRootDestination = .launch - @Shared var session: UserSession - var auth = AuthFeature.State() - var registerFlow: RegisterFlowFeature.State? - var onboarding = OnboardingFeature.State() - var onboardingPath = StackState() - var mainTab: MainTabFeature.State? - - init(session: UserSession = .init()) { - self._session = Shared(value: session) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.destination == rhs.destination - && lhs.session == rhs.session - && lhs.auth == rhs.auth - && lhs.registerFlow == rhs.registerFlow - && lhs.onboarding == rhs.onboarding - && lhs.mainTab == rhs.mainTab - } - } - - enum Action { - case launchTask - case launchResolved(UserSession, AppRootDestination) - case auth(AuthFeature.Action) - case registerFlow(RegisterFlowFeature.Action) - case onboarding(OnboardingFeature.Action) - case onboardingPath(StackActionOf) - case authCompleted(User) - case registerCompleted(User) - case logoutFinished - case mainTab(MainTabFeature.Action) - } - - @Reducer - enum OnboardingPath { - case auth(AuthFeature) - } - - @Dependency(\.localSessionClient) private var localSessionClient: LocalSessionClient - private let sessionStorage: LocalSessionStorage - private let launchStateResolver: AppLaunchStateResolver - - init( - sessionStorage: LocalSessionStorage, - launchStateResolver: AppLaunchStateResolver - ) { - self.sessionStorage = sessionStorage - self.launchStateResolver = launchStateResolver - } - - var body: some ReducerOf { - Scope(state: \.auth, action: \.auth) { - AuthFeature() - } - - EmptyReducer() - .ifLet(\.registerFlow, action: \.registerFlow) { - RegisterFlowFeature() - } - - Scope(state: \.onboarding, action: \.onboarding) { - OnboardingFeature() - } - - Reduce { state, action in - switch action { - case .launchTask: - guard state.destination == .launch else { return .none } - return resolveLaunch() - - case .launchResolved(let session, let destination): - applyLaunchState( - session: session, - destination: destination, - state: &state - ) - return .none - - case .onboarding(.delegate(.authRequested)): - sessionStorage.setOnboardingCompleted(true) - if state.onboardingPath.isEmpty { - state.onboardingPath.append(.auth(.init())) - } - return .none - - case let .auth(.delegate(.authenticated(user))), - let .onboardingPath(.element(_, .auth(.delegate(.authenticated(user))))): - return .send(.authCompleted(user)) - - case let .registerFlow(.delegate(.completed(user))): - return .send(.registerCompleted(user)) - - case .authCompleted(let user), - .registerCompleted(let user): - applyAuthenticatedUser(user, state: &state) - return .run { _ in - await localSessionClient.saveUser(user) - } - - case .mainTab(.delegate(.logout)): - state.destination = .onboarding - return .run { send in - await localSessionClient.clear() - await send(.logoutFinished) - } - - case .logoutFinished: - state.auth = .init() - state.registerFlow = nil - state.onboarding = .init() - state.onboardingPath = StackState() - state.mainTab = nil - state.$session.withLock { $0 = UserSession() } - return .none - - case .auth: - return .none - - case .registerFlow: - return .none - - case .onboardingPath: - return .none - - case .onboarding: - return .none - - case .mainTab: - return .none - } - } - .forEach(\.onboardingPath, action: \.onboardingPath) - .ifLet(\.mainTab, action: \.mainTab) { - MainTabFeature() - } - } -} - -private extension AppFeature { - func resolveLaunch() -> Effect { - let sessionStorage = sessionStorage - let launchStateResolver = launchStateResolver - - return .run { send in - let latestSnapshot = sessionStorage.loadSnapshot() - let session = await localSessionClient.load() - let destination = launchStateResolver.resolve( - snapshot: latestSnapshot, - session: session - ) - - await send(.launchResolved(session, destination)) - } - } - func applyLaunchState( - session: UserSession, - destination: AppRootDestination, - state: inout State - ) { - state.$session.withLock { $0 = session } - state.destination = destination - state.auth = .init() - state.registerFlow = nil - state.onboarding = .init() - state.onboardingPath = StackState() - - if destination == .main, session.user != nil { - state.mainTab = state.mainTab ?? .init(session: state.$session) - } else if destination == .register, let user = session.user { - state.registerFlow = .init(user: user) - state.mainTab = nil - } else { - state.mainTab = nil - } - } - - func applyAuthenticatedUser(_ user: User, state: inout State) { - configureAuthenticatedSession(user) - state.$session.withLock { $0.user = user } - state.auth = .init() - state.onboarding = .init() - state.onboardingPath = StackState() - - if user.nickname == nil { - state.mainTab = nil - state.registerFlow = .init(user: user) - state.destination = .register - } else { - state.registerFlow = nil - state.mainTab = state.mainTab ?? .init(session: state.$session) - state.destination = .main - } - } - - func configureAuthenticatedSession(_ user: User) { - sessionStorage.setOnboardingCompleted(true) - AppNotificationManager.shared.syncStoredToken(userUuid: user.userUuid) - } -} diff --git a/Projects/App/Sources/PopPangApp.swift b/Projects/App/Sources/PopPangApp.swift index 71dd94fa..2bf257bb 100644 --- a/Projects/App/Sources/PopPangApp.swift +++ b/Projects/App/Sources/PopPangApp.swift @@ -1,21 +1,30 @@ +import ComposableArchitecture import SwiftUI @main struct PopPangApp: App { @UIApplicationDelegateAdaptor(PopPangAppDelegate.self) private var appDelegate - private let bootstrap: AppBootstrap private let deepLinkHandler: AppDeepLinkHandler + @State private var store: StoreOf init() { + // 라이브러리 초기화 AppSDKInitializer.configure() - self.bootstrap = AppBootstrap.live() + + // 의존성 그래프 생성 + let bootstrap = AppBootstrap.live() + + // Keep one root store for the app lifetime so session and navigation state survive body updates. + self._store = State(initialValue: bootstrap.makeAppStore()) + + // 앱으로 전달된 URL을 분석하여 소셜 로그인 콜백과 딥링크를 처리 self.deepLinkHandler = AppDeepLinkHandler() } var body: some Scene { WindowGroup { - AppRootFlowView(store: bootstrap.makeAppStore()) + AppRootFlowView(store: store) .versionUpdateAlert() .onOpenURL { url in deepLinkHandler.handleIncomingURL(url) diff --git a/Projects/Features/AlertFeature/Demo/Sources/AlertFeatureDemoApp.swift b/Projects/Features/AlertFeature/Demo/Sources/AlertFeatureDemoApp.swift index 1aa92bf2..ace40da5 100644 --- a/Projects/Features/AlertFeature/Demo/Sources/AlertFeatureDemoApp.swift +++ b/Projects/Features/AlertFeature/Demo/Sources/AlertFeatureDemoApp.swift @@ -1,6 +1,5 @@ import AlertFeature import ComposableArchitecture -import Core import Domain import SwiftUI @@ -11,9 +10,7 @@ struct AlertFeatureDemoApp: App { AlertFeatureView( store: Store( initialState: AlertFeature.State( - session: Shared( - value: UserSession(user: .demo) - ) + user: .demo ) ) { AlertFeature() diff --git a/Projects/Features/AlertFeature/Sources/Presentation/AlertFeature.swift b/Projects/Features/AlertFeature/Sources/Presentation/AlertFeature.swift index 3e89e156..527c953e 100644 --- a/Projects/Features/AlertFeature/Sources/Presentation/AlertFeature.swift +++ b/Projects/Features/AlertFeature/Sources/Presentation/AlertFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import Foundation @@ -21,7 +20,8 @@ public struct AlertFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String + public var nickname: String public var selectedTab: AlertTab = .activity public var alertPopups: [Popup] = [] public var keywords: [Keyword] = [] @@ -31,23 +31,9 @@ public struct AlertFeature { public var isLoading = false public var errorMessage: String? - public init(session: Shared) { - self._session = session - } - - var currentUser: User { - guard let user = session.user else { - preconditionFailure("AlertFeature requires a logged in session.") - } - return user - } - - public var userUuid: String { - currentUser.userUuid - } - - public var nickname: String { - currentUser.nickname ?? "닉네임" + public init(user: User) { + self.userUuid = user.userUuid + self.nickname = user.nickname ?? "닉네임" } public static func == (lhs: Self, rhs: Self) -> Bool { diff --git a/Projects/Features/AuthFeature/Sources/Presentation/RegisterFlowFeatureView.swift b/Projects/Features/AuthFeature/Sources/Presentation/RegisterFlowFeatureView.swift index 67a31c31..a0f44243 100644 --- a/Projects/Features/AuthFeature/Sources/Presentation/RegisterFlowFeatureView.swift +++ b/Projects/Features/AuthFeature/Sources/Presentation/RegisterFlowFeatureView.swift @@ -155,9 +155,9 @@ private struct NicknameSettingStepView: View { RoundedTextField( placeholder: "닉네임을 입력해 주세요", text: $nickname, - validationState: validationState + validationState: validationState, + focus: $isFocused ) - .focused($isFocused) Button { onValidate() @@ -196,8 +196,13 @@ private struct NicknameSettingStepView: View { } .padding(.horizontal, .contentPadding) .task { - try? await Task.sleep(for: .seconds(0.3)) - isFocused = true + do { + try await Task.sleep(for: .seconds(0.3)) + guard !Task.isCancelled else { return } + isFocused = true + } catch { + // The view disappeared before the transition animation completed. + } } } @@ -389,7 +394,7 @@ private struct KeywordSettingStepView: View { Spacer() MainOrangeButton( - buttonTitle: "다음", + buttonTitle: isSubmitting ? "가입 중..." : "다음", buttonColor: isNextEnabled ? Color.mainOrange : Color.mainGray2 ) { onNext() diff --git a/Projects/Features/CalendarFeature/Demo/Sources/CalendarFeatureDemoApp.swift b/Projects/Features/CalendarFeature/Demo/Sources/CalendarFeatureDemoApp.swift index 3797add8..e66a0308 100644 --- a/Projects/Features/CalendarFeature/Demo/Sources/CalendarFeatureDemoApp.swift +++ b/Projects/Features/CalendarFeature/Demo/Sources/CalendarFeatureDemoApp.swift @@ -1,6 +1,5 @@ import CalendarFeature import ComposableArchitecture -import Core import Domain import SwiftUI @@ -11,9 +10,7 @@ struct CalendarFeatureDemoApp: App { CalendarFeatureView( store: Store( initialState: CalendarFeature.State( - session: Shared( - value: UserSession(user: .demo) - ) + userUuid: User.demo.userUuid ) ) { CalendarFeature() diff --git a/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeature.swift b/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeature.swift index 5710d483..1399880f 100644 --- a/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeature.swift +++ b/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import DSKit import Foundation @@ -8,7 +7,7 @@ import Foundation public struct CalendarFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String public var calendarPopups: [Popup] = [] public var selectedDate: Date = Date() public var selectedPopups: [Popup] = [] @@ -20,19 +19,8 @@ public struct CalendarFeature { public var isLoading = false public var errorMessage: String? - public init(session: Shared) { - self._session = session - } - - private var currentUser: User { - guard let user = session.user else { - preconditionFailure("CalendarFeature requires a logged in session.") - } - return user - } - - public var userUuid: String { - currentUser.userUuid + public init(userUuid: String) { + self.userUuid = userUuid } } diff --git a/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeatureView.swift b/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeatureView.swift index e6986eb0..eab41c57 100644 --- a/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeatureView.swift +++ b/Projects/Features/CalendarFeature/Sources/Presentation/CalendarFeatureView.swift @@ -93,6 +93,7 @@ public struct CalendarFeatureView: View { Spacer() } .onAppear { + Logger.d("CalendarFeatureView OnAppear") store.send(.onAppear) } .sheet(item: $sheetRoute) { route in diff --git a/Projects/Features/FavoritesFeature/Demo/Sources/FavoritesFeatureDemoApp.swift b/Projects/Features/FavoritesFeature/Demo/Sources/FavoritesFeatureDemoApp.swift index 9c925932..853a7af5 100644 --- a/Projects/Features/FavoritesFeature/Demo/Sources/FavoritesFeatureDemoApp.swift +++ b/Projects/Features/FavoritesFeature/Demo/Sources/FavoritesFeatureDemoApp.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import FavoritesFeature import SwiftUI @@ -11,9 +10,7 @@ struct FavoritesFeatureDemoApp: App { FavoritesFeatureView( store: Store( initialState: FavoritesFeature.State( - session: Shared( - value: UserSession(user: .demo) - ) + userUuid: User.demo.userUuid ) ) { FavoritesFeature() diff --git a/Projects/Features/FavoritesFeature/Sources/Presentation/FavoritesFeature.swift b/Projects/Features/FavoritesFeature/Sources/Presentation/FavoritesFeature.swift index 75959468..343589d3 100644 --- a/Projects/Features/FavoritesFeature/Sources/Presentation/FavoritesFeature.swift +++ b/Projects/Features/FavoritesFeature/Sources/Presentation/FavoritesFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import Foundation @@ -7,7 +6,7 @@ import Foundation public struct FavoritesFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String public var favoritePopups: [Popup] = [] public var selectedPopups: [Popup] = [] public var selectedDate: Date = Date() @@ -15,19 +14,8 @@ public struct FavoritesFeature { public var isLoading = false public var errorMessage: String? - public init(session: Shared) { - self._session = session - } - - private var currentUser: User { - guard let user = session.user else { - preconditionFailure("FavoritesFeature requires a logged in session.") - } - return user - } - - public var userUuid: String { - currentUser.userUuid + public init(userUuid: String) { + self.userUuid = userUuid } public static func == (lhs: Self, rhs: Self) -> Bool { diff --git a/Projects/Features/HomeFeature/Demo/Sources/HomeFeatureDemoApp.swift b/Projects/Features/HomeFeature/Demo/Sources/HomeFeatureDemoApp.swift index 13c7d5aa..8c402d51 100644 --- a/Projects/Features/HomeFeature/Demo/Sources/HomeFeatureDemoApp.swift +++ b/Projects/Features/HomeFeature/Demo/Sources/HomeFeatureDemoApp.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import HomeFeature import SwiftUI @@ -11,21 +10,17 @@ struct HomeFeatureDemoApp: App { HomeFeatureView( store: Store( initialState: HomeFeature.State( - session: Shared( - value: UserSession( - user: User( - userUuid: "demo-user", - uid: "demo-uid", - provider: "preview", - email: nil, - nickname: "팝팡", - role: "ADMIN", - isAlerted: false, - fcmToken: nil, - alertKeywordList: nil, - recommendList: nil - ) - ) + user: User( + userUuid: "demo-user", + uid: "demo-uid", + provider: "preview", + email: nil, + nickname: "팝팡", + role: "ADMIN", + isAlerted: false, + fcmToken: nil, + alertKeywordList: nil, + recommendList: nil ) ) ) { diff --git a/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeature.swift b/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeature.swift index b93725cb..19d14c31 100644 --- a/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeature.swift +++ b/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import DSKit import Foundation @@ -8,7 +7,9 @@ import Foundation public struct HomeFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String + public var nickname: String + public var isAdmin: Bool var bestPopups: [Popup] = [] var comingPopups: [Popup] = [] var gridPopups: [Popup] = [] @@ -17,40 +18,11 @@ public struct HomeFeature { var errorMessage: String? public init( - session: Shared + user: User ) { - self._session = session - } - - var currentUser: User { - guard let user = session.user else { - preconditionFailure("HomeFeature requires a logged in session.") - } - return user - } - - var userUuid: String { - currentUser.userUuid - } - - var nickname: String { - currentUser.nickname ?? "닉네임" - } - - var isAdmin: Bool { - currentUser.role.uppercased() == "ADMIN" - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.userUuid == rhs.userUuid - && lhs.nickname == rhs.nickname - && lhs.isAdmin == rhs.isAdmin - && lhs.bestPopups == rhs.bestPopups - && lhs.comingPopups == rhs.comingPopups - && lhs.gridPopups == rhs.gridPopups - && lhs.filter == rhs.filter - && lhs.isLoading == rhs.isLoading - && lhs.errorMessage == rhs.errorMessage + self.userUuid = user.userUuid + self.nickname = user.nickname ?? "닉네임" + self.isAdmin = user.role.uppercased() == "ADMIN" } } @@ -70,6 +42,7 @@ public struct HomeFeature { case favoriteUpdated(popupUuid: String, isFavorited: Bool, favoriteCount: Int) case loadingChanged(Bool) case errorMessageChanged(String?) + case nicknameUpdated(String) case delegate(Delegate) public enum Delegate: Equatable { @@ -96,7 +69,10 @@ public struct HomeFeature { case .onAppear: state.isLoading = true state.errorMessage = nil - return loadAllPopupData(state: state) + return loadAllPopupData( + userUuid: state.userUuid, + filter: state.filter + ) case .filter: return .none @@ -167,6 +143,10 @@ public struct HomeFeature { state.errorMessage = errorMessage return .none + case .nicknameUpdated(let nickname): + state.nickname = nickname + return .none + case .delegate: return .none } @@ -175,16 +155,19 @@ public struct HomeFeature { } private extension HomeFeature { - func loadAllPopupData(state: State) -> Effect { + func loadAllPopupData( + userUuid: String, + filter: HomeFilter.State + ) -> Effect { let popupClient = popupClient - return .run { [state, popupClient] send in + return .run { [filter, popupClient, userUuid] send in do { - let regions = state.filter.regions.isEmpty + let regions = filter.regions.isEmpty ? try await popupClient.getRegionList().sortedByHomePriority() - : state.filter.regions - let selectedRegion = state.filter.selectedRegion ?? regions.first - let selectedDistrict = state.filter.selectedDistrict ?? selectedRegion?.districtList.first + : filter.regions + let selectedRegion = filter.selectedRegion ?? regions.first + let selectedDistrict = filter.selectedDistrict ?? selectedRegion?.districtList.first await send(.filter(.regionSelectionPrepared(HomeRegionSelection( regions: regions, @@ -192,13 +175,13 @@ private extension HomeFeature { selectedDistrict: selectedDistrict )))) - async let bestPopups = popupClient.getPersonalRandomPopupList(state.userUuid) - async let comingPopups = popupClient.getPersonalUpcomingPopupList(state.userUuid) + async let bestPopups = popupClient.getPersonalRandomPopupList(userUuid) + async let comingPopups = popupClient.getPersonalUpcomingPopupList(userUuid) async let gridPopups = popupClient.getPersonalFilteredPopupList( - state.userUuid, + userUuid, selectedRegion?.region ?? "전체", selectedDistrict ?? "전체", - state.filter.selectedOption.rawValue + filter.selectedOption.rawValue ) await send(.popupSectionsLoaded(HomePopupSections( diff --git a/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeatureView.swift b/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeatureView.swift index bc2feffc..6af73471 100644 --- a/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeatureView.swift +++ b/Projects/Features/HomeFeature/Sources/Presentation/Home/HomeFeatureView.swift @@ -221,17 +221,15 @@ public struct HomeFeatureView: View { .presentationDetents([.height(270)]) } } - .onAppear { + .task { + await Task.yield() + guard !Task.isCancelled else { return } + Logger.d("HomeViewFeature OnAppear") store.send(.onAppear) } .task(id: nativeAdPlacementIDs) { nativeAdSlotStore.loadAdIfNeeded(for: nativeAdPlacementIDs) } -// .overlay { -// if store.isLoading { -// HomeFeatureLoadingOverlay() -// } -// } .alert("안내", isPresented: isErrorPresented) { Button("확인", role: .cancel) { store.send(.errorMessageChanged(nil)) @@ -331,17 +329,17 @@ private struct HomeNavigationBar: View { onAlert(userUuid) } -// HomeReportButton { -// onReport() -// } -// .accessibilityIdentifier("home_popup_report_button") -// -// if showsPopupRequestManagement { -// HomePopupRequestManagementButton { -// onManagePopupRequests() -// } -// .accessibilityIdentifier("home_popup_request_management_button") -// } + HomeReportButton { + onReport() + } + .accessibilityIdentifier("home_popup_report_button") + + if showsPopupRequestManagement { + HomePopupRequestManagementButton { + onManagePopupRequests() + } + .accessibilityIdentifier("home_popup_request_management_button") + } } .padding(.bottom, 15) } @@ -492,21 +490,17 @@ private extension HomeFeatureView { HomeFeatureView( store: Store( initialState: HomeFeature.State( - session: Shared( - value: UserSession( - user: User( - userUuid: "preview-user", - uid: "preview-uid", - provider: "preview", - email: nil, - nickname: "팝팡", - role: "USER", - isAlerted: false, - fcmToken: nil, - alertKeywordList: nil, - recommendList: nil - ) - ) + user: User( + userUuid: "preview-user", + uid: "preview-uid", + provider: "preview", + email: nil, + nickname: "팝팡", + role: "USER", + isAlerted: false, + fcmToken: nil, + alertKeywordList: nil, + recommendList: nil ) ) ) { diff --git a/Projects/Features/HomeFeature/Tests/HomeFeatureTests.swift b/Projects/Features/HomeFeature/Tests/HomeFeatureTests.swift index d640ee95..7366a667 100644 --- a/Projects/Features/HomeFeature/Tests/HomeFeatureTests.swift +++ b/Projects/Features/HomeFeature/Tests/HomeFeatureTests.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import DSKit import Foundation @@ -26,22 +25,7 @@ struct HomeFeatureTests { let region = RegionList(region: "서울", districtList: ["전체", "성동구"]) let expectedPopups = [makePopup(popupUuid: "popup-1", name: "성수 팝업")] var initialState = HomeFeature.State( - session: Shared( - UserSession( - user: User( - userUuid: "user-1", - uid: "test-uid", - provider: "test", - email: nil, - nickname: "팝팡", - role: "USER", - isAlerted: false, - fcmToken: nil, - alertKeywordList: nil, - recommendList: nil - ) - ) - ) + user: makeUser() ) initialState.filter.selectedRegion = region initialState.filter.selectedDistrict = "전체" @@ -67,18 +51,29 @@ struct HomeFeatureTests { $0.isLoading = true } - await store.receive(.filteredPopupListLoaded(expectedPopups)) { + await store.receive(\.filteredPopupListLoaded, expectedPopups) { $0.gridPopups = expectedPopups $0.errorMessage = nil } - await store.receive(.loadingChanged(false)) { + await store.receive(\.loadingChanged, false) { $0.isLoading = false } } - @Test("ComingPopupDetailReducer가 좋아요 요청 실패 시 optimistic update를 되돌린다") - func comingPopupDetailReducerRollsBackFavoriteWhenRequestFails() async { + @Test("HomeFeature는 사용자 snapshot의 닉네임을 자체 상태로 갱신한다") + func homeFeatureUpdatesNicknameWithoutSharedSession() async { + let store = TestStore(initialState: HomeFeature.State(user: makeUser())) { + HomeFeature() + } + + await store.send(.nicknameUpdated("새 팝팡")) { + $0.nickname = "새 팝팡" + } + } + + @Test("ComingPopupDetailFeature가 좋아요 요청 실패 시 optimistic update를 되돌린다") + func comingPopupDetailFeatureRollsBackFavoriteWhenRequestFails() async { let popup = makePopup( popupUuid: "popup-1", name: "성수 팝업", @@ -87,12 +82,12 @@ struct HomeFeatureTests { ) let store = TestStore( - initialState: ComingPopupDetailReducer.State( + initialState: ComingPopupDetailFeature.State( userUuid: "user-1", popups: [popup] ) ) { - ComingPopupDetailReducer() + ComingPopupDetailFeature() } withDependencies: { $0.homePopupClient.addFavorite = { _, _ in throw TestError.expectedFailure @@ -130,6 +125,21 @@ private enum TestError: LocalizedError { } } +private func makeUser() -> User { + User( + userUuid: "user-1", + uid: "test-uid", + provider: "test", + email: nil, + nickname: "팝팡", + role: "USER", + isAlerted: false, + fcmToken: nil, + alertKeywordList: nil, + recommendList: nil + ) +} + private func makePopup( popupUuid: String, name: String, diff --git a/Projects/Features/MainTabFeature/Project.swift b/Projects/Features/MainTabFeature/Project.swift index 0949bb2f..ef7d8841 100644 --- a/Projects/Features/MainTabFeature/Project.swift +++ b/Projects/Features/MainTabFeature/Project.swift @@ -21,8 +21,7 @@ let project = Project( .project(target: "HomeFeature", path: "../HomeFeature"), .project(target: "MapFeature", path: "../MapFeature"), .project(target: "PopupDetailFeature", path: "../PopupDetailFeature"), - .project(target: "PopupRequestFeature", path: "../PopupRequestFeature"), - .project(target: "PopupRequestManagementFeature", path: "../PopupRequestManagementFeature"), + .project(target: "PopPangRNFeature", path: "../PopPangRNFeature"), .project(target: "ProfileFeature", path: "../ProfileFeature"), .project(target: "ReviewFeature", path: "../ReviewFeature"), .project(target: "SearchFeature", path: "../SearchFeature"), @@ -45,6 +44,7 @@ let project = Project( .target(name: "MainTabFeature"), .project(target: "Core", path: "../../Shared/Core"), .project(target: "Domain", path: "../../Domain"), + .project(target: "PopPangRNFeature", path: "../PopPangRNFeature"), ] ), ] diff --git a/Projects/Features/MainTabFeature/Sources/MainTabFeature.swift b/Projects/Features/MainTabFeature/Sources/MainTabFeature.swift index 94c890df..b6b74989 100644 --- a/Projects/Features/MainTabFeature/Sources/MainTabFeature.swift +++ b/Projects/Features/MainTabFeature/Sources/MainTabFeature.swift @@ -8,8 +8,7 @@ import Foundation import HomeFeature import MapFeature import PopupDetailFeature -import PopupRequestFeature -import PopupRequestManagementFeature +import PopPangRNFeature import ProfileFeature import ReviewFeature import SearchFeature @@ -61,11 +60,12 @@ public struct MainTabFeature { @Reducer public enum Destination { case search(SearchDestinationFeature) - case popupRequest(PopupRequestFeature) + case popupRequest(PopPangRNFeature) } @ObservableState public struct CoreState: Equatable { + var user: User var selectedTab: MainTab = .home var calendar: CalendarFeature.State var favorites: FavoritesFeature.State @@ -75,16 +75,18 @@ public struct MainTabFeature { @Presents var destination: Destination.State? var path = StackState() - public init(session: Shared) { - self.calendar = .init(session: session) - self.favorites = .init(session: session) - self.home = .init(session: session) - self.map = .init(session: session) - self.profile = .init(session: session) + public init(user: User) { + self.user = user + self.calendar = .init(userUuid: user.userUuid) + self.favorites = .init(userUuid: user.userUuid) + self.home = .init(user: user) + self.map = .init(userUuid: user.userUuid) + self.profile = .init(user: user) } public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.selectedTab == rhs.selectedTab + lhs.user == rhs.user + && lhs.selectedTab == rhs.selectedTab && lhs.calendar == rhs.calendar && lhs.favorites == rhs.favorites && lhs.home == rhs.home @@ -125,8 +127,6 @@ public struct MainTabFeature { switch (lhs, rhs) { case (.popupRequestManagement(let lhsState), .popupRequestManagement(let rhsState)): lhsState == rhsState - case (.popupRequestManagementDetail(let lhsState), .popupRequestManagementDetail(let rhsState)): - lhsState == rhsState case (.homeComingPopupDetail(let lhsState), .homeComingPopupDetail(let rhsState)): lhsState == rhsState case (.popupDetail(let lhsState), .popupDetail(let rhsState)): @@ -151,25 +151,27 @@ public struct MainTabFeature { public struct State: Equatable { @Shared var session: UserSession public var core: CoreState + var isLoggingOut = false public init( session: Shared, core: CoreState? = nil ) { self._session = session - self.core = core ?? .init(session: session) - } - - var currentUser: User { - guard let user = session.user else { - preconditionFailure("MainTabFeature requires a logged in user.") + if let core { + self.core = core + } else { + guard let user = session.wrappedValue.user else { + preconditionFailure("MainTabFeature requires a logged in user.") + } + self.core = .init(user: user) } - return user } public static func == (lhs: Self, rhs: Self) -> Bool { lhs.session == rhs.session && lhs.core == rhs.core + && lhs.isLoggingOut == rhs.isLoggingOut } } @@ -182,6 +184,8 @@ public struct MainTabFeature { case profile(ProfileFeature.Action) case destination(PresentationAction) case path(StackActionOf) + case logoutNavigationTeardownRequested + case logoutNavigationTeardownCompleted case delegate(Delegate) public enum Delegate: Equatable { @@ -191,8 +195,7 @@ public struct MainTabFeature { @Reducer public enum Path { - case popupRequestManagement(PopupRequestManagementFlowFeature) - case popupRequestManagementDetail(PopupRequestManagementDetailFeature) + case popupRequestManagement(PopPangRNFeature) case homeComingPopupDetail(HomeComingPopupDetailDestinationFeature) case popupDetail(PopupDetailDestinationFeature) case reviewDetail(ReviewFeature) @@ -247,7 +250,7 @@ public struct MainTabFeature { state.core.path.append( .homeComingPopupDetail( .init( - userUuid: state.currentUser.userUuid, + userUuid: state.core.user.userUuid, popups: popups ) ) @@ -257,31 +260,37 @@ public struct MainTabFeature { case .home(.delegate(.popupRequestManagementRequested)): state.core.path.append( .popupRequestManagement( - .init(adminUuid: state.currentUser.userUuid) + .init( + screen: .popupRequestManagement, + userUuid: state.core.user.userUuid + ) ) ) return .none case .home(.delegate(.alertRequested)): - state.core.path.append(.alert(.init(session: state.$session))) + state.core.path.append(.alert(.init(user: state.core.user))) return .none case .calendar(.delegate(.alertRequested)): - state.core.path.append(.alert(.init(session: state.$session))) + state.core.path.append(.alert(.init(user: state.core.user))) return .none case .home(.delegate(.searchRequested)): state.core.destination = .search( .init( - userUuid: state.currentUser.userUuid, - nickname: state.currentUser.displayNickname + userUuid: state.core.user.userUuid, + nickname: state.core.user.nickname ?? "닉네임" ) ) return .none case .home(.delegate(.popupRequestRequested)): state.core.destination = .popupRequest( - .init(userUuid: state.currentUser.userUuid) + .init( + screen: .popupRequest, + userUuid: state.core.user.userUuid + ) ) return .none @@ -290,12 +299,17 @@ public struct MainTabFeature { return .none case .favorites(.delegate(.alertRequested)): - state.core.path.append(.alert(.init(session: state.$session))) + state.core.path.append(.alert(.init(user: state.core.user))) return .none + case .profile(.delegate(.alertStatusUpdated(let isAlerted))): + state.core.user.isAlerted = isAlerted + state.$session.withLock { $0.user?.isAlerted = isAlerted } + return .none + case .profile(.delegate(.profileSettingRequested)): state.core.path.append( - .profileSetting(.init(session: state.$session)) + .profileSetting(.init(user: state.core.user)) ) return .none @@ -308,7 +322,7 @@ public struct MainTabFeature { return .none case .profile(.delegate(.alertRequested)): - state.core.path.append(.alert(.init(session: state.$session))) + state.core.path.append(.alert(.init(user: state.core.user))) return .none case .destination(.presented(.search(.delegate(.dismiss)))): @@ -318,6 +332,21 @@ public struct MainTabFeature { case .destination(.presented(.popupRequest(.delegate(.dismiss)))): state.core.destination = nil return .none + + case .logoutNavigationTeardownRequested: + // Let NavigationStack observe its cleared bindings before AppFeature replaces the root. + guard !state.isLoggingOut else { return .none } + state.isLoggingOut = true + state.core.destination = nil + state.core.path = StackState() + return .run { send in + await Task.yield() + await send(.logoutNavigationTeardownCompleted) + } + + case .logoutNavigationTeardownCompleted: + guard state.isLoggingOut else { return .none } + return .send(.delegate(.logout)) case .home, .calendar, @@ -349,9 +378,9 @@ private extension MainTabFeature { state.core.path.append( .popupDetail( .init( - userUuid: state.currentUser.userUuid, + userUuid: state.core.user.userUuid, popup: popup, - isAdmin: state.currentUser.isAdminRole + isAdmin: state.core.user.role.uppercased() == "ADMIN" ) ) ) @@ -363,22 +392,7 @@ private extension MainTabFeature { state: inout State ) -> Effect { switch action { - case .popupRequestManagement(.delegate(.dismiss)): - state.core.path.pop(from: id) - return .none - - case .popupRequestManagement(.delegate(.showDetail(let submissionId))): - state.core.path.append( - .popupRequestManagementDetail( - .init( - adminUuid: state.currentUser.userUuid, - submissionId: submissionId - ) - ) - ) - return .none - - case .popupRequestManagementDetail(.delegate(.pop)): + case .popupRequestManagement(.delegate(.pop)): state.core.path.pop(from: id) return .none @@ -407,7 +421,15 @@ private extension MainTabFeature { return .none case .profileSetting(.delegate(.logoutRequested)): - return .send(.delegate(.logout)) + return .send(.logoutNavigationTeardownRequested) + + case .profileSetting(.delegate(.nicknameUpdated(let nickname))): + state.core.path.pop(from: id) + state.core.user.nickname = nickname + state.core.home.nickname = nickname + state.core.profile.nickname = nickname + state.$session.withLock { $0.user?.nickname = nickname } + return .none case .profileSetting(.delegate(.dismiss)): state.core.path.pop(from: id) @@ -419,16 +441,6 @@ private extension MainTabFeature { } } -extension User { - var displayNickname: String { - nickname ?? "닉네임" - } - - var isAdminRole: Bool { - role.uppercased() == "ADMIN" - } -} - @Reducer public struct PopupDetailDestinationFeature { @ObservableState diff --git a/Projects/Features/MainTabFeature/Sources/MainTabFeatureDependencies.swift b/Projects/Features/MainTabFeature/Sources/MainTabFeatureDependencies.swift index 24313d04..7f0fd026 100644 --- a/Projects/Features/MainTabFeature/Sources/MainTabFeatureDependencies.swift +++ b/Projects/Features/MainTabFeature/Sources/MainTabFeatureDependencies.swift @@ -7,8 +7,6 @@ import FavoritesFeature import HomeFeature import MapFeature import PopupDetailFeature -import PopupRequestFeature -import PopupRequestManagementFeature import ProfileFeature import SearchFeature @@ -19,8 +17,6 @@ public struct MainTabFeatureDependencies { private let homePopupClient: HomePopupClient private let mapFeatureClient: MapFeatureClient private let popupDetailClient: PopupDetailClient - private let popupRequestClient: PopupRequestClient - private let popupRequestManagementClient: PopupRequestManagementClient private let profileFeatureClient: ProfileFeatureClient private let searchFeatureClient: SearchFeatureClient @@ -28,7 +24,6 @@ public struct MainTabFeatureDependencies { store: KeyValueStoring, adminUsecase: AdminUsecaseProtocol, popupUsecase: PopupUsecaseProtocol, - popupSubmissionUsecase: PopupSubmissionUsecaseProtocol, userUsecase: UserUsecaseProtocol ) { alertFeatureClient = .live( @@ -44,13 +39,6 @@ public struct MainTabFeatureDependencies { popupUsecase: popupUsecase, adminUsecase: adminUsecase ) - popupRequestClient = .live( - popupSubmissionUsecase: popupSubmissionUsecase, - userUsecase: userUsecase - ) - popupRequestManagementClient = .live( - popupSubmissionUsecase: popupSubmissionUsecase - ) profileFeatureClient = .live(userUsecase: userUsecase) searchFeatureClient = .live( popupUsecase: popupUsecase, @@ -65,8 +53,6 @@ public struct MainTabFeatureDependencies { values.homePopupClient = homePopupClient values.mapFeatureClient = mapFeatureClient values.popupDetailClient = popupDetailClient - values.popupRequestClient = popupRequestClient - values.popupRequestManagementClient = popupRequestManagementClient values.profileFeatureClient = profileFeatureClient values.searchFeatureClient = searchFeatureClient } diff --git a/Projects/Features/MainTabFeature/Sources/MainTabFeatureView.swift b/Projects/Features/MainTabFeature/Sources/MainTabFeatureView.swift index 10cde44f..d6360717 100644 --- a/Projects/Features/MainTabFeature/Sources/MainTabFeatureView.swift +++ b/Projects/Features/MainTabFeature/Sources/MainTabFeatureView.swift @@ -7,8 +7,7 @@ import struct HomeFeature.HomeComingPopupDetailDestinationView import struct HomeFeature.HomeFeatureView import MapFeature import PopupDetailFeature -import PopupRequestFeature -import PopupRequestManagementFeature +import PopPangRNFeature import ProfileFeature import ReviewFeature import SearchFeature @@ -47,10 +46,6 @@ public struct MainTabFeatureView: View { if let store = store.scope(state: \.popupRequestManagement, action: \.popupRequestManagement) { PopupRequestManagementDestinationView(store: store) } - case .popupRequestManagementDetail: - if let store = store.scope(state: \.popupRequestManagementDetail, action: \.popupRequestManagementDetail) { - PopupRequestManagementDetailDestinationView(store: store) - } case .homeComingPopupDetail: if let store = store.scope(state: \.homeComingPopupDetail, action: \.homeComingPopupDetail) { HomeComingPopupDetailDestinationView(store: store) @@ -89,7 +84,7 @@ public struct MainTabFeatureView: View { .fullScreenCover( item: $store.scope(state: \.core.destination?.popupRequest, action: \.destination.popupRequest) ) { store in - PopupRequestFeatureView(store: store) + PopPangRNFeatureView(store: store) } } @@ -153,18 +148,10 @@ private struct SearchDestinationView: View { } private struct PopupRequestManagementDestinationView: View { - let store: StoreOf - - var body: some View { - PopupRequestManagementFlowView(store: store) - } -} - -private struct PopupRequestManagementDetailDestinationView: View { - let store: StoreOf + let store: StoreOf var body: some View { - PopupRequestManagementDetailView(store: store) + PopPangRNFeatureView(store: store) } } diff --git a/Projects/Features/MainTabFeature/Tests/MainTabFeatureTests.swift b/Projects/Features/MainTabFeature/Tests/MainTabFeatureTests.swift index fc00ce20..8667b8fb 100644 --- a/Projects/Features/MainTabFeature/Tests/MainTabFeatureTests.swift +++ b/Projects/Features/MainTabFeature/Tests/MainTabFeatureTests.swift @@ -24,7 +24,7 @@ struct MainTabFeatureTests { #expect(searchState.search.nickname == "팝팡") } - @Test("HomeFeature의 팝업 제보 요청을 MainTabFeature가 PopupRequestFeature presentation으로 연다") + @Test("HomeFeature의 팝업 제보 요청을 MainTabFeature가 RN presentation으로 연다") func presentsPopupRequestFromHomeDelegate() async { let store = TestStore(initialState: MainTabFeature.State(session: makeSession())) { MainTabFeature() @@ -38,6 +38,7 @@ struct MainTabFeatureTests { return } + #expect(popupRequestState.screen == .popupRequest) #expect(popupRequestState.userUuid == "user-1") } @@ -85,6 +86,58 @@ struct MainTabFeatureTests { ) } } + + @Test("프로필 이름 변경을 HomeFeature에 전달하고 설정 화면을 닫는다") + func updatesHomeNicknameAndDismissesProfileSetting() async { + let session = makeSession() + var initialState = MainTabFeature.State(session: session) + initialState.core.path.append(.profileSetting(.init(user: initialState.core.user))) + guard let id = initialState.core.path.ids.last else { + Issue.record("profile setting path should be appended") + return + } + let store = TestStore(initialState: initialState) { + MainTabFeature() + } + + await store.send( + .path(.element(id: id, action: .profileSetting(.delegate(.nicknameUpdated("새 팝팡"))))) + ) { + $0.core.path.pop(from: id) + $0.core.user.nickname = "새 팝팡" + $0.core.home.nickname = "새 팝팡" + $0.core.profile.nickname = "새 팝팡" + $0.$session.withLock { $0.user?.nickname = "새 팝팡" } + } + } + + @Test("로그아웃 전에 MainTab navigation 상태를 정리한다") + func clearsNavigationBeforeForwardingLogout() async { + var initialState = MainTabFeature.State(session: makeSession()) + initialState.core.destination = .popupRequest( + .init(screen: .popupRequest, userUuid: "user-1") + ) + initialState.core.path.append(.profileSetting(.init(user: initialState.core.user))) + guard let id = initialState.core.path.ids.last else { + Issue.record("profile setting path should be appended") + return + } + let store = TestStore(initialState: initialState) { + MainTabFeature() + } + + await store.send( + .path(.element(id: id, action: .profileSetting(.delegate(.logoutRequested)))) + ) + + await store.receive(\.logoutNavigationTeardownRequested) { + $0.isLoggingOut = true + $0.core.destination = nil + $0.core.path = StackState() + } + await store.receive(\.logoutNavigationTeardownCompleted) + await store.receive(\.delegate) + } } private func makeSession() -> Shared { diff --git a/Projects/Features/MapFeature/Demo/Sources/MapFeatureDemoApp.swift b/Projects/Features/MapFeature/Demo/Sources/MapFeatureDemoApp.swift index ef87aa48..948fa5cd 100644 --- a/Projects/Features/MapFeature/Demo/Sources/MapFeatureDemoApp.swift +++ b/Projects/Features/MapFeature/Demo/Sources/MapFeatureDemoApp.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import MapFeature import SwiftUI @@ -11,9 +10,7 @@ struct MapFeatureDemoApp: App { MapFeatureView( store: Store( initialState: MapFeature.State( - session: Shared( - value: UserSession(user: .demo) - ) + userUuid: User.demo.userUuid ) ) { MapFeature() diff --git a/Projects/Features/MapFeature/Sources/Presentation/MapFeature.swift b/Projects/Features/MapFeature/Sources/Presentation/MapFeature.swift index 08996b40..240b48f7 100644 --- a/Projects/Features/MapFeature/Sources/Presentation/MapFeature.swift +++ b/Projects/Features/MapFeature/Sources/Presentation/MapFeature.swift @@ -1,8 +1,8 @@ import BottomSheet import ComposableArchitecture -import Core import Domain import Foundation +import Core private enum CancelID { case mapCenterChanged @@ -54,7 +54,7 @@ public struct MapFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + var userUuid: String var mapPopups: [Popup] = [] var allPopups: [Popup] = [] var categories: [Recommend] = [] @@ -73,19 +73,8 @@ public struct MapFeature { var isWaitingForUserLocation = false var errorMessage: String? - public init(session: Shared) { - self._session = session - } - - private var currentUser: User { - guard let user = session.user else { - preconditionFailure("MapFeature requires a logged in session.") - } - return user - } - - var userUuid: String { - currentUser.userUuid + public init(userUuid: String) { + self.userUuid = userUuid } public static func == (lhs: Self, rhs: Self) -> Bool { diff --git a/Projects/Features/PopPangRNFeature/Project.swift b/Projects/Features/PopPangRNFeature/Project.swift new file mode 100644 index 00000000..53f139d2 --- /dev/null +++ b/Projects/Features/PopPangRNFeature/Project.swift @@ -0,0 +1,23 @@ +import ProjectDescription + +let project = Project( + name: "PopPangRNFeature", + packages: [ + .local(path: "../../../Vendor/PrebuiltReactNativeFrameworks"), + ], + targets: [ + .target( + name: "PopPangRNFeature", + destinations: [.iPhone], + product: .framework, + bundleId: "com.poppang.features.poppangrn", + deploymentTargets: .iOS("17.0"), + infoPlist: .default, + sources: ["Sources/**"], + dependencies: [ + .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), + .package(product: "PrebuiltReactNativeFrameworks"), + ] + ), + ] +) diff --git a/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeature.swift b/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeature.swift new file mode 100644 index 00000000..0084fe0f --- /dev/null +++ b/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeature.swift @@ -0,0 +1,78 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct PopPangRNFeature { + public enum Screen: String, Equatable, Sendable { + case popupRequest = "request" + case popupRequestManagement = "request-management" + + var nativeEvents: [String] { + switch self { + case .popupRequest: + [ + PopPangRNEvent.popupRequestSubmitted, + PopPangRNEvent.popupRequestBack, + ] + case .popupRequestManagement: + [PopPangRNEvent.popupRequestManagementBack] + } + } + } + + @ObservableState + public struct State: Equatable, Identifiable { + public let screen: Screen + public let userUuid: String + + public var id: String { "\(screen.rawValue)-\(userUuid)" } + + public init( + screen: Screen, + userUuid: String + ) { + self.screen = screen + self.userUuid = userUuid + } + } + + public enum Action: Equatable { + case nativeEventReceived(String) + case delegate(Delegate) + + public enum Delegate: Equatable { + case dismiss + case pop + } + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .nativeEventReceived(let eventName): + switch (state.screen, eventName) { + case (.popupRequest, PopPangRNEvent.popupRequestSubmitted), + (.popupRequest, PopPangRNEvent.popupRequestBack): + return .send(.delegate(.dismiss)) + + case (.popupRequestManagement, PopPangRNEvent.popupRequestManagementBack): + return .send(.delegate(.pop)) + + default: + return .none + } + + case .delegate: + return .none + } + } + } +} + +private enum PopPangRNEvent { + static let popupRequestSubmitted = "popupRequestSubmitted" + static let popupRequestBack = "popupRequestBack" + static let popupRequestManagementBack = "popupRequestManagementBack" +} diff --git a/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeatureView.swift b/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeatureView.swift new file mode 100644 index 00000000..1195bc74 --- /dev/null +++ b/Projects/Features/PopPangRNFeature/Sources/Presentation/PopPangRNFeatureView.swift @@ -0,0 +1,26 @@ +import ComposableArchitecture +import SwiftUI + +public struct PopPangRNFeatureView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ReactNativeScreen( + moduleName: "PopPangRNRoot", + initialProperties: [ + "feature": store.screen.rawValue, + "userUuid": store.userUuid, + "nativeEvents": store.screen.nativeEvents, + ], + onNativeEvent: { eventName in + store.send(.nativeEventReceived(eventName)) + } + ) + .ignoresSafeArea() + .toolbar(.hidden, for: .navigationBar) + } +} diff --git a/Projects/Features/PopPangRNFeature/Sources/Support/ReactNativeScreen.swift b/Projects/Features/PopPangRNFeature/Sources/Support/ReactNativeScreen.swift new file mode 100644 index 00000000..cb08d759 --- /dev/null +++ b/Projects/Features/PopPangRNFeature/Sources/Support/ReactNativeScreen.swift @@ -0,0 +1,111 @@ +import SwiftUI +import UIKit +@_implementationOnly import PopPangReactNativeHost +@_implementationOnly import React +@_implementationOnly import React_RCTAppDelegate +@_implementationOnly import ReactAppDependencyProvider + +final class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + bundleURL() + } + + override func bundleURL() -> URL? { + if let url = Bundle.main.url( + forResource: "main", + withExtension: "jsbundle", + subdirectory: "ReactNative" + ) { + return url + } + + if let url = Bundle.main.url(forResource: "main", withExtension: "jsbundle") { + return url + } + + fatalError( + "main.jsbundle을 찾을 수 없습니다. scripts/download-rn-release.sh를 실행한 뒤 다시 빌드해 주세요." + ) + } +} + +final class ReactViewController: UIViewController { + private let moduleName: String + private let initialProperties: [String: Any]? + private var reactNativeFactory: RCTReactNativeFactory? + private var reactNativeFactoryDelegate: RCTReactNativeFactoryDelegate? + + init( + moduleName: String, + initialProperties: [String: Any]? = nil + ) { + self.moduleName = moduleName + self.initialProperties = initialProperties + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let delegate = ReactNativeDelegate() + delegate.dependencyProvider = RCTAppDependencyProvider() + + let factory = RCTReactNativeFactory(delegate: delegate) + reactNativeFactoryDelegate = delegate + reactNativeFactory = factory + view = factory.rootViewFactory.view( + withModuleName: moduleName, + initialProperties: initialProperties + ) + } +} + +struct ReactNativeScreen: UIViewControllerRepresentable { + final class EventHandlerCoordinator { + let id = UUID() + } + + let moduleName: String + let initialProperties: [String: Any]? + let onNativeEvent: ((String) -> Void)? + + private static var eventHandlerOwnerID: UUID? + + func makeCoordinator() -> EventHandlerCoordinator { + EventHandlerCoordinator() + } + + func makeUIViewController(context: Context) -> ReactViewController { + installEventHandler(for: context.coordinator) + return ReactViewController( + moduleName: moduleName, + initialProperties: initialProperties + ) + } + + func updateUIViewController( + _ uiViewController: ReactViewController, + context: Context + ) { + installEventHandler(for: context.coordinator) + } + + static func dismantleUIViewController( + _ uiViewController: ReactViewController, + coordinator: EventHandlerCoordinator + ) { + guard eventHandlerOwnerID == coordinator.id else { return } + PopPangHostAction.setEventHandler(nil) + eventHandlerOwnerID = nil + } + + private func installEventHandler(for coordinator: EventHandlerCoordinator) { + Self.eventHandlerOwnerID = coordinator.id + PopPangHostAction.setEventHandler(onNativeEvent) + } +} diff --git a/Projects/Features/PopupRequestFeature/Demo/Sources/PopupRequestFeatureDemoApp.swift b/Projects/Features/PopupRequestFeature/Demo/Sources/PopupRequestFeatureDemoApp.swift deleted file mode 100644 index 88dbfea6..00000000 --- a/Projects/Features/PopupRequestFeature/Demo/Sources/PopupRequestFeatureDemoApp.swift +++ /dev/null @@ -1,20 +0,0 @@ -import ComposableArchitecture -import PopupRequestFeature -import SwiftUI - -@main -struct PopupRequestFeatureDemoApp: App { - var body: some Scene { - WindowGroup { - NavigationStack { - PopupRequestFeatureView( - store: Store(initialState: PopupRequestFeature.State(userUuid: "demo-user")) { - PopupRequestFeature() - } withDependencies: { - $0.popupRequestClient = .previewValue - } - ) - } - } - } -} diff --git a/Projects/Features/PopupRequestFeature/Project.swift b/Projects/Features/PopupRequestFeature/Project.swift deleted file mode 100644 index 322cb8a8..00000000 --- a/Projects/Features/PopupRequestFeature/Project.swift +++ /dev/null @@ -1,60 +0,0 @@ -import ProjectDescription - -let project = Project( - name: "PopupRequestFeature", - targets: [ - .target( - name: "PopupRequestFeature", - destinations: [.iPhone], - product: .staticFramework, - bundleId: "com.poppang.features.popuprequest", - deploymentTargets: .iOS("17.0"), - infoPlist: .default, - sources: ["Sources/**"], - dependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "DSKit", path: "../../Shared/DSKit"), - .project(target: "Core", path: "../../Shared/Core"), - .project(target: "PopupSubmissionFormFeature", path: "../PopupSubmissionFormFeature"), - .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), - ] - ), - // .target( - // name: "PopupRequestFeatureDemo", - // destinations: [.iPhone], - // product: .app, - // bundleId: "com.poppang.demo.popuprequest", - // deploymentTargets: .iOS("17.0"), - // infoPlist: .extendingDefault( - // with: [ - // "UILaunchScreen": [ - // "UIColorName": "", - // "UIImageName": "", - // ], - // ] - // ), - // sources: ["Demo/Sources/**"], - // dependencies: [ - // .target(name: "PopupRequestFeature"), - // .project(target: "Domain", path: "../../Domain"), - // .project(target: "Data", path: "../../Data"), - // ] - // ), - .target( - name: "PopupRequestFeatureTests", - destinations: [.iPhone], - product: .unitTests, - bundleId: "com.poppang.features.popuprequest.tests", - deploymentTargets: .iOS("17.0"), - infoPlist: .default, - sources: ["Tests/**"], - dependencies: [ - .target(name: "PopupRequestFeature"), - .project(target: "PopupSubmissionFormFeature", path: "../PopupSubmissionFormFeature"), - .project(target: "Domain", path: "../../Domain"), - .project(target: "DSKit", path: "../../Shared/DSKit"), - .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), - ] - ), - ] -) diff --git a/Projects/Features/PopupRequestFeature/Sources/Dependency/PopupRequestClient.swift b/Projects/Features/PopupRequestFeature/Sources/Dependency/PopupRequestClient.swift deleted file mode 100644 index 604bc900..00000000 --- a/Projects/Features/PopupRequestFeature/Sources/Dependency/PopupRequestClient.swift +++ /dev/null @@ -1,77 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation - -public struct PopupRequestClient: Sendable { - var getRecommendList: @Sendable () async throws -> [Recommend] - var createPopupSubmission: @Sendable (PopupSubmissionCreateRequest) async throws -> Void -} - -extension PopupRequestClient { - public static func live( - popupSubmissionUsecase: PopupSubmissionUsecaseProtocol, - userUsecase: UserUsecaseProtocol - ) -> Self { - let popupSubmissionUsecaseBox = PopupSubmissionUsecaseBox(popupSubmissionUsecase) - let userUsecaseBox = UserUsecaseBox(userUsecase) - - return Self( - getRecommendList: { - try await userUsecaseBox.usecase.getRecommandList() - }, - createPopupSubmission: { request in - try await popupSubmissionUsecaseBox.usecase.createPopupSubmission(request) - } - ) - } -} - -extension PopupRequestClient: DependencyKey { - public static let liveValue = PopupRequestClient( - getRecommendList: { [] }, - createPopupSubmission: { _ in } - ) - -#if DEBUG - public static let previewValue = PopupRequestClient( - getRecommendList: { - [ - Recommend(id: 1, recommendName: "패션"), - Recommend(id: 2, recommendName: "디저트"), - Recommend(id: 3, recommendName: "라이프스타일"), - ] - }, - createPopupSubmission: { _ in } - ) -#endif -} - -extension PopupRequestClient: TestDependencyKey { - public static let testValue = PopupRequestClient( - getRecommendList: { [] }, - createPopupSubmission: { _ in } - ) -} - -extension DependencyValues { - public var popupRequestClient: PopupRequestClient { - get { self[PopupRequestClient.self] } - set { self[PopupRequestClient.self] = newValue } - } -} - -private final class PopupSubmissionUsecaseBox: @unchecked Sendable { - let usecase: PopupSubmissionUsecaseProtocol - - init(_ usecase: PopupSubmissionUsecaseProtocol) { - self.usecase = usecase - } -} - -private final class UserUsecaseBox: @unchecked Sendable { - let usecase: UserUsecaseProtocol - - init(_ usecase: UserUsecaseProtocol) { - self.usecase = usecase - } -} diff --git a/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeature.swift b/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeature.swift deleted file mode 100644 index 46dd5882..00000000 --- a/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeature.swift +++ /dev/null @@ -1,137 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation -import PopupSubmissionFormFeature - -@Reducer -public struct PopupRequestFeature { - @ObservableState - public struct State: Equatable, Identifiable { - public let userUuid: String - public var form: PopupSubmissionFormFeature.State - public var isSubmitting = false - public var hasLoaded = false - public var errorMessage: String? - public var isSubmitted = false - - public var id: String { "popup-request-\(userUuid)" } - - public init(userUuid: String) { - self.userUuid = userUuid - self.form = .init(mode: .userCreate, imageItems: [PopupSubmissionImageItem()]) - } - } - - public enum Action: Equatable { - case onAppear - case dismissTapped - case submitButtonTapped - case errorAlertDismissed - case successAlertDismissed - case recommendListLoaded([Recommend]) - case recommendListFailed(String) - case submissionSucceeded - case submissionFailed(String) - case form(PopupSubmissionFormFeature.Action) - case delegate(Delegate) - - public enum Delegate: Equatable { - case dismiss - } - } - - @Dependency(\.popupRequestClient) private var popupRequestClient: PopupRequestClient - - public init() {} - - public var body: some ReducerOf { - Scope(state: \.form, action: \.form) { - PopupSubmissionFormFeature() - } - - Reduce { state, action in - switch action { - case .onAppear: - guard state.hasLoaded == false else { return .none } - state.hasLoaded = true - return loadRecommendList() - - case .dismissTapped: - return .send(.delegate(.dismiss)) - - case .submitButtonTapped: - if let error = PopupSubmissionFormValidator.validateUser(state.form) { - state.errorMessage = error.localizedDescription - return .none - } - - state.isSubmitting = true - state.errorMessage = nil - return submit( - request: PopupSubmissionFormMapper.makeCreateRequest( - from: state.form, - userUuid: state.userUuid - ) - ) - - case .errorAlertDismissed: - state.errorMessage = nil - return .none - - case .successAlertDismissed: - state.isSubmitted = false - return .send(.delegate(.dismiss)) - - case .recommendListLoaded(let recommendList): - state.form.recommendList = recommendList - state.errorMessage = nil - return .none - - case .recommendListFailed(let message): - state.errorMessage = message - return .none - - case .submissionSucceeded: - state.isSubmitting = false - state.isSubmitted = true - return .none - - case .submissionFailed(let message): - state.isSubmitting = false - state.errorMessage = message - return .none - - case .form, - .delegate: - return .none - } - } - } -} - -private extension PopupRequestFeature { - func loadRecommendList() -> Effect { - let popupRequestClient = popupRequestClient - - return .run { [popupRequestClient] send in - do { - await send(.recommendListLoaded(try await popupRequestClient.getRecommendList())) - } catch { - await send(.recommendListFailed(error.localizedDescription)) - } - } - } - - func submit(request: PopupSubmissionCreateRequest) -> Effect { - let popupRequestClient = popupRequestClient - - return .run { [popupRequestClient, request] send in - do { - try await popupRequestClient.createPopupSubmission(request) - await send(.submissionSucceeded) - } catch { - await send(.submissionFailed(error.localizedDescription)) - } - } - } -} diff --git a/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeatureView.swift b/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeatureView.swift deleted file mode 100644 index c2726921..00000000 --- a/Projects/Features/PopupRequestFeature/Sources/Presentation/PopupRequestFeatureView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import ComposableArchitecture -import DSKit -import PopupSubmissionFormFeature -import SwiftUI - -public struct PopupRequestFeatureView: View { - @Environment(\.dismiss) private var dismiss - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - VStack(spacing: 0) { - ScrollView { - PopupSubmissionFormView( - store: store.scope(state: \.form, action: \.form) - ) - .padding(.horizontal, .contentPadding) - .padding(.top, 24) - .padding(.bottom, 120) - } - } - .background(Color.mainGray4.ignoresSafeArea()) - .safeAreaInset(edge: .top, spacing: 0) { - ModalNavigationHeader( - title: "팝업 제보하기", - showsSeparator: true, - onBack: close - ) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - submitButton - } - .task { - store.send(.onAppear) - } - .alert("제출 실패", isPresented: errorPresentedBinding) { - Button("확인") { - store.send(.errorAlertDismissed) - } - } message: { - Text(store.errorMessage ?? "") - } - .alert("제보 완료", isPresented: successPresentedBinding) { - Button("확인") { - store.send(.successAlertDismissed) - dismiss() - } - } message: { - Text("팝업 제보가 등록되었습니다.") - } - } -} - -private extension PopupRequestFeatureView { - var submitButton: some View { - VStack(spacing: 0) { - Divider() - - MainOrangeButton( - buttonTitle: store.isSubmitting ? "제출 중" : "제보하기", - height: 56 - ) { - store.send(.submitButtonTapped) - } - .disabled(store.isSubmitting) - .opacity(store.isSubmitting ? 0.45 : 1) - .padding(.horizontal, .contentPadding) - .padding(.vertical, 12) - .background(Color.subWhite) - } - } - - var errorPresentedBinding: Binding { - Binding( - get: { store.errorMessage != nil }, - set: { isPresented in - if isPresented == false { - store.send(.errorAlertDismissed) - } - } - ) - } - - var successPresentedBinding: Binding { - Binding( - get: { store.isSubmitted }, - set: { isPresented in - if isPresented == false { - store.send(.successAlertDismissed) - } - } - ) - } - - func close() { - store.send(.dismissTapped) - dismiss() - } -} - -#if DEBUG -#Preview("PopupRequestFeatureView") { - PopupRequestFeatureView( - store: Store( - initialState: PopupRequestFeature.State(userUuid: "preview-user") - ) { - PopupRequestFeature() - } withDependencies: { - $0.popupRequestClient = .previewValue - } - ) -} -#endif diff --git a/Projects/Features/PopupRequestFeature/Sources/Support/PopupRequestSubmitState.swift b/Projects/Features/PopupRequestFeature/Sources/Support/PopupRequestSubmitState.swift deleted file mode 100644 index ada6f53d..00000000 --- a/Projects/Features/PopupRequestFeature/Sources/Support/PopupRequestSubmitState.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -struct PopupRequestSubmitState: Equatable { - var isSubmitting = false - var errorMessage: String? - var isSubmitted = false -} diff --git a/Projects/Features/PopupRequestFeature/Tests/PopupRequestFeatureTests.swift b/Projects/Features/PopupRequestFeature/Tests/PopupRequestFeatureTests.swift deleted file mode 100644 index ffb88dfb..00000000 --- a/Projects/Features/PopupRequestFeature/Tests/PopupRequestFeatureTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import ComposableArchitecture -import Domain -import PopupSubmissionFormFeature -import Testing -@testable import PopupRequestFeature - -@MainActor -struct PopupRequestFeatureTests { - @Test("팝업 제보 화면이 최초 진입 시 추천 카테고리를 로드한다") - func loadsRecommendListOnAppear() async { - let expected = [Recommend(id: 1, recommendName: "패션")] - - let store = TestStore( - initialState: PopupRequestFeature.State(userUuid: "user-1") - ) { - PopupRequestFeature() - } withDependencies: { - $0.popupRequestClient.getRecommendList = { expected } - } - - await store.send(.onAppear) { - $0.hasLoaded = true - } - - await store.receive(.recommendListLoaded(expected)) { - $0.form.recommendList = expected - $0.errorMessage = nil - } - } - - @Test("팝업 제보가 유효한 폼이면 제출 요청을 보낸다") - func submitsValidRequest() async throws { - var state = PopupRequestFeature.State(userUuid: "user-1") - state.form.name = "성수 팝업" - state.form.roadAddress = "서울 성동구 성수이로 00" - state.form.region = "서울" - state.form.descriptionText = "브랜드 팝업" - state.form.selectedRecommendIds = [3] - state.form.imageItems = [PopupSubmissionImageItem(imageUrl: "https://cdn.example.com/a.jpg")] - - let store = TestStore(initialState: state) { - PopupRequestFeature() - } withDependencies: { - $0.popupRequestClient.createPopupSubmission = { request in - #expect(request.userUuid == "user-1") - #expect(request.name == "성수 팝업") - #expect(request.recommendIdList == [3]) - #expect(request.imageList.map(\.imageUrl) == ["https://cdn.example.com/a.jpg"]) - } - } - - await store.send(.submitButtonTapped) { - $0.isSubmitting = true - $0.errorMessage = nil - } - - await store.receive(.submissionSucceeded) { - $0.isSubmitting = false - $0.isSubmitted = true - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Demo/Sources/PopupRequestManagementFeatureDemoApp.swift b/Projects/Features/PopupRequestManagementFeature/Demo/Sources/PopupRequestManagementFeatureDemoApp.swift deleted file mode 100644 index 27e488d1..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Demo/Sources/PopupRequestManagementFeatureDemoApp.swift +++ /dev/null @@ -1,18 +0,0 @@ -import ComposableArchitecture -import PopupRequestManagementFeature -import SwiftUI - -@main -struct PopupRequestManagementFeatureDemoApp: App { - var body: some Scene { - WindowGroup { - PopupRequestManagementFlowView( - store: Store(initialState: PopupRequestManagementFlowFeature.State(adminUuid: "admin-user")) { - PopupRequestManagementFlowFeature() - } withDependencies: { - $0.popupRequestManagementClient = .previewValue - } - ) - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Project.swift b/Projects/Features/PopupRequestManagementFeature/Project.swift deleted file mode 100644 index 97b4ab7b..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Project.swift +++ /dev/null @@ -1,59 +0,0 @@ -import ProjectDescription - -let project = Project( - name: "PopupRequestManagementFeature", - targets: [ - .target( - name: "PopupRequestManagementFeature", - destinations: [.iPhone], - product: .staticFramework, - bundleId: "com.poppang.features.popuprequestmanagement", - deploymentTargets: .iOS("17.0"), - infoPlist: .default, - sources: ["Sources/**"], - dependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "DSKit", path: "../../Shared/DSKit"), - .project(target: "Core", path: "../../Shared/Core"), - .project(target: "PopupSubmissionFormFeature", path: "../PopupSubmissionFormFeature"), - .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), - ] - ), - // .target( - // name: "PopupRequestManagementFeatureDemo", - // destinations: [.iPhone], - // product: .app, - // bundleId: "com.poppang.demo.popuprequestmanagement", - // deploymentTargets: .iOS("17.0"), - // infoPlist: .extendingDefault( - // with: [ - // "UILaunchScreen": [ - // "UIColorName": "", - // "UIImageName": "", - // ], - // ] - // ), - // sources: ["Demo/Sources/**"], - // dependencies: [ - // .target(name: "PopupRequestManagementFeature"), - // .project(target: "Domain", path: "../../Domain"), - // ] - // ), - .target( - name: "PopupRequestManagementFeatureTests", - destinations: [.iPhone], - product: .unitTests, - bundleId: "com.poppang.features.popuprequestmanagement.tests", - deploymentTargets: .iOS("17.0"), - infoPlist: .default, - sources: ["Tests/**"], - dependencies: [ - .target(name: "PopupRequestManagementFeature"), - .project(target: "PopupSubmissionFormFeature", path: "../PopupSubmissionFormFeature"), - .project(target: "Domain", path: "../../Domain"), - .project(target: "DSKit", path: "../../Shared/DSKit"), - .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), - ] - ), - ] -) diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Dependency/PopupRequestManagementClient.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Dependency/PopupRequestManagementClient.swift deleted file mode 100644 index 2c2949ee..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Dependency/PopupRequestManagementClient.swift +++ /dev/null @@ -1,160 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation - -public struct PopupRequestManagementClient: Sendable { - var getPopupSubmissionList: @Sendable (_ adminUuid: String, _ filter: PopupSubmissionListFilter) async throws -> [PopupSubmissionListItem] - var getPopupSubmissionDetail: @Sendable (_ adminUuid: String, _ submissionId: Int) async throws -> PopupSubmissionDetail - var updatePopupSubmission: @Sendable ( - _ adminUuid: String, - _ submissionId: Int, - _ request: PopupSubmissionAdminUpdateRequest - ) async throws -> PopupSubmissionAdminUpdateResult -} - -extension PopupRequestManagementClient { - public static func live( - popupSubmissionUsecase: PopupSubmissionUsecaseProtocol - ) -> Self { - let box = PopupSubmissionUsecaseBox(popupSubmissionUsecase) - - return Self( - getPopupSubmissionList: { adminUuid, filter in - try await box.usecase.getPopupSubmissionList(adminUuid: adminUuid, filter: filter) - }, - getPopupSubmissionDetail: { adminUuid, submissionId in - try await box.usecase.getPopupSubmissionDetail(adminUuid: adminUuid, submissionId: submissionId) - }, - updatePopupSubmission: { adminUuid, submissionId, request in - try await box.usecase.updatePopupSubmission( - adminUuid: adminUuid, - submissionId: submissionId, - request: request - ) - } - ) - } -} - -extension PopupRequestManagementClient: DependencyKey { - public static let liveValue = PopupRequestManagementClient( - getPopupSubmissionList: { _, _ in [] }, - getPopupSubmissionDetail: { _, _ in - PopupSubmissionDetail( - id: 0, - name: "", - startDate: Date(), - endDate: Date(), - roadAddress: "", - region: "", - description: "", - recommendIdList: [], - recommendList: [], - imageList: [], - address: nil, - openTime: nil, - closeTime: nil, - instaPostUrl: nil, - status: .pending - ) - }, - updatePopupSubmission: { _, _, _ in - PopupSubmissionAdminUpdateResult(popupUuid: nil) - } - ) - -#if DEBUG - public static let previewValue = PopupRequestManagementClient( - getPopupSubmissionList: { _, _ in - [ - PopupSubmissionListItem( - id: 1, - name: "성수 스니커즈 팝업", - roadAddress: "서울 성동구 성수이로 00", - region: "서울", - submitterUserUuid: "user-1", - submitterNickname: "팝팡", - submittedAt: "2026-06-05T10:20:30", - status: .pending - ), - PopupSubmissionListItem( - id: 2, - name: "홍대 디저트 팝업", - roadAddress: "서울 마포구 와우산로 00", - region: "서울", - submitterUserUuid: "user-2", - submitterNickname: "디저트러버", - submittedAt: "2026-06-04T09:10:00", - status: .approved - ), - ] - }, - getPopupSubmissionDetail: { _, submissionId in - PopupSubmissionDetail( - id: submissionId, - name: "성수 스니커즈 팝업", - startDate: Date(), - endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date(), - roadAddress: "서울 성동구 성수이로 00", - region: "서울", - description: "브랜드 팝업 제보 원문입니다.", - recommendIdList: [1, 2], - recommendList: [ - Recommend(id: 1, recommendName: "패션"), - Recommend(id: 2, recommendName: "라이프스타일"), - ], - imageList: [PopupSubmissionImage(imageUrl: "https://cdn.example.com/a.jpg", sortOrder: 0)], - address: "서울 성동구 성수동 00-0", - openTime: PopupSubmissionLocalTime(hour: 10, minute: 0), - closeTime: PopupSubmissionLocalTime(hour: 20, minute: 0), - instaPostUrl: "https://instagram.com/p/demo", - status: .pending - ) - }, - updatePopupSubmission: { _, _, _ in - PopupSubmissionAdminUpdateResult(popupUuid: "popup-uuid-1") - } - ) -#endif -} - -extension PopupRequestManagementClient: TestDependencyKey { - public static let testValue = PopupRequestManagementClient( - getPopupSubmissionList: { _, _ in [] }, - getPopupSubmissionDetail: { _, _ in - PopupSubmissionDetail( - id: 0, - name: "", - startDate: Date(), - endDate: Date(), - roadAddress: "", - region: "", - description: "", - recommendIdList: [], - recommendList: [], - imageList: [], - address: nil, - openTime: nil, - closeTime: nil, - instaPostUrl: nil, - status: .pending - ) - }, - updatePopupSubmission: { _, _, _ in PopupSubmissionAdminUpdateResult(popupUuid: nil) } - ) -} - -extension DependencyValues { - public var popupRequestManagementClient: PopupRequestManagementClient { - get { self[PopupRequestManagementClient.self] } - set { self[PopupRequestManagementClient.self] = newValue } - } -} - -private final class PopupSubmissionUsecaseBox: @unchecked Sendable { - let usecase: PopupSubmissionUsecaseProtocol - - init(_ usecase: PopupSubmissionUsecaseProtocol) { - self.usecase = usecase - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailFeature.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailFeature.swift deleted file mode 100644 index b0bad170..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailFeature.swift +++ /dev/null @@ -1,181 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation -import PopupSubmissionFormFeature - -@Reducer -public struct PopupRequestManagementDetailFeature { - @ObservableState - public struct State: Equatable, Identifiable { - public let adminUuid: String - public let submissionId: Int - public var form: PopupSubmissionFormFeature.State - public var originalDescription: String = "" - public var status: PopupSubmissionStatus = .pending - public var isLoading = false - public var isSubmitting = false - public var hasLoaded = false - public var pendingDecision: PopupSubmissionStatus? - public var errorMessage: String? - public var resultPopupUuid: String? - public var isCompleted = false - - public var id: Int { submissionId } - - public init( - adminUuid: String, - submissionId: Int - ) { - self.adminUuid = adminUuid - self.submissionId = submissionId - self.form = .init(mode: .adminReview) - } - } - - public enum Action: Equatable { - case onAppear - case refresh - case approveTapped - case rejectTapped - case errorAlertDismissed - case completionAlertDismissed - case detailLoaded(PopupSubmissionDetail) - case detailLoadFailed(String) - case updateSucceeded(PopupSubmissionAdminUpdateResult) - case updateFailed(String) - case form(PopupSubmissionFormFeature.Action) - case delegate(Delegate) - - public enum Delegate: Equatable { - case pop - } - } - - @Dependency(\.popupRequestManagementClient) private var popupRequestManagementClient: PopupRequestManagementClient - - public init() {} - - public var body: some ReducerOf { - Scope(state: \.form, action: \.form) { - PopupSubmissionFormFeature() - } - - Reduce { state, action in - switch action { - case .onAppear: - guard state.hasLoaded == false else { return .none } - state.hasLoaded = true - state.isLoading = true - state.errorMessage = nil - return loadDetail(adminUuid: state.adminUuid, submissionId: state.submissionId) - - case .refresh: - state.isLoading = true - state.errorMessage = nil - return loadDetail(adminUuid: state.adminUuid, submissionId: state.submissionId) - - case .approveTapped: - if let error = PopupSubmissionFormValidator.validateAdmin(state.form) { - state.errorMessage = error.localizedDescription - return .none - } - - state.isSubmitting = true - state.pendingDecision = .approved - state.errorMessage = nil - return updateSubmission( - adminUuid: state.adminUuid, - submissionId: state.submissionId, - request: PopupSubmissionFormMapper.makeAdminUpdateRequest(from: state.form, status: .approved) - ) - - case .rejectTapped: - state.isSubmitting = true - state.pendingDecision = .rejected - state.errorMessage = nil - return updateSubmission( - adminUuid: state.adminUuid, - submissionId: state.submissionId, - request: PopupSubmissionAdminUpdateRequest(status: .rejected) - ) - - case .errorAlertDismissed: - state.errorMessage = nil - return .none - - case .completionAlertDismissed: - state.isCompleted = false - return .send(.delegate(.pop)) - - case .detailLoaded(let detail): - state.isLoading = false - state.form = PopupSubmissionFormMapper.makeAdminFormState(from: detail) - state.originalDescription = detail.description - state.status = detail.status - state.errorMessage = nil - return .none - - case .detailLoadFailed(let message): - state.isLoading = false - state.errorMessage = message - return .none - - case .updateSucceeded(let result): - state.isSubmitting = false - state.status = state.pendingDecision ?? state.status - state.pendingDecision = nil - state.resultPopupUuid = result.popupUuid - state.isCompleted = true - return .none - - case .updateFailed(let message): - state.isSubmitting = false - state.pendingDecision = nil - state.errorMessage = message - return .none - - case .form, - .delegate: - return .none - } - } - } -} - -private extension PopupRequestManagementDetailFeature { - func loadDetail(adminUuid: String, submissionId: Int) -> Effect { - let popupRequestManagementClient = popupRequestManagementClient - - return .run { [popupRequestManagementClient, adminUuid, submissionId] send in - do { - await send(.detailLoaded( - try await popupRequestManagementClient.getPopupSubmissionDetail(adminUuid, submissionId) - )) - } catch { - await send(.detailLoadFailed(error.localizedDescription)) - } - } - } - - func updateSubmission( - adminUuid: String, - submissionId: Int, - request: PopupSubmissionAdminUpdateRequest - ) -> Effect { - let popupRequestManagementClient = popupRequestManagementClient - - return .run { [popupRequestManagementClient, adminUuid, submissionId, request] send in - do { - await send(.updateSucceeded( - try await popupRequestManagementClient.updatePopupSubmission( - adminUuid, - submissionId, - request - ) - )) - } catch { - await send(.updateFailed(error.localizedDescription)) - } - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailView.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailView.swift deleted file mode 100644 index 1f4eb4df..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementDetailView.swift +++ /dev/null @@ -1,171 +0,0 @@ -import ComposableArchitecture -import DSKit -import PopupSubmissionFormFeature -import SwiftUI - -public struct PopupRequestManagementDetailView: View { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - detailContent - .background(Color.mainGray4.ignoresSafeArea()) - .ppBackNavigationBar( - title: "제보 상세", - showsSeparator: true, - onBack: { - store.send(.delegate(.pop)) - } - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - actionButtons - } - .task { - store.send(.onAppear) - } - .alert("처리 실패", isPresented: errorPresentedBinding) { - Button("확인") { - store.send(.errorAlertDismissed) - } - } message: { - Text(store.errorMessage ?? "") - } - .alert("처리 완료", isPresented: completionPresentedBinding) { - Button("확인") { - store.send(.completionAlertDismissed) - } - } message: { - Text(store.resultPopupUuid == nil ? "제보가 반려되었습니다." : "팝업 리스트에 반영되었습니다.") - } - } -} - -private extension PopupRequestManagementDetailView { - @ViewBuilder - var detailContent: some View { - if store.isLoading && store.form.name.isEmpty && store.originalDescription.isEmpty { - ScrollView { - ProgressView() - .tint(Color.mainOrange) - .frame(maxWidth: .infinity) - .padding(.top, 48) - } - } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - statusSection - originalDescriptionSection - PopupSubmissionFormView(store: store.scope(state: \.form, action: \.form)) - } - .padding(.horizontal, .contentPadding) - .padding(.top, 16) - .padding(.bottom, 140) - } - .refreshable { - store.send(.refresh) - } - } - } - - var statusSection: some View { - HStack { - Text(statusTitle) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainOrange) - .padding(.horizontal, 12) - .frame(height: 32) - .background(Color.categoryOrange) - .clipShape(Capsule()) - - Spacer() - } - } - - var originalDescriptionSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("원본 제보 내용") - .font(.scdream(.bold, size: 15)) - .foregroundStyle(Color.mainBlack) - - Text(store.originalDescription.isEmpty ? "제보 내용이 없습니다." : store.originalDescription) - .font(.scdream(.medium, size: 13)) - .foregroundStyle(Color.mainGray) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - var actionButtons: some View { - VStack(spacing: 0) { - Divider() - - HStack(spacing: 10) { - Button { - store.send(.rejectTapped) - } label: { - Text(store.isSubmitting ? "처리 중" : "반려") - .font(.scdream(.medium, size: 15)) - .foregroundStyle(Color.mainRed) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background(Color.mainRed.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(PressableButtonStyle()) - .disabled(store.isSubmitting) - - MainOrangeButton( - buttonTitle: store.isSubmitting ? "처리 중" : "승인", - height: 56 - ) { - store.send(.approveTapped) - } - .disabled(store.isSubmitting) - .opacity(store.isSubmitting ? 0.45 : 1) - } - .padding(.horizontal, .contentPadding) - .padding(.vertical, 12) - .background(Color.subWhite) - } - } - - var errorPresentedBinding: Binding { - Binding( - get: { store.errorMessage != nil }, - set: { isPresented in - if isPresented == false { - store.send(.errorAlertDismissed) - } - } - ) - } - - var completionPresentedBinding: Binding { - Binding( - get: { store.isCompleted }, - set: { isPresented in - if isPresented == false { - store.send(.completionAlertDismissed) - } - } - ) - } - - var statusTitle: String { - switch store.status { - case .pending: - "검토 대기" - case .approved: - "승인" - case .rejected: - "반려" - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowFeature.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowFeature.swift deleted file mode 100644 index 234c9154..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowFeature.swift +++ /dev/null @@ -1,66 +0,0 @@ -import ComposableArchitecture -import Foundation - -@Reducer -public struct PopupRequestManagementFlowFeature { - @ObservableState - public struct State: Equatable, Identifiable { - public let adminUuid: String - public var list: PopupRequestManagementListFeature.State - - public var id: String { "popup-request-management-\(adminUuid)" } - - public init(adminUuid: String) { - self.adminUuid = adminUuid - self.list = .init(adminUuid: adminUuid) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.adminUuid == rhs.adminUuid - && lhs.list == rhs.list - } - } - - public enum Action: Equatable { - case list(PopupRequestManagementListFeature.Action) - case delegate(Delegate) - - public enum Delegate: Equatable { - case dismiss - case showDetail(Int) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.list(let lhsAction), .list(let rhsAction)): - return lhsAction == rhsAction - case (.delegate(let lhsDelegate), .delegate(let rhsDelegate)): - return lhsDelegate == rhsDelegate - default: - return false - } - } - } - - public init() {} - - public var body: some ReducerOf { - Scope(state: \.list, action: \.list) { - PopupRequestManagementListFeature() - } - - Reduce { state, action in - switch action { - case .list(.delegate(.backRequested)): - return .send(.delegate(.dismiss)) - - case .list(.delegate(.submissionSelected(let submissionId))): - return .send(.delegate(.showDetail(submissionId))) - - case .list, - .delegate: - return .none - } - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowView.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowView.swift deleted file mode 100644 index ff7bd36c..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementFlowView.swift +++ /dev/null @@ -1,16 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -public struct PopupRequestManagementFlowView: View { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - PopupRequestManagementListView( - store: store.scope(state: \.list, action: \.list) - ) - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListFeature.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListFeature.swift deleted file mode 100644 index 32ef9c68..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListFeature.swift +++ /dev/null @@ -1,135 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation - -@Reducer -public struct PopupRequestManagementListFeature { - @ObservableState - public struct State: Equatable { - public let adminUuid: String - public var allItems: [PopupRequestManagementListItem] = [] - public var items: [PopupRequestManagementListItem] = [] - public var selectedFilter: PopupRequestManagementFilter = .all - public var isLoading = false - public var hasLoaded = false - public var errorMessage: String? - - public init(adminUuid: String) { - self.adminUuid = adminUuid - } - - var filteredItems: [PopupRequestManagementListItem] { - items - } - - var pendingCount: Int { allItems.filter { $0.status == .pending }.count } - var approvedCount: Int { allItems.filter { $0.status == .approved }.count } - var rejectedCount: Int { allItems.filter { $0.status == .rejected }.count } - } - - public enum Action: Equatable { - case onAppear - case refresh - case backTapped - case filterSelected(PopupRequestManagementFilter) - case submissionTapped(Int) - case submissionsLoaded( - summaryItems: [PopupRequestManagementListItem], - items: [PopupRequestManagementListItem] - ) - case submissionsFailed(String) - case delegate(Delegate) - - public enum Delegate: Equatable { - case backRequested - case submissionSelected(Int) - } - } - - @Dependency(\.popupRequestManagementClient) private var popupRequestManagementClient: PopupRequestManagementClient - - public init() {} - - public var body: some ReducerOf { - Reduce { state, action in - switch action { - case .onAppear: - guard state.hasLoaded == false else { return .none } - state.hasLoaded = true - state.isLoading = true - state.errorMessage = nil - return loadList( - adminUuid: state.adminUuid, - filter: state.selectedFilter - ) - - case .refresh: - state.isLoading = true - state.errorMessage = nil - return loadList( - adminUuid: state.adminUuid, - filter: state.selectedFilter - ) - - case .backTapped: - return .send(.delegate(.backRequested)) - - case .filterSelected(let filter): - state.selectedFilter = filter - state.isLoading = true - state.errorMessage = nil - return loadList( - adminUuid: state.adminUuid, - filter: filter - ) - - case .submissionTapped(let submissionId): - return .send(.delegate(.submissionSelected(submissionId))) - - case let .submissionsLoaded(summaryItems, items): - state.isLoading = false - state.allItems = summaryItems - state.items = items - state.errorMessage = nil - return .none - - case .submissionsFailed(let message): - state.isLoading = false - state.errorMessage = message - return .none - - case .delegate: - return .none - } - } - } -} - -private extension PopupRequestManagementListFeature { - func loadList( - adminUuid: String, - filter: PopupRequestManagementFilter - ) -> Effect { - let popupRequestManagementClient = popupRequestManagementClient - - return .run { [popupRequestManagementClient, adminUuid, filter] send in - do { - async let summaryResponse = popupRequestManagementClient.getPopupSubmissionList(adminUuid, .all) - async let filteredResponse = popupRequestManagementClient.getPopupSubmissionList( - adminUuid, - filter.domainFilter - ) - - let summaryItems = try await summaryResponse.map(PopupRequestManagementListItem.init(item:)) - let items = try await filteredResponse.map(PopupRequestManagementListItem.init(item:)) - - await send(.submissionsLoaded( - summaryItems: summaryItems, - items: items - )) - } catch { - await send(.submissionsFailed(error.localizedDescription)) - } - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListView.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListView.swift deleted file mode 100644 index 9e33cd81..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Presentation/PopupRequestManagementListView.swift +++ /dev/null @@ -1,324 +0,0 @@ -import ComposableArchitecture -import DSKit -import Domain -import SwiftUI - -public struct PopupRequestManagementListView: View { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - listContent - .background(Color.mainGray4.ignoresSafeArea()) - .ppBackNavigationBar( - title: "팝업 제보 관리", - showsSeparator: true, - onBack: { - store.send(.backTapped) - } - ) { - refreshButton - } - .task { - store.send(.onAppear) - } - } -} - -private extension PopupRequestManagementListView { - var listContent: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - summarySection - filterSection - listSection - } - .padding(.horizontal, .contentPadding) - .padding(.top, 12) - .padding(.bottom, 32) - } - .refreshable { - store.send(.refresh) - } - } - - var refreshButton: some View { - Button { - store.send(.refresh) - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color.subBlack) - .frame(width: 38, height: 38) - .contentShape(Rectangle()) - } - .buttonStyle(PressableButtonStyle()) - .disabled(store.isLoading) - .opacity(store.isLoading ? 0.4 : 1) - } - - var summarySection: some View { - HStack(spacing: 8) { - PopupRequestSummaryTile(title: "대기", count: store.pendingCount) - PopupRequestSummaryTile(title: "승인", count: store.approvedCount) - PopupRequestSummaryTile(title: "반려", count: store.rejectedCount) - } - } - - var filterSection: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(PopupRequestManagementFilter.allCases, id: \.self) { filter in - PopupRequestFilterChip( - title: filter.title, - isSelected: store.selectedFilter == filter - ) { - store.send(.filterSelected(filter)) - } - } - } - } - } - - @ViewBuilder - var listSection: some View { - if store.isLoading, store.allItems.isEmpty { - PopupRequestManagementLoadingView() - } else if let errorMessage = store.errorMessage { - PopupRequestManagementErrorView(message: errorMessage) { - store.send(.refresh) - } - } else if store.filteredItems.isEmpty { - PopupRequestManagementEmptyView() - } else { - LazyVStack(spacing: 10) { - ForEach(store.filteredItems) { item in - Button { - store.send(.submissionTapped(item.id)) - } label: { - PopupRequestManagementCell(item: item) - } - .buttonStyle(PressableButtonStyle()) - } - } - } - } -} - -private struct PopupRequestSummaryTile: View { - let title: String - let count: Int - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray) - - Text("\(count)") - .font(.scdream(.bold, size: 20)) - .foregroundStyle(Color.mainBlack) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .frame(height: 72) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} - -private struct PopupRequestFilterChip: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(isSelected ? Color.subWhite : Color.mainGray) - .padding(.horizontal, 14) - .frame(height: 34) - .background(isSelected ? Color.mainOrange : Color.subWhite) - .clipShape(Capsule()) - .overlay { - Capsule() - .stroke(isSelected ? Color.mainOrange : Color.mainGray3, lineWidth: 1) - } - } - .buttonStyle(PressableButtonStyle()) - } -} - -private struct PopupRequestManagementCell: View { - let item: PopupRequestManagementListItem - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 6) { - Text(item.name) - .font(.scdream(.bold, size: 15)) - .foregroundStyle(Color.mainBlack) - .lineLimit(2) - - Text(item.roadAddress) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray) - .lineLimit(2) - } - - Spacer(minLength: 8) - - PopupRequestStatusBadge(status: item.status) - - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.mainGray2) - .frame(width: 16, height: 28) - } - - HStack(spacing: 8) { - PopupRequestCellMetaText(text: item.region) - PopupRequestCellDivider() - PopupRequestCellMetaText(text: item.submitterNickname) - PopupRequestCellDivider() - PopupRequestCellMetaText(text: item.submittedAtText) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} - -private struct PopupRequestStatusBadge: View { - let status: PopupSubmissionStatus - - var body: some View { - Text(title) - .font(.scdream(.medium, size: 11)) - .foregroundStyle(foregroundColor) - .padding(.horizontal, 10) - .frame(height: 28) - .background(backgroundColor) - .clipShape(Capsule()) - } - - private var title: String { - switch status { - case .pending: - "검토 대기" - case .approved: - "승인" - case .rejected: - "반려" - } - } - - private var foregroundColor: Color { - switch status { - case .pending: - Color.mainOrange - case .approved: - Color.mainGreen - case .rejected: - Color.mainRed - } - } - - private var backgroundColor: Color { - switch status { - case .pending: - Color.categoryOrange - case .approved: - Color.mainGreen.opacity(0.12) - case .rejected: - Color.mainRed.opacity(0.12) - } - } -} - -private struct PopupRequestCellMetaText: View { - let text: String - - var body: some View { - Text(text) - .font(.scdream(.medium, size: 11)) - .foregroundStyle(Color.mainGray2) - .lineLimit(1) - } -} - -private struct PopupRequestCellDivider: View { - var body: some View { - Circle() - .fill(Color.mainGray3) - .frame(width: 3, height: 3) - } -} - -private struct PopupRequestManagementLoadingView: View { - var body: some View { - VStack(spacing: 12) { - ProgressView() - .tint(Color.mainOrange) - - Text("팝업 제보를 불러오는 중입니다.") - .font(.scdream(.medium, size: 13)) - .foregroundStyle(Color.mainGray) - } - .frame(maxWidth: .infinity) - .frame(height: 220) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} - -private struct PopupRequestManagementErrorView: View { - let message: String - let retry: () -> Void - - var body: some View { - VStack(spacing: 14) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28)) - .foregroundStyle(Color.mainOrange) - - Text(message) - .font(.scdream(.medium, size: 13)) - .foregroundStyle(Color.mainGray) - .multilineTextAlignment(.center) - - MainOrangeButton(buttonTitle: "다시 시도", height: 44) { - retry() - } - } - .padding(20) - .frame(maxWidth: .infinity) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} - -private struct PopupRequestManagementEmptyView: View { - var body: some View { - VStack(spacing: 12) { - Image(systemName: "tray") - .font(.system(size: 28)) - .foregroundStyle(Color.mainGray2) - - Text("표시할 팝업 제보가 없습니다.") - .font(.scdream(.medium, size: 13)) - .foregroundStyle(Color.mainGray) - } - .frame(maxWidth: .infinity) - .frame(height: 220) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementFilter.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementFilter.swift deleted file mode 100644 index ab5d268c..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementFilter.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Domain -import Foundation - -public enum PopupRequestManagementFilter: CaseIterable, Equatable, Sendable { - case all - case pending - case approved - case rejected - - var title: String { - switch self { - case .all: - "전체" - case .pending: - "대기" - case .approved: - "승인" - case .rejected: - "반려" - } - } - - var domainFilter: PopupSubmissionListFilter { - switch self { - case .all: - .all - case .pending: - .pending - case .approved: - .approved - case .rejected: - .rejected - } - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementListItem.swift b/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementListItem.swift deleted file mode 100644 index d2d4f12a..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Sources/Support/PopupRequestManagementListItem.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Domain -import Foundation - -public struct PopupRequestManagementListItem: Identifiable, Equatable, Sendable { - public let id: Int - public let name: String - public let roadAddress: String - public let region: String - public let submitterNickname: String - public let submittedAtText: String - public let status: PopupSubmissionStatus - - init(item: PopupSubmissionListItem) { - self.id = item.id - self.name = item.name - self.roadAddress = item.roadAddress - self.region = item.region - self.submitterNickname = item.submitterNickname - self.submittedAtText = item.submittedAt.split(separator: "T").first.map(String.init) ?? item.submittedAt - self.status = item.status - } -} diff --git a/Projects/Features/PopupRequestManagementFeature/Tests/PopupRequestManagementFeatureTests.swift b/Projects/Features/PopupRequestManagementFeature/Tests/PopupRequestManagementFeatureTests.swift deleted file mode 100644 index 2e50c066..00000000 --- a/Projects/Features/PopupRequestManagementFeature/Tests/PopupRequestManagementFeatureTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import ComposableArchitecture -import Domain -import Testing -@testable import PopupRequestManagementFeature - -@MainActor -struct PopupRequestManagementFeatureTests { - @Test("관리자 목록이 최초 진입 시 전체 제보를 로드한다") - func loadsSubmissionListOnAppear() async { - let expected = [ - PopupSubmissionListItem( - id: 1, - name: "성수 팝업", - roadAddress: "서울 성동구 성수이로 00", - region: "서울", - submitterUserUuid: "user-1", - submitterNickname: "팝팡", - submittedAt: "2026-06-05T10:20:30", - status: .pending - ), - ] - - let store = TestStore( - initialState: PopupRequestManagementListFeature.State(adminUuid: "admin-1") - ) { - PopupRequestManagementListFeature() - } withDependencies: { - $0.popupRequestManagementClient.getPopupSubmissionList = { _, _ in expected } - } - - await store.send(.onAppear) { - $0.hasLoaded = true - $0.isLoading = true - $0.errorMessage = nil - } - - let mapped = expected.map(PopupRequestManagementListItem.init(item:)) - - await store.receive(.submissionsLoaded(summaryItems: mapped, items: mapped)) { - $0.isLoading = false - $0.allItems = mapped - $0.items = mapped - $0.errorMessage = nil - } - } - - @Test("필터 선택 시 서버 필터 기준으로 최신 목록을 재조회한다") - func reloadsListWhenFilterChanges() async { - let allItems = [ - PopupSubmissionListItem( - id: 1, - name: "성수 팝업", - roadAddress: "서울 성동구 성수이로 00", - region: "서울", - submitterUserUuid: "user-1", - submitterNickname: "팝팡", - submittedAt: "2026-06-05T10:20:30", - status: .pending - ), - PopupSubmissionListItem( - id: 2, - name: "홍대 팝업", - roadAddress: "서울 마포구 와우산로 00", - region: "서울", - submitterUserUuid: "user-2", - submitterNickname: "홍대러버", - submittedAt: "2026-06-04T09:10:00", - status: .approved - ), - ] - - let approvedItems = [allItems[1]] - - let store = TestStore( - initialState: PopupRequestManagementListFeature.State(adminUuid: "admin-1") - ) { - PopupRequestManagementListFeature() - } withDependencies: { - $0.popupRequestManagementClient.getPopupSubmissionList = { _, filter in - switch filter { - case .all: - return allItems - case .approved: - return approvedItems - case .pending, .rejected: - return [] - } - } - } - - await store.send(.filterSelected(.approved)) { - $0.selectedFilter = .approved - $0.isLoading = true - $0.errorMessage = nil - } - - let mappedAll = allItems.map(PopupRequestManagementListItem.init(item:)) - let mappedApproved = approvedItems.map(PopupRequestManagementListItem.init(item:)) - - await store.receive(.submissionsLoaded(summaryItems: mappedAll, items: mappedApproved)) { - $0.isLoading = false - $0.allItems = mappedAll - $0.items = mappedApproved - $0.errorMessage = nil - } - } - - @Test("관리자 플로우가 목록 선택 시 상세 표시 delegate를 올린다") - func sendsShowDetailDelegateWhenSubmissionSelected() async { - let store = TestStore( - initialState: PopupRequestManagementFlowFeature.State(adminUuid: "admin-1") - ) { - PopupRequestManagementFlowFeature() - } - - await store.send(.list(.delegate(.submissionSelected(7)))) - await store.receive(.delegate(.showDetail(7))) - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Project.swift b/Projects/Features/PopupSubmissionFormFeature/Project.swift deleted file mode 100644 index cab4f74b..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Project.swift +++ /dev/null @@ -1,22 +0,0 @@ -import ProjectDescription - -let project = Project( - name: "PopupSubmissionFormFeature", - targets: [ - .target( - name: "PopupSubmissionFormFeature", - destinations: [.iPhone], - product: .staticFramework, - bundleId: "com.poppang.features.popupsubmissionform", - deploymentTargets: .iOS("17.0"), - infoPlist: .default, - sources: ["Sources/**"], - dependencies: [ - .project(target: "Domain", path: "../../Domain"), - .project(target: "DSKit", path: "../../Shared/DSKit"), - .project(target: "Core", path: "../../Shared/Core"), - .project(target: "ThirdParty", path: "../../Shared/ThirdParty"), - ] - ), - ] -) diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormFeature.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormFeature.swift deleted file mode 100644 index 51362137..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormFeature.swift +++ /dev/null @@ -1,130 +0,0 @@ -import ComposableArchitecture -import Domain -import Foundation - -@Reducer -public struct PopupSubmissionFormFeature { - @ObservableState - public struct State: Equatable { - public let mode: PopupSubmissionFormMode - public var name: String - public var startDate: Date - public var endDate: Date - public var roadAddress: String - public var region: String - public var descriptionText: String - public var address: String - public var openTime: String - public var closeTime: String - public var latitude: String - public var longitude: String - public var instaPostUrl: String - public var instaPostId: String - public var captionSummary: String - public var caption: String - public var geocodingQuery: String - public var mediaType: Popup.MediaType - public var isActive: Bool - public var recommendList: [Recommend] - public var selectedRecommendIds: [Int] - public var imageItems: [PopupSubmissionImageItem] - - public init( - mode: PopupSubmissionFormMode, - name: String = "", - startDate: Date = Date(), - endDate: Date = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date(), - roadAddress: String = "", - region: String = "", - descriptionText: String = "", - address: String = "", - openTime: String = "", - closeTime: String = "", - latitude: String = "", - longitude: String = "", - instaPostUrl: String = "", - instaPostId: String = "", - captionSummary: String = "", - caption: String = "", - geocodingQuery: String = "", - mediaType: Popup.MediaType = .image, - isActive: Bool = true, - recommendList: [Recommend] = [], - selectedRecommendIds: [Int] = [], - imageItems: [PopupSubmissionImageItem] = [] - ) { - self.mode = mode - self.name = name - self.startDate = startDate - self.endDate = endDate - self.roadAddress = roadAddress - self.region = region - self.descriptionText = descriptionText - self.address = address - self.openTime = openTime - self.closeTime = closeTime - self.latitude = latitude - self.longitude = longitude - self.instaPostUrl = instaPostUrl - self.instaPostId = instaPostId - self.captionSummary = captionSummary - self.caption = caption - self.geocodingQuery = geocodingQuery - self.mediaType = mediaType - self.isActive = isActive - self.recommendList = recommendList - self.selectedRecommendIds = selectedRecommendIds - self.imageItems = imageItems - } - } - - public enum Action: BindableAction, Equatable { - case binding(BindingAction) - case recommendToggled(Int) - case addImageRowTapped - case removeImageRow(UUID) - case imageURLChanged(UUID, String) - } - - public init() {} - - public var body: some ReducerOf { - BindingReducer() - - Reduce { state, action in - switch action { - case .binding(\.startDate): - if state.endDate < state.startDate { - state.endDate = state.startDate - } - return .none - - case .binding: - return .none - - case .recommendToggled(let id): - if let index = state.selectedRecommendIds.firstIndex(of: id) { - state.selectedRecommendIds.remove(at: index) - } else { - state.selectedRecommendIds.append(id) - } - return .none - - case .addImageRowTapped: - state.imageItems.append(PopupSubmissionImageItem()) - return .none - - case .removeImageRow(let id): - state.imageItems.removeAll { $0.id == id } - return .none - - case let .imageURLChanged(id, text): - guard let index = state.imageItems.firstIndex(where: { $0.id == id }) else { - return .none - } - state.imageItems[index].imageUrl = text - return .none - } - } - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormView.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormView.swift deleted file mode 100644 index 226fa459..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Presentation/PopupSubmissionFormView.swift +++ /dev/null @@ -1,477 +0,0 @@ -import ComposableArchitecture -import DSKit -import Domain -import SwiftUI - -public struct PopupSubmissionFormView: View { - @Bindable var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - VStack(alignment: .leading, spacing: 28) { - requiredSection - imageSection - optionalSection - if store.mode == .adminReview { - adminSection - } - } - } -} - -private extension PopupSubmissionFormView { - var requiredSection: some View { - PopupSubmissionFormSection(title: "필수 입력") { - PopupSubmissionTextInput( - title: "팝업명", - placeholder: "팝업명을 입력해 주세요", - text: $store.name, - isRequired: true - ) - - PopupSubmissionDateInput( - title: "운영 기간", - startDate: $store.startDate, - endDate: $store.endDate - ) - - PopupSubmissionTextInput( - title: "도로명 주소", - placeholder: "예: 서울 성동구 성수이로 00", - text: $store.roadAddress, - isRequired: true - ) - - PopupSubmissionTextInput( - title: "지역", - placeholder: "예: 서울", - text: $store.region, - isRequired: true - ) - - if store.mode == .userCreate { - PopupSubmissionTextEditor( - title: "제보 내용", - placeholder: "팝업의 주요 내용과 참고할 정보를 입력해 주세요", - text: $store.descriptionText, - isRequired: true - ) - } else { - PopupSubmissionTextEditor( - title: "팝업 한줄 소개", - placeholder: "노출용 한줄 소개를 입력해 주세요", - text: $store.captionSummary, - isRequired: true - ) - - PopupSubmissionTextEditor( - title: "팝업 상세 소개", - placeholder: "최종 노출용 상세 소개를 입력해 주세요", - text: $store.caption, - isRequired: true - ) - } - - PopupSubmissionCategoryPicker( - title: "추천 카테고리", - categories: store.recommendList, - selectedIds: store.selectedRecommendIds, - isRequired: true - ) { id in - store.send(.recommendToggled(id)) - } - } - } - - var imageSection: some View { - PopupSubmissionFormSection(title: "이미지 URL", isRequired: true) { - VStack(spacing: 10) { - ForEach(store.imageItems) { item in - HStack(spacing: 10) { - PopupSubmissionTextInput( - title: "", - placeholder: "https://...", - text: Binding( - get: { item.imageUrl }, - set: { store.send(.imageURLChanged(item.id, $0)) } - ), - keyboardType: .URL, - textInputAutocapitalization: .never - ) - - Button { - store.send(.removeImageRow(item.id)) - } label: { - Image(systemName: "trash") - .foregroundStyle(Color.mainRed) - .frame(width: 34, height: 48) - } - .buttonStyle(PressableButtonStyle()) - } - } - - Button { - store.send(.addImageRowTapped) - } label: { - HStack(spacing: 8) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .bold)) - Text("이미지 URL 추가") - .font(.scdream(.medium, size: 12)) - Spacer() - } - .foregroundStyle(Color.mainOrange) - .padding(.horizontal, 14) - .frame(height: 44) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.mainOrange, lineWidth: 1) - } - } - .buttonStyle(PressableButtonStyle()) - } - } - } - - var optionalSection: some View { - PopupSubmissionFormSection(title: "기본 정보") { - PopupSubmissionTextInput( - title: "지번 주소", - placeholder: "도로명 주소와 다를 때 입력", - text: $store.address, - isRequired: store.mode == .adminReview - ) - - HStack(spacing: 10) { - PopupSubmissionTextInput( - title: "오픈 시간", - placeholder: "10:00", - text: $store.openTime, - keyboardType: .numbersAndPunctuation - ) - - PopupSubmissionTextInput( - title: "마감 시간", - placeholder: "20:00", - text: $store.closeTime, - keyboardType: .numbersAndPunctuation - ) - } - - HStack(spacing: 10) { - PopupSubmissionTextInput( - title: "위도", - placeholder: "37.544", - text: $store.latitude, - isRequired: store.mode == .adminReview, - keyboardType: .decimalPad - ) - - PopupSubmissionTextInput( - title: "경도", - placeholder: "127.055", - text: $store.longitude, - isRequired: store.mode == .adminReview, - keyboardType: .decimalPad - ) - } - - PopupSubmissionTextInput( - title: "인스타그램 URL", - placeholder: "https://instagram.com/p/...", - text: $store.instaPostUrl, - keyboardType: .URL, - textInputAutocapitalization: .never - ) - } - } - - var adminSection: some View { - PopupSubmissionFormSection(title: "관리자 입력") { - PopupSubmissionMediaTypePicker(selected: $store.mediaType) - - PopupSubmissionTextInput( - title: "인스타그램 Post ID", - placeholder: "선택 입력", - text: $store.instaPostId - ) - - PopupSubmissionTextInput( - title: "Geocoding Query", - placeholder: "선택 입력", - text: $store.geocodingQuery - ) - - Toggle(isOn: $store.isActive) { - PopupSubmissionFieldTitle(title: "활성화 여부", isRequired: true) - } - .tint(Color.mainOrange) - } - } -} - -private struct PopupSubmissionFormSection: View { - let title: String - let isRequired: Bool - private let content: Content - - init( - title: String, - isRequired: Bool = false, - @ViewBuilder content: () -> Content - ) { - self.title = title - self.isRequired = isRequired - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 3) { - Text(title) - .font(.scdream(.bold, size: 15)) - .foregroundStyle(Color.mainBlack) - - if isRequired { - Text("*") - .font(.scdream(.bold, size: 15)) - .foregroundStyle(Color.mainOrange) - } - } - - VStack(spacing: 14) { - content - } - } - } -} - -private struct PopupSubmissionTextInput: View { - let title: String - let placeholder: String - @Binding var text: String - var isRequired = false - var keyboardType: UIKeyboardType = .default - var textInputAutocapitalization: TextInputAutocapitalization? = .sentences - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if title.isEmpty == false { - PopupSubmissionFieldTitle(title: title, isRequired: isRequired) - } - - TextField("", text: $text) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainBlack) - .keyboardType(keyboardType) - .textInputAutocapitalization(textInputAutocapitalization) - .autocorrectionDisabled(keyboardType == .URL) - .padding(.horizontal, 14) - .frame(height: 48) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay(alignment: .leading) { - if text.isEmpty { - Text(placeholder) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray2) - .padding(.horizontal, 14) - } - } - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.mainGray3, lineWidth: 0.8) - } - } - } -} - -private struct PopupSubmissionTextEditor: View { - let title: String - let placeholder: String - @Binding var text: String - var isRequired = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - PopupSubmissionFieldTitle(title: title, isRequired: isRequired) - - TextEditor(text: $text) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainBlack) - .frame(minHeight: 116) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay(alignment: .topLeading) { - if text.isEmpty { - Text(placeholder) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray2) - .padding(.top, 18) - .padding(.leading, 14) - .allowsHitTesting(false) - } - } - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.mainGray3, lineWidth: 0.8) - } - } - } -} - -private struct PopupSubmissionCategoryPicker: View { - let title: String - let categories: [Recommend] - let selectedIds: [Int] - var isRequired = false - let onToggle: (Int) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - PopupSubmissionFieldTitle(title: title, isRequired: isRequired) - - if categories.isEmpty { - Text("추천 카테고리를 불러오는 중입니다.") - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray2) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .frame(height: 48) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.mainGray3, lineWidth: 0.8) - } - } else { - SearchFlowLayout { - ForEach(categories) { category in - Button { - onToggle(category.id) - } label: { - Text(category.recommendName) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(selectedIds.contains(category.id) ? Color.mainOrange : Color.mainGray) - .padding(.horizontal, 16) - .frame(minHeight: 34) - .background { - Capsule() - .fill(selectedIds.contains(category.id) ? Color.categoryOrange : Color.subWhite) - } - .overlay { - Capsule() - .stroke(selectedIds.contains(category.id) ? Color.mainOrange : Color.mainGray3, lineWidth: 1) - } - } - .buttonStyle(PressableButtonStyle()) - .padding(4) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } -} - -private struct PopupSubmissionMediaTypePicker: View { - @Binding var selected: Popup.MediaType - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - PopupSubmissionFieldTitle(title: "미디어 타입", isRequired: true) - - HStack(spacing: 8) { - ForEach([Popup.MediaType.image, .carousel, .video], id: \.self) { mediaType in - Button { - selected = mediaType - } label: { - Text(title(for: mediaType)) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(selected == mediaType ? Color.subWhite : Color.mainGray) - .padding(.horizontal, 14) - .frame(height: 34) - .background(selected == mediaType ? Color.mainOrange : Color.subWhite) - .clipShape(Capsule()) - .overlay { - Capsule() - .stroke(selected == mediaType ? Color.mainOrange : Color.mainGray3, lineWidth: 1) - } - } - .buttonStyle(PressableButtonStyle()) - } - } - } - } - - private func title(for mediaType: Popup.MediaType) -> String { - switch mediaType { - case .image: - "IMAGE" - case .carousel: - "CAROUSEL" - case .video: - "VIDEO" - } - } -} - -private struct PopupSubmissionDateInput: View { - let title: String - @Binding var startDate: Date - @Binding var endDate: Date - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - PopupSubmissionFieldTitle(title: title, isRequired: true) - - HStack(spacing: 10) { - DatePicker("시작일", selection: $startDate, displayedComponents: .date) - .labelsHidden() - .frame(maxWidth: .infinity, alignment: .leading) - - Text("-") - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainGray) - - DatePicker("종료일", selection: $endDate, in: startDate..., displayedComponents: .date) - .labelsHidden() - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(.horizontal, 14) - .frame(height: 48) - .background(Color.subWhite) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.mainGray3, lineWidth: 0.8) - } - } - } -} - -private struct PopupSubmissionFieldTitle: View { - let title: String - let isRequired: Bool - - var body: some View { - HStack(spacing: 3) { - Text(title) - .font(.scdream(.medium, size: 12)) - .foregroundStyle(Color.mainBlack) - - if isRequired { - Text("*") - .font(.scdream(.bold, size: 12)) - .foregroundStyle(Color.mainOrange) - } - } - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMapper.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMapper.swift deleted file mode 100644 index 55f7f3d0..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMapper.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Domain -import Foundation - -public enum PopupSubmissionFormMapper { - public static func makeCreateRequest( - from state: PopupSubmissionFormFeature.State, - userUuid: String - ) -> PopupSubmissionCreateRequest { - PopupSubmissionCreateRequest( - userUuid: userUuid, - name: PopupSubmissionFormValidator.trimmed(state.name), - startDate: state.startDate, - endDate: state.endDate, - openTime: PopupSubmissionTimeParser.parse(state.openTime), - closeTime: PopupSubmissionTimeParser.parse(state.closeTime), - address: PopupSubmissionFormValidator.trimmed(state.address).isEmpty - ? PopupSubmissionFormValidator.trimmed(state.roadAddress) - : PopupSubmissionFormValidator.trimmed(state.address), - roadAddress: PopupSubmissionFormValidator.trimmed(state.roadAddress), - region: PopupSubmissionFormValidator.trimmed(state.region), - instaPostUrl: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.instaPostUrl)), - description: PopupSubmissionFormValidator.trimmed(state.descriptionText), - imageList: makeImageList(from: state), - recommendIdList: state.selectedRecommendIds.sorted() - ) - } - - public static func makeAdminUpdateRequest( - from state: PopupSubmissionFormFeature.State, - status: PopupSubmissionStatus - ) -> PopupSubmissionAdminUpdateRequest { - PopupSubmissionAdminUpdateRequest( - status: status, - name: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.name)), - startDate: state.startDate, - endDate: state.endDate, - roadAddress: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.roadAddress)), - region: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.region)), - address: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.address)), - openTime: PopupSubmissionTimeParser.parse(state.openTime), - closeTime: PopupSubmissionTimeParser.parse(state.closeTime), - latitude: Double(PopupSubmissionFormValidator.trimmed(state.latitude)), - longitude: Double(PopupSubmissionFormValidator.trimmed(state.longitude)), - captionSummary: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.captionSummary)), - caption: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.caption)), - mediaType: state.mediaType, - instaPostUrl: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.instaPostUrl)), - instaPostId: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.instaPostId)), - geocodingQuery: nilIfEmpty(PopupSubmissionFormValidator.trimmed(state.geocodingQuery)), - imageList: makeImageList(from: state), - recommendIdList: state.selectedRecommendIds.sorted(), - isActive: state.isActive - ) - } - - public static func makeAdminFormState(from detail: PopupSubmissionDetail) -> PopupSubmissionFormFeature.State { - PopupSubmissionFormFeature.State( - mode: .adminReview, - name: detail.name, - startDate: detail.startDate, - endDate: detail.endDate, - roadAddress: detail.roadAddress, - region: detail.region, - address: detail.address ?? "", - openTime: PopupSubmissionTimeParser.string(from: detail.openTime), - closeTime: PopupSubmissionTimeParser.string(from: detail.closeTime), - instaPostUrl: detail.instaPostUrl ?? "", - recommendList: detail.recommendList, - selectedRecommendIds: detail.recommendIdList, - imageItems: detail.imageList - .sorted { $0.sortOrder < $1.sortOrder } - .map { PopupSubmissionImageItem(imageUrl: $0.imageUrl) } - ) - } - - private static func makeImageList(from state: PopupSubmissionFormFeature.State) -> [PopupSubmissionImage] { - PopupSubmissionFormValidator.validImageURLs(state).enumerated().map { index, imageURL in - PopupSubmissionImage(imageUrl: imageURL, sortOrder: index) - } - } - - private static func nilIfEmpty(_ text: String) -> String? { - text.isEmpty ? nil : text - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMode.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMode.swift deleted file mode 100644 index b73a684f..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormMode.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -public enum PopupSubmissionFormMode: Equatable, Sendable { - case userCreate - case adminReview -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormValidator.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormValidator.swift deleted file mode 100644 index 9d7bf44d..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionFormValidator.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation - -public enum PopupSubmissionFormValidationError: LocalizedError, Equatable { - case message(String) - - public var errorDescription: String? { - switch self { - case .message(let message): - return message - } - } -} - -public enum PopupSubmissionFormValidator { - public static func validateUser( - _ state: PopupSubmissionFormFeature.State - ) -> PopupSubmissionFormValidationError? { - guard trimmed(state.name).isEmpty == false else { return .message("팝업명을 입력해 주세요.") } - guard trimmed(state.roadAddress).isEmpty == false else { return .message("도로명 주소를 입력해 주세요.") } - guard trimmed(state.region).isEmpty == false else { return .message("지역을 입력해 주세요.") } - guard trimmed(state.descriptionText).isEmpty == false else { return .message("제보 내용을 입력해 주세요.") } - guard state.selectedRecommendIds.isEmpty == false else { return .message("추천 카테고리를 1개 이상 선택해 주세요.") } - guard state.endDate >= state.startDate else { return .message("종료일은 시작일보다 빠를 수 없습니다.") } - guard validImageURLs(state).isEmpty == false else { return .message("이미지 URL을 1개 이상 입력해 주세요.") } - guard validTimeField(state.openTime) else { return .message("오픈 시간을 HH:mm 형식으로 입력해 주세요.") } - guard validTimeField(state.closeTime) else { return .message("마감 시간을 HH:mm 형식으로 입력해 주세요.") } - guard validWebURLField(state.instaPostUrl) else { return .message("인스타그램 URL을 올바르게 입력해 주세요.") } - return nil - } - - public static func validateAdmin( - _ state: PopupSubmissionFormFeature.State - ) -> PopupSubmissionFormValidationError? { - guard trimmed(state.name).isEmpty == false else { return .message("팝업명을 입력해 주세요.") } - guard trimmed(state.roadAddress).isEmpty == false else { return .message("도로명 주소를 입력해 주세요.") } - guard trimmed(state.region).isEmpty == false else { return .message("지역을 입력해 주세요.") } - guard trimmed(state.address).isEmpty == false else { return .message("지번 주소를 입력해 주세요.") } - guard trimmed(state.latitude).isEmpty == false else { return .message("위도를 입력해 주세요.") } - guard trimmed(state.longitude).isEmpty == false else { return .message("경도를 입력해 주세요.") } - guard Double(trimmed(state.latitude)) != nil else { return .message("위도를 숫자로 입력해 주세요.") } - guard Double(trimmed(state.longitude)) != nil else { return .message("경도를 숫자로 입력해 주세요.") } - guard trimmed(state.captionSummary).isEmpty == false else { return .message("한줄 소개를 입력해 주세요.") } - guard trimmed(state.caption).isEmpty == false else { return .message("상세 소개를 입력해 주세요.") } - guard state.selectedRecommendIds.isEmpty == false else { return .message("추천 카테고리를 1개 이상 선택해 주세요.") } - guard state.endDate >= state.startDate else { return .message("종료일은 시작일보다 빠를 수 없습니다.") } - guard validImageURLs(state).isEmpty == false else { return .message("이미지 URL을 1개 이상 입력해 주세요.") } - guard validTimeField(state.openTime) else { return .message("오픈 시간을 HH:mm 형식으로 입력해 주세요.") } - guard validTimeField(state.closeTime) else { return .message("마감 시간을 HH:mm 형식으로 입력해 주세요.") } - guard validWebURLField(state.instaPostUrl) else { return .message("인스타그램 URL을 올바르게 입력해 주세요.") } - return nil - } - - static func trimmed(_ text: String) -> String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func validImageURLs(_ state: PopupSubmissionFormFeature.State) -> [String] { - state.imageItems - .map { trimmed($0.imageUrl) } - .filter { $0.isEmpty == false } - } - - static func validTimeField(_ text: String) -> Bool { - let trimmedValue = trimmed(text) - return trimmedValue.isEmpty || PopupSubmissionTimeParser.parse(trimmedValue) != nil - } - - static func validWebURLField(_ text: String) -> Bool { - let trimmedValue = trimmed(text) - guard trimmedValue.isEmpty == false else { return true } - guard let url = URL(string: trimmedValue), - let scheme = url.scheme?.lowercased() - else { return false } - return scheme == "http" || scheme == "https" - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionImageItem.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionImageItem.swift deleted file mode 100644 index 13688091..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionImageItem.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -public struct PopupSubmissionImageItem: Identifiable, Equatable, Sendable { - public let id: UUID - public var imageUrl: String - - public init( - id: UUID = UUID(), - imageUrl: String = "" - ) { - self.id = id - self.imageUrl = imageUrl - } -} diff --git a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionTimeParser.swift b/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionTimeParser.swift deleted file mode 100644 index e8e98eb9..00000000 --- a/Projects/Features/PopupSubmissionFormFeature/Sources/Support/PopupSubmissionTimeParser.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Domain -import Foundation - -enum PopupSubmissionTimeParser { - static func parse(_ text: String) -> PopupSubmissionLocalTime? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty == false else { return nil } - - let components = trimmed.split(separator: ":") - guard components.count == 2, - let hour = Int(components[0]), - let minute = Int(components[1]), - (0...23).contains(hour), - (0...59).contains(minute) - else { - return nil - } - - return PopupSubmissionLocalTime(hour: hour, minute: minute) - } - - static func string(from value: PopupSubmissionLocalTime?) -> String { - guard let value else { return "" } - return String(format: "%02d:%02d", value.hour, value.minute) - } -} diff --git a/Projects/Features/ProfileFeature/Demo/Sources/ProfileFeatureDemoApp.swift b/Projects/Features/ProfileFeature/Demo/Sources/ProfileFeatureDemoApp.swift index 7e2e8661..e4f6be8a 100644 --- a/Projects/Features/ProfileFeature/Demo/Sources/ProfileFeatureDemoApp.swift +++ b/Projects/Features/ProfileFeature/Demo/Sources/ProfileFeatureDemoApp.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import ProfileFeature import SwiftUI @@ -11,9 +10,7 @@ struct ProfileFeatureDemoApp: App { ProfileFeatureView( store: Store( initialState: ProfileFeature.State( - session: Shared( - value: UserSession(user: .demo) - ) + user: .demo ) ) { ProfileFeature() diff --git a/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeature.swift b/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeature.swift index ade5dc7c..38bd97fa 100644 --- a/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeature.swift +++ b/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Core import Domain import DSKit import Foundation @@ -8,29 +7,16 @@ import Foundation public struct ProfileFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String + public var nickname: String public var isLoading = false public var errorMessage: String? public var localIsAlerted: Bool - public init(session: Shared) { - self._session = session - self.localIsAlerted = session.wrappedValue.user?.isAlerted ?? false - } - - var currentUser: User { - guard let user = session.user else { - preconditionFailure("ProfileFeature requires a logged in session.") - } - return user - } - - public var userUuid: String { - currentUser.userUuid - } - - public var nickname: String { - currentUser.nickname ?? "닉네임" + public init(user: User) { + self.userUuid = user.userUuid + self.nickname = user.nickname ?? "닉네임" + self.localIsAlerted = user.isAlerted } public static func == (lhs: Self, rhs: Self) -> Bool { @@ -53,6 +39,7 @@ public struct ProfileFeature { public enum Delegate: Equatable { case alertRequested + case alertStatusUpdated(Bool) case profileSettingRequested case notificationsRequested case serviceTermsRequested @@ -98,8 +85,7 @@ public struct ProfileFeature { state.isLoading = false state.localIsAlerted = resolvedValue state.errorMessage = nil - state.$session.withLock { $0.user?.isAlerted = resolvedValue } - return .none + return .send(.delegate(.alertStatusUpdated(resolvedValue))) case .alertStatusResponse(.failure(let error), let resolvedValue): state.isLoading = false @@ -118,29 +104,16 @@ public struct ProfileFeature { public struct ProfileSettingFeature { @ObservableState public struct State: Equatable { - @Shared var session: UserSession + public var userUuid: String + public var nickname: String public var newNickname = "" public var validationState: NicknameValidationState = .none public var isLoading = false public var errorMessage: String? - public init(session: Shared) { - self._session = session - } - - var currentUser: User { - guard let user = session.user else { - preconditionFailure("ProfileSettingFeature requires a logged in session.") - } - return user - } - - public var userUuid: String { - currentUser.userUuid - } - - public var nickname: String { - currentUser.nickname ?? "닉네임" + public init(user: User) { + self.userUuid = user.userUuid + self.nickname = user.nickname ?? "닉네임" } public static func == (lhs: Self, rhs: Self) -> Bool { @@ -166,6 +139,7 @@ public struct ProfileSettingFeature { public enum Delegate: Equatable { case dismiss + case nicknameUpdated(String) case logoutRequested } } @@ -241,8 +215,7 @@ public struct ProfileSettingFeature { state.isLoading = false state.errorMessage = nil let nickname = state.newNickname - state.$session.withLock { $0.user?.nickname = nickname } - return .send(.delegate(.dismiss)) + return .send(.delegate(.nicknameUpdated(nickname))) case .updateNicknameResponse(.failure(let error)): state.isLoading = false diff --git a/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeatureView.swift b/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeatureView.swift index 1c6cef7d..1b187ff2 100644 --- a/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeatureView.swift +++ b/Projects/Features/ProfileFeature/Sources/Presentation/ProfileFeatureView.swift @@ -156,7 +156,7 @@ public struct ProfileSettingFeatureView: View { @State private var showHardDeleteAlert = false @State private var draftNickname = "" @FocusState private var isFocused: Bool - @Environment(\.dismiss) private var dismiss + // @Environment(\.dismiss) private var dismiss public init(store: StoreOf) { self.store = store @@ -230,12 +230,10 @@ public struct ProfileSettingFeatureView: View { .padding(.top, 24) .padding(.horizontal, 24) .ppBackNavigationBar(title: "프로필 설정") { - isFocused = false - dismiss() + store.send(.delegate(.dismiss)) } .onAppear { draftNickname = store.newNickname - isFocused = true } .onChange(of: draftNickname) { _, newValue in store.send(.nicknameChanged(newValue)) diff --git a/Projects/Shared/DSKit/Sources/Component/TextField/RoundedTextField.swift b/Projects/Shared/DSKit/Sources/Component/TextField/RoundedTextField.swift index 2ee9cd5b..fcba1c9d 100644 --- a/Projects/Shared/DSKit/Sources/Component/TextField/RoundedTextField.swift +++ b/Projects/Shared/DSKit/Sources/Component/TextField/RoundedTextField.swift @@ -4,6 +4,7 @@ public struct RoundedTextField: View { var placeholder: String @Binding var text: String var validationState: NicknameValidationState + private let focus: FocusState.Binding? private var borderColor: Color { switch validationState { @@ -30,11 +31,13 @@ public struct RoundedTextField: View { public init( placeholder: String, text: Binding, - validationState: NicknameValidationState = .none + validationState: NicknameValidationState = .none, + focus: FocusState.Binding? = nil ) { self.placeholder = placeholder _text = text self.validationState = validationState + self.focus = focus } public var body: some View { @@ -54,6 +57,7 @@ public struct RoundedTextField: View { .keyboardType(.default) .padding(.horizontal, 16) .tint(.mainBlack) + .modifier(OptionalFocusModifier(focus: focus)) if let icon = statusIcon { Image(icon) @@ -72,3 +76,16 @@ public struct RoundedTextField: View { .animation(.easeInOut(duration: 0.15), value: borderColor) } } + +private struct OptionalFocusModifier: ViewModifier { + let focus: FocusState.Binding? + + @ViewBuilder + func body(content: Content) -> some View { + if let focus { + content.focused(focus) + } else { + content + } + } +} diff --git a/scripts/download-rn-release.sh b/scripts/download-rn-release.sh new file mode 100755 index 00000000..9a7e65aa --- /dev/null +++ b/scripts/download-rn-release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VERSION="${1:-v0.1.0}" +REPO="team-PopPang/PopPang-RN" +BUNDLE_ASSET_NAME="poppang-rn-ios-bundle-$VERSION.zip" +FRAMEWORK_ASSET_NAME="poppang-rn-spm-$VERSION.zip" +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUNDLE_OUTPUT_DIR="$ROOT_DIR/Projects/App/Resources/ReactNative" +FRAMEWORK_OUTPUT_DIR="$ROOT_DIR/Vendor/PrebuiltReactNativeFrameworks" +TMP_DIR="$ROOT_DIR/.rn-release-temp" + +cleanup() { + rm -rf "$TMP_DIR" +} + +trap cleanup EXIT + +echo "RN iOS 릴리즈 다운로드 시작: $VERSION" +rm -rf "$TMP_DIR" +mkdir -p "$TMP_DIR" + +echo "iOS bundle 다운로드" +gh release download "$VERSION" \ + --repo "$REPO" \ + --pattern "$BUNDLE_ASSET_NAME" \ + --dir "$TMP_DIR" \ + --clobber + +echo "SPM 패키지 다운로드" +gh release download "$VERSION" \ + --repo "$REPO" \ + --pattern "$FRAMEWORK_ASSET_NAME" \ + --dir "$TMP_DIR" \ + --clobber + +echo "iOS bundle 압축 해제" +mkdir -p "$TMP_DIR/bundle" +unzip -o "$TMP_DIR/$BUNDLE_ASSET_NAME" -d "$TMP_DIR/bundle" >/dev/null + +echo "SPM 패키지 압축 해제" +mkdir -p "$TMP_DIR/frameworks" +unzip -o "$TMP_DIR/$FRAMEWORK_ASSET_NAME" -d "$TMP_DIR/frameworks" >/dev/null + +if [[ ! -f "$TMP_DIR/bundle/ios/main.jsbundle" ]]; then + echo "main.jsbundle을 찾을 수 없습니다." >&2 + exit 1 +fi + +if [[ ! -f "$TMP_DIR/frameworks/PrebuiltReactNativeFrameworks/Package.swift" ]]; then + echo "PrebuiltReactNativeFrameworks/Package.swift를 찾을 수 없습니다." >&2 + exit 1 +fi + +echo "iOS bundle 적용" +rm -rf "$BUNDLE_OUTPUT_DIR" +mkdir -p "$BUNDLE_OUTPUT_DIR" +cp "$TMP_DIR/bundle/ios/main.jsbundle" "$BUNDLE_OUTPUT_DIR/main.jsbundle" +if [[ -d "$TMP_DIR/bundle/ios/assets" ]]; then + cp -R "$TMP_DIR/bundle/ios/assets" "$BUNDLE_OUTPUT_DIR/assets" +fi + +echo "SPM 패키지 적용" +rm -rf "$FRAMEWORK_OUTPUT_DIR" +mkdir -p "$(dirname "$FRAMEWORK_OUTPUT_DIR")" +ditto \ + "$TMP_DIR/frameworks/PrebuiltReactNativeFrameworks" \ + "$FRAMEWORK_OUTPUT_DIR" + +echo "다운로드 및 적용 완료" +echo "번들 위치: $BUNDLE_OUTPUT_DIR" +echo "프레임워크 위치: $FRAMEWORK_OUTPUT_DIR"