diff --git a/App/Sources/ContentView.swift b/App/Sources/ContentView.swift index e8b1e15..c47a5a5 100644 --- a/App/Sources/ContentView.swift +++ b/App/Sources/ContentView.swift @@ -12,6 +12,8 @@ struct ContentView: View { .navigationDestination(for: Route.self) { route in Coordinator.view(for: route) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() } .toolbar(.hidden) .environment(router) diff --git a/Domain/Entities/Sources/Profile/ProfileModel.swift b/Domain/Entities/Sources/Profile/ProfileModel.swift new file mode 100644 index 0000000..e02d758 --- /dev/null +++ b/Domain/Entities/Sources/Profile/ProfileModel.swift @@ -0,0 +1,47 @@ +// +// ProfileModel.swift +// Entities +// +// Created by summercat on 1/30/25. +// + +public struct ProfileModel { + public init( + nickname: String, + description: String, + age: Int, + birthdate: String, + height: Int, + weight: Int, + job: String, + location: String, + smokingStatus: String, + snsActivityLevel: String, + imageUrl: String + ) { + self.nickname = nickname + self.description = description + self.age = age + self.birthdate = birthdate + self.height = height + self.weight = weight + self.job = job + self.location = location + self.smokingStatus = smokingStatus + self.snsActivityLevel = snsActivityLevel + self.imageUrl = imageUrl + } + + public let nickname: String + public let description: String + public let age: Int + public let birthdate: String + public let height: Int + public let weight: Int + public let job: String + public let location: String + public let smokingStatus: String + public let snsActivityLevel: String + public let imageUrl: String +// public let contacts // TODO: - 스키마 이름이 안 정해진 것 같음... +} diff --git a/Domain/UseCases/Sources/Profile/GetProfileUseCase.swift b/Domain/UseCases/Sources/Profile/GetProfileUseCase.swift new file mode 100644 index 0000000..84e377e --- /dev/null +++ b/Domain/UseCases/Sources/Profile/GetProfileUseCase.swift @@ -0,0 +1,31 @@ +// +// GetProfileUseCase.swift +// UseCases +// +// Created by summercat on 1/30/25. +// + +import Entities + +public protocol GetProfileUseCase { + func execute() async throws -> ProfileModel +} + +final class GetProfileUseCaseImpl: GetProfileUseCase { + func execute() async throws -> ProfileModel { + // TODO: - 네트워크 모듈 작업 후 수정 + return ProfileModel( + nickname: "닉네임", + description: "소개글", + age: 25, + birthdate: "00", + height: 180, + weight: 72, + job: "프리랜서", + location: "세종특별자치시", + smokingStatus: "비흡연", + snsActivityLevel: "", + imageUrl: "https://www.thesprucepets.com/thmb/AyzHgPQM_X8OKhXEd8XTVIa-UT0=/750x0/filters:no_upscale():max_bytes(150000):strip_icc():format(webp)/GettyImages-145577979-d97e955b5d8043fd96747447451f78b7.jpg" + ) + } +} diff --git a/Domain/UseCases/Sources/UseCaseFactory.swift b/Domain/UseCases/Sources/UseCaseFactory.swift index 42fa5cd..7a3ec38 100644 --- a/Domain/UseCases/Sources/UseCaseFactory.swift +++ b/Domain/UseCases/Sources/UseCaseFactory.swift @@ -8,6 +8,12 @@ import Foundation public struct UseCaseFactory { + // MARK: - 사용자 프로필 + public static func createGetProfileUseCase() -> GetProfileUseCase { + GetProfileUseCaseImpl() + } + + // MARK: - 매칭 상세 public static func createGetMatchProfileBasicUseCase() -> GetMatchProfileBasicUseCase { GetMatchProfileBasicUseCaseImpl() } diff --git a/Presentation/Coordinator/Project.swift b/Presentation/Coordinator/Project.swift index 3c89345..b393773 100644 --- a/Presentation/Coordinator/Project.swift +++ b/Presentation/Coordinator/Project.swift @@ -13,6 +13,7 @@ let project = Project.dynamicFramework( .domain(target: .UseCases), .presentation(target: .Router), .presentation(target: .Home), + .presentation(target: .Profile), .presentation(target: .MatchingDetail), ] ) diff --git a/Presentation/Coordinator/Sources/Coordinator.swift b/Presentation/Coordinator/Sources/Coordinator.swift index 9509f84..b1988c8 100644 --- a/Presentation/Coordinator/Sources/Coordinator.swift +++ b/Presentation/Coordinator/Sources/Coordinator.swift @@ -16,7 +16,8 @@ public struct Coordinator { public static func view(for route: Route) -> some View { switch route { case .home: - HomeViewFactory.createHomeView() + let getProfileUseCase = UseCaseFactory.createGetProfileUseCase() + HomeViewFactory.createHomeView(getProfileUseCase: getProfileUseCase) case .matchProfileBasic: let getMatchProfileBasicUseCase = UseCaseFactory.createGetMatchProfileBasicUseCase() MatchDetailViewFactory.createMatchProfileBasicView(getMatchProfileBasicUseCase: getMatchProfileBasicUseCase) diff --git a/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/Contents.json b/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/Contents.json new file mode 100644 index 0000000..1023779 --- /dev/null +++ b/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "talk-20.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/talk-20.svg b/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/talk-20.svg new file mode 100644 index 0000000..6056eca --- /dev/null +++ b/Presentation/DesignSystem/Resources/Icons.xcassets/talk-20.imageset/talk-20.svg @@ -0,0 +1,3 @@ + + + diff --git a/Presentation/DesignSystem/Sources/HomeNavigationBar.swift b/Presentation/DesignSystem/Sources/HomeNavigationBar.swift new file mode 100644 index 0000000..006d1dc --- /dev/null +++ b/Presentation/DesignSystem/Sources/HomeNavigationBar.swift @@ -0,0 +1,63 @@ +// +// HomeNavigationBar.swift +// DesignSystem +// +// Created by summercat on 1/30/25. +// + +import SwiftUI + +public struct HomeNavigationBar: View { + public init( + title: String, + foregroundColor: Color, + rightIcon: Image? = nil, + rightIconTap: (() -> Void)? = nil + ) { + self.title = title + self.foregroundColor = foregroundColor + self.rightIcon = rightIcon + self.rightIconTap = rightIconTap + } + + public var body: some View { + HStack(alignment: .center, spacing: 20) { + Text(title) + .foregroundStyle(foregroundColor) + .wixMadeforDisplay(.branding) + .frame(maxWidth: .infinity, alignment: .leading) + if let rightIcon { + rightIcon + .renderingMode(.template) + .foregroundStyle(foregroundColor) + .onTapGesture { + rightIconTap?() + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Color.clear) + } + + public let title: String + public let foregroundColor: Color + public let rightIcon: Image? + public let rightIconTap: (() -> Void)? +} + +#Preview { + ZStack(alignment: .top) { + Rectangle() + .fill(.red) + + HomeNavigationBar( + title: "Profile", + foregroundColor: .black, + rightIcon: DesignSystemAsset.Icons.alarm32.swiftUIImage + ) { + + } + .background(Color.yellow) + } +} diff --git a/Presentation/Feature/Home/Project.swift b/Presentation/Feature/Home/Project.swift index 6af56a1..dc5e780 100644 --- a/Presentation/Feature/Home/Project.swift +++ b/Presentation/Feature/Home/Project.swift @@ -13,6 +13,7 @@ let project = Project.staticLibrary( dependencies: [ .presentation(target: .DesignSystem), .presentation(target: .Router), + .presentation(target: .Profile), .presentation(target: .MatchingMain), ] ) diff --git a/Presentation/Feature/Home/Sources/HomeView.swift b/Presentation/Feature/Home/Sources/HomeView.swift index 4079309..dce0a42 100644 --- a/Presentation/Feature/Home/Sources/HomeView.swift +++ b/Presentation/Feature/Home/Sources/HomeView.swift @@ -7,24 +7,34 @@ import DesignSystem import MatchingMain +import Router +import Profile import SwiftUI +import UseCases struct HomeView: View { - @State var viewModel: HomeViewModel + @State private var viewModel: HomeViewModel + + init(getProfileUseCase: GetProfileUseCase) { + _viewModel = .init(wrappedValue: .init(getProfileUseCase: getProfileUseCase)) + } var body: some View { ZStack { content TabBarView(viewModel: viewModel.tabbarViewModel) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() } + @ViewBuilder private var content: some View { switch viewModel.tabbarViewModel.selectedTab { case .profile: - // TODO: - ProfileView - Rectangle() - .fill(Color.yellow) + ProfileViewFactory.createProfileView( + getProfileUseCase: viewModel.getProfileUseCase + ) case .home: // TODO: - MatchingMainView Rectangle() @@ -38,5 +48,5 @@ struct HomeView: View { } #Preview { - HomeView(viewModel: HomeViewModel()) + HomeView(getProfileUseCase: UseCaseFactory.createGetProfileUseCase()) } diff --git a/Presentation/Feature/Home/Sources/HomeViewFactory.swift b/Presentation/Feature/Home/Sources/HomeViewFactory.swift index 168ede6..97a1720 100644 --- a/Presentation/Feature/Home/Sources/HomeViewFactory.swift +++ b/Presentation/Feature/Home/Sources/HomeViewFactory.swift @@ -6,10 +6,11 @@ // import SwiftUI +import UseCases public struct HomeViewFactory { @ViewBuilder - public static func createHomeView() -> some View { - HomeView(viewModel: HomeViewModel()) + public static func createHomeView(getProfileUseCase: GetProfileUseCase) -> some View { + HomeView(getProfileUseCase: getProfileUseCase) } } diff --git a/Presentation/Feature/Home/Sources/HomeViewModel.swift b/Presentation/Feature/Home/Sources/HomeViewModel.swift index 7049303..00bd6ac 100644 --- a/Presentation/Feature/Home/Sources/HomeViewModel.swift +++ b/Presentation/Feature/Home/Sources/HomeViewModel.swift @@ -8,12 +8,18 @@ import DesignSystem import Observation import SwiftUI +import UseCases @Observable final class HomeViewModel { enum Action { } - init() { } + init( + getProfileUseCase: GetProfileUseCase + ) { + self.getProfileUseCase = getProfileUseCase + } let tabbarViewModel = TabBarViewModel() + private(set) var getProfileUseCase: GetProfileUseCase } diff --git a/Presentation/Feature/Profile/Project.swift b/Presentation/Feature/Profile/Project.swift new file mode 100644 index 0000000..f5d70c8 --- /dev/null +++ b/Presentation/Feature/Profile/Project.swift @@ -0,0 +1,18 @@ +// +// Project.swift +// Profile +// +// Created by summercat on 2025/01/30. +// + +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.staticLibrary( + name: Modules.Presentation.Profile.rawValue, + dependencies: [ + .domain(target: .UseCases), + .presentation(target: .DesignSystem), + .presentation(target: .Router), + ] +) diff --git a/Presentation/Feature/Profile/Sources/ProfileView.swift b/Presentation/Feature/Profile/Sources/ProfileView.swift new file mode 100644 index 0000000..74c741c --- /dev/null +++ b/Presentation/Feature/Profile/Sources/ProfileView.swift @@ -0,0 +1,253 @@ +// +// ProfileView.swift +// Profile +// +// Created by summercat on 2025/01/30. +// + +import DesignSystem +import Router +import SwiftUI +import UseCases + +struct ProfileView: View { + @State var viewModel: ProfileViewModel + @Environment(Router.self) private var router: Router + + init(getProfileUseCase: GetProfileUseCase) { + _viewModel = .init(wrappedValue: .init(getProfileUseCase: getProfileUseCase)) + } + + var body: some View { + VStack(spacing :0) { + navigationBar + + if let userProfile = viewModel.userProfile { + profile(userProfile: userProfile) + } else { + EmptyView() + } + + Divider(weight: .thick, isVertical: false) + + matchingPiece + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(edges: .top) + } + + private var navigationBar: some View { + HomeNavigationBar( + title: "Profile", + foregroundColor: .grayscaleBlack, + rightIcon: DesignSystemAsset.Icons.alarm32.swiftUIImage + ) { + // TODO: - 알림 리스트로 이동 + } + } + + // MARK: - 프로필 영역 + + private func profile(userProfile: UserProfile) -> some View { + VStack(spacing: 24) { + nameCard(userProfile: userProfile) + basicInfoCards(userProfile: userProfile) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + } + + private func nameCard(userProfile: UserProfile) -> some View { + HStack(alignment: .center, spacing: 20) { + AsyncImage(url: URL(string: userProfile.imageUri)) { image in + image.image? + .resizable() + .scaledToFill() + } + .frame(width: 80, height: 80) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text(userProfile.description) + .lineLimit(1) + .pretendard(.body_M_R) + .foregroundStyle(Color.grayscaleBlack) + + Text(userProfile.nickname) + .lineLimit(1) + .pretendard(.heading_L_SB) + .foregroundStyle(Color.primaryDefault) + } + .frame(maxWidth: .infinity, alignment: .leading) + + DesignSystemAsset.Icons.chevronRight24.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Color.grayscaleBlack) + } + .contentShape(Rectangle()) + .onTapGesture { + // TODO: - 기본 정보 수정 화면으로 이동 + } + } + + // MARK: - Basic info card + + private func basicInfoCards(userProfile: UserProfile) -> some View { + VStack(spacing: 4) { + infoCardFirstRow(userProfile: userProfile) + infoCardSecondRow(userProfile: userProfile) + } + } + + private func infoCardFirstRow(userProfile: UserProfile) -> some View { + HStack(spacing: 4) { + ProfileCard( + type: .profile, + category: "나이", + answer: { ageAnswer(userProfile: userProfile) } + ) + .frame(width: 144) + + ProfileCard( + type: .profile, + category: "키", + answer: { heightAnswer(userProfile: userProfile) } + ) + ProfileCard( + type: .profile, + category: "몸무게", + answer: { weightAnswer(userProfile: userProfile) } + ) + } + } + + private func infoCardSecondRow(userProfile: UserProfile) -> some View { + HStack(spacing: 4) { + ProfileCard( + type: .profile, + category: "활동 지역", + answer: { locationAnswer(userProfile: userProfile) } + ) + .frame(width: 144) + + ProfileCard( + type: .profile, + category: "직업", + answer: { jobAnswer(userProfile: userProfile) } + ) + ProfileCard( + type: .profile, + category: "흡연", + answer: { smokingAnswer(userProfile: userProfile) } + ) + } + } + + private func ageAnswer(userProfile: UserProfile) -> some View { + HStack(alignment: .center, spacing: 4) { + Text("만") + .pretendard(.body_S_M) + .foregroundStyle(Color.grayscaleBlack) + HStack(alignment: .center, spacing: 0) { + Text("\(userProfile.age)") + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + Text("세") + .pretendard(.body_S_M) + .foregroundStyle(Color.grayscaleBlack) + } + Text("\(userProfile.birthdate)년생") + .lineLimit(1) + .pretendard(.body_S_M) + .foregroundStyle(Color.grayscaleDark2) + } + } + + private func heightAnswer(userProfile: UserProfile) -> some View { + HStack(alignment: .center, spacing: 0) { + Text(userProfile.height.description) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + Text("cm") + .pretendard(.body_S_M) + .foregroundStyle(Color.grayscaleBlack) + } + } + + private func weightAnswer(userProfile: UserProfile) -> some View { + HStack(alignment: .center, spacing: 0) { + Text(userProfile.weight.description) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + Text("kg") + .pretendard(.body_S_M) + .foregroundStyle(Color.grayscaleBlack) + } + } + + private func locationAnswer(userProfile: UserProfile) -> some View { + Text(userProfile.location) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + } + + private func jobAnswer(userProfile: UserProfile) -> some View { + Text(userProfile.job) + .lineLimit(1) + .truncationMode(.tail) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + } + + private func smokingAnswer(userProfile: UserProfile) -> some View { + Text(userProfile.smokingStatus) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleBlack) + } + + // MARK: - 나의 매칭 조각 수정 영역 + + private var matchingPiece: some View { + VStack(alignment: .leading, spacing: 12) { + Text("나의 매칭 조각") + .pretendard(.body_M_R) + .foregroundStyle(Color.grayscaleDark2) + + settingCategories + } + .background(Color.grayscaleWhite) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 60) + } + + private var settingCategories: some View { + VStack(spacing: 0) { + SettingCategory( + icon: DesignSystemAsset.Icons.talk20.swiftUIImage, + categoryText: "가치관 Talk", + descriptionText: "꿈과 목표, 관심사와 취향, 연애에 관련된\n내 생각을 확인하고 수정할 수 있습니다." + ) + .contentShape(Rectangle()) + .onTapGesture { + // TODO: - 가치관 Talk 편집 화면으로 이동 + } + Divider(weight: .normal, isVertical: false) + SettingCategory( + icon: DesignSystemAsset.Icons.question20.swiftUIImage, + categoryText: "가치관 Pick", + descriptionText: "퀴즈를 통해 나의 연애 스타일을 파악해보고\n선택한 답변을 수정할 수 있습니다." + ) + .contentShape(Rectangle()) + .onTapGesture { + // TODO: - 가치관 Pick 편집 화면으로 이동 + } + } + } +} + +#Preview { + ProfileView(getProfileUseCase: UseCaseFactory.createGetProfileUseCase()) + .environment(Router()) +} diff --git a/Presentation/Feature/Profile/Sources/ProfileViewFactory.swift b/Presentation/Feature/Profile/Sources/ProfileViewFactory.swift new file mode 100644 index 0000000..9489020 --- /dev/null +++ b/Presentation/Feature/Profile/Sources/ProfileViewFactory.swift @@ -0,0 +1,18 @@ +// +// ProfileViewFactory.swift +// Profile +// +// Created by summercat on 1/30/25. +// + +import SwiftUI +import UseCases + +public struct ProfileViewFactory { + @ViewBuilder + public static func createProfileView( + getProfileUseCase: GetProfileUseCase + ) -> some View { + ProfileView(getProfileUseCase: getProfileUseCase) + } +} diff --git a/Presentation/Feature/Profile/Sources/ProfileViewModel.swift b/Presentation/Feature/Profile/Sources/ProfileViewModel.swift new file mode 100644 index 0000000..15db527 --- /dev/null +++ b/Presentation/Feature/Profile/Sources/ProfileViewModel.swift @@ -0,0 +1,49 @@ +// +// ProfileViewModel.swift +// Profile +// +// Created by summercat on 2025/01/30. +// + +import Foundation +import UseCases + +@Observable +final class ProfileViewModel { + enum Action { } + + init(getProfileUseCase: GetProfileUseCase) { + self.getProfileUseCase = getProfileUseCase + + Task { + await fetchUserProfile() + } + } + + private let getProfileUseCase: GetProfileUseCase + private(set) var isLoading = true + private(set) var error: Error? + private(set) var userProfile: UserProfile? + + private func fetchUserProfile() async { + do { + let entity = try await getProfileUseCase.execute() + userProfile = UserProfile( + nickname: entity.nickname, + description: entity.description, + age: entity.age, + birthdate: entity.birthdate, + height: entity.height, + weight: entity.weight, + job: entity.job, + location: entity.location, + smokingStatus: entity.smokingStatus, + imageUri: entity.imageUrl + ) + error = nil + } catch { + self.error = error + } + isLoading = false + } +} diff --git a/Presentation/Feature/Profile/Sources/SettingCategory.swift b/Presentation/Feature/Profile/Sources/SettingCategory.swift new file mode 100644 index 0000000..1f14edd --- /dev/null +++ b/Presentation/Feature/Profile/Sources/SettingCategory.swift @@ -0,0 +1,61 @@ +// +// SettingCategory.swift +// Profile +// +// Created by summercat on 1/30/25. +// + +import DesignSystem +import SwiftUI + +struct SettingCategory: View { + private let icon: Image + private let categoryText: String + private let descriptionText: String + + init( + icon: Image, + categoryText: String, + descriptionText: String + ) { + self.icon = icon + self.categoryText = categoryText + self.descriptionText = descriptionText + } + + var body: some View { + HStack(alignment: .top, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { + categoryRow + description + } + .frame(maxWidth: .infinity, alignment: .leading) + + DesignSystemAsset.Icons.chevronRight24.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Color.grayscaleDark1) + } + .padding(.vertical, 16) + } + + private var categoryRow: some View { + HStack(alignment: .center, spacing: 8) { + icon + .renderingMode(.template) + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(Color.grayscaleDark1) + + Text(categoryText) + .pretendard(.heading_S_SB) + .foregroundStyle(Color.grayscaleDark1) + } + } + + private var description: some View { + Text(descriptionText) + .pretendard(.caption_M_M) + .foregroundStyle(Color.grayscaleDark3) + .padding(.leading, 28) + } +} diff --git a/Presentation/Feature/Profile/Sources/UserProfile.swift b/Presentation/Feature/Profile/Sources/UserProfile.swift new file mode 100644 index 0000000..9d5b7c0 --- /dev/null +++ b/Presentation/Feature/Profile/Sources/UserProfile.swift @@ -0,0 +1,19 @@ +// +// UserProfile.swift +// Profile +// +// Created by summercat on 1/30/25. +// + +struct UserProfile { + let nickname: String + let description: String + let age: Int + let birthdate: String + let height: Int + let weight: Int + let job: String + let location: String + let smokingStatus: String + let imageUri: String +} diff --git a/Tuist/ProjectDescriptionHelpers/Modules.swift b/Tuist/ProjectDescriptionHelpers/Modules.swift index fd7a13f..fb1a504 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules.swift @@ -75,6 +75,7 @@ public extension Modules { case Login case SignUp case Home + case Profile case MatchingMain case MatchingDetail