diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index 8b5b8b6d..95d2c27f 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -37,5 +37,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: OnboardingRepositoryProtocol.self) { _ in return OnboardingRepository(networkService: networkService, keychainStorage: keychainStorage) } + + DIContainer.shared.register(type: UserDataRepositoryProtocol.self) { _ in + return UserDataRepository(keychainStorage: keychainStorage, userDefaultsStorage: userDefaultsStorage) + } } } diff --git a/Projects/DataSource/Sources/Common/Error/UserError.swift b/Projects/DataSource/Sources/Common/Error/UserError.swift new file mode 100644 index 00000000..c8c1e26d --- /dev/null +++ b/Projects/DataSource/Sources/Common/Error/UserError.swift @@ -0,0 +1,24 @@ +// +// UserError.swift +// DataSource +// +// Created by 이동현 on 7/20/25. +// + +enum UserError: Error, CustomStringConvertible { + case accessTokenLoadFailed + case nicknameLoadFailed + case unknown(error: Error) + + + var description: String { + switch self { + case .accessTokenLoadFailed: + return "토큰 불러오기에 실패했습니다." + case .nicknameLoadFailed: + return "닉네임 불러오기에 실패했습니다." + case .unknown(let error): + return "알 수 없는 에러가 발생했습니다. \(error.localizedDescription)" + } + } +} diff --git a/Projects/DataSource/Sources/Repository/UserDataRepository.swift b/Projects/DataSource/Sources/Repository/UserDataRepository.swift new file mode 100644 index 00000000..1eb3d511 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/UserDataRepository.swift @@ -0,0 +1,37 @@ +// +// UserDataRepository.swift +// DataSource +// +// Created by 이동현 on 7/20/25. +// + +import Domain +import Foundation + +final class UserDataRepository: UserDataRepositoryProtocol { + private let keychainStorage: KeychainStorageProtocol + private let userDefaultsStorage: UserDefaultsStorageProtocol + + init(keychainStorage: KeychainStorageProtocol, userDefaultsStorage: UserDefaultsStorageProtocol) { + self.keychainStorage = keychainStorage + self.userDefaultsStorage = userDefaultsStorage + } + + // TODO: - accessToken fetch 로직 상의 후 결정 + func loadAccessToken() throws -> String { + guard let token = keychainStorage.load(forKey: TokenType.accessToken.rawValue) else { + throw UserError.accessTokenLoadFailed + } + + return token + } + + func loadNickname() throws -> String { + guard let nickname: String = userDefaultsStorage.load(forKey: UserDefaultsKey.nickname.rawValue) else { + throw UserError.nicknameLoadFailed + } + + return nickname + } +} + diff --git a/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift new file mode 100644 index 00000000..99f37db7 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// UserDataRepositoryProtocol.swift +// DataSource +// +// Created by 이동현 on 7/20/25. +// + +/// 유저의 이름, 토큰 등 유저 정보와 관련된 데이터를 가져오는 Repository +public protocol UserDataRepositoryProtocol { + /// 저장한 accessToken을 가져옵니다. + /// - Returns: accessToken + func loadAccessToken() throws -> String + + /// 저장한 닉네임을 가져옵니다. + /// - Returns: 유저 닉네임 + func loadNickname() throws -> String +} diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json new file mode 100644 index 00000000..30cca47b --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "chevron_right_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "chevron_right_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "chevron_right_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png new file mode 100644 index 00000000..5e32064b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png new file mode 100644 index 00000000..4d4a4d74 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png new file mode 100644 index 00000000..4b2d170a Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/Contents.json new file mode 100644 index 00000000..ffcd3ef6 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "setting_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "setting_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "setting_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@1x.png new file mode 100644 index 00000000..aece89bf Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@2x.png new file mode 100644 index 00000000..bb6082bd Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@3x.png new file mode 100644 index 00000000..d22d9cf0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/setting_icon.imageset/setting_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 350f0b41..c4010e88 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -33,6 +33,10 @@ enum BitnagilIcon { static let mypageFillIcon = UIImage(named: "mypage_fill_icon", in: bundle, with: nil) static let mypageEmptyIcon = UIImage(named: "mypage_empty_icon", in: bundle, with: nil) + + static let settingIcon = UIImage(named: "setting_icon", in: bundle, with: nil) + + static let chevronRightIcon = UIImage(named: "chevron_right_icon", in: bundle, with: nil) } enum Direction { diff --git a/Projects/Presentation/Sources/Common/Extension/NSObject+.swift b/Projects/Presentation/Sources/Common/Extension/NSObject+.swift new file mode 100644 index 00000000..66e8907b --- /dev/null +++ b/Projects/Presentation/Sources/Common/Extension/NSObject+.swift @@ -0,0 +1,18 @@ +// +// NSObject+.swift +// Presentation +// +// Created by 이동현 on 7/20/25. +// + +import Foundation + +extension NSObject { + var className: String { + return String(describing: type(of: self)) + } + + class var className: String { + return String(describing: self) + } +} diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 22c9ae6b..686b5547 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -39,5 +39,12 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: RecommendedRoutineViewModel.self) { _ in return RecommendedRoutineViewModel() } + + DIContainer.shared.register(type: MypageViewModel.self) { container in + guard let userDataRepository = container.resolve(type: UserDataRepositoryProtocol.self) + else { fatalError("userDataRepository 의존성이 등록되지 않았습니다.") } + + return MypageViewModel(userDataRepository: userDataRepository) + } } } diff --git a/Projects/Presentation/Sources/Common/TabBarView.swift b/Projects/Presentation/Sources/Common/TabBarView.swift index 03e28180..5a859f38 100644 --- a/Projects/Presentation/Sources/Common/TabBarView.swift +++ b/Projects/Presentation/Sources/Common/TabBarView.swift @@ -38,13 +38,19 @@ final public class TabBarView: UITabBarController { tabBar.tintColor = BitnagilColor.navy600 tabBar.unselectedItemTintColor = BitnagilColor.navy100 + guard let viewModel = DIContainer.shared.resolve(type: RecommendedRoutineViewModel.self) else { fatalError("RecommendedViewModel 의존성이 등록되지 않았습니다.") } + + guard let mypageViewModel = DIContainer.shared.resolve(type: MypageViewModel.self) else { + fatalError("mypageViewModel 의존성이 등록되지 않았습니다.") + } + let homeView = HomeView() let recommendView = RecommendedRoutineView(viewModel: viewModel) let reportView = ReportView() - let mypageView = MypageView() + let mypageView = MypageView(viewModel: mypageViewModel) homeView.tabBarItem = UITabBarItem( title: "홈", @@ -89,10 +95,3 @@ final class ReportView: UIViewController { view.backgroundColor = .white } } - -final class MypageView: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .white - } -} diff --git a/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift b/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift new file mode 100644 index 00000000..54046b63 --- /dev/null +++ b/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift @@ -0,0 +1,58 @@ +// +// MyPageTableViewCell.swift +// Presentation +// +// Created by 이동현 on 7/17/25. +// + +import SnapKit +import UIKit + +final class MypageTableViewCell: UITableViewCell { + private enum Layout { + static let titleLableLeadingSpacing: CGFloat = 20 + static let titleLableTrailingSpacing: CGFloat = 8 + static let chevronImageViewTrailingSpacing: CGFloat = 6 + static let chevronImageViewSize: CGFloat = 36 + } + + private let titleLabel = UILabel() + private let chevronImageView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } + + private func configureAttribute() { + titleLabel.font = BitnagilFont(style: .body1, weight: .regular).font + + chevronImageView.image = BitnagilIcon.chevronRightIcon + } + + private func configureLayout() { + contentView.addSubview(titleLabel) + contentView.addSubview(chevronImageView) + + chevronImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.chevronImageViewTrailingSpacing) + make.size.equalTo(Layout.chevronImageViewSize) + } + + titleLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview() + make.leading.equalToSuperview().offset(Layout.titleLableLeadingSpacing) + make.trailing.equalTo(chevronImageView.snp.leading).offset(-Layout.titleLableTrailingSpacing) + } + } +} diff --git a/Projects/Presentation/Sources/MyPage/View/MypageView.swift b/Projects/Presentation/Sources/MyPage/View/MypageView.swift new file mode 100644 index 00000000..f2749b25 --- /dev/null +++ b/Projects/Presentation/Sources/MyPage/View/MypageView.swift @@ -0,0 +1,164 @@ +// +// MypageView.swift +// Presentation +// +// Created by 이동현 on 7/17/25. +// +import Combine +import SafariServices +import Shared +import SnapKit +import UIKit + +final class MypageView: BaseViewController { + private enum Layout { + static let profileImageViewSize: CGFloat = 80 + static let profileImageViewCornerRadius: CGFloat = profileImageViewSize / 2 + static let profileImageViewTopSpacing: CGFloat = 32 + static let nicknameLabelHeight: CGFloat = 24 + static let nicknameLabelTopSpacing: CGFloat = 12 + static let divideLineHeight: CGFloat = 6 + static let divideLineTopSpacing: CGFloat = 28 + static let tableViewCellHeight: CGFloat = 48 + } + + private let titleLabel = UILabel() + private let settingButton = UIBarButtonItem() + private let profileImageView = UIImageView() + private let nicknameLabel = UILabel() + private let dividerView = UIView() + private let tableView = UITableView() + private var cancellables: Set + + override init(viewModel: MypageViewModel) { + cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func configureAttribute() { + view.backgroundColor = .white + navigationItem.rightBarButtonItem = settingButton + title = "마이페이지" + + settingButton.action = #selector(settingButtonTapped) + settingButton.tintColor = .black + settingButton.image = BitnagilIcon + .settingIcon? + .withRenderingMode(.alwaysTemplate) + + profileImageView.layer.cornerRadius = Layout.profileImageViewCornerRadius + profileImageView.layer.masksToBounds = true + profileImageView.backgroundColor = BitnagilColor.gray40 // 임시 + + nicknameLabel.font = BitnagilFont(style: .title3, weight: .semiBold).font + nicknameLabel.textColor = .black + nicknameLabel.textAlignment = .center + + dividerView.backgroundColor = BitnagilColor.gray99 + + tableView.register(MypageTableViewCell.self, forCellReuseIdentifier: MypageTableViewCell.className) + tableView.dataSource = self + tableView.delegate = self + tableView.separatorStyle = .none + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.addSubview(profileImageView) + view.addSubview(nicknameLabel) + view.addSubview(dividerView) + view.addSubview(tableView) + + profileImageView.snp.makeConstraints { make in + make.top.equalTo(safeArea.snp.top).offset(Layout.profileImageViewTopSpacing) + make.centerX.equalToSuperview() + make.size.equalTo(Layout.profileImageViewSize) + } + + nicknameLabel.snp.makeConstraints { make in + make.top.equalTo(profileImageView.snp.bottom).offset(Layout.nicknameLabelTopSpacing) + make.centerX.equalToSuperview() + make.height.equalTo(Layout.nicknameLabelHeight) + } + + dividerView.snp.makeConstraints { make in + make.top.equalTo(nicknameLabel.snp.bottom).offset(Layout.divideLineTopSpacing) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Layout.divideLineHeight) + } + + tableView.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.bottom) + make.horizontalEdges.bottom.equalTo(safeArea) + } + + } + + override func bind() { + viewModel.output.nickNamePublisher + .sink { [weak self] nickname in + self?.nicknameLabel.text = nickname + } + .store(in: &cancellables) + + viewModel.output.externalURLPublisher + .sink { [weak self] url in + let safariView = SFSafariViewController(url: url) + self?.present(safariView, animated: true) + } + .store(in: &cancellables) + } + + @objc private func settingButtonTapped() { + // TODO: - 추후 설정 페이지 연결 + } +} + +extension MypageView: UITableViewDelegate { + +} + +extension MypageView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return MypageViewModel.MypageMenu.allCases.count + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Layout.tableViewCellHeight + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: MypageTableViewCell.className) as? MypageTableViewCell else { + return .init() + } + + let title = MypageViewModel.MypageMenu + .allCases[indexPath.row] + .rawValue + cell.configure(title: title) + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + defer { tableView.deselectRow(at: indexPath, animated: true) } + + let selectedMenu = MypageViewModel.MypageMenu.allCases[indexPath.row] + + guard selectedMenu == .resetGoal else { + viewModel.action(input: .didSelectMenu(menu: selectedMenu)) + return + } + + guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { + fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + } + + let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + navigationController?.pushViewController(onboardingView, animated: true) + } +} diff --git a/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift b/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift new file mode 100644 index 00000000..d91e0ca8 --- /dev/null +++ b/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift @@ -0,0 +1,70 @@ +// +// MyPageViewModel.swift +// Presentation +// +// Created by 이동현 on 7/17/25. +// + +import Combine +import Domain +import Foundation +import Shared + +final class MypageViewModel: ViewModel { + enum Input { + case didSelectMenu(menu: MypageMenu) + } + + struct Output { + let nickNamePublisher: AnyPublisher + let externalURLPublisher: AnyPublisher + } + + enum MypageMenu: String, CaseIterable { + case resetGoal = "내 목표 재설정" + case notice = "공지사항" + case faq = "자주 묻는 질문" + } + + private(set) var output: Output + private let nicknamePublisher = CurrentValueSubject("") + private let externalURLPublisher = PassthroughSubject() + private let userDataRepository: UserDataRepositoryProtocol + + init(userDataRepository: UserDataRepositoryProtocol) { + self.userDataRepository = userDataRepository + + output = .init( + nickNamePublisher: nicknamePublisher.eraseToAnyPublisher(), + externalURLPublisher: externalURLPublisher.eraseToAnyPublisher()) + + do { + let nickname = try userDataRepository.loadNickname() + nicknamePublisher.send(nickname) + } catch { + BitnagilLogger.log(logType: .debug, message: "\(error.localizedDescription)") + } + } + + func action(input: Input) { + switch input { + case .didSelectMenu(let menu): + handleMenuSelection(menu: menu) + } + } + + private func handleMenuSelection(menu: MypageMenu) { + switch menu { + case .resetGoal: + break + case .notice: // 임시 url + if let url = URL(string: "https://www.google.com") { + externalURLPublisher.send(url) + } + case .faq: // 임시 url + if let url = URL(string: "https://www.naver.com") { + externalURLPublisher.send(url) + } + } + } +}