diff --git a/Common/Package.swift b/Common/Package.swift index 168956b..2cc80e0 100644 --- a/Common/Package.swift +++ b/Common/Package.swift @@ -31,6 +31,10 @@ let package = Package( name: "SharedUI", targets: ["SharedUI"] ), + .library( + name: "SharedDomain", + targets: ["SharedDomain"] + ), ], targets: [ .target( @@ -50,6 +54,7 @@ let package = Package( name: "SharedUI", dependencies: ["DesignSystem"] ), + .target(name: "SharedDomain"), .plugin( name: "ColorGenerator", diff --git a/Common/Sources/DesignSystem/HambugCommonAlertView.swift b/Common/Sources/DesignSystem/HambugCommonAlertView.swift index e9d3abb..102364b 100644 --- a/Common/Sources/DesignSystem/HambugCommonAlertView.swift +++ b/Common/Sources/DesignSystem/HambugCommonAlertView.swift @@ -106,6 +106,34 @@ public struct AlertButton { self.title = title self.action = action } + + public init( + _ type: AlertButtonType, + action: @escaping @MainActor () -> Void + ) { + self.title = type.title + self.action = action + } + + public enum AlertButtonType { + case ok + case cancel + case save + case accountDelete + + var title: String { + switch self { + case .ok: + return "확인" + case .cancel: + return "취소" + case .save: + return "저장" + case .accountDelete: + return "탈퇴" + } + } + } } #Preview { diff --git a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json similarity index 76% rename from Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json rename to Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json index 8946336..30c0889 100644 --- a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json +++ b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json @@ -1,11 +1,11 @@ { "images" : [ { - "filename" : "기본 프로필 이미지.png", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "placeholder_profile.png", "idiom" : "universal", "scale" : "2x" }, diff --git "a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/\352\270\260\353\263\270 \355\224\204\353\241\234\355\225\204 \354\235\264\353\257\270\354\247\200.png" b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/placeholder_profile.png similarity index 100% rename from "Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/\352\270\260\353\263\270 \355\224\204\353\241\234\355\225\204 \354\235\264\353\257\270\354\247\200.png" rename to Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/placeholder_profile.png diff --git a/Common/Sources/Managers/AppStateManager.swift b/Common/Sources/Managers/AppStateManager.swift index f553fad..3831dfb 100644 --- a/Common/Sources/Managers/AppStateManager.swift +++ b/Common/Sources/Managers/AppStateManager.swift @@ -66,10 +66,12 @@ public final class AppStateManager { state = .main } - // 로그아웃 (선택사항 - 나중에 구현 예정이지만 미리 추가) + // 로그아웃 public func logout() { do { try tokenStorage.clear() + UserDefaultsManager.shared.clearAll() + // TODO: - push key 추가시 삭제 해야할듯 ? state = .login } catch { print("⚠️ Failed to logout: \(error)") diff --git a/Common/Sources/Managers/AppStorageKey.swift b/Common/Sources/Managers/AppStorageKey.swift index bc8534e..724d792 100644 --- a/Common/Sources/Managers/AppStorageKey.swift +++ b/Common/Sources/Managers/AppStorageKey.swift @@ -10,7 +10,6 @@ import Foundation public extension String { struct Storage { static let hasSeenOnboarding = "hasSeenOnboarding" - static let userResponse = "userResponse" static let currentUserId = "currentUserId" } } diff --git a/Common/Sources/Managers/UserDefaultsManager.swift b/Common/Sources/Managers/UserDefaultsManager.swift index d90c46d..aa89590 100644 --- a/Common/Sources/Managers/UserDefaultsManager.swift +++ b/Common/Sources/Managers/UserDefaultsManager.swift @@ -47,4 +47,14 @@ public final class UserDefaultsManager { @UDDefaultWrapper(key: .Storage.currentUserId, defaultValue: nil) public var currentUserId: Int64? + + func clearAll() { + let keys: [String] = [ + .Storage.currentUserId, + + ] + keys.forEach { + UserDefaults.standard.removeObject(forKey: $0) + } + } } diff --git a/Common/Sources/SharedDomain/User.swift b/Common/Sources/SharedDomain/User.swift new file mode 100644 index 0000000..9f2411d --- /dev/null +++ b/Common/Sources/SharedDomain/User.swift @@ -0,0 +1,35 @@ +// +// User.swift +// Common +// +// Created by 강동영 on 1/12/26. +// + + +public struct User { + public let userId: Int64 + public let nickname: String + public var profileImageURL: String + public let loginType: String + public let role: String + public let isRegister: Bool? + public let kakao: Bool + + public init( + userId: Int64 = 0, + nickname: String = "", + profileImageURL: String = "", + loginType: String = "", + role: String = "", + isRegister: Bool? = false, + kakao: Bool = false + ) { + self.userId = userId + self.nickname = nickname + self.profileImageURL = profileImageURL + self.loginType = loginType + self.role = role + self.isRegister = isRegister + self.kakao = kakao + } +} \ No newline at end of file diff --git a/Common/Sources/Util/ImageProcessor.swift b/Common/Sources/Util/ImageProcessor.swift index dffcd84..3ea6e43 100644 --- a/Common/Sources/Util/ImageProcessor.swift +++ b/Common/Sources/Util/ImageProcessor.swift @@ -13,10 +13,10 @@ public enum ImageProcessor { // MARK: - Constants /// 최대 파일 크기: 10MB - public static let maxFileSize: Int = 10 * 1024 * 1024 + public static let maxFileSize: Int = 2 * 1024 * 1024 - /// 최대 해상도: 1920px - public static let maxDimension: CGFloat = 1920 + /// 최대 해상도: 1280px + public static let maxDimension: CGFloat = 1280 /// 기본 압축 품질 public static let compressionQuality: CGFloat = 0.85 diff --git a/Common/Sources/Util/NSNotification.Name+.swift b/Common/Sources/Util/NSNotification.Name+.swift new file mode 100644 index 0000000..552df98 --- /dev/null +++ b/Common/Sources/Util/NSNotification.Name+.swift @@ -0,0 +1,12 @@ +// +// NSNotification.Name+.swift +// Common +// +// Created by 강동영 on 1/12/26. +// + +import Foundation.NSNotification + +public extension NSNotification.Name { + static let userDidLogout = NSNotification.Name("userDidLogout") +} diff --git a/DI/Package.swift b/DI/Package.swift index 8b7c706..3bbaa27 100644 --- a/DI/Package.swift +++ b/DI/Package.swift @@ -10,7 +10,6 @@ enum Config: String, CaseIterable { case app = "App" case intro = "Intro" case login = "Login" - case myPage = "MyPage" var name: String { switch self { @@ -42,7 +41,6 @@ let package = Package( .package(name: "Infrastructure", path: "../Infrastructure"), .package(name: "Intro", path: "../Intro"), .package(name: "Login", path: "../Login"), - .package(name: "MyPage", path: "../MyPage"), ], targets: [ .target(name: Config.interface.name), @@ -70,13 +68,6 @@ let package = Package( .target(config: .app), .product(name: "Login", package: "Login"), ] - ), - .target( - name: Config.myPage.name, - dependencies: [ - .target(config: .app), - .product(name: "MyPage", package: "MyPage"), - ] ) ] ) diff --git a/Hambug/ContentView.swift b/Hambug/ContentView.swift index e58c5de..d0c6fe2 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -52,11 +52,16 @@ struct ContentView: View { .tag(1) - Text("MyPage") - .tag(2) + NavigationStack { + MyPageView( + viewModel: mypageDIContainer.makeMyPageViewModel() + ) + } + .tag(2) } .toolbar(.hidden, for: .tabBar) } + .ignoresSafeArea(.keyboard) } } diff --git a/Hambug/RootView.swift b/Hambug/RootView.swift index 04d468f..e864d8b 100644 --- a/Hambug/RootView.swift +++ b/Hambug/RootView.swift @@ -15,6 +15,7 @@ import LoginDI import AppDI import IntroDI import NetworkImpl +import Util struct RootView: View { @Environment(AppStateManager.self) var appStateManager diff --git a/Infrastructure/Package.swift b/Infrastructure/Package.swift index 0bbb767..a397695 100644 --- a/Infrastructure/Package.swift +++ b/Infrastructure/Package.swift @@ -35,6 +35,7 @@ let package = Package( dependencies: [ .target(name: Config.networkInterface), .product(name: "DataSources", package: "Common"), + .product(name: "Util", package: "Common"), "Alamofire", ] ), diff --git a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift index e92fc2d..abba0f7 100644 --- a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift +++ b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift @@ -9,7 +9,7 @@ import Foundation import DataSources import NetworkInterface - +import Util import Alamofire // MARK: - Auth Interceptor @@ -142,6 +142,3 @@ public final class AuthInterceptor: RequestInterceptor { } -public extension NSNotification.Name { - static let userDidLogout = NSNotification.Name("userDidLogout") -} diff --git a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift index 3fe9f8d..d6e9950 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift @@ -92,10 +92,16 @@ public final class NetworkServiceImpl: NetworkServiceInterface { responseType: T.Type ) -> AnyPublisher { do { + let urlRequest = try endpoint.createURLRequest() + +#if DEBUG + logger?.requestLogger(request: urlRequest) +#endif + guard let url = endpoint.createURL() else { throw NetworkError.invalidURL } - + return session.upload( multipartFormData: { multipartFormData in // 1. JSON body의 텍스트 필드 추가 (title, content, category) diff --git a/MyPage/Package.swift b/MyPage/Package.swift index baa07a9..3d355b5 100644 --- a/MyPage/Package.swift +++ b/MyPage/Package.swift @@ -6,6 +6,7 @@ import PackageDescription enum Config: String, CaseIterable { static let name: String = "MyPage" + case di = "DI" case data = "Data" case domain = "Domain" case presentation = "Presentation" @@ -33,10 +34,22 @@ let package = Package( .package(name: "Infrastructure", path: "../Infrastructure") ], targets: [ + .target( + name: Config.di.name, + dependencies: [ + .target(config: .domain), + .target(config: .data), + .target(config: .presentation), + .product(name: "NetworkInterface", package: "Infrastructure"), + .product(name: "NetworkImpl", package: "Infrastructure") + ], + path: Config.di.path + ), .target( name: Config.data.name, dependencies: [ .target(config: .domain), + .product(name: "SharedDomain", package: "Common"), .product(name: "NetworkInterface", package: "Infrastructure"), .product(name: "NetworkImpl", package: "Infrastructure") ], diff --git a/DI/Sources/MyPageDI/MyPageDIContainer.swift b/MyPage/Sources/DI/MyPageDIContainer.swift similarity index 89% rename from DI/Sources/MyPageDI/MyPageDIContainer.swift rename to MyPage/Sources/DI/MyPageDIContainer.swift index bd00375..c46c767 100644 --- a/DI/Sources/MyPageDI/MyPageDIContainer.swift +++ b/MyPage/Sources/DI/MyPageDIContainer.swift @@ -7,6 +7,7 @@ import DIKit import AppDI +import NetworkInterface import NetworkImpl import MyPageDomain import MyPageData @@ -18,7 +19,7 @@ struct MyPageAssembly: Assembly { container.register(MyPageRepository.self) { resolver in MyPageRepositoryImpl( - networkService: NetworkServiceImpl() + networkService: resolver.resolve(NetworkServiceInterface.self) ) } @@ -46,7 +47,7 @@ public final class MyPageDIContainer { // MARK: - Initialization public init(appContainer: AppDIContainer? = nil) { let parent = appContainer ?? AppDIContainer.shared - self.container = GenericDIContainer(parent: parent.baseContainer) + self.container = AppDIContainer.shared.baseContainer MyPageAssembly().assemble(container: container) } diff --git a/MyPage/Sources/Data/MyPage.swift b/MyPage/Sources/Data/MyPage.swift deleted file mode 100644 index 08b22b8..0000000 --- a/MyPage/Sources/Data/MyPage.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/MyPage/Sources/Data/MyPageDTO.swift b/MyPage/Sources/Data/MyPageDTO.swift new file mode 100644 index 0000000..36918b7 --- /dev/null +++ b/MyPage/Sources/Data/MyPageDTO.swift @@ -0,0 +1,79 @@ +// +// MyPageDTO.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import SharedDomain + +// MARK: - Request DTOs +struct UpdateProfileRequest: Codable { + let userId: Int + let profileImageURL: String? +} + +struct UpdateNicknameRequest: Codable { + let nickname: String +} + +// MARK: - Response DTOs + +/// 회원 정보 조회 +struct UserProfileDTO: Decodable { + let userId: Int + let nickname: String + let profileImageUrl: String + let loginType: String + let role: String + let isRegister: Bool? + let kakao: Bool +} + +extension UserProfileDTO { + func toDomain() -> User { + return User( + userId: Int64(userId), + nickname: nickname, + profileImageURL: profileImageUrl, + loginType: loginType, + role: role, + isRegister: isRegister, + kakao: kakao + ) + } +} + +struct MyPostsResponse: Codable { + let success: Bool + let data: [MyPostItem] + let message: String + let code: Int +} + +struct MyCommentsResponse: Codable { + let success: Bool + let data: [MyCommentItem] + let message: String + let code: Int +} + +struct MyPostItem: Codable, Identifiable { + let id: Int + let title: String + let content: String? + let imageURL: String? + let likeCount: String + let commentCount: String + let createdAt: String + let category: String +} + +struct MyCommentItem: Codable, Identifiable { + let id: Int + let content: String + let createdAt: String + let postId: Int + let postTitle: String + let likeCount: String +} diff --git a/MyPage/Sources/Data/MyPageEndpoint.swift b/MyPage/Sources/Data/MyPageEndpoint.swift new file mode 100644 index 0000000..cf1c16e --- /dev/null +++ b/MyPage/Sources/Data/MyPageEndpoint.swift @@ -0,0 +1,76 @@ +// +// MyPageEndpoint.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import Foundation +import NetworkInterface +import NetworkImpl + +// MARK: - MyPage Endpoints +enum MyPageEndpoint: Endpoint { + case authMe + case updateProfile(UpdateProfileRequest) + case updateNickname(userID: Int, nickname: String) + case logout + case deleteAccount(provider: String) + + var baseURL: String { + NetworkConfig.baseURL + } + + var path: String { + switch self { + case .authMe: + return "/api/v1/auth/me" + case let .updateProfile(param): + return "/api/v1/users/\(param.userId)/profile" + case let .updateNickname(id, _): + return "/api/v1/users/\(id)/nickname" + case .logout: + return "/api/v1/auth/logout" + case let .deleteAccount(provider): + return "/api/v1/auth/unlink/\(provider)" + } + } + + var method: HTTPMethod { + switch self { + case .authMe: + return .GET + case .updateProfile, .updateNickname: + return .PUT + case .logout, .deleteAccount: + return .POST + } + } + + var headers: [String: String] { + var headers: [String: String] = [:] + // TODO: Add Authorization header when auth is implemented + // headers["Authorization"] = "Bearer \(token)" + return headers + } + + var queryParameters: [String: Any] { + switch self { + default: + return [:] + } + } + + var body: Data? { + switch self { + case .updateProfile(let request): + // TODO: multi-part + return try? JSONEncoder().encode(request.profileImageURL) + case let .updateNickname(_, nickname): + let request = UpdateNicknameRequest(nickname: nickname) + return try? JSONEncoder().encode(request) + default: + return nil + } + } +} diff --git a/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift new file mode 100644 index 0000000..61398bf --- /dev/null +++ b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift @@ -0,0 +1,137 @@ +// +// MyPageRepositoryImpl.swift +// Hambug +// +// Created by 강동영 on 10/30/25. +// + +import Foundation +import Combine +import UIKit +import MyPageDomain +import NetworkInterface +import SharedDomain + +// MARK: - MyPage Repository Implementation +public final class MyPageRepositoryImpl: MyPageRepository { + + private let networkService: NetworkServiceInterface + + public init(networkService: NetworkServiceInterface) { + self.networkService = networkService + } + + public func fetchProfile() async throws -> SharedDomain.User { + let endpoint = MyPageEndpoint.authMe + do { + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .async() + + self.currentUserId = response.data.userId + return response.data.toDomain() + } catch { + print(error.localizedDescription) + throw error + } + } + + public func updateNickname(_ nickName: String) async { + guard let userId = currentUserId else { + return + } + + let endpoint = MyPageEndpoint.updateNickname(userID: userId, nickname: nickName) + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + public func changeProfileImage(_ image: UIImage?) async throws -> ProfileURL { + guard let userId = currentUserId else { + let error = NSError(domain: "incorrect user id", code: -1) + throw error + } + + let request = UpdateProfileRequest(userId: userId, profileImageURL: nil) + let endpoint = MyPageEndpoint.updateProfile(request) + + do { + if let image = image { + // Upload with multipart if image exists + return try await networkService.uploadMultipart( + endpoint, + images: [image], + responseType: SuccessResponse.self + ) + .map { $0.data.profileImageUrl } + .async() + } else { + // Send null request for default image + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { $0.data.profileImageUrl } + .async() + } + } catch { + throw error + } + + } + + public func applyDefaultImage() async throws { + do { + _ = try await changeProfileImage(nil) + } catch { + throw error + } + + } + + public func logout() async { + let endpoint = MyPageEndpoint.logout + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + public func deleteAccount(provider: String) async { + let endpoint = MyPageEndpoint.deleteAccount(provider: provider) + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + private var currentUserId: Int? +} diff --git a/MyPage/Sources/Domain/MyPage.swift b/MyPage/Sources/Domain/MyPage.swift deleted file mode 100644 index 08b22b8..0000000 --- a/MyPage/Sources/Domain/MyPage.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift index 45957f6..953a9e5 100644 --- a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift +++ b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift @@ -7,15 +7,18 @@ import Foundation import Combine +import UIKit +import SharedDomain // MARK: - MyPage Repository Interface public protocol MyPageRepository { - func fetchProfile(userId: Int) -> AnyPublisher - - func updateNickname(_ nickName: String) - func changeProfileImage() - func applyDefaultImage() - - func logout() - func deleteAccount() + typealias ProfileURL = String + func fetchProfile() async throws -> User + + func updateNickname(_ nickName: String) async + func changeProfileImage(_ image: UIImage?) async throws -> ProfileURL + func applyDefaultImage() async throws + + func logout() async + func deleteAccount(provider: String) async } diff --git a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift index c6cfcc3..331a6be 100644 --- a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift +++ b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift @@ -6,16 +6,20 @@ // import Foundation +import Combine +import UIKit +import SharedDomain +import Util public protocol MyPageUseCase { - func fetchProfile() - - func updateNickname(_ nickName: String) - func changeProfileImage() - func applyDefaultImage() - - func logout() - func deleteAccount() + func fetchProfile() async throws -> User + + func updateNickname(_ nickName: String) async + func changeProfileImage(_ image: UIImage?) async throws -> String + func applyDefaultImage() async + + func logout() async + func deleteAccount(provider: String) async } // MARK: - MyPage UseCase 구현체 @@ -26,14 +30,37 @@ public final class MyPageUseCaseImpl: MyPageUseCase { self.repository = repository } - public func fetchProfile() { - repository.fetchProfile(userId: 0) + public func fetchProfile() async throws -> User { + do { + return try await repository.fetchProfile() + } catch { + throw error + } + + } + + public func updateNickname(_ nickName: String) async { + await repository.updateNickname(nickName) } - public func updateNickname(_ nickName: String) {} - public func changeProfileImage() {} - public func applyDefaultImage() {} + public func changeProfileImage(_ image: UIImage?) async throws -> String { + try await repository.changeProfileImage(image) + } + + public func applyDefaultImage() async { + do { + try await repository.applyDefaultImage() + } catch { + print(error.localizedDescription) + } + + } - public func logout() {} - public func deleteAccount() {} + public func logout() async { + await repository.logout() + } + + public func deleteAccount(provider: String) async { + await repository.deleteAccount(provider: provider) + } } diff --git a/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json new file mode 100644 index 0000000..bf8972c --- /dev/null +++ b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "community_pencil.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png new file mode 100644 index 0000000..39ca317 Binary files /dev/null and b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png differ diff --git a/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift b/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift new file mode 100644 index 0000000..651625e --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift @@ -0,0 +1,74 @@ +// +// MyPage+LocalizedString.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + + +import LocalizedString +import SwiftUI + +extension Text { + @inlinable + public init(myPage: KeyPath) { + let value = String.LocalizedString.MyPage.self[keyPath: myPage] + self.init(verbatim: value) + } +} + +extension String.LocalizedString { + enum Login { + static let hello: String = "안녕하세요." + static let hambug: String = "햄버그" + static let hambugSuffix: String = "입니다 :)" + static let descriptionOfSNS: String = "SNS 계정으로 간편 가입하기" + } + + public enum MyPage { + static let header: String = "마이페이지" + struct ActionSheetTitle2 { + static let profile = "프로필 설정" + } + public enum ActionSheetTitle { + static let profile = "프로필 설정" + static let changeNickname = "닉네임 변경" + static let changeImage = "프로필 이미지 변경" + static let defaultImage = "기본 이미지 적용" + } +// enum Strings { +// enum ActionSheetTitle { +// static let profile = "프로필 설정" +// static let changeNickname = "닉네임 변경" +// static let changeImage = "프로필 이미지 변경" +// static let defaultImage = "기본 이미지 적용" +// } +// +// enum CardTitle { +// static let activity = "활동 내역" +// static let logout = "로그아웃" +// static let accountDelete = "탈퇴하기" +// } +// +// enum PopupTitle { +// static let changeNickname = "닉네임 변경" +// static let logout = "로그아웃 하시겠어요?" +// static let deleteAccount = "정말 탈퇴하시겠어요?" +// static let deleteSuccess = "회원 탈퇴가 완료되었습니다." +// } +// +// enum PopupMessage { +// static let deleteAccount = "회원탈퇴 후 계정 복구가 불가능하며, 작성한 게시글과 댓글은 유지됩니다. 탈퇴하시겠습니까?" +// } +// } + } + + + enum MyPageBottomLineTextField { + enum PopupMessage { + static let isCorrected = "닉네임을 다시 확인해주세요" + } + } +} + + diff --git a/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift b/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift new file mode 100644 index 0000000..891e258 --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift @@ -0,0 +1,103 @@ +// +// MyPageCardView.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import SwiftUI + +struct MyPageCardView: View { + private let title: String + private let image: ImageResource + private let action: @MainActor () -> Void + + private var foregroundColor: Color = .textG800 + + init( + config: Config, + foregroundColor: Color = .textG800, + action: @escaping @MainActor () -> Void + ) { + self.title = config.title + self.image = config.image + self.action = action + } + + init( + title: String, + image: ImageResource, + foregroundColor: Color = .textG800, + action: @escaping @MainActor () -> Void + ) { + self.title = title + self.image = image + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 0) { + Label { + Text(title) + } icon: { + Image(image) + .resizable() + .frame(width: 16, height: 16) + } + .pretendard(.body(.base)) + .foregroundStyle(foregroundColor) + + Spacer() + + Image(.mypageTriangleRight) + .resizable() + .frame(width: 16, height: 16) + } + .myPageCardStyle() + + } + } +} + +extension MyPageCardView { + struct Config { + let title: String + let image: ImageResource + } +} + +extension MyPageCardView.Config { + static let activity: Self = .init(title: "활동 내역", image: .myCardActivities) + static let logout: Self = .init(title: "로그아웃", image: .myCardLogout) + static let accountDelete: Self = .init(title: "탈퇴하기", image: .myCardDeleteAccount) +} + +fileprivate extension MyPageCardView { + func foregroundColor(_ color: Color) -> Self { + var view = self + view.foregroundColor = color + return view + } +} + +fileprivate struct MyPageCardStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 20) + .padding(.horizontal, 20) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.bgG100) + ) + } +} + +fileprivate extension View { + func myPageCardStyle() -> some View { + modifier(MyPageCardStyleModifier()) + } +} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageView.swift b/MyPage/Sources/Presentation/MyPage/MyPageView.swift new file mode 100644 index 0000000..0430ca5 --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageView.swift @@ -0,0 +1,431 @@ +// +// MyPageView.swift +// Hambug +// +// Created by 강동영 on 10/27/25. +// + +import SwiftUI +import PhotosUI +import DesignSystem +import Util + +public struct MyPageView: View { + @Bindable var viewModel: MyPageViewModel + @State private var showMyActivitiesView: Bool = false + + // MARK: popup state + @State private var popupState: MyPageView.PopupState = .none + @State private var showInfoActionSheet: Bool = false + + // MARK: Photo picker state + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var selectedImageData: Data? + @State private var showPhotoPicker: Bool = false + + public init(viewModel: MyPageViewModel) { + self._viewModel = Bindable(viewModel) + } + + public var body: some View { + NavigationStack { + ZStack { + Color.white + + VStack(spacing: 0) { + headerSection + imageSection + nicknameSection + navigationSection + + Spacer() + } + .navigationDestination(isPresented: $showMyActivitiesView, destination: { + MyActivitiesView() + }) + } + .confirmationDialog("프로필 편집", isPresented: $showInfoActionSheet, actions: { + Button(Strings.ActionSheetTitle.changeImage) { + showPhotoPicker = true + } + Button(Strings.ActionSheetTitle.defaultImage) { + viewModel.applyDefaultImage() + popupState = .none + } + Button(Strings.ActionSheetTitle.changeNickname) { + popupState = .changeNickname + } + Button("취소", role: .cancel) { + popupState = .none + } + }) + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItem, + matching: .images + ) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { + guard let newItem = newItem, + let imageData = try? await newItem.loadTransferable(type: Data.self), + let image = UIImage(data: imageData) else { + return + } + + let originalSize = imageData.count + print("\(originalSize / (1024 * 1024))MB") + // 크기 검증 (10MB) + if originalSize > ImageProcessor.maxFileSize { + // 크기 초과 시 이미지 처리 시도 + if let processedData = ImageProcessor.process(image) { + // 처리 성공 - 압축된 이미지로 업로드 + selectedImageData = processedData + viewModel.changeProfileImage(processedData) + popupState = .none + } else { + // 처리 실패 - 알림 표시 + viewModel.showImageSizeAlert = true + } + } else { + // 크기가 괜찮으면 원본 데이터로 업로드 + selectedImageData = imageData + viewModel.changeProfileImage(imageData) + popupState = .none + } + } + } + .overlay(content: { + if popupState != .none && popupState != .infoAction { + currentPopup() + } + }) + .overlay(content: { + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + } + }) + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) { + viewModel.showError = false + } + } message: { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } + } + .alert("이미지 크기 초과", isPresented: $viewModel.showImageSizeAlert) { + Button("확인", role: .cancel) { + viewModel.showImageSizeAlert = false + } + } message: { + Text("이미지 크기가 너무 큽니다. 10MB 이하의 이미지를 선택해주세요.") + } + } + .navigationBarHidden(true) + .onAppear { + viewModel.fetchProfile() + } + .onChange(of: viewModel.shouldNavigateToLogin) { _, shouldNavigate in + if shouldNavigate { + NotificationCenter.default.post(name: .userDidLogout, object: nil) + } + } + } + + // MARK: - Sections + private var headerSection: some View { + HStack { + Text(Strings.header) + .padding(.leading, 15) + .padding(.bottom, 15) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + Spacer() + } + } + + private var imageSection: some View { + ProfileImageView( + with: viewModel.user?.profileImageURL ?? "", + width: 110, + height: 110 + ) + .applyCilpShape() + .overlay(content: { + Circle() + .stroke(Color.primaryHambugRed, lineWidth: 2) + .scaleEffect(1.08) + }) + .overlay(content: { + // 우측 하단 펜슬 버튼 + Color.bgPencil + .frame(width: 30, height: 30) + .clipShape(Circle()) + .overlay { + Image(.communityPencil) + .resizable() + .foregroundColor(.white) + .frame(width: 16, height: 16) + } + .offset(x: 40, y: 50) + + }) + .onTapGesture { + showInfoActionSheet = true + } + .padding(.top, 25) + } + + private var nicknameSection: some View { + Text(viewModel.profileNickName.isEmpty ? "nickName" : viewModel.profileNickName) + .pretendard(.body(.base)) + .foregroundStyle(Color.textG800) + .padding(.top, 22) + } + + private var navigationSection: some View { + VStack(spacing: 0) { + MyPageCardView(config: .activity) { + showMyActivitiesView = true + } + + Spacer() + .frame(height: 24) + + MyPageCardView(config: .logout) { + print("로그아웃") + popupState = .logout + } + + MyPageCardView(config: .accountDelete) { + print("탈퇴하기") + popupState = .accountDelete + } + .foregroundColor(.textR100) + } + .padding(.top, 40) + .padding(.leading, 32) + .padding(.trailing, 38) + } + + // MARK: - Popup + + @ViewBuilder + private func currentPopup() -> some View { + switch popupState { + case .none, .infoAction: + EmptyView() + case .changeNickname: + changeNicknamePopup + case .logout: + logoutPopup + case .accountDelete: + accountDeletePopup + case .accountDeleteSuccess: + accountDeleteSuccessPopup + } + } + + private var changeNicknamePopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .changeNickname }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.changeNickname) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + + MyPageBottomLineTextField( + title: $viewModel.currentNickName, + isCorrected: $viewModel.isCorrectedNickName + ) + .padding(.horizontal, 45) + } + + }, + secondaryButton: AlertButton(.cancel) { + print("취소") + + }, + primaryButton: AlertButton(.save) { + print("저장") + viewModel.updateNickname() + } + ) + } + + private var logoutPopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .logout }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.logout) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + } + .padding(.horizontal, 10) + + }, + secondaryButton: AlertButton(.cancel) { + print("취소") + + }, + primaryButton: AlertButton(.ok) { + print("확인") + viewModel.logout() + } + ) + } + + private var accountDeletePopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .accountDelete }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.deleteAccount) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + + Text(Strings.PopupMessage.deleteAccount) + .pretendard(.body(.small)) + .multilineTextAlignment(.center) + .foregroundStyle(Color.textG600) + .padding(.top, 16) + + } + .padding(.horizontal, 10) + + }, + secondaryButton: .init(.cancel) { + print("취소") + + }, + primaryButton: .init(.accountDelete) { + print("탈퇴") + viewModel.deleteAccount() + } + ) + } + + private var accountDeleteSuccessPopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .accountDeleteSuccess }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.deleteSuccess) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + } + .padding(.horizontal, 10) + + }, + secondaryButton: nil, + primaryButton: AlertButton(.ok) { + print("확인") + } + ) + } +} + +extension MyPageView { + enum PopupState { + case none + case infoAction + case changeNickname + case logout + case accountDelete + case accountDeleteSuccess + } +} + +#Preview { +// MyPageView(viewModel: .: MyPageDIContainer()) +} + +struct MyPageBottomLineTextField: View { + @Binding var title: String + @Binding var isCorrected: Bool + + var body: some View { + VStack(spacing: 4) { + TextField("", text: $title) + .pretendard(.body(.base)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(isCorrected ? Color.borderG500 : Color.borderR100), + alignment: .bottom + ) + + if !isCorrected { + Text(Strings.PopupMessage.isCorrected) + .pretendard(.caption(.base)) + .foregroundColor(Color.textR100) + } + } + } + + init( + title: Binding, + isCorrected: Binding + ) { + self._title = title + self._isCorrected = isCorrected + } +} + +extension MyPageView { + enum Strings { + static let header = "마이페이지" + + enum ActionSheetTitle { + static let profile = "프로필 설정" + static let changeNickname = "닉네임 변경" + static let changeImage = "프로필 이미지 변경" + static let defaultImage = "기본 이미지 적용" + } + + enum PopupTitle { + static let changeNickname = "닉네임 변경" + static let logout = "로그아웃 하시겠어요?" + static let deleteAccount = "정말 탈퇴하시겠어요?" + static let deleteSuccess = "회원 탈퇴가 완료되었습니다." + } + + enum PopupMessage { + static let deleteAccount = "회원탈퇴 후 계정 복구가 불가능하며, 작성한 게시글과 댓글은 유지됩니다. 탈퇴하시겠습니까?" + } + } +} + + +extension MyPageBottomLineTextField { + enum Strings { + enum PopupMessage { + static let isCorrected = "닉네임을 다시 확인해주세요" + } + } +} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift new file mode 100644 index 0000000..9518e25 --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift @@ -0,0 +1,140 @@ +// +// MyPageViewModel.swift +// Hambug +// +// Created by 강동영 on 10/28/25. +// + +import Combine +import Foundation +import Observation +import UIKit +import MyPageDomain +import SharedDomain + +@Observable +public final class MyPageViewModel { + private let usecase: MyPageUseCase + private var cancellables: Set = [] + + var currentNickName: String = "" + var profileNickName: String = "" + var isCorrectedNickName: Bool = true + var user: User? + + var isLoading: Bool = false + var errorMessage: String? + var showError: Bool = false + var shouldNavigateToLogin: Bool = false + var showImageSizeAlert: Bool = false + + public init(usecase: MyPageUseCase) { + self.usecase = usecase + } + + func fetchProfile() { + isLoading = true + usecase.fetchProfile() + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + self?.isLoading = false + self?.user = user + self?.profileNickName = user.nickname + } + .store(in: &cancellables) + } + + func updateNickname() { + let trimmed = currentNickName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + isCorrectedNickName = false + return + } + + isCorrectedNickName = true + isLoading = true + usecase.updateNickname(trimmed) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.profileNickName = trimmed + } + .store(in: &cancellables) + } + + func changeProfileImage(_ imageData: Data) { + guard let image = UIImage(data: imageData) else { + errorMessage = "이미지를 불러올 수 없습니다." + showError = true + return + } + + isLoading = true + usecase.changeProfileImage(image) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + + switch completion { + case .failure: + self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." + self?.showError = true + case .finished: +// self?.fetchProfile() + break + } + }, + receiveValue: { [weak self] in + self?.user?.profileImageURL = $0 + } + ) + .store(in: &cancellables) + } + + func applyDefaultImage() { + isLoading = true + usecase.applyDefaultImage() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + + switch completion { + case .failure: + self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." + self?.showError = true + case .finished: +// self?.fetchProfile() + self?.user?.profileImageURL = "" + } + }, + receiveValue: {} + ) + .store(in: &cancellables) + } + + func logout() { + isLoading = true + usecase.logout() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.shouldNavigateToLogin = true + } + .store(in: &cancellables) + } + + func deleteAccount() { + guard let provider = user?.loginType.lowercased(), !provider.isEmpty else { return } + + isLoading = true + usecase.deleteAccount(provider: provider) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.shouldNavigateToLogin = true + } + .store(in: &cancellables) + } +} diff --git a/MyPage/Sources/Presentation/MyPageView.swift b/MyPage/Sources/Presentation/MyPageView.swift deleted file mode 100644 index 2f1b23e..0000000 --- a/MyPage/Sources/Presentation/MyPageView.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MyPageView.swift -// Hambug -// -// Created by 강동영 on 10/27/25. -// - -import SwiftUI - -public struct MyPageView: View { - @StateObject var viewModel: MyPageViewModel - - public var body: some View { - Text("My Page") - } - - public init(viewModel: MyPageViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - } -} - - diff --git a/MyPage/Sources/Presentation/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPageViewModel.swift deleted file mode 100644 index 7edf466..0000000 --- a/MyPage/Sources/Presentation/MyPageViewModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// MyPageViewModel.swift -// Hambug -// -// Created by 강동영 on 10/28/25. -// - -import Combine -import MyPageDomain - -public final class MyPageViewModel: ObservableObject { - private let usecase: MyPageUseCase - - public init(usecase: MyPageUseCase) { - self.usecase = usecase - } - - func fetchProfile() { - - } - - func updateNickname(_ nickName: String) {} - func changeProfileImage() {} - func applyDefaultImage() {} - - func logout() {} - func deleteAccount() {} -}