diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 32be413b..e834b177 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -30,8 +30,32 @@ Prefix [#이슈번호] 작업 설명
## 📚 참고자료
-## 👀 기타 더 이야기해볼 점
+## 👀 리뷰어에게 전달할 사항
+## ✅ 이번 PR에서 이런 부분을 중점적으로 체크해주세요!
+
+
+
+
+
+잠깐 확인하고 갈까요?
+
+- 들여쓰기를 5번 이하로 준수했는지, 코드 가독성이 적절한지 확인해주세요.
+- 한 줄당 120자 제한을 준수했는지 확인해주세요.
+- MARK 주석이 정해진 순서와 형식에 맞게 작성되었는지 확인해주세요.
+
+- 반복되는 상수 값이 있는지, 있다면 Constant enum으로 분리되어 있는지 확인해주세요.
+- 삼항 연산자가 길어질 경우 적절히 개행되어 있는지 확인해주세요.
+- 조건문에서 중괄호가 올바르게 사용되었는지 확인해주세요.
+- 라이브러리 import가 퍼스트파티와 서드파티로 구분되고 알파벳순으로 정렬되었는지 확인해주세요.
+
+- 용량이 큰 리소스나 호출되지 않을 가능성이 있는 프로퍼티에 lazy var가 적절히 사용되었는지 확인해주세요.
+- 메모리 누수 방지를 위한 weak 참조가 필요한 곳에 적용되었는지 확인해주세요.
+- 도메인 로직과 UI 로직이 적절히 분리되어 있는지 확인해주세요.
+
+
+
+
## 🔗 연결된 이슈
- Resolved: #이슈번호
diff --git a/README.md b/README.md
index 6271c94e..c66861db 100644
--- a/README.md
+++ b/README.md
@@ -201,106 +201,228 @@ print("와블 iOS 레포지토리입니다 🚀")

-
- 인터페이스(프로토콜)와 실구현체
-- 프로토콜의 네이밍: 구현하고자 하는 객체 이름
-- 실 구현체의 네이밍: 프로토콜 네이밍 + `Impl`
+## 📝 기본 규칙
+
+### 코드 작성 규칙
+- **한 줄 당 최대 글자 수**는 120자로 제한한다.
+- **들여쓰기 제한**은 최대 5번까지만 허용한다. 6번 이상 들여쓰기가 필요할 경우 메서드 분리로 최대한 가독성을 지켜 작성한다.
+- **자동 포매팅(`Control` + `M`)**을 적극적으로 활용한다.
+
+### 라이브러리 선언
+퍼스트 파티와 서드 파티를 구분하여 각각 알파벳 순으로 정렬한다. 퍼스트 파티를 먼저 작성한다.
```swift
-protocol UserRepository {}
-final class UserRepositoryImpl {}
+import Combine
+import Foundation
+
+import CombineMoya
+import Moya
```
-
-
-
- 함수명
-
-- 조회: `fetch`
-- 수정: `update`
-- 삭제: `delete`
-- 생성: `create`
-- 초기 상태 설정: `configure`
-- 액션 메서드: `~DidTap`
-
-
-
-
- UseCase
-
-- 단일 메서드일 경우, 메서드 명은 `execute`로 한다.
-
-
-
-
- Setup Method
+
+---
+
+## 🏗️ 네이밍 규칙
+
+### 인터페이스(프로토콜)와 실구현체 네이밍 규칙
+- **프로토콜**: 구현하고자 하는 객체 이름으로 네이밍한다.
+- **실구현체**: 프로토콜 이름 + `Impl` 접미사 형태로 네이밍한다.
```swift
-func setupNavigationBar()
-func setupView()
-func setupConstraints()
-func setupAction()
-func setupDelegate()
-func setupDataSource()
-func setupBinding()
+protocol UserRepository {}
+final class UserRepositoryImpl: UserRepository {}
```
-
+### 메서드 네이밍 규칙
+
+메서드의 역할에 따라 일관된 접두사를 사용한다.
+
+- **조회**: `fetch`
+- **수정**: `update`
+- **삭제**: `delete`
+- **생성**: `create`
+- **초기 상태 설정**: `configure`
+- **액션 메서드**: `~DidTap` 형식
+
+---
+
+## 🔧 Setup Method 규칙
+
+`Setup` 관련 메서드는 다음 명명 규칙과 역할에 따라 구분하여 작성한다.
+아래 순서대로 차례대로 작성한다.
+
+
+
+| 메서드명 | 역할 및 담당 업무 |
+|---|---|
+| `setupNavigationBar()` | 네비게이션 바 설정 및 구성 |
+| `setupView()` | 해당 클래스의 프로퍼티 관리, 기본 뷰 설정 |
+| `setupConstraints()` | addSubviews, SnapKit 등 오토레이아웃 관련 코드 |
+| `setupAction()` | 액션 이벤트 관련 설정 |
+| `setupDelegate()` | Delegate, DataSource 관련 코드 |
+| `setupDataSource()` | 데이터 소스 초기화 및 설정 |
+| `setupBinding()` | 데이터 바인딩 및 리액티브 프로그래밍 관련 코드 |
+
+
+
+---
+
+## 📚 MARK 주석 작성 규칙
-
- MARK 주석
-- **위, 아래로 한 줄 씩 공백**을 두고 작성합니다.
+MARK 주석은 위아래로 한 줄씩 공백을 두고 작성하며, 되도록이면 아래 순서를 준수한다.
+
+### 클래스 내부 구조
```swift
+// MARK: - Section & Item
+
+// MARK: - Typealias
+
// MARK: - Property
-// MARK: - Initializer
+
+// MARK: - UIComponent
+
// MARK: - Life Cycle
+```
+
+### Extension 구조
+
+Extension은 public으로 선언하고 내부에서 필요한 부분만 private으로 선언한다. 되도록이면 아래 순서를 준수한다.
+
+```swift
+// MARK: - Delegate 관련 Extension들
+
+// MARK: - Computed Property
+
// MARK: - Setup Method
-// MARK: - UICollectionViewDelegate (ex)
-// MARK: - Private Method
+
+// MARK: - Helper Method (재사용성을 위한 private 메서드)
+
+// MARK: - Action Method (@objc 메서드)
+
+// MARK: - UICollectionViewCompositionalLayout (CollectionView 레이아웃 관련 코드)
+
+// MARK: - Constant
```
-
+---
-
- Mapper
+## 🎨 코드 포매팅 규칙
-> DTO → Entity
->
-- `enum`의 `static` method로 구현
-- 메서드 네이밍: `map(with dto:)`
+### 삼항 연산자
+120자를 초과하는 경우 다음과 같이 개행한다.
-
+```swift
+let result = someCondition
+ ? valueWhenTrue
+ : valueWhenFalse
+```
-
- 라이브러리 선언
-
-- 퍼스트 파티와 서드 파티를 분리
-- 순서는 무조건 알파벳순
+### 조건문 (if-else)
+반드시 아래와 같은 양식으로 개행해 작성한다.
```swift
-import Combine
-import Foundation
+if condition {
+ // 실행 코드
+} else {
+ // 실행 코드
+}
+```
-import CombineMoya
-import Moya
+### Guard Let 구문
+120자 이하인 경우 한 줄로 작성 가능하며, 초과 시 개행한다.
+
+```swift
+// 120자 이하인 경우
+guard let self = self, let profile = sessionProfile else { return }
+
+// 120자 초과인 경우
+guard let self = self,
+ let profile = sessionProfile,
+ let userData = profile.userData
+else {
+ return
+}
```
-
-
- 반복되는 숫자, 문자열 등 선언
-
-- 객체에서 반복되는 숫자, 문자열 등에 대해서 중첩 타입으로 Constant를 정의하고 타입 프로퍼티로 선언한다.
+### 클로저
+한 줄로 작성 가능하고 120자 이하인 경우 개행하지 않는다.
```swift
-final class CustomView: UIView {}
+// 한 줄 작성 가능
+items.map { $0.name }
+
+// 복잡하거나 120자보다 긴 경우 개행
+items.compactMap { item in
+ return item.isValid ? item.processedValue : nil
+}
+```
+
+---
+
+## 🔄 지연 저장 프로퍼티 사용 규칙
+
+다음 조건에 해당하는 경우 `lazy var`를 사용한다.
+
+- 호출되지 않을 가능성이 있는 프로퍼티
+- 용량이 큰 리소스 (특히 애셋 파일이나 화면을 꽉 채우는 이미지)
+
+```swift
+private lazy var backgroundImageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.image = UIImage(named: "large-background-image")
+ return imageView
+}()
+```
+
+---
+
+## 🔧 Mapper 규칙
+
+`DTO`에서 `Entity로` 변환하는 `Mapper는` 다음 규칙을 따른다.
+
+- `enum`의 `static` 메서드로 구현
+- 메서드명: `map(with dto:)`
+
+```swift
+enum UserMapper {
+ static func map(with dto: UserDTO) -> User {
+ return User(
+ id: dto.id,
+ name: dto.name,
+ email: dto.email
+ )
+ }
+}
+```
+
+---
+
+## 📊 상수 선언 규칙
+
+객체에서 반복되는 숫자나 문자열은 중첩 타입으로 `Constant`를 정의하여 관리한다.
+
+```swift
+final class CustomView: UIView {
+ // 구현 코드
+}
// MARK: - Constant
private extension CustomView {
- enum Constant {
- static let padding: CGFloat = 16
- }
+ enum Constant {
+ static let padding: CGFloat = 16
+ static let cornerRadius: CGFloat = 8
+ static let animationDuration: TimeInterval = 0.3
+ }
}
```
-
+---
+
+## 🚀 성능 최적화 가이드라인
+
+성능 테스트와 관련된 지침은 추후 업데이트 예정이며 현재는 다음 기본 원칙을 준수한다.
+
+- 메모리 누수 방지를 위한 적절한 `weak` 참조 사용
+- 불필요한 연산 최소화
+- 적절한 캐싱 전략 적용
diff --git a/Wable-iOS.xcodeproj/project.pbxproj b/Wable-iOS.xcodeproj/project.pbxproj
index c04af475..c045d5ef 100644
--- a/Wable-iOS.xcodeproj/project.pbxproj
+++ b/Wable-iOS.xcodeproj/project.pbxproj
@@ -18,6 +18,9 @@
DD005BAB2D80071E00B1661F /* CommentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD005BAA2D80071E00B1661F /* CommentButton.swift */; };
DD1F30452D9D71DE003DB820 /* CreateContentLikedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1F30442D9D71C7003DB820 /* CreateContentLikedUseCase.swift */; };
DD1F30472D9D71E1003DB820 /* DeleteContentLikedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1F30462D9D71E1003DB820 /* DeleteContentLikedUseCase.swift */; };
+ DD214B582E028FB80065D995 /* ProfileEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B572E028FB80065D995 /* ProfileEditView.swift */; };
+ DD214B5A2E029E480065D995 /* TeamCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B592E029E430065D995 /* TeamCollectionView.swift */; };
+ DD214B5C2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */; };
DD2967F92D6DAC4800143851 /* AnyPublisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29675E2D6DAC3100143851 /* AnyPublisher+.swift */; };
DD2967FA2D6DAC4800143851 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2967602D6DAC3100143851 /* Publisher+.swift */; };
DD2967FB2D6DAC4800143851 /* DIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29675B2D6DAC3100143851 /* DIContainer.swift */; };
@@ -140,6 +143,7 @@
DD29687C2D6DB27F00143851 /* OAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29687B2D6DB27200143851 /* OAuthenticator.swift */; };
DD29687E2D6DC70700143851 /* OAuthCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29687D2D6DC70100143851 /* OAuthCredential.swift */; };
DD3935B62DDDB9F700B6CA94 /* MyProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3935B42DDDB9F700B6CA94 /* MyProfileView.swift */; };
+ DD431E1A2E136C8100BA55FB /* UpdateFCMTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */; };
DD4AFD452D8FF19F00D56B94 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFD442D8FF19B00D56B94 /* LoginViewModel.swift */; };
DD4AFD472D90264500D56B94 /* OAuthEventManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFD462D90263400D56B94 /* OAuthEventManager.swift */; };
DD50980C2DA2C1A300F666DE /* FetchContentCommentListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50980B2DA2C18F00F666DE /* FetchContentCommentListUseCase.swift */; };
@@ -152,7 +156,6 @@
DD514D0A2D8C958B0095021D /* AgreementItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D092D8C957E0095021D /* AgreementItemView.swift */; };
DD514D0E2D8C99DC0095021D /* AgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D0D2D8C99DC0095021D /* AgreementView.swift */; };
DD514D102D8CA3780095021D /* AttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D0F2D8CA36D0095021D /* AttributedString+.swift */; };
- DD51A43B2DD3BE10004295B6 /* UpdateFCMTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */; };
DD51A44C2DD458A8004295B6 /* ProfileEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */; };
DD547AB92D7E43A600B8BA5A /* GhostButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD547AB82D7E439E00B8BA5A /* GhostButton.swift */; };
DD69C5902D71A3BE000A3349 /* OAuthTokenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD69C58F2D71A3B6000A3349 /* OAuthTokenProvider.swift */; };
@@ -201,7 +204,6 @@
DDED595E2D78C3E000A0BEF1 /* KakaoAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED595D2D78C3D900A0BEF1 /* KakaoAuthProvider.swift */; };
DDED59752D78F16400A0BEF1 /* FetchNicknameDuplicationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */; };
DDED59782D78F1E500A0BEF1 /* UserProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */; };
- DDED597C2D794F0D00A0BEF1 /* FetchUserAuthUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */; };
DDED597E2D79578C00A0BEF1 /* AuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED597D2D79578800A0BEF1 /* AuthProvider.swift */; };
DDED59862D7965E900A0BEF1 /* OverviewUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59852D7965DB00A0BEF1 /* OverviewUseCase.swift */; };
DDED59882D7965F100A0BEF1 /* NotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59872D7965EC00A0BEF1 /* NotificationUseCase.swift */; };
@@ -239,7 +241,6 @@
DDFB223B2D8B9F5100FEA24A /* LCKYearCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB223A2D8B9F3F00FEA24A /* LCKYearCollectionViewCell.swift */; };
DDFB22402D8BA68900FEA24A /* LCKTeamCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB223F2D8BA67D00FEA24A /* LCKTeamCollectionViewCell.swift */; };
DDFB22422D8BA9D200FEA24A /* LCKTeamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22412D8BA9C800FEA24A /* LCKTeamViewController.swift */; };
- DDFB22442D8BB8D500FEA24A /* LCKTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */; };
DDFB22462D8BBB8600FEA24A /* ProfileRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22452D8BBB7900FEA24A /* ProfileRegisterViewController.swift */; };
DDFB22482D8BBCB300FEA24A /* ProfileRegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */; };
DDFF126F2D7EE39500980BA7 /* UIColor+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF126E2D7EE39200980BA7 /* UIColor+.swift */; };
@@ -248,10 +249,11 @@
DE0BFB602DA4F63800F06656 /* CommunityInviteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BFB5F2DA4F63800F06656 /* CommunityInviteCell.swift */; };
DE0BFB632DA4F7FA00F06656 /* CommunityCellBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BFB622DA4F7FA00F06656 /* CommunityCellBaseView.swift */; };
DE0D0C9C2DD1E35B00FB64DC /* UserRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0C9B2DD1E35B00FB64DC /* UserRole.swift */; };
- DE0D0CA22DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */; };
DE0D0CA52DD2013A00FB64DC /* CheckUserRoleUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA42DD2013A00FB64DC /* CheckUserRoleUseCase.swift */; };
DE0D0CAA2DD2041100FB64DC /* LikeViewitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA92DD2041100FB64DC /* LikeViewitUseCase.swift */; };
DE0D0CAC2DD204D000FB64DC /* ReportViewitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CAB2DD204D000FB64DC /* ReportViewitUseCase.swift */; };
+ DE0DC22B2E0AEB9600FF061E /* GhostInfoTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */; };
+ DE0DC22F2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */; };
DE202FF62DE784FB00B04CAA /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF52DE784FB00B04CAA /* AppVersion.swift */; };
DE202FF82DE7856E00B04CAA /* UpdateRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF72DE7856E00B04CAA /* UpdateRequirement.swift */; };
DE202FFA2DE785E800B04CAA /* AppVersionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF92DE785E800B04CAA /* AppVersionRepository.swift */; };
@@ -355,6 +357,8 @@
DEB60AF62DD210E600FE8BFD /* AccountInfoCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AF52DD210E600FE8BFD /* AccountInfoCellItem.swift */; };
DEB60AF92DD2158A00FE8BFD /* AccountInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AF82DD2158A00FE8BFD /* AccountInfoViewModel.swift */; };
DEB60AFF2DD238AF00FE8BFD /* ViewitListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AFE2DD238AF00FE8BFD /* ViewitListCell.swift */; };
+ DEC5268A2E0837DA000F7900 /* WableTextSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */; };
+ DEC5268C2E085AAA000F7900 /* WableTextSheetShowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */; };
DECC36DA2DC3A4D800336B7B /* CreateViewitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DECC36D92DC3A4D800336B7B /* CreateViewitViewModel.swift */; };
DECC36DF2DC3B24000336B7B /* CreateViewitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DECC36DE2DC3B24000336B7B /* CreateViewitViewController.swift */; };
DED1344E2DCF6C73008463BD /* ViewitListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1344D2DCF6C73008463BD /* ViewitListViewModel.swift */; };
@@ -372,7 +376,6 @@
DEE5D61A2D91537C009E5A25 /* GameScheduleViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE5D6192D91537C009E5A25 /* GameScheduleViewItem.swift */; };
DEF08E232DDB46170024B33C /* MyProfileEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF08E222DDB46170024B33C /* MyProfileEmptyCell.swift */; };
DEF08E292DDB73200024B33C /* ProfileEmptyCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF08E282DDB73200024B33C /* ProfileEmptyCellItem.swift */; };
- DEF148402DA7B586003B2AD8 /* CommunityUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */; };
DEF148422DA7B7E5003B2AD8 /* IsUserRegistered.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148412DA7B7E5003B2AD8 /* IsUserRegistered.swift */; };
DEF148442DA7B966003B2AD8 /* RegisterResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148432DA7B966003B2AD8 /* RegisterResult.swift */; };
DEF148542DA7EF7B003B2AD8 /* CommunityRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148532DA7EF7B003B2AD8 /* CommunityRegistration.swift */; };
@@ -395,6 +398,9 @@
DD005BAA2D80071E00B1661F /* CommentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentButton.swift; sourceTree = ""; };
DD1F30442D9D71C7003DB820 /* CreateContentLikedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateContentLikedUseCase.swift; sourceTree = ""; };
DD1F30462D9D71E1003DB820 /* DeleteContentLikedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteContentLikedUseCase.swift; sourceTree = ""; };
+ DD214B572E028FB80065D995 /* ProfileEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; };
+ DD214B592E029E430065D995 /* TeamCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamCollectionView.swift; sourceTree = ""; };
+ DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+KeyboardHandling.swift"; sourceTree = ""; };
DD29675B2D6DAC3100143851 /* DIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIContainer.swift; sourceTree = ""; };
DD29675C2D6DAC3100143851 /* Injected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Injected.swift; sourceTree = ""; };
DD29675E2D6DAC3100143851 /* AnyPublisher+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+.swift"; sourceTree = ""; };
@@ -514,6 +520,7 @@
DD29687B2D6DB27200143851 /* OAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthenticator.swift; sourceTree = ""; };
DD29687D2D6DC70100143851 /* OAuthCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthCredential.swift; sourceTree = ""; };
DD3935B42DDDB9F700B6CA94 /* MyProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileView.swift; sourceTree = ""; };
+ DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFCMTokenUseCase.swift; sourceTree = ""; };
DD4AFD442D8FF19B00D56B94 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; };
DD4AFD462D90263400D56B94 /* OAuthEventManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthEventManager.swift; sourceTree = ""; };
DD50980B2DA2C18F00F666DE /* FetchContentCommentListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchContentCommentListUseCase.swift; sourceTree = ""; };
@@ -526,7 +533,6 @@
DD514D092D8C957E0095021D /* AgreementItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementItemView.swift; sourceTree = ""; };
DD514D0D2D8C99DC0095021D /* AgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementView.swift; sourceTree = ""; };
DD514D0F2D8CA36D0095021D /* AttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+.swift"; sourceTree = ""; };
- DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFCMTokenUseCase.swift; sourceTree = ""; };
DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditViewController.swift; sourceTree = ""; };
DD547AB82D7E439E00B8BA5A /* GhostButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostButton.swift; sourceTree = ""; };
DD69C58F2D71A3B6000A3349 /* OAuthTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenProvider.swift; sourceTree = ""; };
@@ -582,7 +588,6 @@
DDED595D2D78C3D900A0BEF1 /* KakaoAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KakaoAuthProvider.swift; sourceTree = ""; };
DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchNicknameDuplicationUseCase.swift; sourceTree = ""; };
DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileUseCase.swift; sourceTree = ""; };
- DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchUserAuthUseCase.swift; sourceTree = ""; };
DDED597D2D79578800A0BEF1 /* AuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthProvider.swift; sourceTree = ""; };
DDED59852D7965DB00A0BEF1 /* OverviewUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewUseCase.swift; sourceTree = ""; };
DDED59872D7965EC00A0BEF1 /* NotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUseCase.swift; sourceTree = ""; };
@@ -608,7 +613,6 @@
DDFB223A2D8B9F3F00FEA24A /* LCKYearCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKYearCollectionViewCell.swift; sourceTree = ""; };
DDFB223F2D8BA67D00FEA24A /* LCKTeamCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamCollectionViewCell.swift; sourceTree = ""; };
DDFB22412D8BA9C800FEA24A /* LCKTeamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamViewController.swift; sourceTree = ""; };
- DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamView.swift; sourceTree = ""; };
DDFB22452D8BBB7900FEA24A /* ProfileRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterViewController.swift; sourceTree = ""; };
DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterView.swift; sourceTree = ""; };
DDFF126E2D7EE39200980BA7 /* UIColor+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+.swift"; sourceTree = ""; };
@@ -617,10 +621,11 @@
DE0BFB5F2DA4F63800F06656 /* CommunityInviteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityInviteCell.swift; sourceTree = ""; };
DE0BFB622DA4F7FA00F06656 /* CommunityCellBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityCellBaseView.swift; sourceTree = ""; };
DE0D0C9B2DD1E35B00FB64DC /* UserRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRole.swift; sourceTree = ""; };
- DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitBottomSheetActionKind.swift; sourceTree = ""; };
DE0D0CA42DD2013A00FB64DC /* CheckUserRoleUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckUserRoleUseCase.swift; sourceTree = ""; };
DE0D0CA92DD2041100FB64DC /* LikeViewitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeViewitUseCase.swift; sourceTree = ""; };
DE0D0CAB2DD204D000FB64DC /* ReportViewitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewitUseCase.swift; sourceTree = ""; };
+ DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostInfoTooltipView.swift; sourceTree = ""; };
+ DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StringLiterals+PhotoDetail.swift"; sourceTree = ""; };
DE202FF52DE784FB00B04CAA /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; };
DE202FF72DE7856E00B04CAA /* UpdateRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRequirement.swift; sourceTree = ""; };
DE202FF92DE785E800B04CAA /* AppVersionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRepository.swift; sourceTree = ""; };
@@ -722,6 +727,8 @@
DEB60AF52DD210E600FE8BFD /* AccountInfoCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfoCellItem.swift; sourceTree = ""; };
DEB60AF82DD2158A00FE8BFD /* AccountInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfoViewModel.swift; sourceTree = ""; };
DEB60AFE2DD238AF00FE8BFD /* ViewitListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitListCell.swift; sourceTree = ""; };
+ DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WableTextSheetViewController.swift; sourceTree = ""; };
+ DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WableTextSheetShowable.swift; sourceTree = ""; };
DECC36D92DC3A4D800336B7B /* CreateViewitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateViewitViewModel.swift; sourceTree = ""; };
DECC36DE2DC3B24000336B7B /* CreateViewitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateViewitViewController.swift; sourceTree = ""; };
DED1344D2DCF6C73008463BD /* ViewitListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitListViewModel.swift; sourceTree = ""; };
@@ -740,7 +747,6 @@
DEE5D6192D91537C009E5A25 /* GameScheduleViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameScheduleViewItem.swift; sourceTree = ""; };
DEF08E222DDB46170024B33C /* MyProfileEmptyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileEmptyCell.swift; sourceTree = ""; };
DEF08E282DDB73200024B33C /* ProfileEmptyCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEmptyCellItem.swift; sourceTree = ""; };
- DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityUseCase.swift; sourceTree = ""; };
DEF148412DA7B7E5003B2AD8 /* IsUserRegistered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsUserRegistered.swift; sourceTree = ""; };
DEF148432DA7B966003B2AD8 /* RegisterResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResult.swift; sourceTree = ""; };
DEF148532DA7EF7B003B2AD8 /* CommunityRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityRegistration.swift; sourceTree = ""; };
@@ -1416,6 +1422,7 @@
DDAD41A52D873E3600EF312C /* View */ = {
isa = PBXGroup;
children = (
+ DD214B592E029E430065D995 /* TeamCollectionView.swift */,
DD9980DE2D834F4E00EBBFBC /* PostUserInfoView.swift */,
DDCD67042D7E271C00EF4C28 /* ToastView.swift */,
);
@@ -1478,11 +1485,9 @@
isa = PBXGroup;
children = (
DE202FFC2DE7865300B04CAA /* AppVersion */,
- DDED59552D78BF5800A0BEF1 /* Login */,
DE0D0CA32DD2012900FB64DC /* UserRole */,
DDED59732D78EFCF00A0BEF1 /* Onboarding */,
DD9EAE202D99C58600803A1A /* Home */,
- DDED596E2D78EB9200A0BEF1 /* Community */,
DDED59822D79614E00A0BEF1 /* Overview */,
DDED59812D79612600A0BEF1 /* Notification */,
DDED59802D79611F00A0BEF1 /* Profile */,
@@ -1491,14 +1496,6 @@
path = UseCase;
sourceTree = "";
};
- DDED59552D78BF5800A0BEF1 /* Login */ = {
- isa = PBXGroup;
- children = (
- DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */,
- );
- path = Login;
- sourceTree = "";
- };
DDED59582D78C31B00A0BEF1 /* Auth */ = {
isa = PBXGroup;
children = (
@@ -1508,20 +1505,12 @@
path = Auth;
sourceTree = "";
};
- DDED596E2D78EB9200A0BEF1 /* Community */ = {
- isa = PBXGroup;
- children = (
- DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */,
- );
- path = Community;
- sourceTree = "";
- };
DDED59732D78EFCF00A0BEF1 /* Onboarding */ = {
isa = PBXGroup;
children = (
DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */,
DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */,
- DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */,
+ DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */,
);
path = Onboarding;
sourceTree = "";
@@ -1588,6 +1577,7 @@
DDF97CB12DE4EAC7009FC612 /* StringLiterals+URL.swift */,
DDF97CB22DE4EAC7009FC612 /* StringLiterals+Viewit.swift */,
DDF97CB32DE4EAC7009FC612 /* StringLiterals+Write.swift */,
+ DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */,
);
path = String;
sourceTree = "";
@@ -1605,7 +1595,6 @@
isa = PBXGroup;
children = (
DDCA70922D88315C00A988B8 /* LCKYearView.swift */,
- DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */,
DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */,
DD514D0D2D8C99DC0095021D /* AgreementView.swift */,
DD514D092D8C957E0095021D /* AgreementItemView.swift */,
@@ -1662,14 +1651,6 @@
path = Subview;
sourceTree = "";
};
- DE0D0CA02DD1EF7900FB64DC /* Model */ = {
- isa = PBXGroup;
- children = (
- DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */,
- );
- path = Model;
- sourceTree = "";
- };
DE0D0CA32DD2012900FB64DC /* UserRole */ = {
isa = PBXGroup;
children = (
@@ -1781,6 +1762,7 @@
DE4EFCCF2DE0060700E4146C /* Edit */ = {
isa = PBXGroup;
children = (
+ DD214B572E028FB80065D995 /* ProfileEditView.swift */,
DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */,
);
path = Edit;
@@ -1867,6 +1849,7 @@
DE54B03D2D82CD82009A7C34 /* NotFoundViewController.swift */,
DE54B03F2D832E78009A7C34 /* LoadingViewController.swift */,
DE54B0412D833B0B009A7C34 /* WableSheetViewController.swift */,
+ DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */,
DE8D12692D859FB000D02993 /* WableBottomSheetController.swift */,
);
path = ViewController;
@@ -2042,6 +2025,7 @@
DE83891A2D906C0C00C089E6 /* Types.swift */,
DE7C53172D761F3A00076E5D /* ReuseIdentifiable.swift */,
DE4AE84E2DE8B645003E20AB /* WableSheetShowable.swift */,
+ DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */,
DE4AE8502DE8B6FF003E20AB /* WableBottomSheetShowable.swift */,
DE4AE8522DE8BA18003E20AB /* UIAlertShowable.swift */,
DE8E86402D91A092000A4292 /* Extension */,
@@ -2071,6 +2055,7 @@
DE8E86402D91A092000A4292 /* Extension */ = {
isa = PBXGroup;
children = (
+ DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */,
DD8B42A32DA15DA9007DC1CA /* UITextView+.swift */,
DE7C530F2D761E3300076E5D /* String+.swift */,
DE9C3BFB2D9AEC0600A5AFEC /* NSMutableAttributedString+.swift */,
@@ -2362,7 +2347,6 @@
DECC36DC2DC3B20600336B7B /* List */ = {
isa = PBXGroup;
children = (
- DE0D0CA02DD1EF7900FB64DC /* Model */,
DED1344C2DCF6B4B008463BD /* ViewModel */,
DEF6A3B92DA97F6E0044ACAB /* View */,
);
@@ -2482,6 +2466,7 @@
children = (
DEA10B5B2DD3F21F001EBBA9 /* ProfileSegmentedHeaderView.swift */,
DEA10B502DD3D995001EBBA9 /* ProfileInfoCell.swift */,
+ DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */,
);
path = Component;
sourceTree = "";
@@ -2672,6 +2657,7 @@
DDF97CC02DE4EAC7009FC612 /* StringLiterals+URL.swift in Sources */,
DDF97CC12DE4EAC7009FC612 /* StringLiterals+Ban.swift in Sources */,
DDF97CC22DE4EAC7009FC612 /* StringLiterals+Exit.swift in Sources */,
+ DEC5268C2E085AAA000F7900 /* WableTextSheetShowable.swift in Sources */,
DDF97CC32DE4EAC7009FC612 /* StringLiterals+Detail.swift in Sources */,
DDF97CC42DE4EAC7009FC612 /* StringLiterals+Viewit.swift in Sources */,
DDF97CC52DE4EAC7009FC612 /* StringLiterals+ProfileDelete.swift in Sources */,
@@ -2692,7 +2678,6 @@
DD2968102D6DACF700143851 /* Notification.swift in Sources */,
DD514D0A2D8C958B0095021D /* AgreementItemView.swift in Sources */,
DEE5D6112D9121E5009E5A25 /* OverviewPageView.swift in Sources */,
- DDFB22442D8BB8D500FEA24A /* LCKTeamView.swift in Sources */,
DD2968112D6DACF700143851 /* Viewit.swift in Sources */,
DDCD66F52D7DF5B700EF4C28 /* CommunityViewController.swift in Sources */,
DD514D082D8C83560095021D /* AgreementViewController.swift in Sources */,
@@ -2702,6 +2687,7 @@
DE4AE8532DE8BA18003E20AB /* UIAlertShowable.swift in Sources */,
DD2968132D6DACF700143851 /* TriggerType.swift in Sources */,
DD9980DF2D834F4E00EBBFBC /* CommentCollectionViewCell.swift in Sources */,
+ DE0DC22F2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift in Sources */,
DD9980E02D834F4E00EBBFBC /* PostUserInfoView.swift in Sources */,
DD9980E12D834F4E00EBBFBC /* ContentCollectionViewCell.swift in Sources */,
DD2968142D6DACF700143851 /* AccountInfo.swift in Sources */,
@@ -2737,6 +2723,7 @@
DEE5D61A2D91537C009E5A25 /* GameScheduleViewItem.swift in Sources */,
DE72BB102D93CC68008E5774 /* NotificationPageView.swift in Sources */,
DDFB22462D8BBB8600FEA24A /* ProfileRegisterViewController.swift in Sources */,
+ DD214B582E028FB80065D995 /* ProfileEditView.swift in Sources */,
DE833BA22DE22D4600812A80 /* Publishers+UIGesture.swift in Sources */,
DD29685B2D6DAD4400143851 /* CreateAccountRequest.swift in Sources */,
DE9B94762D927F140060FF92 /* NoticeViewModel.swift in Sources */,
@@ -2804,6 +2791,7 @@
DD2968432D6DAD2F00143851 /* FetchGameSchedules.swift in Sources */,
DE3CA2832DD4464E00BF5E87 /* FetchUserCommentListUseCase.swift in Sources */,
DEB60AF92DD2158A00FE8BFD /* AccountInfoViewModel.swift in Sources */,
+ DEC5268A2E0837DA000F7900 /* WableTextSheetViewController.swift in Sources */,
DE6459D52D97B8C2005569B8 /* InformationNotificationViewController.swift in Sources */,
DD8326962D95836F0014B62D /* HomeViewModel.swift in Sources */,
DD2968442D6DAD2F00143851 /* FetchUserProfile.swift in Sources */,
@@ -2875,6 +2863,7 @@
DD29686C2D6DAD5500143851 /* ProfileTargetType.swift in Sources */,
DD29686D2D6DAD5500143851 /* AccountTargetType.swift in Sources */,
DD514D102D8CA3780095021D /* AttributedString+.swift in Sources */,
+ DD214B5A2E029E480065D995 /* TeamCollectionView.swift in Sources */,
DD2968392D6DAD1700143851 /* LoginRepositoryImpl.swift in Sources */,
DEDC29182D720D030073D512 /* PostStatus.swift in Sources */,
DEB60AF22DD20C2D00FE8BFD /* AccountInfoCell.swift in Sources */,
@@ -2909,6 +2898,7 @@
DEAFE05B2D8C4E930097E91C /* GameScheduleHeaderView.swift in Sources */,
DE7C53182D761F3A00076E5D /* ReuseIdentifiable.swift in Sources */,
DECC36DA2DC3A4D800336B7B /* CreateViewitViewModel.swift in Sources */,
+ DD214B5C2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift in Sources */,
DDFB22402D8BA68900FEA24A /* LCKTeamCollectionViewCell.swift in Sources */,
DDCA70972D88797200A988B8 /* AuthorType.swift in Sources */,
DD2967FC2D6DAC4800143851 /* Injected.swift in Sources */,
@@ -2931,11 +2921,8 @@
DD2968182D6DAD0200143851 /* CommunityRepository.swift in Sources */,
DD4AFD472D90264500D56B94 /* OAuthEventManager.swift in Sources */,
DDED59862D7965E900A0BEF1 /* OverviewUseCase.swift in Sources */,
- DEF148402DA7B586003B2AD8 /* CommunityUseCase.swift in Sources */,
DE202FF62DE784FB00B04CAA /* AppVersion.swift in Sources */,
- DDED597C2D794F0D00A0BEF1 /* FetchUserAuthUseCase.swift in Sources */,
DE7C53102D761E3300076E5D /* String+.swift in Sources */,
- DD51A43B2DD3BE10004295B6 /* UpdateFCMTokenUseCase.swift in Sources */,
DD9EAE222D99C59900803A1A /* FetchContentListUseCase.swift in Sources */,
DD2968192D6DAD0200143851 /* ReportRepository.swift in Sources */,
DE6C3E3E2D91A8E00046DB30 /* NewsViewModel.swift in Sources */,
@@ -2945,6 +2932,7 @@
DDFB22422D8BA9D200FEA24A /* LCKTeamViewController.swift in Sources */,
DD765D9D2D8747950029A317 /* SplashViewController.swift in Sources */,
DD29681B2D6DAD0200143851 /* ProfileRepository.swift in Sources */,
+ DD431E1A2E136C8100BA55FB /* UpdateFCMTokenUseCase.swift in Sources */,
DD1F30472D9D71E1003DB820 /* DeleteContentLikedUseCase.swift in Sources */,
DD29681C2D6DAD0200143851 /* InformationRepository.swift in Sources */,
DD69C5902D71A3BE000A3349 /* OAuthTokenProvider.swift in Sources */,
@@ -2973,6 +2961,7 @@
DD2968252D6DAD0B00143851 /* ViewitMapper.swift in Sources */,
DEA10B4C2DD3C504001EBBA9 /* WithdrawalGuideViewModel.swift in Sources */,
DEA10B2B2DD399F4001EBBA9 /* WithdrawalReasonViewController.swift in Sources */,
+ DE0DC22B2E0AEB9600FF061E /* GhostInfoTooltipView.swift in Sources */,
DD2968262D6DAD0B00143851 /* CommunityMapper.swift in Sources */,
DE7004842D8E87DC00B7AB71 /* RankListViewController.swift in Sources */,
DD2968272D6DAD0B00143851 /* LoginMapper.swift in Sources */,
@@ -2997,7 +2986,6 @@
DE0BFB632DA4F7FA00F06656 /* CommunityCellBaseView.swift in Sources */,
DD29682D2D6DAD0B00143851 /* NotificationMapper.swift in Sources */,
DE3390302D8EC6C400638BB4 /* NoticeViewController.swift in Sources */,
- DE0D0CA22DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift in Sources */,
DE4AE8512DE8B6FF003E20AB /* WableBottomSheetShowable.swift in Sources */,
DD2968232D6DAD0200143851 /* NotificationRepository.swift in Sources */,
DD2968002D6DAC8900143851 /* AppDelegate.swift in Sources */,
@@ -3147,7 +3135,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = DD8CEF3F2D6A007900DBE580 /* Development.xcconfig */;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Dev;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Wable-iOS/Wable-iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
@@ -3158,7 +3146,7 @@
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = HGVD26K7DP;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Wable-iOS/Resource/Info.plist";
- INFOPLIST_KEY_CFBundleDisplayName = "와블";
+ INFOPLIST_KEY_CFBundleDisplayName = "술먹고미친와블";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "게시글이나 프로필을 변경할 때 사용합니다.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@@ -3170,7 +3158,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.wable.Wable-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3212,7 +3200,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.wable.Wable-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
diff --git a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme
new file mode 100644
index 00000000..01aba8d5
--- /dev/null
+++ b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme
index 39297408..452e3149 100644
--- a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme
+++ b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme
@@ -24,14 +24,14 @@
+ buildConfiguration = "Release">
diff --git a/Wable-iOS/App/AppDelegate+InjectDependency.swift b/Wable-iOS/App/AppDelegate+InjectDependency.swift
index c6cd15c0..ace0b7d6 100644
--- a/Wable-iOS/App/AppDelegate+InjectDependency.swift
+++ b/Wable-iOS/App/AppDelegate+InjectDependency.swift
@@ -12,6 +12,18 @@ extension AppDelegate {
func injectDependency() {
+ // MARK: - UserSession
+
+ diContainer.register(
+ for: UserSessionRepository.self,
+ object: UserSessionRepositoryImpl(userDefaults: UserDefaultsStorage())
+ )
+
+ // MARK: - Login
+
+ diContainer.register(for: LoginRepository.self, object: LoginRepositoryImpl())
+ diContainer.register(for: TokenStorage.self, object: TokenStorage(keyChainStorage: KeychainStorage()))
+
// MARK: - Overview
diContainer.register(for: InformationRepository.self, object: InformationRepositoryImpl())
@@ -21,10 +33,8 @@ extension AppDelegate {
diContainer.register(for: ReportRepository.self) { env in
switch env {
- case .mock:
- return MockReportRepository()
- case .production:
- return ReportRepositoryImpl()
+ case .mock: MockReportRepository()
+ case .production: ReportRepositoryImpl()
}
}
@@ -32,10 +42,8 @@ extension AppDelegate {
diContainer.register(for: ViewitRepository.self) { env in
switch env {
- case .mock:
- return MockViewitRepository()
- case .production:
- return ViewitRepositoryImpl()
+ case .mock: MockViewitRepository()
+ case .production: ViewitRepositoryImpl()
}
}
@@ -45,10 +53,8 @@ extension AppDelegate {
diContainer.register(for: CommentRepository.self) { env in
switch env {
- case .mock:
- return MockCommentRepository()
- case .production:
- return CommentRepositoryImpl()
+ case .mock: MockCommentRepository()
+ case .production: CommentRepositoryImpl()
}
}
diContainer.register(for: CommentLikedRepository.self, object: CommentLikedRepositoryImpl())
@@ -72,19 +78,24 @@ extension AppDelegate {
diContainer.register(for: AppVersionRepository.self) { env in
switch env {
- case .mock:
- return MockAppVersionRepository()
- case .production:
- return AppVersionRepositoryImpl()
+ case .mock: MockAppVersionRepository()
+ case .production: AppVersionRepositoryImpl()
}
}
diContainer.register(for: UpdateAlertPolicyRepository.self) { env in
switch env {
- case .mock:
- return MockUpdateAlertPolicyRepository()
- case .production:
- return UpdateAlertPolicyRepositoryImpl()
+ case .mock: MockUpdateAlertPolicyRepository()
+ case .production: UpdateAlertPolicyRepositoryImpl()
+ }
+ }
+
+ // MARK: - Community
+
+ diContainer.register(for: CommunityRepository.self) { env in
+ switch env {
+ case .mock: MockCommunityRepository()
+ case .production: CommunityRepositoryImpl()
}
}
}
diff --git a/Wable-iOS/App/SceneDelegate.swift b/Wable-iOS/App/SceneDelegate.swift
index a4230f35..208fe7cb 100644
--- a/Wable-iOS/App/SceneDelegate.swift
+++ b/Wable-iOS/App/SceneDelegate.swift
@@ -20,13 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private let loginRepository = LoginRepositoryImpl()
private let profileRepository = ProfileRepositoryImpl()
- private let userSessionRepository = UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(
- userDefaults: UserDefaults.standard,
- jsonEncoder: JSONEncoder(),
- jsonDecoder: JSONDecoder()
- )
- )
+ private let userSessionRepository = UserSessionRepositoryImpl(userDefaults: UserDefaultsStorage())
private let checkAppUpdateRequirementUseCase = CheckAppUpdateRequirementUseCaseImpl()
// MARK: - UIComponent
@@ -66,19 +60,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private extension SceneDelegate {
func configureLoginScreen() {
- self.window?.rootViewController = LoginViewController(
- viewModel: LoginViewModel(
- updateFCMTokenUseCase: UpdateFCMTokenUseCase(
- repository: ProfileRepositoryImpl()
- ),
- fetchUserAuthUseCase: FetchUserAuthUseCase(
- loginRepository: loginRepository,
- userSessionRepository: userSessionRepository
- ),
- updateUserSessionUseCase: FetchUserInformationUseCase(repository: userSessionRepository),
- userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl())
- )
- )
+ self.window?.rootViewController = LoginViewController(viewModel: LoginViewModel())
}
func configureMainScreen() {
diff --git a/Wable-iOS/Core/Amplitude/AmplitudeManager.swift b/Wable-iOS/Core/Amplitude/AmplitudeManager.swift
index c93a773c..300c6619 100644
--- a/Wable-iOS/Core/Amplitude/AmplitudeManager.swift
+++ b/Wable-iOS/Core/Amplitude/AmplitudeManager.swift
@@ -74,6 +74,7 @@ extension AmplitudeManager {
case clickNextDeleteguide
case clickDoneDeleteaccount
case clickGobackHome
+ case clickDownloadPhoto
var value: String {
switch self {
@@ -123,6 +124,7 @@ extension AmplitudeManager {
case .clickNextDeleteguide: return "click_next_deleteguide"
case .clickDoneDeleteaccount: return "click_done_deleteaccount"
case .clickGobackHome: return "click_goback_home"
+ case .clickDownloadPhoto: return "click_download_photo"
}
}
}
diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift b/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift
index c8d9122a..962e2734 100644
--- a/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift
+++ b/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift
@@ -14,6 +14,11 @@ extension StringLiterals {
enum Ghost {
static let sheetTitle = "와블의 온화한 문화를 해치는\n누군가를 발견하신 건가요?"
+ static let sheetPlaceholder = """
+ 투명도를 내리려는 이유가 무엇인가요?
+ ex. 선수를 비하했어요
+ """
static let completeToast = "덕분에 와블이 더 온화해지고 있어요!"
+ static let tooltip = "투명도란? 와블의 건강하고 유쾌한 문화를 유지하기 위한 최소한의 장치입니다. 과도한 혐오나 비방, 조롱을 보게 된다면 투명도를 내려주세요! 클린 LCK 문화 함께 만들어가요."
}
}
diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift b/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift
new file mode 100644
index 00000000..029cccc4
--- /dev/null
+++ b/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift
@@ -0,0 +1,18 @@
+//
+// StringLiterals+PhotoDetail.swift
+// Wable-iOS
+//
+// Created by 김진웅 on 6/25/25.
+//
+
+import Foundation
+
+extension StringLiterals {
+
+ // MARK: - PhotoDetail
+
+ enum PhotoDetail {
+ static let successMessage = "사진을 앨범에 저장했습니다."
+ static let errorMessage = "사진 저장에 실패했습니다.\n잠시 후 다시 시도해 주세요."
+ }
+}
diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift b/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift
index ad1e1341..f46530a6 100644
--- a/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift
+++ b/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift
@@ -13,8 +13,12 @@ extension StringLiterals {
// MARK: - Report
enum Report {
- static let sheetTitle = "신고하시겠어요?"
- static let sheetMessage = "해당 유저 혹은 게시글을 신고하시려면 신고하기 버튼을 눌러주세요"
+ static let sheetTitle = "신고하기"
+ static let sheetPlaceholder = """
+ 신고하시려는 사유를 알려주시면 더 건강한
+ 문화를 만드는 것에 도움돼요.
+ 사유는 생략 가능해요.
+ """
static let completeToast = "신고 접수가 완료되었어요.\n24시간 이내에 조치할 예정이예요."
}
}
diff --git a/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift
index 7633ca0f..7d7a8c5d 100644
--- a/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift
+++ b/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift
@@ -31,7 +31,7 @@ final class AppVersionRepositoryImpl: AppVersionRepository {
}
return AppVersion(from: appStoreVersion)
- } catch let error as URLError {
+ } catch _ as URLError {
throw WableError.networkError
} catch {
throw error
diff --git a/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift
index e103a511..f9e0d505 100644
--- a/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift
+++ b/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift
@@ -17,7 +17,7 @@ final class CommunityRepositoryImpl {
}
extension CommunityRepositoryImpl: CommunityRepository {
- func updateRegister(communityName: String) -> AnyPublisher {
+ func updateRegistration(communityName: String) -> AnyPublisher {
return provider.request(
.updateRegister(
request: DTO.Request.UpdateRegister(
@@ -39,9 +39,45 @@ extension CommunityRepositoryImpl: CommunityRepository {
.mapWableError()
}
- func isUserRegistered() -> AnyPublisher {
+ func checkUserRegistration() -> AnyPublisher {
return provider.request(.isUserRegistered, for: DTO.Response.IsUserRegistered.self)
.map(CommunityMapper.toDomain)
.mapWableError()
}
}
+
+struct MockCommunityRepository: CommunityRepository {
+ private var randomDelay: TimeInterval { Double.random(in: 0.7...1.3) }
+
+ func updateRegistration(communityName: String) -> AnyPublisher {
+ return .just(88.0)
+ .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
+ .eraseToAnyPublisher()
+ }
+
+ func fetchCommunityList() -> AnyPublisher<[Community], WableError> {
+ let communityMockData: [Community] = [
+ Community(team: .t1, registrationRate: 0.91),
+ Community(team: .gen, registrationRate: 0.88),
+ Community(team: .hle, registrationRate: 0.72),
+ Community(team: .dk, registrationRate: 0.79),
+ Community(team: .kt, registrationRate: 0.65),
+ Community(team: .ns, registrationRate: 0.54),
+ Community(team: .drx, registrationRate: 0.49),
+ Community(team: .bro, registrationRate: 0.37),
+ Community(team: .bfx, registrationRate: 0.42),
+ Community(team: .dnf, registrationRate: 0.33)
+ ]
+
+ return .just(communityMockData)
+ .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
+ .eraseToAnyPublisher()
+ }
+
+ func checkUserRegistration() -> AnyPublisher {
+ let registration = CommunityRegistration(team: nil, hasRegisteredTeam: false)
+ return .just(registration)
+ .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift
index 74db404c..2667b07f 100644
--- a/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift
+++ b/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift
@@ -89,12 +89,12 @@ extension ProfileRepositoryImpl: ProfileRepository {
}
func updateUserProfile(
- profile: UserProfile? = nil,
- isPushAlarmAllowed: Bool? = nil,
- isAlarmAllowed: Bool? = nil,
- image: UIImage? = nil,
- fcmToken: String? = nil,
- defaultProfileType: String? = nil
+ profile: UserProfile?,
+ isPushAlarmAllowed: Bool?,
+ isAlarmAllowed: Bool?,
+ image: UIImage?,
+ fcmToken: String?,
+ defaultProfileType: String?
) -> AnyPublisher {
return provider.request(
.updateUserProfile(
diff --git a/Wable-iOS/Domain/Error/WableError.swift b/Wable-iOS/Domain/Error/WableError.swift
index 5ffe1194..ef195328 100644
--- a/Wable-iOS/Domain/Error/WableError.swift
+++ b/Wable-iOS/Domain/Error/WableError.swift
@@ -34,8 +34,8 @@ enum WableError: String, Error {
case notFoundComment = "해당하는 답글이 없습니다."
case unauthorizedMember = "권한이 없는 유저입니다."
case unauthorizedToken = "유효하지 않은 토큰입니다."
- case kakaoUnauthorizedUser = "카카오 로그인 실패. 만료되었거나 잘못된 카카오 토큰입니다."
- case failedToValidateAppleLogin = "애플 로그인 실패. 만료되었거나 잘못된 애플 토큰입니다."
+ case failedToKakaoLogin = "카카오 로그인 실패. 작업이 취소되었거나 토큰 오류입니다."
+ case failedToAppleLogin = "애플 로그인 실패. 작업이 취소되었거나 토큰 오류입니다."
case signinRequired = "access, refreshToken 모두 만료되었습니다. 재로그인이 필요합니다."
case validAccessToken = "아직 유효한 accessToken 입니다."
diff --git a/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift b/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift
index e33c16e8..c3a52e48 100644
--- a/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift
+++ b/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift
@@ -10,7 +10,7 @@ import Combine
import Foundation
protocol CommunityRepository {
- func updateRegister(communityName: String) -> AnyPublisher
+ func updateRegistration(communityName: String) -> AnyPublisher
func fetchCommunityList() -> AnyPublisher<[Community], WableError>
- func isUserRegistered() -> AnyPublisher
+ func checkUserRegistration() -> AnyPublisher
}
diff --git a/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift b/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift
deleted file mode 100644
index d9acfd5f..00000000
--- a/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift
+++ /dev/null
@@ -1,71 +0,0 @@
-//
-// CommunityUseCase.swift
-// Wable-iOS
-//
-// Created by 김진웅 on 4/10/25.
-//
-
-import Combine
-import Foundation
-
-protocol CommunityUseCase {
- func isUserRegistered() -> AnyPublisher
- func fetchCommunityList() -> AnyPublisher<[Community], WableError>
- func register(for communityTeam: LCKTeam) -> AnyPublisher
-}
-
-final class CommunityUseCaseImpl: CommunityUseCase {
- private let repository: CommunityRepository
-
- init(repository: CommunityRepository) {
- self.repository = repository
- }
-
- func isUserRegistered() -> AnyPublisher {
- return repository.isUserRegistered()
- }
-
- func fetchCommunityList() -> AnyPublisher<[Community], WableError> {
- return repository.fetchCommunityList()
- }
-
- func register(for communityTeam: LCKTeam) -> AnyPublisher {
- return repository.updateRegister(communityName: communityTeam.rawValue)
- }
-}
-
-final class MockCommunityUseCaseImpl: CommunityUseCase {
- private var randomDelay: TimeInterval { Double.random(in: 0.7...1.3) }
-
- func isUserRegistered() -> AnyPublisher {
- let registration = CommunityRegistration(team: nil, hasRegisteredTeam: false)
- return .just(registration)
- .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
- .eraseToAnyPublisher()
- }
-
- func fetchCommunityList() -> AnyPublisher<[Community], WableError> {
- let communityMockData: [Community] = [
- Community(team: .t1, registrationRate: 0.91),
- Community(team: .gen, registrationRate: 0.88),
- Community(team: .hle, registrationRate: 0.72),
- Community(team: .dk, registrationRate: 0.79),
- Community(team: .kt, registrationRate: 0.65),
- Community(team: .ns, registrationRate: 0.54),
- Community(team: .drx, registrationRate: 0.49),
- Community(team: .bro, registrationRate: 0.37),
- Community(team: .bfx, registrationRate: 0.42),
- Community(team: .dnf, registrationRate: 0.33)
- ]
-
- return .just(communityMockData)
- .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
- .eraseToAnyPublisher()
- }
-
- func register(for communityTeam: LCKTeam) -> AnyPublisher {
- return .just(0.92)
- .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main)
- .eraseToAnyPublisher()
- }
-}
diff --git a/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift b/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift
index e8aac0cd..d88b06a8 100644
--- a/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift
+++ b/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift
@@ -20,12 +20,12 @@ final class FetchGhostUseCase {
// MARK: - Extension
extension FetchGhostUseCase {
- func execute(type: PostType, targetID: Int, userID: Int) -> AnyPublisher {
+ func execute(type: PostType, targetID: Int, userID: Int, reason: String?) -> AnyPublisher {
return repository.postGhostReduction(
alarmTriggerType: type == .comment ? "commentGhost" : "contentGhost",
alarmTriggerID: targetID,
targetMemberID: userID,
- reason: ""
+ reason: reason ?? ""
)
}
}
diff --git a/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift b/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift
deleted file mode 100644
index 562aeb01..00000000
--- a/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// FetchUserAuthUseCase.swift
-// Wable-iOS
-//
-// Created by YOUJIM on 3/6/25.
-//
-
-
-import Combine
-
-final class FetchUserAuthUseCase {
- private let loginRepository: LoginRepository
- private let userSessionRepository: UserSessionRepository
- private let tokenStorage = TokenStorage(keyChainStorage: KeychainStorage())
-
- init(loginRepository: LoginRepository, userSessionRepository: UserSessionRepository) {
- self.loginRepository = loginRepository
- self.userSessionRepository = userSessionRepository
- }
-}
-
-// MARK: - Extension
-
-extension FetchUserAuthUseCase {
- func execute(platform: SocialPlatform) -> AnyPublisher {
- return loginRepository.fetchUserAuth(platform: platform, userName: nil)
- .handleEvents(
- receiveOutput: { account in
- do {
- try self.tokenStorage.save(account.token.accessToken, for: .wableAccessToken)
-
- WableLogger.log("액세스 토큰 저장 성공: \(account.token.accessToken)", for: .debug)
- try self.tokenStorage.save(account.token.refreshToken, for: .wableRefreshToken)
-
- WableLogger.log("리프레시 토큰 저장 성공: \(account.token.refreshToken)", for: .debug)
- } catch {
- WableLogger.log("토큰 저장 실패: \(error)", for: .debug)
- }
-
- self.userSessionRepository.updateUserSession(
- userID: account.user.id,
- nickname: account.user.nickname,
- profileURL: account.user.profileURL,
- isPushAlarmAllowed: account.isPushAlarmAllowed ?? false,
- isAdmin: account.isAdmin,
- isAutoLoginEnabled: true,
- notificationBadgeCount: nil
- )
-
- self.userSessionRepository.updateActiveUserID(account.user.id)
- })
- .eraseToAnyPublisher()
- }
-}
diff --git a/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift b/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift
index b7efa50c..fe540cde 100644
--- a/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift
+++ b/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift
@@ -20,10 +20,7 @@ final class UpdateFCMTokenUseCase {
extension UpdateFCMTokenUseCase {
func execute(nickname: String) -> AnyPublisher {
- guard let token = repository.fetchFCMToken() else {
- return .fail(WableError.noToken)
- }
-
+ guard let token = repository.fetchFCMToken() else { return .fail(WableError.noToken) }
return repository.updateUserProfile(nickname: nickname, fcmToken: token)
}
}
diff --git a/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift b/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift
index 87205d44..c63ccc88 100644
--- a/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift
+++ b/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift
@@ -18,7 +18,13 @@ final class UserProfileUseCase {
}
extension UserProfileUseCase {
- func execute(profile: UserProfile? = nil, isPushAlarmAllowed: Bool? = nil, isAlarmAllowed: Bool? = nil, image: UIImage? = nil, defaultProfileType: String? = nil) -> AnyPublisher {
+ func updateProfile(
+ profile: UserProfile?,
+ isPushAlarmAllowed: Bool? = nil,
+ isAlarmAllowed: Bool? = nil,
+ image: UIImage? = nil,
+ defaultProfileType: String? = nil
+ ) -> AnyPublisher {
return repository.updateUserProfile(
profile: profile,
isPushAlarmAllowed: isPushAlarmAllowed,
@@ -29,7 +35,30 @@ extension UserProfileUseCase {
)
}
- func execute(userID: Int) -> AnyPublisher {
+ func fetchProfile(userID: Int) -> AnyPublisher {
return repository.fetchUserProfile(memberID: userID)
}
+
+ func updateProfileWithUserID(
+ userID: Int,
+ isPushAlarmAllowed: Bool? = nil,
+ isAlarmAllowed: Bool? = nil,
+ image: UIImage? = nil,
+ defaultProfileType: String? = nil
+ ) -> AnyPublisher {
+ let fetchProfile = repository.fetchUserProfile(memberID: userID)
+ let updateProfile = { [weak self] (profile: UserProfile) -> AnyPublisher in
+ guard let self = self else { return Fail(error: WableError.unknownError).eraseToAnyPublisher() }
+
+ return self.updateProfile(
+ profile: profile,
+ isPushAlarmAllowed: isPushAlarmAllowed,
+ isAlarmAllowed: isAlarmAllowed,
+ image: image,
+ defaultProfileType: defaultProfileType
+ )
+ }
+
+ return fetchProfile.flatMap(updateProfile).eraseToAnyPublisher()
+ }
}
diff --git a/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift b/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift
index 48aeff78..5b8bb426 100644
--- a/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift
+++ b/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift
@@ -9,15 +9,16 @@ import Combine
import Foundation
protocol ReportViewitUseCase {
- func report(viewit: Viewit) -> AnyPublisher
+ func report(viewit: Viewit, message: String) -> AnyPublisher
func ban(viewit: Viewit) -> AnyPublisher
}
final class ReportViewitUseCaseImpl: ReportViewitUseCase {
@Injected private var repository: ReportRepository
- func report(viewit: Viewit) -> AnyPublisher {
- return repository.createReport(nickname: viewit.userNickname, text: viewit.text)
+ func report(viewit: Viewit, message: String) -> AnyPublisher {
+ let text = message.isEmpty ? viewit.text : message
+ return repository.createReport(nickname: viewit.userNickname, text: text)
.map { viewit }
.eraseToAnyPublisher()
}
diff --git a/Wable-iOS/Infra/Auth/AppleAuthProvider.swift b/Wable-iOS/Infra/Auth/AppleAuthProvider.swift
index b31f13bc..7de8ad1e 100644
--- a/Wable-iOS/Infra/Auth/AppleAuthProvider.swift
+++ b/Wable-iOS/Infra/Auth/AppleAuthProvider.swift
@@ -57,7 +57,7 @@ extension AppleAuthProvider: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
guard let promise = self.promise else { return }
- promise(.failure(.failedToValidateAppleLogin))
+ promise(.failure(.failedToAppleLogin))
}
}
diff --git a/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift b/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift
index 833bb827..8d4ff705 100644
--- a/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift
+++ b/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift
@@ -41,12 +41,12 @@ private extension KakaoAuthProvider {
promise: @escaping (Result) -> Void
) {
if error != nil {
- promise(.failure(.kakaoUnauthorizedUser))
+ promise(.failure(.failedToKakaoLogin))
return
}
guard let token = oauthToken?.accessToken else {
- promise(.failure(.kakaoUnauthorizedUser))
+ promise(.failure(.failedToKakaoLogin))
return
}
diff --git a/Wable-iOS/Infra/Local/UserDefaultsStorage.swift b/Wable-iOS/Infra/Local/UserDefaultsStorage.swift
index 7f19c430..7f2662f1 100644
--- a/Wable-iOS/Infra/Local/UserDefaultsStorage.swift
+++ b/Wable-iOS/Infra/Local/UserDefaultsStorage.swift
@@ -13,7 +13,11 @@ struct UserDefaultsStorage {
private let jsonEncoder: JSONEncoder
private let jsonDecoder: JSONDecoder
- init(userDefaults: UserDefaults = .standard, jsonEncoder: JSONEncoder, jsonDecoder: JSONDecoder) {
+ init(
+ userDefaults: UserDefaults = .standard,
+ jsonEncoder: JSONEncoder = JSONEncoder(),
+ jsonDecoder: JSONDecoder = JSONDecoder()
+ ) {
self.userDefaults = userDefaults
self.jsonEncoder = jsonEncoder
self.jsonDecoder = jsonDecoder
diff --git a/Wable-iOS/Presentation/Community/View/CommunityViewController.swift b/Wable-iOS/Presentation/Community/View/CommunityViewController.swift
index 5727880f..c6ce859c 100644
--- a/Wable-iOS/Presentation/Community/View/CommunityViewController.swift
+++ b/Wable-iOS/Presentation/Community/View/CommunityViewController.swift
@@ -5,6 +5,7 @@
// Created by 김진웅 on 4/8/25.
//
+import Combine
import UIKit
import SafariServices
@@ -31,11 +32,8 @@ final class CommunityViewController: UIViewController {
private var dataSource: DataSource?
private let viewModel: ViewModel
- private let viewDidLoadRelay = PassthroughRelay()
- private let viewDidRefreshRelay = PassthroughRelay()
- private let registerRelay = PassthroughRelay()
- private let copyLinkRelay = PassthroughRelay()
- private let checkNotificationAuthorizationRelay = PassthroughRelay()
+ private let registerSubject = PassthroughSubject()
+ private let checkNotificationAuthorizationSubject = PassthroughSubject()
private let cancelBag = CancelBag()
private let rootView = CommunityView()
@@ -65,18 +63,20 @@ final class CommunityViewController: UIViewController {
setupNavigationBar()
setupDataSource()
setupBinding()
-
- viewDidLoadRelay.send()
}
}
-// MARK: - Setup Method
-
private extension CommunityViewController {
+
+ // MARK: - Setup Method
+
func setupAction() {
- askButton.addTarget(self, action: #selector(askButtonDidTap), for: .touchUpInside)
-
- refreshControl?.addTarget(self, action: #selector(collectionViewDidRefresh), for: .valueChanged)
+ rootView.askDidTap
+ .compactMap { URL(string: StringLiterals.URL.feedbackForm) }
+ .sink { [weak self] url in
+ self?.present(SFSafariViewController(url: url), animated: true)
+ }
+ .store(in: cancelBag)
}
func setupNavigationBar() {
@@ -102,7 +102,6 @@ private extension CommunityViewController {
let teamName = item.community.team?.rawValue ?? ""
let progress = Float(item.community.registrationRate) / Float(100)
- WableLogger.log("프로그레스 값은: \(progress)", for: .debug)
cell.configure(
image: UIImage(named: teamName.lowercased()),
@@ -120,7 +119,7 @@ private extension CommunityViewController {
let headerKind = UICollectionView.elementKindSectionHeader
let headerRegistration = SupplementaryRegistration(elementKind: headerKind) { _, _, _ in }
- dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, item in
+ dataSource = DataSource(collectionView: rootView.collectionView) { collectionView, indexPath, item in
if item.hasRegisteredCommunity, item.isRegistered {
return collectionView.dequeueConfiguredReusableCell(
using: inviteCellRegistration,
@@ -150,48 +149,38 @@ private extension CommunityViewController {
func setupBinding() {
let input = ViewModel.Input(
- viewDidLoad: viewDidLoadRelay.eraseToAnyPublisher(),
- viewDidRefresh: viewDidRefreshRelay.eraseToAnyPublisher(),
- register: registerRelay.eraseToAnyPublisher(),
- checkNotificationAuthorization: checkNotificationAuthorizationRelay.eraseToAnyPublisher()
+ refresh: rootView.didRefresh,
+ register: registerSubject.eraseToAnyPublisher(),
+ checkNotificationAuthorization: checkNotificationAuthorizationSubject.eraseToAnyPublisher()
)
let output = viewModel.transform(input: input, cancelBag: cancelBag)
output.communityItems
- .removeDuplicates()
- .sink { [weak self] communityItems in
- self?.applySnapshot(items: communityItems)
- }
+ .sink { [weak self] in self?.applySnapshot(items: $0) }
.store(in: cancelBag)
output.isLoading
.filter { !$0 }
- .sink { [weak self] _ in
- self?.refreshControl?.endRefreshing()
- }
+ .sink { [weak self] _ in self?.rootView.refreshControl.endRefreshing() }
.store(in: cancelBag)
- output.completeRegistration
+ output.registrationCompleted
.compactMap { $0 }
.sink { [weak self] team in
- self?.scrollToTopItem()
+ self?.scrollToTop()
self?.showCompleteSheet(for: team.rawValue)
}
.store(in: cancelBag)
output.isNotificationAuthorized
.filter { !$0 }
- .sink { [weak self] _ in
- self?.showAlarmSettingSheet()
- }
+ .sink { [weak self] _ in self?.showAlarmSettingSheet() }
.store(in: cancelBag)
}
-}
-
-// MARK: - Helper Method
+
+ // MARK: - Helper Method
-private extension CommunityViewController {
func applySnapshot(items: [Item]) {
var snapshot = Snapshot()
snapshot.appendSections([.main])
@@ -209,7 +198,7 @@ private extension CommunityViewController {
let registerAction = WableSheetAction(title: "신청하기", style: .primary) { [weak self] in
AmplitudeManager.shared.trackEvent(tag: .clickApplyTeamzone)
- self?.registerRelay.send(item)
+ self?.registerSubject.send(item)
}
wableSheet.addActions(cancelAction, registerAction)
present(wableSheet, animated: true)
@@ -221,7 +210,7 @@ private extension CommunityViewController {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
completeSheet.dismiss(animated: true) { [weak self] in
- self?.checkNotificationAuthorizationRelay.send()
+ self?.checkNotificationAuthorizationSubject.send()
}
}
}
@@ -262,37 +251,7 @@ private extension CommunityViewController {
UIPasteboard.general.string = StringLiterals.URL.littly
}
- func scrollToTopItem() {
- guard collectionView.numberOfSections > 0,
- collectionView.numberOfItems(inSection: 0) > 0
- else {
- return
- }
-
- let indexPath = IndexPath(item: 0, section: 0)
- collectionView.scrollToItem(at: indexPath, at: .top, animated: true)
- }
-}
-
-// MARK: - Action Method
-
-private extension CommunityViewController {
- @objc func askButtonDidTap() {
- guard let url = URL(string: StringLiterals.URL.feedbackForm) else { return }
-
- let safariController = SFSafariViewController(url: url)
- present(safariController, animated: true)
- }
-
- @objc func collectionViewDidRefresh() {
- viewDidRefreshRelay.send()
+ func scrollToTop() {
+ rootView.collectionView.setContentOffset(.zero, animated: true)
}
}
-
-// MARK: - Computed Property
-
-private extension CommunityViewController {
- var collectionView: UICollectionView { rootView.collectionView }
- var refreshControl: UIRefreshControl? { rootView.collectionView.refreshControl }
- var askButton: UIButton { rootView.askButton }
-}
diff --git a/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift b/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift
index 90f0c3db..cd096d6d 100644
--- a/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift
+++ b/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift
@@ -5,6 +5,7 @@
// Created by 김진웅 on 4/11/25.
//
+import Combine
import UIKit
import SnapKit
@@ -14,22 +15,29 @@ final class CommunityView: UIView {
// MARK: - UIComponent
+ private let statusBarBackgroundView = UIView(backgroundColor: .wableBlack)
+
+ private let navigationView = NavigationView(type: .hub(title: "커뮤니티", isBeta: true))
+
lazy var collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: collectionViewLayout
).then {
- $0.refreshControl = UIRefreshControl()
$0.alwaysBounceVertical = true
}
+ let refreshControl = UIRefreshControl()
+
let askButton = WableButton(style: .black).then {
var config = $0.configuration
- config?.attributedTitle = Constant.askButtonTitle
+ config?.attributedTitle = StringLiterals.Community.askButtonTitle
.pretendardString(with: .body3)
.highlight(textColor: .sky50, to: "요청하기")
$0.configuration = config
}
+ private let underlineView = UIView(backgroundColor: .gray200)
+
// MARK: - Initializer
override init(frame: CGRect) {
@@ -44,19 +52,19 @@ final class CommunityView: UIView {
}
}
-// MARK: - Setup Method
+extension CommunityView {
+ var askDidTap: AnyPublisher { askButton.publisher(for: .touchUpInside).eraseToAnyPublisher() }
+ var didRefresh: AnyPublisher { refreshControl.publisher(for: .valueChanged).eraseToAnyPublisher() }
+}
private extension CommunityView {
+
+ // MARK: - Setup Method
+
func setupView() {
backgroundColor = .wableWhite
- let statusBarBackgroundView = UIView(backgroundColor: .wableBlack)
-
- let navigationView = NavigationView(type: .hub(title: "커뮤니티", isBeta: true)).then {
- $0.configureView()
- }
-
- let underlineView = UIView(backgroundColor: .gray200)
+ collectionView.refreshControl = refreshControl
addSubviews(
statusBarBackgroundView,
@@ -93,11 +101,9 @@ private extension CommunityView {
make.height.equalTo(1)
}
}
-}
-
-// MARK: - Computed Property
+
+ // MARK: - CollectionViewLayout
-private extension CommunityView {
var collectionViewLayout: UICollectionViewCompositionalLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
@@ -128,11 +134,3 @@ private extension CommunityView {
return UICollectionViewCompositionalLayout(section: section)
}
}
-
-// MARK: - Constant
-
-private extension CommunityView {
- enum Constant {
- static let askButtonTitle = "더 추가하고 싶은 게시판이 있다면? 요청하기"
- }
-}
diff --git a/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift b/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift
index 1ba90ebb..218210a9 100644
--- a/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift
+++ b/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift
@@ -10,17 +10,18 @@ import Foundation
import UserNotifications
final class CommunityViewModel {
- private let useCase: CommunityUseCase
+ @Injected private var repository: CommunityRepository
- init(useCase: CommunityUseCase) {
- self.useCase = useCase
- }
+ private var userRegistrationState = CommunityRegistration.initialState()
+
+ private let communityListSubject = CurrentValueSubject<[Community], Never>([])
+ private let loadingStateSubject = CurrentValueSubject(false)
+ private let registrationCompletedSubject = CurrentValueSubject(nil)
}
extension CommunityViewModel: ViewModelType {
struct Input {
- let viewDidLoad: Driver
- let viewDidRefresh: Driver
+ let refresh: Driver
let register: Driver
let checkNotificationAuthorization: Driver
}
@@ -28,97 +29,91 @@ extension CommunityViewModel: ViewModelType {
struct Output {
let communityItems: Driver<[CommunityItem]>
let isLoading: Driver
- let completeRegistration: Driver
+ let registrationCompleted: Driver
let isNotificationAuthorized: Driver
}
func transform(input: Input, cancelBag: CancelBag) -> Output {
- let registrationRelay = CurrentValueRelay(.initialState())
- let communityListRelay = CurrentValueRelay<[Community]>([])
- let isLoadingRelay = CurrentValueRelay(false)
- let completeRegistrationRelay = CurrentValueRelay(nil)
+ bindInitialLoad(cancelBag: cancelBag)
+ bindRefresh(input: input, cancelBag: cancelBag)
+ bindRegister(input: input, cancelBag: cancelBag)
- useCase.isUserRegistered()
+ return Output(
+ communityItems: createItemsPublisher(),
+ isLoading: loadingStateSubject.removeDuplicates().asDriver(),
+ registrationCompleted: registrationCompletedSubject.asDriver(),
+ isNotificationAuthorized: createIsNotificationAuthorizedPublisher(input: input)
+ )
+ }
+}
+
+private extension CommunityViewModel {
+ func bindInitialLoad(cancelBag: CancelBag) {
+ repository.checkUserRegistration()
.catch { error -> AnyPublisher in
WableLogger.log("\(error.localizedDescription)", for: .error)
return .just(.initialState())
}
- .sink { status in
- registrationRelay.send(status)
- }
- .store(in: cancelBag)
-
- input.viewDidLoad
+ .handleEvents(receiveOutput: { [weak self] in self?.userRegistrationState = $0 })
.withUnretained(self)
- .flatMap { owner, _ -> AnyPublisher<[Community], Never> in
- owner.useCase.fetchCommunityList()
- .catch { error -> AnyPublisher<[Community], Never> in
- WableLogger.log("\(error.localizedDescription)", for: .error)
- return .just([])
- }
- .eraseToAnyPublisher()
+ .flatMap(maxPublishers: .max(1)) { owner, _ -> AnyPublisher<[Community], Never> in
+ owner.fetchCommunityList()
}
- .filter { !$0.isEmpty }
- .sink { communityListRelay.send($0) }
+ .sink { [weak self] in self?.communityListSubject.send($0) }
.store(in: cancelBag)
-
- let viewDidRefresh = input.viewDidRefresh
- .handleEvents(receiveOutput: { _ in
- isLoadingRelay.send(true)
- })
-
- Publishers.Merge(input.viewDidLoad, viewDidRefresh)
+ }
+
+ func bindRefresh(input: Input, cancelBag: CancelBag) {
+ input.refresh
+ .handleEvents(receiveOutput: { [weak self] in self?.loadingStateSubject.send(true) })
.withUnretained(self)
- .flatMap { owner, _ -> AnyPublisher<[Community], Never> in
- owner.useCase.fetchCommunityList()
- .catch { error -> AnyPublisher<[Community], Never> in
- WableLogger.log("\(error.localizedDescription)", for: .error)
- return .just([])
- }
- .eraseToAnyPublisher()
+ .flatMap { owner, _ -> AnyPublisher<[Community], Never> in
+ owner.fetchCommunityList()
}
- .filter { !$0.isEmpty }
- .sink { communityListRelay.send($0) }
+ .sink { [weak self] in self?.communityListSubject.send($0) }
.store(in: cancelBag)
-
- input.register
- .compactMap { communityListRelay.value[$0].team }
- .handleEvents(receiveOutput: { team in
- registrationRelay.send(.init(team: team, hasRegisteredTeam: true))
+ }
+
+ func bindRegister(input: Input, cancelBag: CancelBag) {
+ let teamPublisher: AnyPublisher = input.register
+ .compactMap { [weak self] in self?.communityListSubject.value[$0].team }
+ .handleEvents(receiveOutput: { [weak self] team in
+ self?.userRegistrationState = CommunityRegistration(team: team, hasRegisteredTeam: true)
})
+ .eraseToAnyPublisher()
+
+ teamPublisher
.withUnretained(self)
- .flatMap { owner, team -> AnyPublisher in
- return owner.useCase.register(for: team)
- .map { value -> Double? in
- return value
- }
+ .flatMap { owner, team -> AnyPublisher<(LCKTeam, Double), Never> in
+ return owner.repository.updateRegistration(communityName: team.rawValue)
+ .map { Optional.some($0) }
.catch { error -> AnyPublisher in
WableLogger.log("\(error.localizedDescription)", for: .error)
return .just(nil)
}
.compactMap { $0 }
- .handleEvents(receiveOutput: { _ in
- completeRegistrationRelay.send(team)
- })
+ .handleEvents(receiveOutput: { [weak self] _ in self?.registrationCompletedSubject.send(team) })
+ .map { (team, $0) }
.eraseToAnyPublisher()
}
- .sink { updatedRate in
- guard let team = registrationRelay.value.team,
- let index = communityListRelay.value.firstIndex(where: { $0.team == team })
- else {
- WableLogger.log("팀을 찾을 수 없습니다.", for: .debug)
- return
+ .sink { [weak self] team, updatedRate in
+ guard let index = self?.communityListSubject.value.firstIndex(where: { $0.team == team }) else {
+ return WableLogger.log("팀을 찾을 수 없습니다.", for: .debug)
}
- communityListRelay.value[index].registrationRate = updatedRate
+ self?.communityListSubject.value[index].registrationRate = updatedRate
}
.store(in: cancelBag)
-
- let communityItems = communityListRelay
- .map { communityList in
- let registration = registrationRelay.value
+ }
+
+ func createItemsPublisher() -> AnyPublisher<[CommunityItem], Never> {
+ return communityListSubject
+ .map { [weak self] list in
+ guard let registration = self?.userRegistrationState else {
+ return []
+ }
- return communityList.map {
+ return list.map {
let isRegistered = registration.hasRegisteredTeam
? $0.team == registration.team
: false
@@ -131,27 +126,29 @@ extension CommunityViewModel: ViewModelType {
}
.sorted { $0.isRegistered && !$1.isRegistered }
}
- .handleEvents(receiveOutput: { _ in
- isLoadingRelay.send(false)
- })
+ .handleEvents(receiveOutput: { [weak self] _ in self?.loadingStateSubject.send(false) })
+ .removeDuplicates()
.asDriver()
-
- let isNotificationAuthorized = input.checkNotificationAuthorization
- .flatMap { _ -> AnyPublisher in
- Future { promise in
+ }
+
+ func createIsNotificationAuthorizedPublisher(input: Input) -> AnyPublisher {
+ return input.checkNotificationAuthorization
+ .flatMap { _ in
+ return Future { promise in
UNUserNotificationCenter.current().getNotificationSettings { settings in
promise(.success(settings.authorizationStatus == .authorized))
}
}
- .eraseToAnyPublisher()
}
.asDriver()
-
- return Output(
- communityItems: communityItems,
- isLoading: isLoadingRelay.asDriver(),
- completeRegistration: completeRegistrationRelay.asDriver(),
- isNotificationAuthorized: isNotificationAuthorized
- )
+ }
+
+ func fetchCommunityList() -> AnyPublisher<[Community], Never> {
+ return repository.fetchCommunityList()
+ .catch { error -> AnyPublisher<[Community], Never> in
+ WableLogger.log("\(error.localizedDescription)", for: .error)
+ return .just([])
+ }
+ .eraseToAnyPublisher()
}
}
diff --git a/Wable-iOS/Presentation/Enum/DefaultProfileType.swift b/Wable-iOS/Presentation/Enum/DefaultProfileType.swift
index 077ebbcd..e8d107ff 100644
--- a/Wable-iOS/Presentation/Enum/DefaultProfileType.swift
+++ b/Wable-iOS/Presentation/Enum/DefaultProfileType.swift
@@ -8,7 +8,7 @@
import Foundation
-enum DefaultProfileType: String {
+enum DefaultProfileType: String, CaseIterable {
case green = "img_profile_green"
case blue = "img_profile_blue"
case purple = "img_profile_purple"
diff --git a/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift b/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift
new file mode 100644
index 00000000..20642d1b
--- /dev/null
+++ b/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift
@@ -0,0 +1,93 @@
+//
+// UIViewController+KeyboardHandling.swift
+// Wable-iOS
+//
+// Created by YOUJIM on 6/18/25.
+//
+
+
+import UIKit
+
+// MARK: - Keyboard Handling Extension
+
+extension UIViewController {
+ func enableKeyboardHandling() {
+ NotificationCenter.default.addObserver(
+ forName: UIResponder.keyboardWillShowNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ self?.handleKeyboardShow(notification: notification)
+ }
+
+ NotificationCenter.default.addObserver(
+ forName: UIResponder.keyboardWillHideNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] notification in
+ self?.handleKeyboardHide(notification: notification)
+ }
+ }
+
+ func disableKeyboardHandling() {
+ NotificationCenter.default.removeObserver(self)
+ }
+}
+
+// MARK: - Helper Method
+
+private extension UIViewController {
+ private func handleKeyboardShow(notification: Notification) {
+ guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
+ let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
+ let animationCurve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else {
+ return
+ }
+
+ let window = UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .flatMap { $0.windows }
+ .first { $0.isKeyWindow }
+
+ animateViewTransform(
+ window: window,
+ yOffset: -(keyboardFrame.cgRectValue.height - (window?.safeAreaInsets.bottom ?? 0)),
+ duration: animationDuration,
+ curve: animationCurve
+ )
+ }
+
+ private func handleKeyboardHide(notification: Notification) {
+ guard let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double,
+ let animationCurve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt
+ else {
+ return
+ }
+
+ animateViewTransform(
+ window: UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .flatMap { $0.windows }
+ .first { $0.isKeyWindow },
+ yOffset: 0,
+ duration: animationDuration,
+ curve: animationCurve
+ )
+ }
+
+ private func animateViewTransform(window: UIWindow?, yOffset: CGFloat, duration: Double, curve: UInt) {
+ guard let window = window else { return }
+
+ let animationOptions = UIView.AnimationOptions(rawValue: curve << 16)
+
+ UIView.animate(
+ withDuration: duration,
+ delay: 0,
+ options: [animationOptions, .beginFromCurrentState],
+ animations: {
+ window.transform = CGAffineTransform(translationX: 0, y: yOffset)
+ },
+ completion: nil
+ )
+ }
+}
diff --git a/Wable-iOS/Presentation/Helper/UIAlertShowable.swift b/Wable-iOS/Presentation/Helper/UIAlertShowable.swift
index 384dcf66..38b71c58 100644
--- a/Wable-iOS/Presentation/Helper/UIAlertShowable.swift
+++ b/Wable-iOS/Presentation/Helper/UIAlertShowable.swift
@@ -11,6 +11,7 @@ import UIKit
protocol UIAlertShowable: AnyObject {
func showAlert(title: String, message: String?, actions: UIAlertAction...)
+ func showAlert(title: String, message: String?, actions: [UIAlertAction])
}
extension UIAlertShowable where Self: UIViewController {
@@ -20,6 +21,12 @@ extension UIAlertShowable where Self: UIViewController {
present(alert, animated: true)
}
+ func showAlert(title: String, message: String?, actions: [UIAlertAction]) {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ actions.forEach { alert.addAction($0) }
+ present(alert, animated: true)
+ }
+
func showAlertWithCancel(title: String, message: String? = nil, action: UIAlertAction) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: "취소", style: .cancel)
diff --git a/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift
index 66244756..022c95da 100644
--- a/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift
+++ b/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift
@@ -11,6 +11,7 @@ import UIKit
protocol WableBottomSheetShowable: AnyObject {
func showBottomSheet(actions: WableBottomSheetAction...)
+ func showBottomSheet(actions: [WableBottomSheetAction])
}
extension WableBottomSheetShowable where Self: UIViewController {
@@ -19,6 +20,12 @@ extension WableBottomSheetShowable where Self: UIViewController {
actions.forEach { wableBottomSheet.addActions($0) }
present(wableBottomSheet, animated: true)
}
+
+ func showBottomSheet(actions: [WableBottomSheetAction]) {
+ let wableBottomSheet = WableBottomSheetController()
+ wableBottomSheet.addActions(actions)
+ present(wableBottomSheet, animated: true)
+ }
}
// MARK: - UIViewController Extension
diff --git a/Wable-iOS/Presentation/Helper/WableSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableSheetShowable.swift
index 70aba696..7219563c 100644
--- a/Wable-iOS/Presentation/Helper/WableSheetShowable.swift
+++ b/Wable-iOS/Presentation/Helper/WableSheetShowable.swift
@@ -11,6 +11,7 @@ import UIKit
protocol WableSheetShowable {
func showWableSheet(title: String, message: String?, actions: WableSheetAction...)
+ func showWableSheet(title: String, message: String?, actions: [WableSheetAction])
}
extension WableSheetShowable where Self: UIViewController {
@@ -20,6 +21,12 @@ extension WableSheetShowable where Self: UIViewController {
present(wableSheet, animated: true)
}
+ func showWableSheet(title: String, message: String?, actions: [WableSheetAction]) {
+ let wableSheet = WableSheetViewController(title: title, message: message)
+ wableSheet.addActions(actions)
+ present(wableSheet, animated: true)
+ }
+
func showWableSheetWithCancel(title: String, message: String? = nil, action: WableSheetAction) {
let wableSheet = WableSheetViewController(title: title, message: message)
let cancelAction = WableSheetAction(title: "취소", style: .gray)
diff --git a/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift
new file mode 100644
index 00000000..a1ee1370
--- /dev/null
+++ b/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift
@@ -0,0 +1,55 @@
+//
+// WableTextSheetShowable.swift
+// Wable-iOS
+//
+// Created by 김진웅 on 6/23/25.
+//
+
+import UIKit
+
+// MARK: - WableTextSheetShowable
+
+protocol WableTextSheetShowable {
+ func showWableTextSheet(title: String, placeholder: String, actions: WableTextSheetAction...)
+ func showWableTextSheet(title: String, placeholder: String, actions: [WableTextSheetAction])
+}
+
+extension WableTextSheetShowable where Self: UIViewController {
+ func showWableTextSheet(title: String, placeholder: String, actions: WableTextSheetAction...) {
+ let wableTextSheet = WableTextSheetViewController(title: title, placeholder: placeholder)
+ actions.forEach { wableTextSheet.addAction($0) }
+ present(wableTextSheet, animated: true)
+ }
+
+ func showWableTextSheet(title: String, placeholder: String, actions: [WableTextSheetAction]) {
+ let wableTextSheet = WableTextSheetViewController(title: title, placeholder: placeholder)
+ wableTextSheet.addActions(actions)
+ present(wableTextSheet, animated: true)
+ }
+
+ func showGhostSheet(onCancel cancelAction: (() -> Void)?, onPrimary primaryAction: @escaping (String?) -> Void) {
+ let wableTextSheet = WableTextSheetViewController(
+ title: StringLiterals.Ghost.sheetTitle,
+ placeholder: StringLiterals.Ghost.sheetPlaceholder
+ )
+ let cancel = WableTextSheetAction(title: "고민할게요", style: .gray, handler: { _ in cancelAction?() })
+ let confirm = WableTextSheetAction(title: "투명도 내리기", style: .primary, handler: primaryAction)
+ wableTextSheet.addActions(cancel, confirm)
+ present(wableTextSheet, animated: true)
+ }
+
+ func showReportSheet(onPrimary handler: @escaping (String?) -> Void) {
+ let wableTextSheet = WableTextSheetViewController(
+ title: StringLiterals.Report.sheetTitle,
+ placeholder: StringLiterals.Report.sheetPlaceholder
+ )
+ let cancel = WableTextSheetAction(title: "고민할게요", style: .gray)
+ let confirm = WableTextSheetAction(title: "신고하기", style: .primary, handler: handler)
+ wableTextSheet.addActions(cancel, confirm)
+ present(wableTextSheet, animated: true)
+ }
+}
+
+// MARK: - UIViewController Extension
+
+extension UIViewController: WableTextSheetShowable {}
diff --git a/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift b/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift
index d3f7e89d..cc357817 100644
--- a/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift
+++ b/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift
@@ -40,7 +40,7 @@ final class HomeDetailViewController: NavigationViewController {
private let didCommentHeartTappedSubject = PassthroughSubject<(Bool, ContentComment), Never>()
private let didReplyTappedSubject = PassthroughSubject<(Int, Int), Never>()
private let didCommentTappedSubject = PassthroughSubject()
- private let didGhostTappedSubject = PassthroughSubject<(Int, Int, PostType), Never>()
+ private let didGhostTappedSubject = PassthroughSubject<(Int, Int, String?, PostType), Never>()
private let didDeleteTappedSubject = PassthroughSubject<(Int, PostType), Never>()
private let didBannedTappedSubject = PassthroughSubject<(Int, Int, TriggerType.Ban), Never>()
private let didReportTappedSubject = PassthroughSubject<(String, String), Never>()
@@ -203,7 +203,10 @@ private extension HomeDetailViewController {
let contentCellRegistration = UICollectionView.CellRegistration <
ContentCollectionViewCell,
Content
- > { [weak self] cell, indexPath, item in
+ > {
+ [weak self] cell,
+ indexPath,
+ item in
guard let self = self else { return }
cell.divideView.snp.updateConstraints { make in
@@ -217,7 +220,8 @@ private extension HomeDetailViewController {
contentImageViewTapHandler: {
guard let image = cell.contentImageView.image else { return }
- self.present(PhotoDetailViewController(image: image), animated: true)
+ let photoDetailViewController = PhotoDetailViewController(image: image)
+ self.navigationController?.pushViewController(photoDetailViewController, animated: true)
},
likeButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickLikeComment)
@@ -249,50 +253,46 @@ private extension HomeDetailViewController {
})
}))
} else if self.isActiveUserAdmin ?? false {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(title: "취소", style: .gray),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }), WableBottomSheetAction(title: "밴하기", handler: {
- self.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id, .content))
- })
- )
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send(
+ (
+ item.content.contentInfo.author.nickname,
+ message ?? item.content.contentInfo.text
+ )
+ )
+ })
+ })
+ }
+ let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in
+ self?.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id, .content))
+ }
+ self.showBottomSheet(actions: reportAction, banAction)
} else {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(title: "취소", style: .gray),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text))
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send(
+ (
+ item.content.contentInfo.author.nickname,
+ message ?? item.content.contentInfo.text
+ )
+ )
})
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }))
+ })
+ })
+ }
+ self.showBottomSheet(actions: reportAction)
}
self.present(viewController, animated: true)
@@ -321,30 +321,13 @@ private extension HomeDetailViewController {
},
ghostButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickGhostPost)
-
- let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(
- title: "고민할게요",
- style: .gray,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
- }),
- WableSheetAction(
- title: "네 맞아요",
- style: .primary,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
-
- viewController.dismiss(animated: true, completion: {
- self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, .content))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
+ self.showGhostSheet(onCancel: {
+ AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
+ }, onPrimary: { message in
+ AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
+
+ self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, message, .content))
+ })
}
)
@@ -372,7 +355,10 @@ private extension HomeDetailViewController {
let commentCellRegistration = UICollectionView.CellRegistration <
CommentCollectionViewCell,
ContentComment
- > { [weak self] cell, indexPath, item in
+ > {
+ [weak self] cell,
+ indexPath,
+ item in
guard let self = self else { return }
cell.configureCell(
@@ -413,62 +399,37 @@ private extension HomeDetailViewController {
})
}))
} else if self.isActiveUserAdmin ?? false {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(
- title: "취소",
- style: .gray,
- handler: {
- viewController.dismiss(animated: true)
- }
- ),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.comment.author.nickname, item.comment.text))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }), WableBottomSheetAction(title: "밴하기", handler: {
- self.didBannedTappedSubject.send((item.comment.author.id, item.comment.id, .comment))
- })
- )
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send((item.comment.author.nickname, message ?? item.comment.text))
+ })
+ })
+ }
+ let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in
+ self?.didBannedTappedSubject.send((item.comment.author.id, item.comment.id, .comment))
+ }
+ self.showBottomSheet(actions: reportAction, banAction)
} else {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(
- title: "취소",
- style: .gray,
- handler: {
- viewController.dismiss(animated: true)
- }
- ),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.comment.author.nickname, item.comment.text))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }))
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send(
+ (
+ item.comment.author.nickname,
+ message ?? item.comment.text
+ )
+ )
+ })
+ })
+ }
+ self.showBottomSheet(actions: reportAction)
}
self.present(viewController, animated: true)
@@ -497,33 +458,13 @@ private extension HomeDetailViewController {
},
ghostButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickGhostComment)
-
- let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(
- title: "고민할게요",
- style: .gray,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
-
- viewController.dismiss(animated: true)
- }
- ),
- WableSheetAction(
- title: "네 맞아요",
- style: .primary,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
-
- viewController.dismiss(animated: true, completion: {
- self.didGhostTappedSubject.send((item.comment.id, item.comment.author.id, .comment))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
+ self.showGhostSheet(onCancel: {
+ AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
+ }, onPrimary: { message in
+ AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
+
+ self.didGhostTappedSubject.send((item.comment.id, item.comment.author.id, message, .comment))
+ })
},
replyButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickWriteRecomment)
@@ -650,6 +591,12 @@ private extension HomeDetailViewController {
}
.store(in: cancelBag)
+ output.contentNotFound
+ .sink { [weak self] _ in
+ self?.showNotFoundViewController()
+ }
+ .store(in: cancelBag)
+
output.comments
.receive(on: DispatchQueue.main)
.withUnretained(self)
@@ -857,6 +804,15 @@ extension HomeDetailViewController {
}
}
}
+
+ func showNotFoundViewController() {
+ let tabBar = navigationController?.tabBarController
+ let notFoundViewController = NotFoundViewController { [weak self] in
+ self?.navigationController?.popToRootViewController(animated: false)
+ tabBar?.selectedIndex = 0
+ }
+ present(notFoundViewController, animated: true)
+ }
}
// MARK: - UITextViewDelegate
diff --git a/Wable-iOS/Presentation/Home/View/HomeViewController.swift b/Wable-iOS/Presentation/Home/View/HomeViewController.swift
index 4e67a0e4..e6cf0ea7 100644
--- a/Wable-iOS/Presentation/Home/View/HomeViewController.swift
+++ b/Wable-iOS/Presentation/Home/View/HomeViewController.swift
@@ -33,7 +33,7 @@ final class HomeViewController: NavigationViewController {
private let didRefreshSubject = PassthroughSubject()
private let didSelectedSubject = PassthroughSubject()
private let didHeartTappedSubject = PassthroughSubject<(Int, Bool), Never>()
- private let didGhostTappedSubject = PassthroughSubject<(Int, Int), Never>()
+ private let didGhostTappedSubject = PassthroughSubject<(Int, Int, String?), Never>()
private let didDeleteTappedSubject = PassthroughSubject()
private let didBannedTappedSubject = PassthroughSubject<(Int, Int), Never>()
private let didReportTappedSubject = PassthroughSubject<(String, String), Never>()
@@ -186,7 +186,7 @@ private extension HomeViewController {
return
}
- self.present(PhotoDetailViewController(image: image), animated: true)
+ self.navigationController?.pushViewController(PhotoDetailViewController(image: image), animated: true)
},
likeButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickLikePost)
@@ -220,50 +220,42 @@ private extension HomeViewController {
})
}))
} else if self.isActiveUserAdmin ?? false {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(title: "취소", style: .gray),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }), WableBottomSheetAction(title: "밴하기", handler: {
- self.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id))
- })
- )
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send(
+ (
+ item.content.contentInfo.author.nickname,
+ message ?? item.content.contentInfo.text
+ )
+ )
+ })
+ })
+ }
+ let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in
+ self?.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id))
+ }
+ self.showBottomSheet(actions: reportAction, banAction)
} else {
- viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: {
- viewController.dismiss(animated: true, completion: {
- let viewController = WableSheetViewController(title: "신고하시겠어요?")
-
- viewController.addActions(
- WableSheetAction(title: "취소", style: .gray),
- WableSheetAction(
- title: "신고하기",
- style: .primary,
- handler: {
- viewController.dismiss(animated: true, completion: {
- self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
- })
- }))
+ let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
+ self?.showReportSheet(
+ onPrimary: { message in
+ viewController.dismiss(
+ animated: true,
+ completion: {
+ self?.didReportTappedSubject.send(
+ (
+ item.content.contentInfo.author.nickname,
+ message ?? item.content.contentInfo.text
+ )
+ )
+ })
+ })
+ }
+ self.showBottomSheet(actions: reportAction)
}
self.present(viewController, animated: true)
@@ -295,31 +287,13 @@ private extension HomeViewController {
},
ghostButtonTapHandler: {
AmplitudeManager.shared.trackEvent(tag: .clickGhostPost)
-
- let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle)
-
- viewController.addActions(
- WableSheetAction(
- title: "고민할게요",
- style: .gray,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
- }
- ),
- WableSheetAction(
- title: "네 맞아요",
- style: .primary,
- handler: {
- AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
-
- viewController.dismiss(animated: true, completion: {
- self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id))
- })
- }
- )
- )
-
- self.present(viewController, animated: true)
+ self.showGhostSheet(onCancel: {
+ AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
+ }, onPrimary: { message in
+ AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
+
+ self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, message))
+ })
}
)
}
@@ -508,6 +482,7 @@ private extension HomeViewController {
)
)
)
+
viewController.onPostCompleted = { [weak self] in
self?.scrollToTop()
}
diff --git a/Wable-iOS/Presentation/Home/View/WritePostViewController.swift b/Wable-iOS/Presentation/Home/View/WritePostViewController.swift
index 14b05d66..99c2a55f 100644
--- a/Wable-iOS/Presentation/Home/View/WritePostViewController.swift
+++ b/Wable-iOS/Presentation/Home/View/WritePostViewController.swift
@@ -208,6 +208,8 @@ private extension WritePostViewController {
self.isPosting = false
self.updatePostButtonState(isLoading: false)
+ self.onPostCompleted?()
+
if self.navigationController?.topViewController == self {
let toast = ToastView(status: .complete, message: "게시물이 작성되었습니다")
toast.show()
diff --git a/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift b/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift
index 690aa864..cdc1f8d3 100644
--- a/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift
+++ b/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift
@@ -67,7 +67,7 @@ extension HomeDetailViewModel: ViewModelType {
let didCommentTappedItem: AnyPublisher
let didReplyTappedItem: AnyPublisher<(Int, Int), Never>
let didCreateTappedItem: AnyPublisher
- let didGhostTappedItem: AnyPublisher<(Int, Int, PostType), Never>
+ let didGhostTappedItem: AnyPublisher<(Int, Int, String?, PostType), Never>
let didDeleteTappedItem: AnyPublisher<(Int, PostType), Never>
let didBannedTappedItem: AnyPublisher<(Int, Int, TriggerType.Ban), Never>
let didReportTappedItem: AnyPublisher<(String, String), Never>
@@ -78,6 +78,7 @@ extension HomeDetailViewModel: ViewModelType {
let activeUserID: AnyPublisher
let isAdmin: AnyPublisher
let content: AnyPublisher
+ let contentNotFound: AnyPublisher
let comments: AnyPublisher<[ContentComment], Never>
let isLoading: AnyPublisher
let isLoadingMore: AnyPublisher
@@ -89,6 +90,7 @@ extension HomeDetailViewModel: ViewModelType {
func transform(input: Input, cancelBag: CancelBag) -> Output {
let contentSubject = CurrentValueSubject(nil)
+ let contentNotFoundSubject = PassthroughSubject()
let commentsSubject = CurrentValueSubject<[ContentComment], Never>([])
let isLoadingSubject = CurrentValueSubject(false)
let isLoadingMoreSubject = CurrentValueSubject(false)
@@ -142,6 +144,13 @@ extension HomeDetailViewModel: ViewModelType {
.map { contentInfo -> ContentInfo? in
return contentInfo
}
+ .catch { error -> AnyPublisher in
+ WableLogger.log("\(error.localizedDescription)", for: .error)
+ if case WableError.notFoundContent = error {
+ contentNotFoundSubject.send()
+ }
+ return .just(nil)
+ }
.replaceError(with: nil)
let commentsPublisher = owner.fetchContentCommentListUseCase.execute(
@@ -303,7 +312,7 @@ extension HomeDetailViewModel: ViewModelType {
input.didGhostTappedItem
.withUnretained(self)
.flatMap { owner, input -> AnyPublisher in
- return owner.fetchGhostUseCase.execute(type: input.2, targetID: input.0, userID: input.1)
+ return owner.fetchGhostUseCase.execute(type: input.3, targetID: input.0, userID: input.1, reason: input.2)
.map { _ in input.1 }
.asDriver(onErrorJustReturn: input.1)
}
@@ -487,6 +496,7 @@ extension HomeDetailViewModel: ViewModelType {
activeUserID: activeUserIDSubject.eraseToAnyPublisher(),
isAdmin: isAdminSubject.eraseToAnyPublisher(),
content: contentSubject.eraseToAnyPublisher(),
+ contentNotFound: contentNotFoundSubject.asDriver(),
comments: commentsSubject.eraseToAnyPublisher(),
isLoading: isLoadingSubject.eraseToAnyPublisher(),
isLoadingMore: isLoadingMoreSubject.eraseToAnyPublisher(),
diff --git a/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift b/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift
index 021700d2..891fdfdc 100644
--- a/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift
+++ b/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift
@@ -46,7 +46,7 @@ extension HomeViewModel: ViewModelType {
let viewDidRefresh: AnyPublisher
let didSelectedItem: AnyPublisher
let didHeartTappedItem: AnyPublisher<(Int, Bool), Never>
- let didGhostTappedItem: AnyPublisher<(Int, Int), Never>
+ let didGhostTappedItem: AnyPublisher<(Int, Int, String?), Never>
let didDeleteTappedItem: AnyPublisher
let didBannedTappedItem: AnyPublisher<(Int, Int), Never>
let didReportTappedItem: AnyPublisher<(String, String), Never>
@@ -197,7 +197,7 @@ extension HomeViewModel: ViewModelType {
input.didGhostTappedItem
.withUnretained(self)
.flatMap { owner, id -> AnyPublisher in
- return owner.fetchGhostUseCase.execute(type: .content, targetID: id.0, userID: id.1)
+ return owner.fetchGhostUseCase.execute(type: .content, targetID: id.0, userID: id.1, reason: id.2)
.map { _ in id.1 }
.asDriver(onErrorJustReturn: id.1)
}
diff --git a/Wable-iOS/Presentation/Login/LoginViewController.swift b/Wable-iOS/Presentation/Login/LoginViewController.swift
index 1469be9f..08a283f5 100644
--- a/Wable-iOS/Presentation/Login/LoginViewController.swift
+++ b/Wable-iOS/Presentation/Login/LoginViewController.swift
@@ -32,33 +32,33 @@ final class LoginViewController: UIViewController {
}
private let titleLabel: UILabel = UILabel().then {
- $0.attributedText = StringLiterals.Login.title.pretendardString(with: .head0)
- $0.textAlignment = .center
$0.numberOfLines = 0
- $0.textColor = .black
+ $0.textColor = .wableBlack
+ $0.textAlignment = .center
+ $0.attributedText = StringLiterals.Login.title.pretendardString(with: .head0)
}
private lazy var kakaoButton: UIButton = UIButton().then {
- $0.setImage(.btnKakao, for: .normal)
- $0.imageView?.contentMode = .scaleAspectFit
+ $0.clipsToBounds = true
$0.contentVerticalAlignment = .fill
+ $0.setImage(.btnKakao, for: .normal)
$0.contentHorizontalAlignment = .fill
$0.backgroundColor = UIColor("FEE500")
$0.layer.cornerRadius = 6.adjustedHeight
- $0.clipsToBounds = true
+ $0.imageView?.contentMode = .scaleAspectFit
}
private lazy var appleButton: UIButton = UIButton().then {
- $0.setImage(.btnApple, for: .normal)
- $0.imageView?.contentMode = .scaleAspectFit
+ $0.clipsToBounds = true
+ $0.backgroundColor = .wableBlack
$0.contentVerticalAlignment = .fill
+ $0.setImage(.btnApple, for: .normal)
$0.contentHorizontalAlignment = .fill
- $0.backgroundColor = .wableBlack
$0.layer.cornerRadius = 6.adjustedHeight
- $0.clipsToBounds = true
+ $0.imageView?.contentMode = .scaleAspectFit
}
- // MARK: - LifeCycle
+ // MARK: - Life Cycle
init(viewModel: LoginViewModel) {
self.viewModel = viewModel
@@ -66,6 +66,7 @@ final class LoginViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
}
+ @available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -73,19 +74,15 @@ final class LoginViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
- setupView()
setupConstraint()
setupBinding()
}
}
-// MARK: - Private Extension
+// MARK: - Setup Method
private extension LoginViewController {
-
- // MARK: - Setup
-
- func setupView() {
+ func setupConstraint() {
view.addSubviews(
backgroundImageView,
logoImageView,
@@ -94,40 +91,38 @@ private extension LoginViewController {
kakaoButton,
appleButton
)
- }
-
- func setupConstraint() {
+
backgroundImageView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
logoImageView.snp.makeConstraints {
- $0.top.equalTo(view.safeAreaLayoutGuide).offset(44)
- $0.centerX.equalToSuperview()
$0.adjustedWidthEqualTo(104)
$0.adjustedHeightEqualTo(34)
+ $0.centerX.equalToSuperview()
+ $0.top.equalTo(view.safeAreaLayoutGuide).offset(44)
}
titleLabel.snp.makeConstraints {
- $0.top.equalTo(logoImageView.snp.bottom).offset(26)
$0.centerX.equalToSuperview()
+ $0.top.equalTo(logoImageView.snp.bottom).offset(26)
}
loginImageView.snp.makeConstraints {
- $0.top.equalTo(titleLabel.snp.bottom).offset(42)
$0.horizontalEdges.equalToSuperview()
+ $0.top.equalTo(titleLabel.snp.bottom).offset(42)
}
appleButton.snp.makeConstraints {
- $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(56)
- $0.horizontalEdges.equalToSuperview().inset(16)
$0.adjustedHeightEqualTo(50)
+ $0.horizontalEdges.equalToSuperview().inset(16)
+ $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(56)
}
kakaoButton.snp.makeConstraints {
- $0.bottom.equalTo(appleButton.snp.top).offset(-18)
- $0.horizontalEdges.equalToSuperview().inset(16)
$0.adjustedHeightEqualTo(50)
+ $0.horizontalEdges.equalToSuperview().inset(16)
+ $0.bottom.equalTo(appleButton.snp.top).offset(-18)
}
}
@@ -156,16 +151,31 @@ private extension LoginViewController {
.sink { owner, sessionInfo in
let condition = sessionInfo.isNewUser || sessionInfo.user.nickname == ""
- if condition {
- AmplitudeManager.shared.trackEvent(tag: .clickAgreePopupSignup)
- }
-
+ if condition { AmplitudeManager.shared.trackEvent(tag: .clickAgreePopupSignup) }
condition ? owner.navigateToOnboarding() : owner.navigateToHome()
}
.store(in: cancelBag)
+
+ output.error
+ .receive(on: DispatchQueue.main)
+ .withUnretained(self)
+ .sink { owner, error in
+ let toast = WableSheetViewController(
+ title: "로그인 중 오류가 발생했어요",
+ message: "\(error.localizedDescription)\n다시 시도해주세요."
+ )
+
+ toast.addAction(.init(title: "확인", style: .primary))
+ owner.present(toast, animated: true)
+ }
+ .store(in: cancelBag)
}
-
- private func navigateToOnboarding() {
+}
+
+// MARK: - Helper Method
+
+private extension LoginViewController {
+ func navigateToOnboarding() {
let noticeViewController = WableSheetViewController(
title: "앗 잠깐!",
message: StringLiterals.Onboarding.enterSheetTitle
@@ -183,9 +193,10 @@ private extension LoginViewController {
present(noticeViewController, animated: true)
}
- private func navigateToHome() {
+ func navigateToHome() {
let tabBarController = TabBarController()
present(tabBarController, animated: true)
}
}
+
diff --git a/Wable-iOS/Presentation/Login/LoginViewModel.swift b/Wable-iOS/Presentation/Login/LoginViewModel.swift
index 1a68dd04..25a4b9b1 100644
--- a/Wable-iOS/Presentation/Login/LoginViewModel.swift
+++ b/Wable-iOS/Presentation/Login/LoginViewModel.swift
@@ -14,30 +14,15 @@ final class LoginViewModel {
// MARK: Property
- private let updateFCMTokenUseCase: UpdateFCMTokenUseCase
- private let fetchUserAuthUseCase: FetchUserAuthUseCase
- private let updateUserSessionUseCase: FetchUserInformationUseCase
- private let userProfileUseCase: UserProfileUseCase
private let loginSuccessSubject = PassthroughSubject()
private let loginErrorSubject = PassthroughSubject()
- // MARK: - LifeCycle
-
- init(
- updateFCMTokenUseCase: UpdateFCMTokenUseCase,
- fetchUserAuthUseCase: FetchUserAuthUseCase,
- updateUserSessionUseCase: FetchUserInformationUseCase,
- userProfileUseCase: UserProfileUseCase
- ) {
- self.updateFCMTokenUseCase = updateFCMTokenUseCase
- self.fetchUserAuthUseCase = fetchUserAuthUseCase
- self.updateUserSessionUseCase = updateUserSessionUseCase
- self.userProfileUseCase = userProfileUseCase
- }
+ @Injected private var tokenStorage: TokenStorage
+ @Injected private var loginRepository: LoginRepository
+ @Injected private var profileRepository: ProfileRepository
+ @Injected private var userSessionRepository: UserSessionRepository
}
-// MARK: - Extension
-
extension LoginViewModel: ViewModelType {
struct Input {
let kakaoLoginTrigger: AnyPublisher
@@ -45,92 +30,110 @@ extension LoginViewModel: ViewModelType {
}
struct Output {
+ let error: AnyPublisher
let account: AnyPublisher
}
func transform(input: Input, cancelBag: CancelBag) -> Output {
- let kakaoLoginTrigger = input.kakaoLoginTrigger
- .map { SocialPlatform.kakao }
- let appleLoginTrigger = input.appleLoginTrigger
- .map { SocialPlatform.apple }
+ let kakaoLoginTrigger = input.kakaoLoginTrigger.map { SocialPlatform.kakao }
+ let appleLoginTrigger = input.appleLoginTrigger.map { SocialPlatform.apple }
Publishers.Merge(appleLoginTrigger, kakaoLoginTrigger)
.withUnretained(self)
- .flatMap { owner, flatform -> AnyPublisher in
- return owner.fetchUserAuthUseCase.execute(platform: flatform)
- .handleEvents(receiveCompletion: { completion in
- if case .failure(let error) = completion {
- WableLogger.log("로그인 중 오류 발생: \(error)", for: .error)
- }
- })
- .catch { error -> AnyPublisher in
- return Empty().eraseToAnyPublisher()
- }
- .eraseToAnyPublisher()
+ .flatMap { owner, platform -> AnyPublisher in
+ return owner.fetchUserAuth(platform: platform)
}
- .sink(
- receiveCompletion: { completion in
- WableLogger.log("로그인 작업 완료", for: .debug)
- },
- receiveValue: { [weak self] account in
- guard let self = self else { return }
-
- self.updateFCMTokenUseCase.execute(nickname: account.user.nickname)
- .sink { completion in
- if case .failure(let error) = completion {
- WableLogger.log("FCM 토큰 저장 중 에러 발생: \(error)", for: .error)
- } else {
- WableLogger.log("FCM 토큰 저장 성공", for: .network)
- }
- } receiveValue: { () in
- }
- .store(in: cancelBag)
-
- Task {
- let isAuthorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus == .authorized
-
- WableLogger.log("\(isAuthorized)", for: .debug)
-
- self.updateUserSessionUseCase.updateUserSession(
- userID: account.user.id,
- nickname: account.user.nickname,
- profileURL: account.user.profileURL,
- isPushAlarmAllowed: isAuthorized,
- isAdmin: account.isAdmin,
- isAutoLoginEnabled: true
- )
- .sink(receiveCompletion: { _ in
- }, receiveValue: { _ in
- WableLogger.log("로컬에 세션 저장 완료", for: .debug)
- })
- .store(in: cancelBag)
-
- self.userProfileUseCase.execute(userID: account.user.id)
- .sink { _ in
- } receiveValue: { profile in
- self.userProfileUseCase.execute(profile: profile, isPushAlarmAllowed: isAuthorized)
- .sink { completion in
- switch completion {
- case .failure(let error):
- WableLogger.log("서버로 프로필 업데이트 중 오류 발생: \(error)", for: .error)
- default:
- WableLogger.log("로그인 및 서버로 프로필 업데이트 완료", for: .debug)
- }
- } receiveValue: { _ in
-
- }
- .store(in: cancelBag)
- }
- .store(in: cancelBag)
- }
-
- self.loginSuccessSubject.send(account)
- }
- )
+ .sink(receiveValue: { [weak self] account in
+ guard let self = self else { return }
+
+ self.updateFCMToken(account: account, cancelBag: cancelBag)
+ self.updateUserProfile(userID: account.user.id, cancelBag: cancelBag)
+ self.loginSuccessSubject.send(account)
+ })
.store(in: cancelBag)
return Output(
+ error: loginErrorSubject.eraseToAnyPublisher(),
account: loginSuccessSubject.eraseToAnyPublisher()
)
}
}
+
+// MARK: - Helper Method
+
+private extension LoginViewModel {
+ func fetchUserAuth(platform: SocialPlatform) -> AnyPublisher {
+ return loginRepository.fetchUserAuth(platform: platform, userName: nil)
+ .handleEvents(receiveOutput: { [weak self] account in
+ guard let self = self else { return }
+
+ self.updateToken(accessToken: account.token.accessToken, refreshToken: account.token.refreshToken)
+ self.updateUserSession(account: account)
+ })
+ .catch { [weak self] error -> AnyPublisher in
+ self?.loginErrorSubject.send(error)
+ return Empty().eraseToAnyPublisher()
+ }
+ .eraseToAnyPublisher()
+ }
+
+ func updateToken(accessToken: String, refreshToken: String) {
+ do {
+ try self.tokenStorage.save(accessToken, for: .wableAccessToken)
+ try self.tokenStorage.save(refreshToken, for: .wableRefreshToken)
+ } catch let error as WableError {
+ loginErrorSubject.send(error)
+ } catch {
+ loginErrorSubject.send(.unknownError)
+ }
+ }
+
+ func updateUserSession(account: Account) {
+ userSessionRepository.updateUserSession(
+ userID: account.user.id,
+ nickname: account.user.nickname,
+ profileURL: account.user.profileURL,
+ isPushAlarmAllowed: account.isPushAlarmAllowed ?? false,
+ isAdmin: account.isAdmin,
+ isAutoLoginEnabled: true,
+ notificationBadgeCount: nil
+ )
+
+ userSessionRepository.updateActiveUserID(account.user.id)
+ }
+
+ func updateFCMToken(account: Account, cancelBag: CancelBag) {
+ guard let token = profileRepository.fetchFCMToken() else { return }
+
+ profileRepository.updateUserProfile(nickname: account.user.nickname, fcmToken: token)
+ .catch { [weak self] error -> AnyPublisher in
+ self?.loginErrorSubject.send(error)
+ return .just(())
+ }
+ .sink(receiveValue: {})
+ .store(in: cancelBag)
+ }
+
+ func updateUserProfile(userID: Int, cancelBag: CancelBag) {
+ Task {
+ let authorizedStatus = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
+ let isAuthorized = authorizedStatus == .authorized
+ let profile = try await profileRepository.fetchUserProfile(memberID: userID)
+
+ profileRepository.updateUserProfile(
+ profile: profile,
+ isPushAlarmAllowed: isAuthorized,
+ isAlarmAllowed: nil,
+ image: nil,
+ fcmToken: nil,
+ defaultProfileType: nil
+ )
+ .catch { [weak self] error -> AnyPublisher in
+ self?.loginErrorSubject.send(error)
+ return .just(())
+ }
+ .sink(receiveValue: {})
+ .store(in: cancelBag)
+ }
+ }
+}
diff --git a/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift b/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift
index 4e566ec4..0c6761b1 100644
--- a/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift
+++ b/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift
@@ -192,37 +192,12 @@ private extension InformationNotificationViewController {
}
.store(in: cancelBag)
+ let tabBar = navigationController?.tabBarController
output.selectedNotification
.receive(on: DispatchQueue.main)
- .sink { [weak self] item in
- let viewController = HomeDetailViewController(
- viewModel: HomeDetailViewModel(
- contentID: item.id,
- fetchContentInfoUseCase: FetchContentInfoUseCase(repository: ContentRepositoryImpl()),
- fetchContentCommentListUseCase: FetchContentCommentListUseCase(repository: CommentRepositoryImpl()),
- createCommentUseCase: CreateCommentUseCase(repository: CommentRepositoryImpl()),
- deleteCommentUseCase: DeleteCommentUseCase(repository: CommentRepositoryImpl()),
- createContentLikedUseCase: CreateContentLikedUseCase(repository: ContentLikedRepositoryImpl()),
- deleteContentLikedUseCase: DeleteContentLikedUseCase(repository: ContentLikedRepositoryImpl()),
- createCommentLikedUseCase: CreateCommentLikedUseCase(repository: CommentLikedRepositoryImpl()),
- deleteCommentLikedUseCase: DeleteCommentLikedUseCase(repository: CommentLikedRepositoryImpl()),
- fetchUserInformationUseCase: FetchUserInformationUseCase(
- repository: UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(
- jsonEncoder: JSONEncoder(),
- jsonDecoder: JSONDecoder()
- )
- )
- ),
- fetchGhostUseCase: FetchGhostUseCase(repository: GhostRepositoryImpl()),
- createReportUseCase: CreateReportUseCase(repository: ReportRepositoryImpl()),
- createBannedUseCase: CreateBannedUseCase(repository: ReportRepositoryImpl()),
- deleteContentUseCase: DeleteContentUseCase(repository: ContentRepositoryImpl())
- ),
- cancelBag: CancelBag()
- )
-
- self?.navigationController?.pushViewController(viewController, animated: true)
+ .sink { [weak self] _ in
+ self?.navigationController?.popToRootViewController(animated: false)
+ tabBar?.selectedIndex = 2
}
.store(in: cancelBag)
diff --git a/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift b/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift
deleted file mode 100644
index 138ecf27..00000000
--- a/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-//
-// LCKTeamView.swift
-// Wable-iOS
-//
-// Created by YOUJIM on 3/20/25.
-//
-
-
-import UIKit
-
-final class LCKTeamView: UIView {
-
- // MARK: - UIComponent
-
- private let titleLabel: UILabel = UILabel().then {
- $0.attributedText = StringLiterals.Onboarding.teamSheetTitle.pretendardString(with: .head0)
- $0.textColor = .wableBlack
- }
-
- private let descriptionLabel: UILabel = UILabel().then {
- $0.attributedText = StringLiterals.Onboarding.teamSheetMessage.pretendardString(with: .body2)
- $0.textColor = .gray600
- $0.numberOfLines = 2
- }
-
- lazy var teamCollectionView: UICollectionView = UICollectionView(
- frame: .zero,
- collectionViewLayout: UICollectionViewFlowLayout().then {
- $0.itemSize = .init(width: 166.adjustedWidth, height: 64.adjustedHeight)
- $0.minimumInteritemSpacing = 11
- $0.minimumLineSpacing = 12
- }).then {
- $0.register(
- LCKTeamCollectionViewCell.self,
- forCellWithReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier
- )
- $0.isScrollEnabled = false
- }
-
- lazy var skipButton: UIButton = UIButton(configuration: .plain()).then {
- $0.configuration?.attributedTitle = StringLiterals.Onboarding.teamEmptyButtonTitle.pretendardString(with: .body2)
- $0.configuration?.baseForegroundColor = .gray600
- }
-
- lazy var nextButton: WableButton = WableButton(style: .gray).then {
- $0.configuration?.attributedTitle = "다음으로".pretendardString(with: .head2)
- $0.isUserInteractionEnabled = false
- }
-
- // MARK: - LifeCycle
-
- override init(frame: CGRect) {
- super.init(frame: frame)
-
- setupView()
- setupConstraint()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-}
-
-// MARK: - Private Extension
-
-private extension LCKTeamView {
-
- // MARK: Setup Method
-
- func setupView() {
- addSubviews(
- titleLabel,
- descriptionLabel,
- teamCollectionView,
- skipButton,
- nextButton
- )
- }
-
- func setupConstraint() {
- titleLabel.snp.makeConstraints {
- $0.top.equalToSuperview().offset(10)
- $0.leading.equalToSuperview().offset(16)
- }
-
- descriptionLabel.snp.makeConstraints {
- $0.top.equalTo(titleLabel.snp.bottom).offset(6)
- $0.leading.equalToSuperview().offset(16)
- }
-
- teamCollectionView.snp.makeConstraints {
- $0.top.equalTo(descriptionLabel.snp.bottom).offset(22)
- $0.horizontalEdges.equalToSuperview().inset(16)
- $0.adjustedHeightEqualTo(368)
- }
-
- nextButton.snp.makeConstraints {
- $0.top.equalTo(skipButton.snp.bottom).offset(12)
- $0.horizontalEdges.equalToSuperview().inset(16)
- $0.bottom.equalToSuperview().inset(64)
- $0.adjustedHeightEqualTo(56)
- }
-
- skipButton.snp.makeConstraints {
- $0.bottom.equalTo(nextButton.snp.top).offset(-12)
- $0.horizontalEdges.equalToSuperview().inset(48)
- $0.adjustedHeightEqualTo(48)
- }
- }
-}
diff --git a/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift b/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift
index 6b171cfb..949966cc 100644
--- a/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift
+++ b/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift
@@ -154,7 +154,7 @@ private extension AgreementViewController {
userSession in
guard let userSession = userSession else { return }
- owner.profileUseCase.execute(
+ owner.profileUseCase.updateProfile(
profile: UserProfile(
user: User(
id: userSession.id,
diff --git a/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift b/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift
index 2fdd7324..7a8be786 100644
--- a/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift
+++ b/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift
@@ -11,15 +11,40 @@ import UIKit
final class LCKTeamViewController: NavigationViewController {
// MARK: - Property
- // TODO: 유즈케이스 리팩 후에 뷰모델 만들어 넘기기
private let lckYear: Int
- private let randomTeamList: [LCKTeam] = [.t1, .gen, .bro, .drx, .dk, .kt, .ns, .bfx, .hle, .dnf].shuffled()
private var lckTeam = "LCK"
// MARK: - UIComponent
- private let rootView = LCKTeamView()
+ private let titleLabel: UILabel = UILabel().then {
+ $0.attributedText = StringLiterals.Onboarding.teamSheetTitle.pretendardString(with: .head0)
+ $0.textColor = .wableBlack
+ }
+
+ private let descriptionLabel: UILabel = UILabel().then {
+ $0.attributedText = StringLiterals.Onboarding.teamSheetMessage.pretendardString(with: .body2)
+ $0.textColor = .gray600
+ $0.numberOfLines = 2
+ }
+
+ lazy var teamCollectionView: TeamCollectionView = TeamCollectionView(cellDidTapped: { [weak self] selectedTeam in
+ guard let self = self else { return }
+
+ self.lckTeam = selectedTeam
+ self.nextButton.updateStyle(.primary)
+ self.nextButton.isUserInteractionEnabled = true
+ })
+
+ lazy var skipButton: UIButton = UIButton(configuration: .plain()).then {
+ $0.configuration?.attributedTitle = StringLiterals.Onboarding.teamEmptyButtonTitle.pretendardString(with: .body2)
+ $0.configuration?.baseForegroundColor = .gray600
+ }
+
+ lazy var nextButton: WableButton = WableButton(style: .gray).then {
+ $0.configuration?.attributedTitle = "다음으로".pretendardString(with: .head2)
+ $0.isUserInteractionEnabled = false
+ }
// MARK: - LifeCycle
@@ -38,7 +63,6 @@ final class LCKTeamViewController: NavigationViewController {
setupView()
setupConstraint()
- setupDelegate()
setupAction()
}
}
@@ -49,24 +73,49 @@ private extension LCKTeamViewController {
func setupView() {
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
- view.addSubview(rootView)
+ view.addSubviews(
+ titleLabel,
+ descriptionLabel,
+ teamCollectionView,
+ skipButton,
+ nextButton
+ )
}
func setupConstraint() {
- rootView.snp.makeConstraints {
- $0.top.equalTo(navigationView.snp.bottom)
- $0.horizontalEdges.bottom.equalToSuperview()
+ titleLabel.snp.makeConstraints {
+ $0.top.equalTo(navigationView.snp.bottom).offset(10)
+ $0.leading.equalToSuperview().offset(16)
+ }
+
+ descriptionLabel.snp.makeConstraints {
+ $0.top.equalTo(titleLabel.snp.bottom).offset(6)
+ $0.leading.equalToSuperview().offset(16)
+ }
+
+ teamCollectionView.snp.makeConstraints {
+ $0.top.equalTo(descriptionLabel.snp.bottom).offset(22)
+ $0.horizontalEdges.equalToSuperview().inset(16)
+ $0.adjustedHeightEqualTo(368)
+ }
+
+ nextButton.snp.makeConstraints {
+ $0.top.equalTo(skipButton.snp.bottom).offset(12)
+ $0.horizontalEdges.equalToSuperview().inset(16)
+ $0.bottom.equalToSuperview().inset(64)
+ $0.adjustedHeightEqualTo(56)
+ }
+
+ skipButton.snp.makeConstraints {
+ $0.bottom.equalTo(nextButton.snp.top).offset(-12)
+ $0.horizontalEdges.equalToSuperview().inset(48)
+ $0.adjustedHeightEqualTo(48)
}
- }
-
- func setupDelegate() {
- rootView.teamCollectionView.delegate = self
- rootView.teamCollectionView.dataSource = self
}
func setupAction() {
- rootView.skipButton.addTarget(self, action: #selector(skipButtonDidTap), for: .touchUpInside)
- rootView.nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside)
+ skipButton.addTarget(self, action: #selector(skipButtonDidTap), for: .touchUpInside)
+ nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside)
}
// MARK: - @objc Method
@@ -95,49 +144,3 @@ private extension LCKTeamViewController {
)
}
}
-
-// MARK: - UICollectionViewDelegate
-
-extension LCKTeamViewController: UICollectionViewDelegate {
- func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
- for cell in collectionView.visibleCells {
- guard let cell = cell as? LCKTeamCollectionViewCell else { return }
-
- cell.layer.borderColor = UIColor.gray300.cgColor
- cell.teamLabel.textColor = .gray700
- }
-
- lckTeam = randomTeamList[indexPath.row].rawValue
-
- guard let cell = collectionView.cellForItem(at: indexPath) as? LCKTeamCollectionViewCell else { return }
-
- cell.layer.borderColor = UIColor.purple50.cgColor
- cell.teamLabel.textColor = .wableBlack
-
- rootView.nextButton.updateStyle(.primary)
- rootView.nextButton.isUserInteractionEnabled = true
- }
-}
-
-// MARK: - UICollectionViewDataSource
-
-extension LCKTeamViewController: UICollectionViewDataSource {
- func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
- return 10
- }
-
- func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
- guard let cell = collectionView.dequeueReusableCell(
- withReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier,
- for: indexPath
- ) as? LCKTeamCollectionViewCell else {
- return UICollectionViewCell()
- }
-
- cell.teamLabel.text = randomTeamList[indexPath.row].rawValue
- cell.teamLabel.textColor = .gray700
- cell.teamImageView.image = UIImage(named: randomTeamList[indexPath.row].rawValue.lowercased())
-
- return cell
- }
-}
diff --git a/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift b/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift
index d2b98f81..11ed6422 100644
--- a/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift
+++ b/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift
@@ -112,7 +112,7 @@ private extension AnnouncementDetailViewController {
guard let image = imageView.image else { return }
let photoDetailViewController = PhotoDetailViewController(image: image)
- present(photoDetailViewController, animated: true)
+ navigationController?.pushViewController(photoDetailViewController, animated: true)
}
@objc func submitButtonDidTap() {
diff --git a/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift b/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift
new file mode 100644
index 00000000..1549bb30
--- /dev/null
+++ b/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift
@@ -0,0 +1,56 @@
+//
+// GhostInfoTooltipView.swift
+// Wable-iOS
+//
+// Created by 김진웅 on 6/24/25.
+//
+
+import UIKit
+
+import SnapKit
+import Then
+
+final class GhostInfoTooltipView: UIView {
+ private let tooltipImageView = UIImageView(image: .imgGhostTooltip).then {
+ $0.contentMode = .scaleAspectFill
+ }
+
+ private let label = UILabel().then {
+ $0.textColor = .wableWhite
+ $0.attributedText = StringLiterals.Ghost.tooltip
+ .pretendardString(with: .caption3)
+ .highlight(textColor: .sky50, to: "투명도란?")
+ $0.numberOfLines = 0
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ setupView()
+ setupConstraint()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+private extension GhostInfoTooltipView {
+ func setupView() {
+ addSubviews(tooltipImageView, label)
+ }
+
+ func setupConstraint() {
+ tooltipImageView.snp.makeConstraints { make in
+ make.edges.equalToSuperview()
+ }
+
+ label.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(16)
+ make.horizontalEdges.equalToSuperview().inset(12)
+ make.bottom.equalToSuperview().offset(-8)
+ }
+ }
+}
+
diff --git a/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift b/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift
index af7759c8..88a4c5d4 100644
--- a/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift
+++ b/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift
@@ -5,6 +5,7 @@
// Created by 김진웅 on 5/14/25.
//
+import Combine
import UIKit
import SnapKit
@@ -57,14 +58,22 @@ final class ProfileInfoCell: UICollectionViewCell {
$0.numberOfLines = 0
}
- // MARK: - Ghost UIComponent
+ // MARK: - Footer UIComponent
- private let ghostView = UIView()
+ private let footerView = UIView()
private let ghostTitleLabel = UILabel().then {
$0.attributedText = "투명도".pretendardString(with: .caption1)
}
+ private let ghostInfoButton = UIButton().then {
+ $0.setImage(.icGhostInfo, for: .normal)
+ }
+
+ private let ghostInfoTooltip = GhostInfoTooltipView().then {
+ $0.isHidden = true
+ }
+
private let ghostImageView = UIImageView(image: .icPurpleGhost)
private let ghostValueLabel = UILabel().then {
@@ -79,10 +88,6 @@ final class ProfileInfoCell: UICollectionViewCell {
$0.clipsToBounds = true
}
- // MARK: - Badge UIComponent
-
- private let badgeView = UIView()
-
private let badgeTitleLabel = UILabel().then {
$0.attributedText = "뱃지".pretendardString(with: .caption1)
}
@@ -90,6 +95,12 @@ final class ProfileInfoCell: UICollectionViewCell {
private let defaultBadgeImageView = UIImageView(image: .imgBadge).then {
$0.contentMode = .scaleAspectFit
}
+
+ // MARK: - Property
+
+ private var tooltipTimer: AnyCancellable?
+
+ private let cancelBag = CancelBag()
// MARK: - Initializer
@@ -99,8 +110,7 @@ final class ProfileInfoCell: UICollectionViewCell {
setupView()
setupHeaderView()
setupIntroductionView()
- setupGhostView()
- setupBadgeView()
+ setupFooterView()
setupAction()
}
@@ -157,7 +167,7 @@ final class ProfileInfoCell: UICollectionViewCell {
private extension ProfileInfoCell {
func setupView() {
- contentView.addSubviews(headerView, introductionView, ghostView, badgeView)
+ contentView.addSubviews(headerView, introductionView, footerView)
headerView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
@@ -167,17 +177,12 @@ private extension ProfileInfoCell {
introductionView.snp.makeConstraints { make in
make.horizontalEdges.equalTo(headerView)
- make.bottom.equalTo(ghostView.snp.top).offset(-12)
+ make.bottom.equalTo(footerView.snp.top).offset(-12)
}
- ghostView.snp.makeConstraints { make in
+ footerView.snp.makeConstraints { make in
make.horizontalEdges.equalTo(headerView)
- make.bottom.equalTo(badgeView.snp.top).offset(-12)
- }
-
- badgeView.snp.makeConstraints { make in
- make.horizontalEdges.equalTo(headerView)
- make.bottom.equalToSuperview().offset(-16)
+ make.bottom.equalToSuperview().offset(-12)
}
}
@@ -220,13 +225,30 @@ private extension ProfileInfoCell {
}
}
- func setupGhostView() {
- ghostView.addSubviews(ghostTitleLabel, ghostImageView, ghostValueLabel, ghostProgressBar)
+ func setupFooterView() {
+ footerView.addSubviews(
+ ghostTitleLabel,
+ ghostInfoButton,
+ ghostImageView,
+ ghostValueLabel,
+ ghostProgressBar,
+ ghostInfoTooltip,
+ badgeTitleLabel,
+ defaultBadgeImageView
+ )
+
+ footerView.bringSubviewToFront(ghostInfoTooltip)
ghostTitleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
}
+ ghostInfoButton.snp.makeConstraints { make in
+ make.centerY.equalTo(ghostTitleLabel)
+ make.leading.equalTo(ghostTitleLabel.snp.trailing).offset(4)
+ make.size.equalTo(12)
+ }
+
ghostImageView.snp.makeConstraints { make in
make.centerY.equalTo(ghostValueLabel)
make.trailing.equalTo(ghostValueLabel.snp.leading).offset(-8)
@@ -239,32 +261,93 @@ private extension ProfileInfoCell {
}
ghostProgressBar.snp.makeConstraints { make in
- make.horizontalEdges.bottom.equalToSuperview()
+ make.horizontalEdges.equalToSuperview()
make.adjustedHeightEqualTo(12)
}
- }
-
- func setupBadgeView() {
- badgeView.addSubviews(badgeTitleLabel, defaultBadgeImageView)
+
+ ghostInfoTooltip.snp.makeConstraints { make in
+ make.top.equalTo(ghostProgressBar.snp.bottom).offset(4)
+ make.leading.equalTo(ghostProgressBar)
+ make.trailing.equalToSuperview()
+ }
badgeTitleLabel.snp.makeConstraints { make in
- make.top.leading.equalToSuperview()
+ make.top.equalTo(ghostProgressBar.snp.bottom).offset(12)
+ make.leading.equalTo(ghostInfoTooltip)
make.bottom.equalTo(defaultBadgeImageView.snp.top).offset(-4)
}
defaultBadgeImageView.snp.makeConstraints { make in
make.leading.equalTo(badgeTitleLabel)
make.adjustedHeightEqualTo(Constant.badgeImageViewHeight)
+ make.bottom.equalToSuperview()
}
}
func setupAction() {
- editButton.addAction(
- UIAction(handler: { [weak self] _ in self?.editButtonTapHandler?() }),
- for: .touchUpInside
- )
+ editButton.publisher(for: .touchUpInside)
+ .sink { [weak self] _ in
+ self?.editButtonTapHandler?()
+ }
+ .store(in: cancelBag)
+
+ let onGhostTitleTap = ghostTitleLabel.gesture().asVoid()
+ let onGhostInfoTap = ghostInfoButton.publisher(for: .touchUpInside)
+ Publishers.Merge(onGhostTitleTap, onGhostInfoTap)
+ .sink { [weak self] _ in
+ self?.toggleTooltip()
+ }
+ .store(in: cancelBag)
}
+ // MARK: - Helper Method
+
+ func toggleTooltip() {
+ tooltipTimer?.cancel()
+
+ if ghostInfoTooltip.isHidden {
+ showTooltipWithAnimation()
+
+ tooltipTimer = Just(())
+ .delay(for: .seconds(5), scheduler: DispatchQueue.main)
+ .sink { [weak self] _ in self?.hideTooltipWithAnimation() }
+ } else {
+ hideTooltipWithAnimation()
+ }
+ }
+
+ func showTooltipWithAnimation() {
+ ghostInfoTooltip.alpha = 0
+ ghostInfoTooltip.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
+ ghostInfoTooltip.isHidden = false
+
+ UIView.animate(
+ withDuration: 0.3,
+ delay: 0,
+ usingSpringWithDamping: 0.8,
+ initialSpringVelocity: 0.2
+ ) {
+ self.ghostInfoTooltip.alpha = 1
+ self.ghostInfoTooltip.transform = .identity
+ }
+ }
+
+ func hideTooltipWithAnimation() {
+ UIView.animate(
+ withDuration: 0.25,
+ delay: 0,
+ options: [.curveEaseInOut]
+ ) {
+ self.ghostInfoTooltip.alpha = 0
+ self.ghostInfoTooltip.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
+ } completion: { _ in
+ self.ghostInfoTooltip.isHidden = true
+ self.ghostInfoTooltip.transform = .identity
+ }
+ }
+
+ // MARK: - Constant
+
enum Constant {
static let profileImageViewSize: CGFloat = 80
static let editButtonSize: CGFloat = 48
diff --git a/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift b/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift
new file mode 100644
index 00000000..b69a7877
--- /dev/null
+++ b/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift
@@ -0,0 +1,217 @@
+//
+// ProfileEditView.swift
+// Wable-iOS
+//
+// Created by YOUJIM on 6/18/25.
+//
+
+
+import UIKit
+
+import Kingfisher
+
+final class ProfileEditView: UIView {
+
+ // MARK: Property
+
+ var defaultImageList = DefaultProfileType.allCases
+
+ private let cellTapped: ((String) -> Void)?
+
+ // MARK: - UIComponent
+
+ let profileImageView: UIImageView = UIImageView().then {
+ $0.contentMode = .scaleAspectFill
+ $0.layer.cornerRadius = (112 / 2).adjustedWidth
+ $0.clipsToBounds = true
+ }
+
+ let nickNameTextField: UITextField = UITextField(
+ pretendard: .body2,
+ placeholder: "예) 중꺾마"
+ ).then {
+ $0.textColor = .wableBlack
+ $0.layer.cornerRadius = 6.adjustedWidth
+ $0.backgroundColor = .gray200
+ $0.addPadding(left: 16)
+ }
+
+ let duplicationCheckButton: UIButton = UIButton(configuration: .filled()).then {
+ $0.configuration?.attributedTitle = "중복확인".pretendardString(with: .body3)
+ $0.configuration?.baseForegroundColor = .gray600
+ $0.configuration?.baseBackgroundColor = .gray200
+ $0.isUserInteractionEnabled = false
+ }
+
+ let conditionLabel: UILabel = UILabel().then {
+ $0.attributedText = StringLiterals.ProfileSetting.checkDefaultMessage.pretendardString(with: .caption2)
+ $0.textColor = .gray600
+ }
+
+ let myTeamLabel: UILabel = UILabel().then {
+ $0.attributedText = " ".pretendardString(with: .body3)
+ $0.textColor = .purple50
+ }
+
+ lazy var switchButton: UIButton = UIButton(configuration: .plain()).then {
+ $0.configuration?.image = .icChange
+ }
+
+ lazy var addButton: UIButton = UIButton(configuration: .plain()).then {
+ $0.configuration?.image = .icProfileplus
+ }
+
+ lazy var teamCollectionView: TeamCollectionView = {
+ return TeamCollectionView(cellDidTapped: { [weak self] selectedTeam in
+ guard let self = self else { return }
+
+ myTeamLabel.text = selectedTeam
+ cellTapped?(selectedTeam)
+ })
+ }()
+
+ private let nicknameDiscriptionLabel: UILabel = UILabel().then {
+ $0.attributedText = "닉네임".pretendardString(with: .body3)
+ $0.textColor = .wableBlack
+ }
+
+ private let teamDescriptionLabel: UILabel = UILabel().then {
+ $0.attributedText = "응원팀".pretendardString(with: .body3)
+ $0.textColor = .wableBlack
+ }
+
+ // MARK: - LifeCycle
+
+ init(cellTapped: ((String) -> Void)?) {
+ self.cellTapped = cellTapped
+
+ super.init(frame: .zero)
+
+ setupView()
+ setupConstraint()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+// MARK: Public Extension
+
+extension ProfileEditView {
+ func configureDefaultImage() {
+ defaultImageList.shuffle()
+
+ profileImageView.image = UIImage(named: defaultImageList[0].rawValue)
+ }
+}
+
+// MARK: - Private Extension
+
+private extension ProfileEditView {
+
+ // MARK: Setup Method
+
+ func setupView() {
+ addSubviews(
+ profileImageView,
+ switchButton,
+ addButton,
+ nicknameDiscriptionLabel,
+ nickNameTextField,
+ conditionLabel,
+ duplicationCheckButton,
+ teamDescriptionLabel,
+ myTeamLabel,
+ teamCollectionView
+ )
+ }
+
+ func setupConstraint() {
+ profileImageView.snp.makeConstraints {
+ $0.top.equalToSuperview().offset(24)
+ $0.centerX.equalToSuperview()
+ $0.size.equalTo(112.adjustedWidth)
+ }
+
+ switchButton.snp.makeConstraints {
+ $0.leading.equalTo(profileImageView.snp.leading).offset(-17)
+ $0.bottom.equalTo(profileImageView.snp.bottom).offset(6)
+ $0.size.equalTo(48.adjustedWidth)
+ }
+
+ addButton.snp.makeConstraints {
+ $0.trailing.equalTo(profileImageView.snp.trailing).offset(19)
+ $0.bottom.equalTo(profileImageView.snp.bottom).offset(6)
+ $0.size.equalTo(48.adjustedWidth)
+ }
+
+ nicknameDiscriptionLabel.snp.makeConstraints {
+ $0.top.equalTo(profileImageView.snp.bottom).offset(6)
+ $0.leading.equalToSuperview().offset(16)
+ }
+
+ nickNameTextField.snp.makeConstraints {
+ $0.top.equalTo(nicknameDiscriptionLabel.snp.bottom).offset(4)
+ $0.leading.equalToSuperview().offset(16)
+ $0.trailing.equalTo(duplicationCheckButton.snp.leading).offset(-8)
+ $0.adjustedHeightEqualTo(48)
+ }
+
+ duplicationCheckButton.snp.makeConstraints {
+ $0.centerY.equalTo(nickNameTextField)
+ $0.trailing.equalToSuperview().inset(16)
+ $0.adjustedWidthEqualTo(94)
+ $0.adjustedHeightEqualTo(48)
+ }
+
+ conditionLabel.snp.makeConstraints {
+ $0.top.equalTo(nickNameTextField.snp.bottom).offset(9)
+ $0.leading.equalToSuperview().inset(16)
+ }
+
+ teamDescriptionLabel.snp.makeConstraints {
+ $0.top.equalTo(conditionLabel.snp.bottom).offset(24)
+ $0.leading.equalToSuperview().offset(16)
+ }
+
+ myTeamLabel.snp.makeConstraints {
+ $0.centerY.equalTo(teamDescriptionLabel)
+ $0.leading.equalTo(teamDescriptionLabel.snp.trailing).offset(4)
+ }
+
+ teamCollectionView.snp.makeConstraints {
+ $0.top.equalTo(teamDescriptionLabel.snp.bottom).offset(4)
+ $0.horizontalEdges.equalToSuperview().inset(16)
+ $0.adjustedHeightEqualTo(368)
+ }
+ }
+}
+
+extension ProfileEditView {
+
+ // MARK: Configure Method
+
+ func configureView(profileImageURL: URL? = .none, team: LCKTeam?) {
+ myTeamLabel.text = team?.rawValue ?? "LCK"
+ teamCollectionView.selectInitialTeam(team: team)
+
+ if let profileImageURL = profileImageURL {
+ switch profileImageURL.absoluteString {
+ case "PURPLE":
+ profileImageView.image = .imgProfilePurple
+ case "GREEN":
+ profileImageView.image = .imgProfileGreen
+ case "BLUE":
+ profileImageView.image = .imgProfileBlue
+ default:
+ profileImageView.kf.setImage(
+ with: profileImageURL,
+ placeholder: [UIImage.imgProfilePurple, UIImage.imgProfileBlue, UIImage.imgProfileGreen].randomElement()
+ )
+ }
+ } else {
+ configureDefaultImage()
+ }
+ }
+}
diff --git a/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift b/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift
index f2151577..d6190ea9 100644
--- a/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift
+++ b/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift
@@ -26,18 +26,25 @@ final class ProfileEditViewController: NavigationViewController {
)
private let cancelBag = CancelBag()
+ private var lckTeam = "LCK"
private var sessionProfile: UserProfile? = nil
private var defaultImage: String? = nil
private var hasUserSelectedImage = false
// MARK: - UIComponent
- private let rootView = ProfileRegisterView()
+ private lazy var rootView = ProfileEditView(cellTapped: { [weak self] selectedTeam in
+ guard let self = self else { return }
+
+ lckTeam = selectedTeam
+ })
// MARK: - LifeCycle
init() {
- super.init(type: .page(type: .detail, title: "프로필 편집"))
+ super.init(type: .page(type: .profileEdit, title: "프로필 편집"))
+
+ hidesBottomBarWhenPushed = true
}
required init?(coder: NSCoder) {
@@ -92,12 +99,56 @@ private extension ProfileEditViewController {
rootView.switchButton.addTarget(self, action: #selector(switchButtonDidTap), for: .touchUpInside)
rootView.addButton.addTarget(self, action: #selector(addButtonDidTap), for: .touchUpInside)
rootView.duplicationCheckButton.addTarget(self, action: #selector(duplicationCheckButtonDidTap), for: .touchUpInside)
- rootView.nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside)
+ navigationView.doneButton
+ .publisher(for: .touchUpInside)
+ .sink { [weak self] _ in
+ guard let self = self,
+ let profile = sessionProfile
+ else {
+ return
+ }
+
+ let nicknameText = rootView.nickNameTextField.text ?? ""
+ let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname
+ let hasImageChanged = defaultImage != nil || hasUserSelectedImage
+ let hasTeamChanged = lckTeam != (profile.user.fanTeam?.rawValue ?? "LCK")
+
+ if hasNicknameChanged || hasImageChanged || hasTeamChanged {
+ let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname
+
+ profileUseCase.updateProfile(
+ profile: UserProfile(
+ user: User(
+ id: profile.user.id,
+ nickname: finalNickname,
+ profileURL: profile.user.profileURL,
+ fanTeam: LCKTeam(rawValue: lckTeam)
+ ),
+ introduction: profile.introduction,
+ ghostCount: profile.ghostCount,
+ lckYears: profile.lckYears,
+ userLevel: profile.userLevel
+ ),
+ image: defaultImage == nil ? rootView.profileImageView.image : nil,
+ defaultProfileType: defaultImage
+ )
+ .replaceError(with: ())
+ .withUnretained(self)
+ .sink(receiveValue: { owner, _ in
+ owner.navigationController?.popViewController(animated: true)
+ })
+ .store(in: cancelBag)
+ } else {
+ navigationController?.popViewController(animated: true)
+ }
+ }
+ .store(in: cancelBag)
}
private func setupTapGesture() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
+ tapGesture.cancelsTouchesInView = false
view.addGestureRecognizer(tapGesture)
}
@@ -107,11 +158,49 @@ private extension ProfileEditViewController {
view.endEditing(true)
}
+ @objc func doneButtonDidTap() {
+ guard let profile = sessionProfile else { return }
+
+ let nicknameText = rootView.nickNameTextField.text ?? ""
+ let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname
+ let hasImageChanged = defaultImage != nil || hasUserSelectedImage
+ let hasTeamChanged = lckTeam != (profile.user.fanTeam?.rawValue ?? "LCK")
+
+ if hasNicknameChanged || hasImageChanged || hasTeamChanged {
+ let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname
+
+ profileUseCase.updateProfile(
+ profile: UserProfile(
+ user: User(
+ id: profile.user.id,
+ nickname: finalNickname,
+ profileURL: profile.user.profileURL,
+ fanTeam: LCKTeam(rawValue: lckTeam)
+ ),
+ introduction: profile.introduction,
+ ghostCount: profile.ghostCount,
+ lckYears: profile.lckYears,
+ userLevel: profile.userLevel
+ ),
+ image: defaultImage == nil ? rootView.profileImageView.image : nil,
+ defaultProfileType: defaultImage
+ )
+ .replaceError(with: ())
+ .withUnretained(self)
+ .sink(receiveValue: { owner, _ in
+ owner.navigationController?.popViewController(animated: true)
+ })
+ .store(in: cancelBag)
+ } else {
+ navigationController?.popViewController(animated: true)
+ }
+ }
+
@objc func switchButtonDidTap() {
rootView.configureDefaultImage()
defaultImage = rootView.defaultImageList[0].uppercased
hasUserSelectedImage = false
- updateNextButtonState()
+ updateDoneButtonState()
}
@objc func addButtonDidTap() {
@@ -141,52 +230,15 @@ private extension ProfileEditViewController {
.sink(receiveCompletion: { [weak self] completion in
let condition = completion == .finished
- self?.rootView.conditiionLabel.text = condition ? StringLiterals.ProfileSetting.checkVaildMessage : StringLiterals.ProfileSetting.checkDuplicateError
- self?.rootView.conditiionLabel.textColor = condition ? .success : .error
- self?.rootView.nextButton.isUserInteractionEnabled = condition
- self?.rootView.nextButton.updateStyle(condition ? .primary : .gray)
+ self?.rootView.conditionLabel.text = condition ? StringLiterals.ProfileSetting.checkVaildMessage : StringLiterals.ProfileSetting.checkDuplicateError
+ self?.rootView.conditionLabel.textColor = condition ? .success : .error
+ self?.navigationView.doneButton.isUserInteractionEnabled = condition
+ self?.navigationView.doneButton.updateStyle(condition ? .primary : .gray)
}, receiveValue: { _ in
})
.store(in: cancelBag)
}
- @objc func nextButtonDidTap() {
- guard let profile = sessionProfile else { return }
-
- let nicknameText = rootView.nickNameTextField.text ?? ""
- let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname
- let hasImageChanged = defaultImage != nil || hasUserSelectedImage
-
- if hasNicknameChanged || hasImageChanged {
- let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname
-
- profileUseCase.execute(
- profile: UserProfile(
- user: User(
- id: profile.user.id,
- nickname: finalNickname,
- profileURL: profile.user.profileURL,
- fanTeam: profile.user.fanTeam
- ),
- introduction: profile.introduction,
- ghostCount: profile.ghostCount,
- lckYears: profile.lckYears,
- userLevel: profile.userLevel
- ),
- image: defaultImage == nil ? rootView.profileImageView.image : nil,
- defaultProfileType: defaultImage
- )
- .withUnretained(self)
- .sink { _ in
- } receiveValue: { owner, _ in
- owner.navigationController?.popViewController(animated: true)
- }
- .store(in: cancelBag)
- } else {
- navigationController?.popViewController(animated: true)
- }
- }
-
// MARK: - Function Method
func presentPhotoPicker() {
@@ -216,7 +268,7 @@ private extension ProfileEditViewController {
present(alert, animated: true, completion: nil)
}
- func updateNextButtonState() {
+ func updateDoneButtonState() {
guard let profile = sessionProfile else { return }
let nicknameText = rootView.nickNameTextField.text ?? ""
@@ -225,8 +277,8 @@ private extension ProfileEditViewController {
let shouldEnable = hasNicknameChanged || hasImageChanged
- rootView.nextButton.isUserInteractionEnabled = shouldEnable
- rootView.nextButton.updateStyle(shouldEnable ? .primary : .gray)
+ navigationView.doneButton.isUserInteractionEnabled = shouldEnable
+ navigationView.doneButton.updateStyle(shouldEnable ? .primary : .gray)
}
}
@@ -238,14 +290,16 @@ extension ProfileEditViewController {
guard let self = self,
let sessionID = sessionID else { return }
- profileUseCase.execute(userID: sessionID)
+ profileUseCase.fetchProfile(userID: sessionID)
.receive(on: DispatchQueue.main)
.sink { _ in } receiveValue: { [weak self] profile in
guard let self = self else { return }
self.sessionProfile = profile
-
- self.rootView.configureProfileView(profileImageURL: profile.user.profileURL)
+ self.rootView.configureView(
+ profileImageURL: profile.user.profileURL,
+ team: profile.user.fanTeam
+ )
}
.store(in: cancelBag)
}
@@ -264,7 +318,7 @@ extension ProfileEditViewController: PHPickerViewControllerDelegate {
self.rootView.profileImageView.image = image
self.defaultImage = nil
self.hasUserSelectedImage = true
- self.updateNextButtonState()
+ self.updateDoneButtonState()
}
}
@@ -287,13 +341,13 @@ extension ProfileEditViewController: UITextFieldDelegate {
let range = NSRange(location: 0, length: text.utf16.count)
let condition = regex?.firstMatch(in: text, options: [], range: range) != nil
- self.rootView.conditiionLabel.text = condition || text == "" ? StringLiterals.ProfileSetting.checkDefaultMessage : StringLiterals.ProfileSetting.checkInvaildError
- self.rootView.conditiionLabel.textColor = condition || text == "" ? .gray600 : .error
+ self.rootView.conditionLabel.text = condition || text == "" ? StringLiterals.ProfileSetting.checkDefaultMessage : StringLiterals.ProfileSetting.checkInvaildError
+ self.rootView.conditionLabel.textColor = condition || text == "" ? .gray600 : .error
self.rootView.duplicationCheckButton.isUserInteractionEnabled = condition
self.rootView.duplicationCheckButton.configuration?.baseForegroundColor = condition ? .white : .gray600
self.rootView.duplicationCheckButton.configuration?.baseBackgroundColor = condition ? .wableBlack : .gray200
- self.rootView.nextButton.updateStyle(.gray)
- self.rootView.nextButton.isUserInteractionEnabled = false
+ self.navigationView.doneButton.updateStyle(.gray)
+ self.navigationView.doneButton.isUserInteractionEnabled = false
}
func textField(
diff --git a/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift b/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift
index f7990704..9d623401 100644
--- a/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift
+++ b/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift
@@ -214,7 +214,8 @@ private extension MyProfileViewController {
contentImageViewTapHandler: { [weak self] in
guard let image = cell.contentImageView.image else { return }
- self?.present(PhotoDetailViewController(image: image), animated: true)
+ let photoDetailViewController = PhotoDetailViewController(image: image)
+ self?.navigationController?.pushViewController(photoDetailViewController, animated: true)
},
likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeContent(for: item.id) },
settingButtonTapHandler: { [weak self] in
@@ -464,25 +465,7 @@ private extension MyProfileViewController {
return WableLogger.log("SceneDelegate 찾을 수 없음.", for: .debug)
}
- let loginViewController = LoginViewController(
- viewModel: .init(
- updateFCMTokenUseCase: UpdateFCMTokenUseCase(
- repository: ProfileRepositoryImpl()
- ),
- fetchUserAuthUseCase: FetchUserAuthUseCase(
- loginRepository: LoginRepositoryImpl(),
- userSessionRepository: UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init())
- )
- ),
- updateUserSessionUseCase: FetchUserInformationUseCase(
- repository: UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init())
- )
- ),
- userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl())
- )
- )
+ let loginViewController = LoginViewController(viewModel: LoginViewModel())
UIView.transition(
with: window,
diff --git a/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift b/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift
index 51098444..5c307bc0 100644
--- a/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift
+++ b/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift
@@ -213,32 +213,47 @@ private extension OtherProfileViewController {
contentImageViewTapHandler: { [weak self] in
guard let image = cell.contentImageView.image else { return }
- self?.present(PhotoDetailViewController(image: image), animated: true)
+ let photoDetailViewController = PhotoDetailViewController(image: image)
+ self?.navigationController?.pushViewController(photoDetailViewController, animated: true)
},
likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeContent(for: item.id) },
settingButtonTapHandler: { [weak self] in
- guard let userRole = self?.viewModel.checkUserRole(),
- userRole != .owner
- else {
- return
- }
+ guard let userRole = self?.viewModel.checkUserRole(), userRole != .owner else { return }
- let bottomSheet = WableBottomSheetController()
let report = WableBottomSheetAction(title: "신고하기") { [weak self] in
- self?.presentReportSheet(contentID: item.id)
+ self?.showReportSheet(onPrimary: { message in
+ let info = item.contentInfo
+ self?.viewModel.reportContent(for: info.author.nickname, message: message ?? info.text)
+ })
+ }
+ guard userRole == .admin else {
+ self?.showBottomSheet(actions: report)
+ return
}
- bottomSheet.addAction(report)
- if userRole == .admin {
- let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in
- self?.presentBanSheet(contentID: item.id)
+ let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in
+ let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in
+ self?.viewModel.banContent(for: item.id)
}
- bottomSheet.addAction(ban)
+ self?.showWableSheetWithCancel(
+ title: Constant.Ban.title,
+ message: StringLiterals.Ban.sheetMessage,
+ action: confirm
+ )
}
- self?.present(bottomSheet, animated: true)
+ self?.showBottomSheet(actions: report, ban)
},
profileImageViewTapHandler: nil,
- ghostButtonTapHandler: { [weak self] in self?.presentGhostSheet(contentID: item.id) }
+ ghostButtonTapHandler: { [weak self] in
+ AmplitudeManager.shared.trackEvent(tag: .clickGhostPost)
+ self?.showGhostSheet(onCancel: {
+ AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
+ }, onPrimary: { message in
+ AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
+
+ self?.viewModel.ghostContent(for: item.id, reason: message ?? "")
+ })
+ }
)
}
@@ -251,28 +266,42 @@ private extension OtherProfileViewController {
authorType: .others,
likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeComment(for: item.comment.id) },
settingButtonTapHandler: { [weak self] in
- guard let userRole = self?.viewModel.checkUserRole(),
- userRole != .owner
- else {
- return
- }
+ guard let userRole = self?.viewModel.checkUserRole(), userRole != .owner else { return }
- let bottomSheet = WableBottomSheetController()
let report = WableBottomSheetAction(title: "신고하기") { [weak self] in
- self?.presentReportSheet(commentID: item.comment.id)
+ self?.showReportSheet(onPrimary: { message in
+ let comment = item.comment
+ self?.viewModel.reportComment(for: comment.author.nickname, message: message ?? comment.text)
+ })
+ }
+ guard userRole == .admin else {
+ self?.showBottomSheet(actions: report)
+ return
}
- bottomSheet.addAction(report)
- if userRole == .admin {
- let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in
- self?.presentBanSheet(commentID: item.comment.id)
+ let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in
+ let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in
+ self?.viewModel.banComment(for: item.comment.id)
}
- bottomSheet.addAction(ban)
+ self?.showWableSheetWithCancel(
+ title: Constant.Ban.title,
+ message: StringLiterals.Ban.sheetMessage,
+ action: confirm
+ )
}
- self?.present(bottomSheet, animated: true)
+ self?.showBottomSheet(actions: report, ban)
},
profileImageViewTapHandler: nil,
- ghostButtonTapHandler: { [weak self] in self?.presentGhostSheet(commentID: item.comment.id) },
+ ghostButtonTapHandler: { [weak self] in
+ AmplitudeManager.shared.trackEvent(tag: .clickGhostComment)
+ self?.showGhostSheet(onCancel: {
+ AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup)
+ }, onPrimary: { message in
+ AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup)
+
+ self?.viewModel.ghostComment(for: item.comment.id, reason: message ?? "")
+ })
+ },
replyButtonTapHandler: nil
)
}
@@ -343,6 +372,12 @@ private extension OtherProfileViewController {
.sink { [weak self] in self?.applySnapshot(item: $0) }
.store(in: cancelBag)
+ viewModel.$userNotFound
+ .receive(on: RunLoop.main)
+ .filter { $0 }
+ .sink { [weak self] _ in self?.showNotFound() }
+ .store(in: cancelBag)
+
viewModel.$isLoading
.receive(on: RunLoop.main)
.filter { $0 }
@@ -408,80 +443,15 @@ private extension OtherProfileViewController {
dataSource?.apply(snapshot)
}
- func presentReportSheet(contentID: Int) {
- let actionSheet = WableSheetViewController(
- title: StringLiterals.Report.sheetTitle,
- message: StringLiterals.Report.sheetMessage
- )
-
- let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray)
- let confirm = WableSheetAction(title: Constant.Report.title, style: .primary) { [weak self] in
- self?.viewModel.reportContent(for: contentID)
- }
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
- }
-
- func presentReportSheet(commentID: Int) {
- let actionSheet = WableSheetViewController(
- title: StringLiterals.Report.sheetTitle,
- message: StringLiterals.Report.sheetMessage
- )
-
- let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray)
- let confirm = WableSheetAction(title: Constant.Report.title, style: .primary) { [weak self] in
- self?.viewModel.reportComment(for: commentID)
- }
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
- }
-
- func presentBanSheet(contentID: Int) {
- let actionSheet = WableSheetViewController(
- title: Constant.Ban.title,
- message: StringLiterals.Ban.sheetMessage
- )
-
- let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray)
- let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in
- self?.viewModel.banContent(for: contentID)
- }
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
- }
-
- func presentBanSheet(commentID: Int) {
- let actionSheet = WableSheetViewController(
- title: Constant.Ban.title,
- message: StringLiterals.Ban.sheetMessage
- )
-
- let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray)
- let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in
- self?.viewModel.banComment(for: commentID)
- }
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
- }
-
- func presentGhostSheet(contentID: Int) {
- let actionSheet = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle)
- let cancel = WableSheetAction(title: Constant.Ghost.grayTitle, style: .gray)
- let confirm = WableSheetAction(title: Constant.Ghost.primaryTitle, style: .primary) { [weak self] in
- self?.viewModel.ghostContent(for: contentID)
- }
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
- }
-
- func presentGhostSheet(commentID: Int) {
- let actionSheet = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle)
- let cancel = WableSheetAction(title: Constant.Ghost.grayTitle, style: .gray)
- let confirm = WableSheetAction(title: Constant.Ghost.primaryTitle, style: .primary) { [weak self] in
- self?.viewModel.ghostComment(for: commentID)
+ // MARK: - Helper
+
+ func showNotFound() {
+ let tabBar = navigationController?.tabBarController
+ let notFoundVC = NotFoundViewController { [weak self] in
+ self?.navigationController?.popToRootViewController(animated: false)
+ tabBar?.selectedIndex = 0
}
- actionSheet.addActions(cancel, confirm)
- present(actionSheet, animated: true)
+ present(notFoundVC, animated: true)
}
// MARK: - Action
@@ -553,14 +523,5 @@ private extension OtherProfileViewController {
enum Ban {
static let title = "밴하기"
}
-
- enum Ghost {
- static let grayTitle = "고민할게요"
- static let primaryTitle = "네 맞아요"
- }
-
- enum Cancel {
- static let title = "취소"
- }
}
}
diff --git a/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift b/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift
index c08e0394..89d66baf 100644
--- a/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift
+++ b/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift
@@ -11,6 +11,7 @@ import Foundation
final class OtherProfileViewModel {
@Published private(set) var nickname: String?
@Published private(set) var item = ProfileViewItem()
+ @Published private(set) var userNotFound = false
@Published private(set) var isLoading = false
@Published private(set) var isLoadingMore = false
@Published private(set) var isReportCompleted = false
@@ -125,13 +126,10 @@ final class OtherProfileViewModel {
return checkUserRoleUseCase.execute(userID: userID)
}
- func reportContent(for contentID: Int) {
- guard let content = item.contentList.first(where: { $0.id == contentID }) else { return }
- let nickname = content.contentInfo.author.nickname
- let text = content.contentInfo.text
+ func reportContent(for nickname: String, message: String) {
Task {
do {
- try await reportRepository.createReport(nickname: nickname, text: text)
+ try await reportRepository.createReport(nickname: nickname, text: message)
await MainActor.run { isReportCompleted = true }
} catch {
await handleError(error: error)
@@ -139,14 +137,10 @@ final class OtherProfileViewModel {
}
}
- func reportComment(for commentID: Int) {
- guard let comment = item.commentList.first(where: { $0.comment.id == commentID }) else { return }
- let nickname = comment.comment.author.nickname
- let text = comment.comment.text
-
+ func reportComment(for nickname: String, message: String) {
Task {
do {
- try await reportRepository.createReport(nickname: nickname, text: text)
+ try await reportRepository.createReport(nickname: nickname, text: message)
await MainActor.run { isReportCompleted = true }
} catch {
await handleError(error: error)
@@ -188,14 +182,14 @@ final class OtherProfileViewModel {
}
}
- func ghostContent(for contentID: Int) {
+ func ghostContent(for contentID: Int, reason: String) {
Task {
do {
try await ghostRepository.postGhostReduction(
alarmTriggerType: TriggerType.Ghost.contentGhost.rawValue,
alarmTriggerID: contentID,
targetMemberID: userID,
- reason: ""
+ reason: reason
)
fetchViewItems(userID: userID, segment: item.currentSegment)
@@ -205,14 +199,14 @@ final class OtherProfileViewModel {
}
}
- func ghostComment(for commentID: Int) {
+ func ghostComment(for commentID: Int, reason: String) {
Task {
do {
try await ghostRepository.postGhostReduction(
alarmTriggerType: TriggerType.Ghost.commentGhost.rawValue,
alarmTriggerID: commentID,
targetMemberID: userID,
- reason: ""
+ reason: reason
)
fetchViewItems(userID: userID, segment: item.currentSegment)
@@ -316,6 +310,11 @@ private extension OtherProfileViewModel {
@MainActor
func handleError(error: Error) {
+ if case WableError.notFoundMember = error {
+ userNotFound = true
+ return
+ }
+
errorMessage = error.localizedDescription
}
}
diff --git a/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift b/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift
index b9257ffe..8de3c51f 100644
--- a/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift
+++ b/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift
@@ -99,25 +99,7 @@ private extension WithdrawalGuideViewController {
return WableLogger.log("SceneDelegate 찾을 수 없음.", for: .debug)
}
- let loginViewController = LoginViewController(
- viewModel: .init(
- updateFCMTokenUseCase: UpdateFCMTokenUseCase(
- repository: ProfileRepositoryImpl()
- ),
- fetchUserAuthUseCase: FetchUserAuthUseCase(
- loginRepository: LoginRepositoryImpl(),
- userSessionRepository: UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init())
- )
- ),
- updateUserSessionUseCase: FetchUserInformationUseCase(
- repository: UserSessionRepositoryImpl(
- userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init())
- )
- ),
- userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl())
- )
- )
+ let loginViewController = LoginViewController(viewModel: LoginViewModel())
UIView.transition(
with: window,
diff --git a/Wable-iOS/Presentation/Splash/SplashViewController.swift b/Wable-iOS/Presentation/Splash/SplashViewController.swift
index f95b9d8d..6dbb4fa9 100644
--- a/Wable-iOS/Presentation/Splash/SplashViewController.swift
+++ b/Wable-iOS/Presentation/Splash/SplashViewController.swift
@@ -18,13 +18,11 @@ final class SplashViewController: UIViewController {
view.contentMode = .scaleAspectFill
view.loopMode = .playOnce
view.play(fromProgress: 0, toProgress: 1, loopMode: .playOnce, completion: {
- $0 ? DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
- view.stop()
- } : nil
+ $0 ? DispatchQueue.main.asyncAfter(deadline: .now() + 2) { view.stop() } : nil
})
}
- // MARK: - LifeCycle
+ // MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
@@ -34,19 +32,16 @@ final class SplashViewController: UIViewController {
}
}
-// MARK: - Private Extension
+// MARK: - Setup Method
private extension SplashViewController {
-
- // MARK: - Setup
-
func setupView() {
view.backgroundColor = .white
-
- view.addSubview(animationView)
}
func setupConstraint() {
+ view.addSubview(animationView)
+
animationView.snp.makeConstraints {
$0.center.equalToSuperview()
}
diff --git a/Wable-iOS/Presentation/TabBar/TabBarController.swift b/Wable-iOS/Presentation/TabBar/TabBarController.swift
index 895e2f63..b2895412 100644
--- a/Wable-iOS/Presentation/TabBar/TabBarController.swift
+++ b/Wable-iOS/Presentation/TabBar/TabBarController.swift
@@ -43,11 +43,7 @@ final class TabBarController: UITabBarController {
}
private let communityViewController = CommunityViewController(
- viewModel: CommunityViewModel(
- useCase: CommunityUseCaseImpl(
- repository: CommunityRepositoryImpl()
- )
- )
+ viewModel: CommunityViewModel()
).then {
$0.tabBarItem.title = "커뮤니티"
$0.tabBarItem.image = .icCommunity
diff --git a/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift b/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift
deleted file mode 100644
index aa31d673..00000000
--- a/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-// ViewitBottomSheetActionKind.swift
-// Wable-iOS
-//
-// Created by 김진웅 on 5/12/25.
-//
-
-import Foundation
-
-enum ViewitBottomSheetActionKind {
- case report
- case ban
- case delete
-}
diff --git a/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift b/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift
index 3ff7c43d..859e574d 100644
--- a/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift
+++ b/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift
@@ -15,10 +15,11 @@ final class ViewitListView: UIView {
// MARK: - UIComponent
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout).then {
- $0.refreshControl = UIRefreshControl()
$0.alwaysBounceVertical = true
}
+ let refreshControl = UIRefreshControl()
+
let emptyLabel = UILabel().then {
$0.attributedText = StringLiterals.Empty.post.pretendardString(with: .body2)
$0.textColor = .gray500
@@ -53,6 +54,8 @@ private extension ViewitListView {
func setupView() {
backgroundColor = .wableWhite
+ collectionView.refreshControl = refreshControl
+
let statusBarBackgroundView = UIView(backgroundColor: .wableBlack)
let navigationView = NavigationView(type: .hub(title: "뷰잇", isBeta: false)).then {
$0.configureView()
diff --git a/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift b/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift
index cc5d1ab9..4636d0bc 100644
--- a/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift
+++ b/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift
@@ -5,6 +5,7 @@
// Created by 김진웅 on 4/12/25.
//
+import Combine
import UIKit
import SafariServices
@@ -32,7 +33,9 @@ final class ViewitListViewController: UIViewController {
private let meatballRelay = PassthroughRelay()
private let likeRelay = PassthroughRelay()
private let willLastDisplayRelay = PassthroughRelay()
- private let bottomSheetActionRelay = PassthroughRelay()
+ private let reportRelay = PassthroughRelay()
+ private let banRelay = PassthroughRelay()
+ private let deleteRelay = PassthroughRelay()
private let profileDidTapRelay = PassthroughRelay()
private let cancelBag = CancelBag()
private let rootView = ViewitListView()
@@ -90,7 +93,6 @@ extension ViewitListViewController: UICollectionViewDelegate {
}
}
-
// MARK: - CreateViewitViewDelegate
extension ViewitListViewController: CreateViewitViewDelegate {
@@ -99,9 +101,10 @@ extension ViewitListViewController: CreateViewitViewDelegate {
}
}
-// MARK: - Setup Method
-
private extension ViewitListViewController {
+
+ // MARK: - Setup Method
+
func setupDataSource() {
let cellRegistration = CellRegistration { cell, indexPath, item in
cell.configure(
@@ -147,8 +150,18 @@ private extension ViewitListViewController {
}
func setupAction() {
- createButton.addTarget(self, action: #selector(createButtonDidTap), for: .touchUpInside)
- refreshControl?.addTarget(self, action: #selector(viewDidRefresh), for: .valueChanged)
+ rootView.createButton.publisher(for: .touchUpInside)
+ .sink { [weak self] _ in
+ let useCase = CreateViewitUseCaseImpl()
+ let writeViewController = CreateViewitViewController(viewModel: .init(useCase: useCase))
+ writeViewController.delegate = self
+ self?.present(writeViewController, animated: true)
+ }
+ .store(in: cancelBag)
+
+ rootView.refreshControl.publisher(for: .valueChanged)
+ .sink { [weak self] _ in self?.didLoadRelay.send() }
+ .store(in: cancelBag)
}
func setupDelegate() {
@@ -161,7 +174,9 @@ private extension ViewitListViewController {
like: likeRelay.eraseToAnyPublisher(),
willLastDisplay: willLastDisplayRelay.eraseToAnyPublisher(),
meatball: meatballRelay.eraseToAnyPublisher(),
- bottomSheetAction: bottomSheetActionRelay.eraseToAnyPublisher(),
+ report: reportRelay.eraseToAnyPublisher(),
+ delete: deleteRelay.eraseToAnyPublisher(),
+ ban: banRelay.eraseToAnyPublisher(),
profileDidTap: profileDidTapRelay.eraseToAnyPublisher()
)
@@ -169,9 +184,7 @@ private extension ViewitListViewController {
output.isLoading
.filter { !$0 }
- .sink { [weak self] _ in
- self?.refreshControl?.endRefreshing()
- }
+ .sink { [weak self] _ in self?.rootView.refreshControl.endRefreshing() }
.store(in: cancelBag)
output.viewitList
@@ -189,9 +202,7 @@ private extension ViewitListViewController {
.store(in: cancelBag)
output.userRole
- .sink { [weak self] role in
- self?.presentBottomSheet(for: role)
- }
+ .sink { [weak self] in self?.presentBottomSheet(for: $0) }
.store(in: cancelBag)
output.isReportSuccess
@@ -231,49 +242,55 @@ private extension ViewitListViewController {
switch userRole {
case .admin:
let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
- self?.presentActionSheet(for: .report)
+ self?.showReportSheet(onPrimary: { message in
+ self?.reportRelay.send(message ?? "")
+ })
}
let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in
- self?.presentActionSheet(for: .ban)
+ self?.showBanConfirmationSheet()
}
showBottomSheet(actions: reportAction, banAction)
case .owner:
let deleteAction = WableBottomSheetAction(title: "삭제하기") { [weak self] in
- self?.presentActionSheet(for: .delete)
+ self?.showDeleteConfirmationSheet()
}
showBottomSheet(actions: deleteAction)
case .viewer:
let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in
- self?.presentActionSheet(for: .report)
+ self?.showReportSheet(onPrimary: { message in
+ self?.reportRelay.send(message ?? "")
+ })
}
showBottomSheet(actions: reportAction)
}
}
- func presentActionSheet(for action: ViewitBottomSheetActionKind) {
- let title: String
- let message: String
- let buttonTitle: String
-
- switch action {
- case .report:
- title = StringLiterals.Report.sheetTitle
- message = StringLiterals.Report.sheetMessage
- buttonTitle = Constant.Report.buttonTitle
- case .delete:
- title = Constant.Delete.title
- message = Constant.Delete.message
- buttonTitle = Constant.Delete.buttonTitle
- case .ban:
- title = Constant.Ban.title
- message = StringLiterals.Ban.sheetMessage
- buttonTitle = Constant.Ban.buttonTitle
+ func showDeleteConfirmationSheet() {
+ let primaryAction = WableSheetAction(
+ title: Constant.Delete.buttonTitle,
+ style: .primary
+ ) { [weak self] in
+ self?.deleteRelay.send()
}
-
- let primaryAction = WableSheetAction(title: buttonTitle, style: .primary) { [weak self] in
- self?.bottomSheetActionRelay.send(action)
+ showWableSheetWithCancel(
+ title: Constant.Delete.title,
+ message: Constant.Delete.message,
+ action: primaryAction
+ )
+ }
+
+ func showBanConfirmationSheet() {
+ let primaryAction = WableSheetAction(
+ title: Constant.Ban.buttonTitle,
+ style: .primary
+ ) { [weak self] in
+ self?.banRelay.send()
}
- showWableSheetWithCancel(title: title, message: message, action: primaryAction)
+ showWableSheetWithCancel(
+ title: Constant.Ban.title,
+ message: StringLiterals.Ban.sheetMessage,
+ action: primaryAction
+ )
}
func showMyProfile() {
@@ -301,24 +318,11 @@ private extension ViewitListViewController {
}
// MARK: - Action Method
-
- @objc func createButtonDidTap() {
- let useCase = CreateViewitUseCaseImpl()
- let writeViewController = CreateViewitViewController(viewModel: .init(useCase: useCase))
- writeViewController.delegate = self
- present(writeViewController, animated: true)
- }
@objc func viewDidRefresh() {
didLoadRelay.send()
}
- // MARK: - Computed Property
-
- var collectionView: UICollectionView { rootView.collectionView }
- var refreshControl: UIRefreshControl? { rootView.collectionView.refreshControl }
- var createButton: UIButton { rootView.createButton }
-
// MARK: - Constant
enum Constant {
diff --git a/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift b/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift
index 72bcac4d..7af8182a 100644
--- a/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift
+++ b/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift
@@ -15,6 +15,14 @@ final class ViewitListViewModel {
private let checkUserRoleUseCase: CheckUserRoleUseCase
private let userSessionUseCase: FetchUserInformationUseCase
+ private let loadingStateRelay = CurrentValueRelay(false)
+ private let viewitListRelay = CurrentValueRelay<[Viewit]>([])
+ private let errorMessageRelay = PassthroughRelay()
+ private let moreLoadingStateRelay = CurrentValueRelay(false)
+ private let lastPageStateRelay = CurrentValueRelay(false)
+ private let indexMeatballDidTapRelay = CurrentValueRelay(0)
+ private let reportStateRelay = CurrentValueRelay(false)
+
init(
useCase: ViewitUseCase,
likeUseCase: LikeViewitUseCase,
@@ -36,7 +44,9 @@ extension ViewitListViewModel: ViewModelType {
let like: Driver
let willLastDisplay: Driver
let meatball: Driver
- let bottomSheetAction: Driver
+ let report: Driver
+ let delete: Driver
+ let ban: Driver
let profileDidTap: Driver
}
@@ -51,165 +61,222 @@ extension ViewitListViewModel: ViewModelType {
}
func transform(input: Input, cancelBag: CancelBag) -> Output {
- let isLoadingRelay = CurrentValueRelay(false)
- let viewitListRelay = CurrentValueRelay<[Viewit]>([])
- let errorMessageRelay = PassthroughRelay()
- let isMoreLoadingRelay = CurrentValueRelay(false)
- let isLastPageRelay = CurrentValueRelay(false)
- let indexMeatballDidTapRelay = CurrentValueRelay(0)
- let isReportSuccess = CurrentValueRelay(false)
-
- let viewitList = viewitListRelay
- .removeDuplicates()
- .asDriver()
+ bindLoad(input: input, cancelBag: cancelBag)
+ bindLike(input: input, cancelBag: cancelBag)
+ bindWillLastDisplay(input: input, cancelBag: cancelBag)
+ bindReport(input: input, cancelBag: cancelBag)
+ bindDelete(input: input, cancelBag: cancelBag)
+ bindBan(input: input, cancelBag: cancelBag)
- input.load
- .handleEvents(receiveOutput: { _ in
- isLoadingRelay.send(true)
- isLastPageRelay.send(false)
+ return Output(
+ isLoading: loadingStateRelay.asDriver(),
+ viewitList: createViewitListPublisher(),
+ isMoreLoading: moreLoadingStateRelay.asDriver(),
+ userRole: createUserRolePublisher(input: input),
+ isReportSuccess: reportStateRelay.asDriver(),
+ moveToProfile: createMoveToProfilePublisher(input: input),
+ errorMessage: errorMessageRelay.asDriver()
+ )
+ }
+}
+
+private extension ViewitListViewModel {
+
+ // MARK: - Binding Methods
+
+ func bindLoad(input: Input, cancelBag: CancelBag) {
+ let loadingEvents = input.load
+ .handleEvents(receiveOutput: { [weak self] _ in
+ self?.loadingStateRelay.send(true)
+ self?.lastPageStateRelay.send(false)
})
+
+ let fetchPublisher: AnyPublisher<[Viewit], Never> = loadingEvents
.withUnretained(self)
- .flatMap { owner, _ in
- return owner.useCase.fetchViewitList(last: IntegerLiterals.initialCursor)
- .catch { error -> AnyPublisher<[Viewit], Never> in
- errorMessageRelay.send(error.localizedDescription)
- return .just([])
- }
- .eraseToAnyPublisher()
+ .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in
+ return owner.fetchViewitList(last: IntegerLiterals.initialCursor)
}
+ .eraseToAnyPublisher()
+
+ fetchPublisher
.handleEvents(receiveOutput: { [weak self] viewitList in
- isLoadingRelay.send(false)
- isLastPageRelay.send(self?.isLastPage(viewitList) ?? false)
+ self?.loadingStateRelay.send(false)
+ self?.lastPageStateRelay.send(self?.checkIsLastPage(viewitList) ?? false)
})
- .sink { viewitListRelay.send($0) }
+ .sink { [weak self] in self?.viewitListRelay.send($0) }
.store(in: cancelBag)
-
- input.like
+ }
+
+ func bindLike(input: Input, cancelBag: CancelBag) {
+ let debouncedLike: AnyPublisher = input.like
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
- .compactMap { viewitID in
- return viewitListRelay.value.firstIndex { $0.id == viewitID }
+ .eraseToAnyPublisher()
+
+ let indexPublisher: AnyPublisher = debouncedLike
+ .compactMap { [weak self] viewitID in
+ return self?.viewitListRelay.value.firstIndex { $0.id == viewitID }
}
+ .eraseToAnyPublisher()
+
+ indexPublisher
.withUnretained(self)
.flatMap { owner, index -> AnyPublisher<(Int, Viewit), Never> in
- let viewit = viewitListRelay.value[index]
-
- let publisher = viewit.like.status
+ let viewit = owner.viewitListRelay.value[index]
+ let likePublisher: AnyPublisher = viewit.like.status
? owner.likeUseCase.unlike(viewit: viewit)
: owner.likeUseCase.like(viewit: viewit)
- return publisher
- .catch { error -> AnyPublisher in
- errorMessageRelay.send(error.localizedDescription)
+ return likePublisher
+ .catch { [weak owner] error -> AnyPublisher in
+ owner?.errorMessageRelay.send(error.localizedDescription)
return .just(nil)
}
.compactMap { $0 }
.map { (index, $0) }
.eraseToAnyPublisher()
}
- .sink { index, viewit in
- viewitListRelay.value[index] = viewit
+ .sink { [weak self] index, viewit in
+ self?.viewitListRelay.value[index] = viewit
}
.store(in: cancelBag)
-
- input.willLastDisplay
+ }
+
+ func bindWillLastDisplay(input: Input, cancelBag: CancelBag) {
+ let canLoadMore: AnyPublisher = input.willLastDisplay
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
- .filter { !isMoreLoadingRelay.value && !isLastPageRelay.value && !viewitListRelay.value.isEmpty }
- .handleEvents(receiveOutput: { _ in
- isMoreLoadingRelay.send(true)
+ .filter { [weak self] _ in
+ guard let self = self else { return false }
+ return !self.moreLoadingStateRelay.value &&
+ !self.lastPageStateRelay.value &&
+ !self.viewitListRelay.value.isEmpty
+ }
+ .eraseToAnyPublisher()
+
+ let loadingEvents = canLoadMore
+ .handleEvents(receiveOutput: { [weak self] _ in
+ self?.moreLoadingStateRelay.send(true)
})
- .compactMap { viewitListRelay.value.last?.id }
+
+ let lastItemID: AnyPublisher = loadingEvents
+ .compactMap { [weak self] _ in self?.viewitListRelay.value.last?.id }
+ .eraseToAnyPublisher()
+
+ lastItemID
.withUnretained(self)
.flatMap { owner, lastItemID -> AnyPublisher<[Viewit], Never> in
- return owner.useCase.fetchViewitList(last: lastItemID)
- .catch { error -> AnyPublisher<[Viewit], Never> in
- errorMessageRelay.send(error.localizedDescription)
- return .just([])
- }
- .eraseToAnyPublisher()
+ return owner.fetchViewitList(last: lastItemID)
}
- .handleEvents(receiveOutput: { _ in
- isMoreLoadingRelay.send(false)
+ .handleEvents(receiveOutput: { [weak self] _ in
+ self?.moreLoadingStateRelay.send(false)
})
- .sink { viewitListRelay.value.append(contentsOf: $0) }
+ .sink { [weak self] in self?.viewitListRelay.value.append(contentsOf: $0) }
.store(in: cancelBag)
-
- let userRole = input.meatball
- .compactMap { viewitID in
- return viewitListRelay.value.firstIndex { $0.id == viewitID }
- }
- .handleEvents(receiveOutput: { index in
- indexMeatballDidTapRelay.send(index)
- })
- .compactMap { [weak self] index in
- let userID = viewitListRelay.value[index].userID
- return self?.checkUserRoleUseCase.execute(userID: userID)
+ }
+
+ func bindReport(input: Input, cancelBag: CancelBag) {
+ let reportData: AnyPublisher<(Int, String), Never> = input.report
+ .map { [weak self] message in
+ (self?.indexMeatballDidTapRelay.value ?? 0, message)
}
- .asDriver()
+ .eraseToAnyPublisher()
- input.bottomSheetAction
- .filter { $0 == .ban }
- .map { _ in indexMeatballDidTapRelay.value }
+ reportData
.withUnretained(self)
- .flatMap { owner, index in
- let viewit = viewitListRelay.value[index]
- return owner.reportUseCase.ban(viewit: viewit)
- .catch { error -> AnyPublisher in
- errorMessageRelay.send(error.localizedDescription)
+ .flatMap { owner, data -> AnyPublisher in
+ let (index, message) = data
+ let viewit = owner.viewitListRelay.value[index]
+ return owner.reportUseCase.report(viewit: viewit, message: message)
+ .catch { [weak owner] error -> AnyPublisher in
+ owner?.errorMessageRelay.send(error.localizedDescription)
return .just(nil)
}
- .compactMap { $0 }
- .withUnretained(self)
- .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in
- return owner.useCase.fetchViewitList(last: IntegerLiterals.initialCursor)
- .catch { error -> AnyPublisher<[Viewit], Never> in
- errorMessageRelay.send(error.localizedDescription)
- return .just([])
- }
- .eraseToAnyPublisher()
- }
.eraseToAnyPublisher()
}
- .sink { viewitListRelay.send($0) }
+ .compactMap { $0 }
+ .sink { [weak self] _ in self?.reportStateRelay.send(true) }
.store(in: cancelBag)
+ }
+
+ func bindDelete(input: Input, cancelBag: CancelBag) {
+ let indexPublisher: AnyPublisher = input.delete
+ .map { [weak self] _ in self?.indexMeatballDidTapRelay.value ?? 0 }
+ .eraseToAnyPublisher()
- input.bottomSheetAction
- .filter { $0 == .delete }
- .map { _ in indexMeatballDidTapRelay.value }
+ indexPublisher
.withUnretained(self)
- .flatMap { owner, index in
- let viewit = viewitListRelay.value[index]
+ .flatMap { owner, index -> AnyPublisher<(Int, Viewit?), Never> in
+ let viewit = owner.viewitListRelay.value[index]
return owner.useCase.delete(viewit: viewit)
- .catch { error -> AnyPublisher in
- errorMessageRelay.send(error.localizedDescription)
+ .catch { [weak owner] error -> AnyPublisher in
+ owner?.errorMessageRelay.send(error.localizedDescription)
return .just(nil)
}
- .compactMap { $0 }
.map { (index, $0) }
.eraseToAnyPublisher()
}
- .sink { index, viewit in
- viewitListRelay.value.remove(at: index)
+ .compactMap { index, viewit in viewit.map { (index, $0) } }
+ .sink { [weak self] index, _ in
+ self?.viewitListRelay.value.remove(at: index)
}
.store(in: cancelBag)
+ }
+
+ func bindBan(input: Input, cancelBag: CancelBag) {
+ let indexPublisher: AnyPublisher = input.ban
+ .map { [weak self] _ in self?.indexMeatballDidTapRelay.value ?? 0 }
+ .eraseToAnyPublisher()
- input.bottomSheetAction
- .filter { $0 == .report }
- .map { _ in indexMeatballDidTapRelay.value }
+ let banPublisher: AnyPublisher<[Viewit], Never> = indexPublisher
.withUnretained(self)
- .flatMap { owner, index in
- let viewit = viewitListRelay.value[index]
- return owner.reportUseCase.report(viewit: viewit)
- .catch { error -> AnyPublisher in
- errorMessageRelay.send(error.localizedDescription)
+ .flatMap { owner, index -> AnyPublisher in
+ let viewit = owner.viewitListRelay.value[index]
+ return owner.reportUseCase.ban(viewit: viewit)
+ .catch { [weak owner] error -> AnyPublisher in
+ owner?.errorMessageRelay.send(error.localizedDescription)
return .just(nil)
}
- .compactMap { $0 }
.eraseToAnyPublisher()
}
- .sink { _ in isReportSuccess.send(true) }
+ .compactMap { $0 }
+ .withUnretained(self)
+ .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in
+ return owner.fetchViewitList(last: IntegerLiterals.initialCursor)
+ }
+ .eraseToAnyPublisher()
+
+ banPublisher
+ .sink { [weak self] in self?.viewitListRelay.send($0) }
.store(in: cancelBag)
+ }
+
+ // MARK: - Publisher Creation Method
+
+ func createViewitListPublisher() -> AnyPublisher<[Viewit], Never> {
+ return viewitListRelay
+ .removeDuplicates()
+ .asDriver()
+ }
+
+ func createUserRolePublisher(input: Input) -> AnyPublisher {
+ let indexPublisher: AnyPublisher = input.meatball
+ .compactMap { [weak self] viewitID in
+ return self?.viewitListRelay.value.firstIndex { $0.id == viewitID }
+ }
+ .handleEvents(receiveOutput: { [weak self] index in
+ self?.indexMeatballDidTapRelay.send(index)
+ })
+ .eraseToAnyPublisher()
- let moveToProfile = input.profileDidTap
+ return indexPublisher
+ .compactMap { [weak self] index in
+ let userID = self?.viewitListRelay.value[index].userID ?? 0
+ return self?.checkUserRoleUseCase.execute(userID: userID)
+ }
+ .asDriver()
+ }
+
+ func createMoveToProfilePublisher(input: Input) -> AnyPublisher {
+ let userIDPairs: AnyPublisher<(Int, Int)?, Never> = input.profileDidTap
.withUnretained(self)
.compactMap { owner, userID -> (Int, Int)? in
guard let activeUserID = owner.userSessionUseCase.fetchActiveUserID() else {
@@ -217,28 +284,28 @@ extension ViewitListViewModel: ViewModelType {
}
return (activeUserID, userID)
}
- .map { activeUserID, userID -> Int? in
+ .eraseToAnyPublisher()
+
+ return userIDPairs
+ .map { pairs -> Int? in
+ guard let (activeUserID, userID) = pairs else { return nil }
return activeUserID == userID ? .none : userID
}
.asDriver()
-
- return Output(
- isLoading: isLoadingRelay.asDriver(),
- viewitList: viewitList,
- isMoreLoading: isMoreLoadingRelay.asDriver(),
- userRole: userRole,
- isReportSuccess: isReportSuccess.asDriver(),
- moveToProfile: moveToProfile,
- errorMessage: errorMessageRelay.asDriver()
- )
}
-}
-
-private extension ViewitListViewModel {
- // MARK: - Helper Method
-
- func isLastPage(_ viewitList: [Viewit]) -> Bool {
+ // MARK: - Helper Methods
+
+ func fetchViewitList(last: Int) -> AnyPublisher<[Viewit], Never> {
+ return useCase.fetchViewitList(last: last)
+ .catch { [weak self] error -> AnyPublisher<[Viewit], Never> in
+ self?.errorMessageRelay.send(error.localizedDescription)
+ return .just([])
+ }
+ .eraseToAnyPublisher()
+ }
+
+ func checkIsLastPage(_ viewitList: [Viewit]) -> Bool {
return viewitList.isEmpty || viewitList.count < IntegerLiterals.defaultCountPerPage
}
}
diff --git a/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift b/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift
index f42a8ca1..d5f165a4 100644
--- a/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift
+++ b/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift
@@ -34,6 +34,7 @@ enum NavigationType {
case plain
case detail
case profile
+ case profileEdit
}
case home(hasNewNotification: Bool)
@@ -105,6 +106,12 @@ final class NavigationView: UIView {
$0.contentMode = .scaleAspectFit
}
+ lazy var doneButton: WableButton = WableButton(style: .primary).then {
+ $0.clipsToBounds = true
+ $0.layer.cornerRadius = 16.adjustedHeight
+ $0.configuration?.attributedTitle = "완료".pretendardString(with: .body3)
+ }
+
lazy var menuButton: UIButton = UIButton().then {
$0.setImage(.btnHamberger, for: .normal)
$0.contentMode = .scaleAspectFit
@@ -144,6 +151,7 @@ private extension NavigationView {
notificationButton,
backButton,
dismissButton,
+ doneButton,
menuButton
].forEach {
addSubviews($0)
@@ -202,6 +210,13 @@ private extension NavigationView {
$0.verticalEdges.trailing.equalToSuperview().inset(12)
}
+ doneButton.snp.makeConstraints {
+ $0.centerY.equalToSuperview()
+ $0.trailing.equalToSuperview().inset(12)
+ $0.adjustedWidthEqualTo(53)
+ $0.adjustedHeightEqualTo(33)
+ }
+
menuButton.snp.makeConstraints {
$0.verticalEdges.trailing.equalToSuperview().inset(12)
}
@@ -249,6 +264,13 @@ extension NavigationView {
menuButton,
pageUnderLineView
]
+ case .profileEdit:
+ visibleViewList = [
+ pageTitleLabel,
+ backButton,
+ doneButton,
+ pageUnderLineView
+ ]
}
case .hub(title: let text, isBeta: let isBeta):
backgroundColor = .wableBlack
diff --git a/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift b/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift
new file mode 100644
index 00000000..ccb5e382
--- /dev/null
+++ b/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift
@@ -0,0 +1,139 @@
+//
+// TeamCollectionView.swift
+// Wable-iOS
+//
+// Created by YOUJIM on 6/18/25.
+//
+
+import UIKit
+
+final class TeamCollectionView: UICollectionView {
+
+ // MARK: - Property
+
+ let randomTeamList: [LCKTeam] = [.t1, .gen, .bro, .drx, .dk, .kt, .ns, .bfx, .hle, .dnf].shuffled()
+ private let onSelected: ((String) -> Void)?
+ private var selectedTeam: LCKTeam?
+
+ // MARK: - LifeCycle
+
+ init(cellDidTapped: ((String) -> Void)?) {
+ self.onSelected = cellDidTapped
+
+ super.init(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout().then {
+ $0.itemSize = .init(width: 166.adjustedWidth, height: 64.adjustedHeight)
+ $0.minimumInteritemSpacing = 11
+ $0.minimumLineSpacing = 12
+ })
+
+ setupView()
+ setupDelegate()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+// MARK: - Setup Method
+
+private extension TeamCollectionView {
+ func setupView() {
+ register(
+ LCKTeamCollectionViewCell.self,
+ forCellWithReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier
+ )
+ isScrollEnabled = false
+ }
+
+ func setupDelegate() {
+ delegate = self
+ dataSource = self
+ }
+}
+
+// MARK: - Helper Method
+
+extension TeamCollectionView {
+ func selectInitialTeam(team: LCKTeam?) {
+ guard let team = team else { return }
+
+ selectedTeam = team
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self,
+ let selectedTeam = selectedTeam,
+ let index = randomTeamList.firstIndex(of: selectedTeam) else {
+ return
+ }
+
+ let indexPath = IndexPath(row: index, section: 0)
+
+ for (index, _) in randomTeamList.enumerated() {
+ if let cell = cellForItem(at: IndexPath(row: index, section: 0)) as? LCKTeamCollectionViewCell {
+ cell.layer.borderColor = UIColor.gray300.cgColor
+ cell.teamLabel.textColor = .gray700
+ }
+ }
+
+ if let cell = cellForItem(at: indexPath) as? LCKTeamCollectionViewCell {
+ cell.layer.borderColor = UIColor.purple50.cgColor
+ cell.teamLabel.textColor = .wableBlack
+ }
+
+ selectItem(at: indexPath, animated: false, scrollPosition: [])
+ }
+ }
+}
+
+// MARK: - UICollectionViewDelegate
+
+extension TeamCollectionView: UICollectionViewDelegate {
+ func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ let selectedTeam = randomTeamList[indexPath.row]
+ self.selectedTeam = selectedTeam
+
+ for cell in collectionView.visibleCells {
+ guard let cell = cell as? LCKTeamCollectionViewCell else { return }
+
+ cell.layer.borderColor = UIColor.gray300.cgColor
+ cell.teamLabel.textColor = .gray700
+ }
+
+ guard let cell = collectionView.cellForItem(at: indexPath) as? LCKTeamCollectionViewCell else { return }
+
+ cell.layer.borderColor = UIColor.purple50.cgColor
+ cell.teamLabel.textColor = .wableBlack
+
+ onSelected?(selectedTeam.rawValue)
+ }
+}
+
+extension TeamCollectionView: UICollectionViewDataSource {
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ return randomTeamList.count
+ }
+
+ func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+ guard let cell = collectionView.dequeueReusableCell(
+ withReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier,
+ for: indexPath
+ ) as? LCKTeamCollectionViewCell else {
+ return UICollectionViewCell()
+ }
+
+ let team = randomTeamList[indexPath.row]
+ cell.teamLabel.text = team.rawValue
+ cell.teamImageView.image = UIImage(named: team.rawValue.lowercased())
+
+ if let selectedTeam = selectedTeam, selectedTeam == team {
+ cell.layer.borderColor = UIColor.purple50.cgColor
+ cell.teamLabel.textColor = .wableBlack
+ } else {
+ cell.layer.borderColor = UIColor.gray300.cgColor
+ cell.teamLabel.textColor = .gray700
+ }
+
+ return cell
+ }
+}
diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift
index 6a99d1cf..d4a1aeb4 100644
--- a/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift
+++ b/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift
@@ -6,6 +6,7 @@
//
import UIKit
+import Photos
import SnapKit
import Then
@@ -14,16 +15,19 @@ final class PhotoDetailViewController: UIViewController {
// MARK: - UIComponent
+ private let backButton = UIButton().then {
+ $0.setImage(.icBackCircle, for: .normal)
+ }
+
+ private let saveButton = UIButton().then {
+ $0.setImage(.icDownloadCircle, for: .normal)
+ }
+
private let imageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.clipsToBounds = true
}
- private let dismissButton = UIButton().then {
- var configuration = UIButton.Configuration.plain()
- configuration.image = .btnRemovePhoto
- $0.configuration = configuration
- }
// MARK: - Property
@@ -36,10 +40,10 @@ final class PhotoDetailViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
- modalTransitionStyle = .crossDissolve
- modalPresentationStyle = .overFullScreen
+ hidesBottomBarWhenPushed = true
}
+ @available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -55,54 +59,115 @@ final class PhotoDetailViewController: UIViewController {
}
}
-// MARK: - Setup Method
-
private extension PhotoDetailViewController {
- func setupView() {
- view.backgroundColor = .wableBlack.withAlphaComponent(0.7)
+
+ // MARK: - Setup Method
+
+ func setupView() {
+ view.backgroundColor = .wableBlack
view.addSubviews(
imageView,
- dismissButton
+ backButton,
+ saveButton
)
imageView.image = image
}
func setupConstraint() {
+ backButton.snp.makeConstraints { make in
+ make.top.equalTo(safeArea).offset(4)
+ make.leading.equalTo(safeArea).offset(12)
+ make.size.equalTo(48)
+ }
+
+ saveButton.snp.makeConstraints { make in
+ make.top.size.equalTo(backButton)
+ make.trailing.equalTo(safeArea).offset(-12)
+ }
+
imageView.snp.makeConstraints { make in
- make.width.equalToSuperview()
make.center.equalToSuperview()
- make.height.equalTo(optimalImageViewHeight)
+ make.width.lessThanOrEqualToSuperview()
+ make.height.lessThanOrEqualToSuperview()
+ make.size.equalTo(optimalImageViewSize)
}
+ }
+
+ func setupAction() {
+ let popAction = UIAction { [weak self] _ in
+ self?.navigationController?.popViewController(animated: true)
+ }
+ backButton.addAction(popAction, for: .touchUpInside)
- dismissButton.snp.makeConstraints { make in
- make.centerX.equalToSuperview()
- make.bottom.equalToSuperview().offset(-100)
- make.adjustedWidthEqualTo(60)
- make.height.equalTo(dismissButton.snp.width)
+ let saveAction = UIAction { [weak self] _ in
+ AmplitudeManager.shared.trackEvent(tag: .clickDownloadPhoto)
+ self?.saveImage()
}
+ saveButton.addAction(saveAction, for: .touchUpInside)
}
- func setupAction() {
- let dismissAction = UIAction { [weak self] _ in
- self?.dismiss(animated: true)
+ // MARK: - Photo Method
+
+ func saveImage() {
+ Task {
+ do {
+ guard try await requestPhotoPermissionIfNeeded() else { return }
+ try await saveImageToPhotoLibrary(image)
+ await MainActor.run {
+ ToastView(status: .complete, message: StringLiterals.PhotoDetail.successMessage).show()
+ }
+ } catch {
+ await MainActor.run {
+ ToastView(status: .error, message: StringLiterals.PhotoDetail.errorMessage).show()
+ }
+ WableLogger.log("\(error)", for: .error)
+ }
+ }
+ }
+
+ func requestPhotoPermissionIfNeeded() async throws -> Bool {
+ let currentStatus = PHPhotoLibrary.authorizationStatus(for: .addOnly)
+
+ if isPermissionGranted(currentStatus) {
+ return true
}
- dismissButton.addAction(dismissAction, for: .touchUpInside)
+ let newStatus = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
+ return isPermissionGranted(newStatus)
}
-}
-
-// MARK: - Computed Property
-
-private extension PhotoDetailViewController {
- var optimalImageViewHeight: CGFloat {
- let aspectRatio = image.size.height / image.size.width
- let screenWidth = UIScreen.main.bounds.width
- let height = screenWidth * aspectRatio
+
+ func isPermissionGranted(_ status: PHAuthorizationStatus) -> Bool {
+ return status == .authorized || status == .limited
+ }
+
+ func saveImageToPhotoLibrary(_ image: UIImage) async throws {
+ try await PHPhotoLibrary.shared().performChanges {
+ PHAssetChangeRequest.creationRequestForAsset(from: image)
+ }
+ }
+
+ // MARK: - Computed Property
+
+ var optimalImageViewSize: CGSize {
+ let screenSize = UIScreen.main.bounds.size
+ let imageSize = image.size
- let maxHeight: CGFloat = 812.adjustedHeight
+ let aspectRatio = imageSize.width / imageSize.height
+
+ var finalWidth: CGFloat
+ var finalHeight: CGFloat
+
+ let condition = imageSize.width > screenSize.width
+ finalWidth = condition ? screenSize.width : imageSize.width
+ finalHeight = condition ? finalWidth / aspectRatio : imageSize.height
+
+ if finalHeight > screenSize.height {
+ finalHeight = screenSize.height
+ finalWidth = finalHeight * aspectRatio
+ }
- return min(height, maxHeight)
+ return CGSize(width: finalWidth, height: finalHeight)
}
}
diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift
index 7e51dd84..04041061 100644
--- a/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift
+++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift
@@ -102,6 +102,10 @@ extension WableBottomSheetController {
buttonStackView.addArrangedSubview(button)
}
+ func addActions(_ actions: [WableBottomSheetAction]) {
+ actions.forEach { addAction($0) }
+ }
+
func addActions(_ actions: WableBottomSheetAction...) {
actions.forEach { addAction($0) }
}
diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift
index 86ac9361..f2001197 100644
--- a/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift
+++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift
@@ -97,6 +97,7 @@ final class WableSheetViewController: UIViewController {
modalPresentationStyle = .overFullScreen
}
+ @available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -111,23 +112,21 @@ final class WableSheetViewController: UIViewController {
}
}
-// MARK: - Public Method
-
extension WableSheetViewController {
func addAction(_ action: WableSheetAction) {
let button = createWableButton(for: action)
buttonStackView.addArrangedSubview(button)
}
+ func addActions(_ actions: [WableSheetAction]) {
+ actions.forEach { addAction($0) }
+ }
+
func addActions(_ actions: WableSheetAction...) {
actions.forEach { addAction($0) }
}
-}
-
-// MARK: - Private Method
-
-private extension WableSheetViewController {
- func createWableButton(for action: WableSheetAction) -> WableButton {
+
+ private func createWableButton(for action: WableSheetAction) -> WableButton {
return WableButton(style: action.style.buttonStyle).then {
var config = $0.configuration ?? .filled()
config.attributedTitle = action.title.pretendardString(with: .body1)
@@ -144,9 +143,10 @@ private extension WableSheetViewController {
}
}
-// MARK: - Setup Method
-
private extension WableSheetViewController {
+
+ // MARK: - Setup Method
+
func setupView() {
view.backgroundColor = .wableBlack.withAlphaComponent(0.7)
diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift
new file mode 100644
index 00000000..9720b57e
--- /dev/null
+++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift
@@ -0,0 +1,348 @@
+//
+// WableTextSheetViewController.swift
+// Wable-iOS
+//
+// Created by 김진웅 on 6/22/25.
+//
+
+import Combine
+import UIKit
+
+import SnapKit
+import Then
+
+// MARK: - WableTextSheetAction
+
+struct WableTextSheetAction {
+ enum Style {
+ case primary
+ case gray
+ }
+
+ let title: String
+ let style: Style
+ let handler: ((String?) -> Void)?
+
+ init(
+ title: String,
+ style: Style,
+ handler: ((String?) -> Void)? = nil
+ ) {
+ self.title = title
+ self.style = style
+ self.handler = handler
+ }
+}
+
+fileprivate extension WableTextSheetAction.Style {
+ var buttonStyle: WableButton.Style {
+ switch self {
+ case .primary:
+ return .primary
+ case .gray:
+ return .gray
+ }
+ }
+}
+
+// MARK: - WableTextSheetViewController
+
+final class WableTextSheetViewController: UIViewController {
+
+ // MARK: - UIComponent
+
+ private let scrollView = UIScrollView().then {
+ $0.showsVerticalScrollIndicator = false
+ $0.showsHorizontalScrollIndicator = false
+ $0.keyboardDismissMode = .interactive
+ }
+
+ private let containerView = UIView(backgroundColor: .wableWhite).then {
+ $0.layer.cornerRadius = 16
+ }
+
+ private let titleLabel = UILabel().then {
+ $0.attributedText = "제목".pretendardString(with: .head1)
+ $0.numberOfLines = 0
+ $0.textAlignment = .center
+ }
+
+ private let messageTextView = UITextView(backgroundColor: .gray100).then {
+ $0.font = .pretendard(.body4)
+ $0.textColor = .wableBlack
+ $0.textContainerInset = UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)
+ $0.layer.cornerRadius = 12
+ }
+
+ private let placeholderLabel = UILabel().then {
+ $0.attributedText = "플레이스홀더".pretendardString(with: .body4)
+ $0.textColor = .gray600
+ $0.numberOfLines = 0
+ }
+
+ private let textCountLabel = UILabel().then {
+ $0.attributedText = "0/100".pretendardString(with: .caption4)
+ $0.textAlignment = .right
+ }
+
+ private let buttonStackView = UIStackView(axis: .horizontal).then {
+ $0.spacing = 8
+ $0.distribution = .fillEqually
+ $0.alignment = .fill
+ }
+
+ // MARK: - Property
+
+ private let sheetTitle: String
+ private let placeholder: String
+ private let cancelBag = CancelBag()
+
+ // MARK: - Life Cycle
+
+ init(title: String, placeholder: String) {
+ self.sheetTitle = title
+ self.placeholder = placeholder
+
+ super.init(nibName: nil, bundle: nil)
+
+ modalTransitionStyle = .crossDissolve
+ modalPresentationStyle = .overFullScreen
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ setupView()
+ setupConstraint()
+ setupDelegate()
+ setupAction()
+ }
+}
+
+extension WableTextSheetViewController {
+ func addAction(_ action: WableTextSheetAction) {
+ let button = createWableButton(for: action)
+ buttonStackView.addArrangedSubview(button)
+ }
+
+ func addActions(_ actions: [WableTextSheetAction]) {
+ actions.forEach { addAction($0) }
+ }
+
+ func addActions(_ actions: WableTextSheetAction...) {
+ actions.forEach { addAction($0) }
+ }
+
+ private func createWableButton(for action: WableTextSheetAction) -> WableButton {
+ return WableButton(style: action.style.buttonStyle).then {
+ var config = $0.configuration ?? .filled()
+ config.attributedTitle = action.title.pretendardString(with: .body1)
+ $0.configuration = config
+
+ let action = UIAction { [weak self] _ in
+ self?.dismiss(animated: true) {
+ action.handler?(self?.messageTextView.text)
+ }
+ }
+
+ $0.addAction(action, for: .touchUpInside)
+ }
+ }
+}
+
+// MARK: - UITextViewDelegate
+
+extension WableTextSheetViewController: UITextViewDelegate {
+ func textViewDidChange(_ textView: UITextView) {
+ configure(textView: textView)
+ }
+
+ func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
+ let currentText = textView.text ?? ""
+ let newLength = currentText.count + text.count - range.length
+ return newLength <= Constant.maxCharacterCount
+ }
+}
+
+// MARK: - UIGestureRecognizerDelegate
+
+extension WableTextSheetViewController: UIGestureRecognizerDelegate {
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
+ if touch.view is UIButton || touch.view is UITextView {
+ return false
+ }
+
+ if let view = touch.view, view.isDescendant(of: buttonStackView) {
+ return false
+ }
+
+ return true
+ }
+}
+
+private extension WableTextSheetViewController {
+
+ // MARK: - Setup Method
+
+ func setupView() {
+ view.backgroundColor = .wableBlack.withAlphaComponent(0.7)
+
+ titleLabel.text = sheetTitle
+ placeholderLabel.text = placeholder
+
+ view.addSubview(scrollView)
+ scrollView.addSubview(containerView)
+ messageTextView.addSubview(placeholderLabel)
+
+ containerView.addSubviews(
+ titleLabel,
+ messageTextView,
+ textCountLabel,
+ buttonStackView
+ )
+ }
+
+ func setupConstraint() {
+ scrollView.snp.makeConstraints { make in
+ make.edges.equalTo(safeArea)
+ }
+
+ containerView.snp.makeConstraints { make in
+ make.horizontalEdges.equalToSuperview().inset(32)
+ make.centerY.equalToSuperview()
+ make.width.equalTo(view.snp.width).offset(-64)
+ }
+
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(24)
+ make.horizontalEdges.equalToSuperview().inset(24)
+ }
+
+ messageTextView.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(12)
+ make.horizontalEdges.equalTo(titleLabel)
+ make.adjustedHeightEqualTo(156)
+ }
+
+ placeholderLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(12)
+ make.horizontalEdges.equalToSuperview().inset(16)
+ }
+
+ textCountLabel.snp.makeConstraints { make in
+ make.top.equalTo(messageTextView.snp.bottom).offset(4)
+ make.trailing.equalTo(messageTextView)
+ }
+
+ buttonStackView.snp.makeConstraints { make in
+ make.top.equalTo(textCountLabel.snp.bottom).offset(16)
+ make.horizontalEdges.equalTo(messageTextView)
+ make.adjustedHeightEqualTo(48)
+ make.bottom.equalToSuperview().offset(-16)
+ }
+ }
+
+ func setupDelegate() {
+ messageTextView.delegate = self
+ }
+
+ func setupAction() {
+ let tapGesture = UITapGestureRecognizer(target: self, action: nil)
+ tapGesture.delegate = self
+
+ view.gesture(.tap(tapGesture))
+ .sink { [weak self] _ in self?.view.endEditing(true) }
+ .store(in: cancelBag)
+
+ NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
+ .compactMap { notification -> (CGRect, TimeInterval)? in
+ guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
+ let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval else {
+ return nil
+ }
+ return (keyboardFrame, duration)
+ }
+ .sink { [weak self] keyboardFrame, duration in
+ self?.adjustScrollViewForKeyboard(keyboardFrame: keyboardFrame, duration: duration, isShowing: true)
+ }
+ .store(in: cancelBag)
+
+ NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
+ .compactMap { notification -> TimeInterval? in
+ notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval
+ }
+ .sink { [weak self] duration in
+ self?.adjustScrollViewForKeyboard(keyboardFrame: .zero, duration: duration, isShowing: false)
+ }
+ .store(in: cancelBag)
+ }
+
+ // MARK: - Helper Method
+
+ func configure(textView: UITextView) {
+ let currentText = textView.text ?? ""
+ let currentCount = currentText.count
+
+ placeholderLabel.isHidden = !currentText.isEmpty
+
+ textCountLabel.text = "\(currentCount)/\(Constant.maxCharacterCount)"
+ textCountLabel.textColor = currentCount >= Constant.maxCharacterCount ? .red : .wableBlack
+ }
+
+ func adjustScrollViewForKeyboard(keyboardFrame: CGRect, duration: TimeInterval, isShowing: Bool) {
+ let adjustedKeyboardHeight = calculateAdjustedKeyboardHeight(keyboardFrame: keyboardFrame, isShowing: isShowing)
+ updateScrollViewInsets(with: adjustedKeyboardHeight)
+
+ isShowing
+ ? scrollToVisibleAreaIfNeeded(keyboardHeight: adjustedKeyboardHeight, duration: duration)
+ : animateScrollViewContentOffset(to: .zero, duration: duration)
+ }
+
+ func calculateAdjustedKeyboardHeight(keyboardFrame: CGRect, isShowing: Bool) -> CGFloat {
+ if !isShowing { return 0 }
+
+ let keyboardHeight = keyboardFrame.height
+ let safeAreaBottom = view.safeAreaInsets.bottom
+ return keyboardHeight - safeAreaBottom
+ }
+
+ func updateScrollViewInsets(with keyboardHeight: CGFloat) {
+ let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: keyboardHeight, right: 0)
+ scrollView.contentInset = contentInsets
+ scrollView.scrollIndicatorInsets = contentInsets
+ }
+
+ func scrollToVisibleAreaIfNeeded(keyboardHeight: CGFloat, duration: TimeInterval) {
+ let textCountLabelFrame = textCountLabel.convert(textCountLabel.bounds, to: scrollView)
+ let textCountLabelBottom = textCountLabelFrame.maxY
+ let keyboardTop = scrollView.frame.height - keyboardHeight
+
+ guard textCountLabelBottom > keyboardTop else { return }
+
+ let scrollOffset = textCountLabelBottom - keyboardTop + 8
+ let targetContentOffset = CGPoint(x: 0, y: scrollOffset)
+
+ animateScrollViewContentOffset(to: targetContentOffset, duration: duration)
+ }
+
+ func animateScrollViewContentOffset(to offset: CGPoint, duration: TimeInterval) {
+ UIView.animate(
+ withDuration: duration,
+ delay: 0,
+ options: .curveEaseInOut
+ ) {
+ self.scrollView.setContentOffset(offset, animated: false)
+ }
+ }
+
+ // MARK: - Constant
+
+ enum Constant {
+ static let maxCharacterCount = 100
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json
new file mode 100644
index 00000000..9e78ac74
--- /dev/null
+++ b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "images" : [
+ {
+ "filename" : "img_wable 3.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "img_wable 1.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "filename" : "img_wable 2.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png
new file mode 100644
index 00000000..9a30ebbe
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png
new file mode 100644
index 00000000..9a30ebbe
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png
new file mode 100644
index 00000000..9a30ebbe
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json
new file mode 100644
index 00000000..4e2cc768
--- /dev/null
+++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_back_circle.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "ic_back_circle@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "ic_back_circle@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png
new file mode 100644
index 00000000..439197d4
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png
new file mode 100644
index 00000000..0bf3574a
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png
new file mode 100644
index 00000000..afa1098b
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json
new file mode 100644
index 00000000..5aa96c2f
--- /dev/null
+++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_download_circle.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "ic_download_circle@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "ic_download_circle@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png
new file mode 100644
index 00000000..f22f5451
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png
new file mode 100644
index 00000000..60871578
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png
new file mode 100644
index 00000000..1d795517
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json
new file mode 100644
index 00000000..9ccc74e5
--- /dev/null
+++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_ghost_info.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "ic_ghost_info@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "ic_ghost_info@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png
new file mode 100644
index 00000000..c5217a0c
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png
new file mode 100644
index 00000000..a86a780a
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png
new file mode 100644
index 00000000..9db531c6
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json
new file mode 100644
index 00000000..2a36bb47
--- /dev/null
+++ b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "img_ghost_tooltip.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "img_ghost_tooltip@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "img_ghost_tooltip@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png
new file mode 100644
index 00000000..5854ead9
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png
new file mode 100644
index 00000000..7f06cc07
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png differ
diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png
new file mode 100644
index 00000000..d3d02dea
Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png differ
diff --git a/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json
new file mode 100644
index 00000000..f4ca2aec
--- /dev/null
+++ b/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "0.500",
+ "blue" : "0x0E",
+ "green" : "0x0E",
+ "red" : "0x0E"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json
new file mode 100644
index 00000000..7e8f38fe
--- /dev/null
+++ b/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json
new file mode 100644
index 00000000..0b427cfa
--- /dev/null
+++ b/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x00",
+ "green" : "0xE4",
+ "red" : "0xFB"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json
new file mode 100644
index 00000000..4ae755e7
--- /dev/null
+++ b/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xEB",
+ "red" : "0xE2"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json
new file mode 100644
index 00000000..b4b60d1f
--- /dev/null
+++ b/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xF2",
+ "green" : "0x50",
+ "red" : "0x0B"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Wable-iOS/Resource/Info.plist b/Wable-iOS/Resource/Info.plist
index 676ab412..27f46388 100644
--- a/Wable-iOS/Resource/Info.plist
+++ b/Wable-iOS/Resource/Info.plist
@@ -2,8 +2,6 @@
- ITSAppUsesNonExemptEncryption
-
AMPLITUDE_APP_KEY
$(AMPLITUDE_APP_KEY)
BASE_URL
@@ -21,6 +19,8 @@
FirebaseAppDelegateProxyEnabled
+ ITSAppUsesNonExemptEncryption
+
LSApplicationQueriesSchemes
kakaolink