diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 08b25c63..d5d7d413 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -63,7 +63,7 @@ struct MainTabView: View { case .feed: FeedView(viewModel: feedDIContainer.makeFeedViewModel()) case .profile: - ProfileView() + ProfileView(navigationRouter: navigationRouter) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -136,6 +136,12 @@ struct MainTabView: View { homeDIContainer.makeEditCategoryView() case .codiBoard: homeDIContainer.makeCodiBoardView() + case .favoriteCodiList(let showHeart): + FavoriteCodiListView(showHeart: showHeart, navigationRouter: navigationRouter) + case .settings: + ProfileSettingView(navigationRouter: navigationRouter) + case .followList(let mode): + FollowListView(mode: mode, navigationRouter: navigationRouter) default: EmptyView() diff --git a/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift b/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift new file mode 100644 index 00000000..ea391deb --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift @@ -0,0 +1,151 @@ +// +// UnderlineField.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +struct UnderlineField: View { + + let title: String + let requiredTag: String? + @Binding var text: String + + let focus: FocusState.Binding + let focusEquals: ProfileSettingView.Field + let keyboardType: UIKeyboardType + + private let trailing: Trailing + + fileprivate var helperEmptyText: String? + fileprivate var helperFilledText: String? + fileprivate var helperErrorText: String? + + init( + title: String, + requiredTag: String?, + text: Binding, + focus: FocusState.Binding, + focusEquals: ProfileSettingView.Field, + keyboardType: UIKeyboardType, + @ViewBuilder trailing: () -> Trailing + ) { + self.title = title + self.requiredTag = requiredTag + self._text = text + self.focus = focus + self.focusEquals = focusEquals + self.keyboardType = keyboardType + self.trailing = trailing() + self.helperEmptyText = nil + self.helperFilledText = nil + self.helperErrorText = nil + } + + init( + title: String, + requiredTag: String?, + text: Binding, + focus: FocusState.Binding, + focusEquals: ProfileSettingView.Field, + keyboardType: UIKeyboardType + ) where Trailing == EmptyView { + self.init( + title: title, + requiredTag: requiredTag, + text: text, + focus: focus, + focusEquals: focusEquals, + keyboardType: keyboardType + ) { EmptyView() } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + titleRow + + HStack(spacing: 10) { + ZStack(alignment: .leading) { + // Placeholder 표시: 텍스트가 비어있고 포커스가 없을 때만 표시 + if text.isEmpty, + focus.wrappedValue != focusEquals, + let helperEmptyText, + !helperEmptyText.isEmpty, + helperErrorText == nil { + Text(helperEmptyText) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale4) + } + + TextField("", text: $text) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + .keyboardType(keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .focused(focus, equals: focusEquals) + } + + trailing + } + .padding(.bottom, helperErrorText != nil && !(helperErrorText ?? "").isEmpty ? 5 : 0) + + Rectangle() + .fill(Color.Codive.grayscale5) + .frame(height: 1) + + helperRow + } + } + + private var titleRow: some View { + HStack(spacing: 6) { + Text(title) + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + + if let requiredTag { + Text(requiredTag) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.point1) + } + + Spacer(minLength: 0) + } + } + + private var helperRow: some View { + let isError = !(helperErrorText ?? "").isEmpty + + let message: String? = { + // 에러가 있으면 에러 메시지 표시 + if isError { return helperErrorText } + if text.isEmpty { return nil } // emptyText는 placeholder로만 표시 + return helperFilledText + }() + + return Group { + if let message, !message.isEmpty { + Text(message) + .font(.codive_body2_medium) + .foregroundStyle(isError ? Color.Codive.point1 : Color.Codive.grayscale4) + .padding(.top, isError ? 5 : 2) + } else { + Color.clear.frame(height: 0) + } + } + } +} + +// MARK: - UnderlineField helper setter +extension UnderlineField { + func setHelper(emptyText: String?, filledText: String?, errorText: String?) -> UnderlineField { + var copy = self + copy.helperEmptyText = emptyText + copy.helperFilledText = filledText + copy.helperErrorText = errorText + return copy + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift new file mode 100644 index 00000000..37ebf590 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -0,0 +1,202 @@ +// +// ProfileSettingView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI +import Combine + +struct ProfileSettingView: View { + @ObservedObject private var navigationRouter: NavigationRouter + @StateObject private var viewModel: ProfileSettingViewModel + + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel(navigationRouter: navigationRouter)) + } + + enum Field: Hashable { + case nickname + case intro + } + + @FocusState private var focus: Field? + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: "프로필 설정", + onBack: { navigationRouter.navigateBack() }, + rightButton: .none + ) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + profileImageSection + .padding(.top, 32) + + formSection + .padding(.top, 56) + + completeButton + .padding(.top, 120) + } + } + } + .background(Color.white) + .navigationBarHidden(true) + .onChange(of: focus) { _ in + // 포커스 변경 시 canComplete 업데이트 + viewModel.updateCanCompleteOnFocusChange() + } + } + + private var profileImageSection: some View { + ZStack(alignment: .bottomTrailing) { + Group { + if let pickedProfileImage = viewModel.pickedProfileImage { + pickedProfileImage + .resizable() + .scaledToFill() + } else { + Circle() + .fill(Color.Codive.grayscale6) + .overlay { + Image("settingProfile") + .resizable() + .scaledToFit() + } + } + } + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Button { + viewModel.onProfileImageTapped() + } label: { + Circle() + .fill(Color.Codive.grayscale1) + .frame(width: 28, height: 28) + .overlay { + Image("plus") + .frame(width: 28, height: 28) + } + } + .buttonStyle(.plain) + .offset(x: 6, y: 6) + } + .frame(maxWidth: .infinity) + } + + private var formSection: some View { + VStack(alignment: .leading, spacing: 0) { + + UnderlineField( + title: "닉네임", + requiredTag: "*", + text: $viewModel.nickname, + focus: $focus, + focusEquals: .nickname, + keyboardType: .default + ) { + Button { + viewModel.runNicknameDuplicateCheck() + } label: { + Text("중복 확인") + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.Codive.grayscale4, lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(!viewModel.canTryNicknameCheck) + .opacity(viewModel.canTryNicknameCheck ? 1 : 0.4) + } + .setHelper( + emptyText: "한글, 소문자, 숫자 조합, 20자 이내", + filledText: viewModel.nicknameFilledHelper, + errorText: viewModel.nicknameErrorText + ) + .padding(.top, 18) + + UnderlineField( + title: "한줄소개", + requiredTag: nil, + text: $viewModel.intro, + focus: $focus, + focusEquals: .intro, + keyboardType: .default + ) + .setHelper( + emptyText: "20자 이내로 나를 소개 해보세요.", + filledText: nil, + errorText: viewModel.introErrorText + ) + .padding(.top, 26) + + privacySection + .padding(.top, 26) + } + .padding(.horizontal, 20) + } + + private var privacySection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Text("계정 공개여부") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + + Text("*") + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.point1) + } + + HStack(spacing: 10) { + pillButton(title: "공개", isOn: viewModel.isPublic) { viewModel.isPublic = true } + pillButton(title: "비공개", isOn: !viewModel.isPublic) { viewModel.isPublic = false } + Spacer(minLength: 0) + } + } + } + + private func pillButton(title: String, isOn: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.codive_body2_medium) + .foregroundStyle(isOn ? Color.Codive.point1 : Color.Codive.grayscale3) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isOn ? Color.Codive.point4 : Color.Codive.grayscale7) + } + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isOn ? Color.Codive.point2 : Color.Codive.grayscale6, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private var completeButton: some View { + CustomButton( + text: "설정 완료", + widthType: .fixed, + isEnabled: viewModel.canComplete + ) { + viewModel.onCompleteTapped() + } + .padding(.horizontal, 20) + } +} + +#Preview { + ProfileSettingView(navigationRouter: NavigationRouter()) +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift new file mode 100644 index 00000000..81e11399 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -0,0 +1,190 @@ +// +// ProfileView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +// MARK: - View +struct ProfileView: View { + @ObservedObject private var navigationRouter: NavigationRouter + @StateObject private var viewModel: ProfileViewModel + + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + self._viewModel = StateObject(wrappedValue: ProfileViewModel(navigationRouter: navigationRouter)) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + topBar + + profileSection + .padding(.top, 32) + + Divider() + .padding(.top, 24) + .foregroundStyle(Color.Codive.grayscale7) + + favoriteCodiSection + .padding(.top, 24) + + calendarSection + .padding(.top, 40) + + Spacer(minLength: 77) + } + } + .background(Color.white) + } + + // MARK: - Top Bar + private var topBar: some View { + HStack(spacing: 12) { + + Spacer(minLength: 0) + + Button { + viewModel.onEditProfileTapped() + } label: { + Image("edit") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + Button { + viewModel.onSettingsTapped() + } label: { + Image("setting") + .resizable() + .scaledToFit() + .frame(width: 27, height: 27) + } + } + .padding(.horizontal, 20) + } + + // MARK: - Profile + private var profileSection: some View { + VStack { + Image("CustomProfile") + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + + Text(viewModel.displayName) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.top, 9) + + HStack(spacing: 20) { + Button { + viewModel.onFollowerTapped() + } label: { + HStack(spacing: 6) { + Text("팔로워") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(viewModel.followerCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + + Button { + viewModel.onFollowingTapped() + } label: { + HStack(spacing: 6) { + Text("팔로잉") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(viewModel.followingCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + .padding(.top, 4) + + Text(viewModel.introText) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + .padding(.top, 4) + } + .frame(maxWidth: .infinity) + } + + // MARK: - Favorite Codi + private var favoriteCodiSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("최애 코디") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + viewModel.onMoreFavoriteCodiTapped() + } label: { + HStack(spacing: 6) { + Text("더보기") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale3) + Image("go") + .frame(width: 16, height: 16) + .foregroundStyle(Color.Codive.grayscale3) + } + } + } + .padding(.horizontal, 20) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(0..<8, id: \.self) { _ in + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white) + .frame(width: 160, height: 160) + .overlay(alignment: .topTrailing) { + Image("heart_on") + .frame(width: 15, height: 18) + .foregroundStyle(Color.Codive.point1) + .padding(14) + } + .codiveCardShadow() + } + } + .padding(.top, 12) + } + .padding(.horizontal, 20) + } + } + + // MARK: - Calendar + private var calendarSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("캘린더") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 20) + + CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) + .padding(16) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .codiveCardShadow() + .padding(.horizontal, 20) + .padding(.top, 12) + } + } +} + +#Preview { + ProfileView(navigationRouter: NavigationRouter()) +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift new file mode 100644 index 00000000..8df50137 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -0,0 +1,153 @@ +// +// ProfileSettingViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI +import Combine + +@MainActor +final class ProfileSettingViewModel: ObservableObject { + // MARK: - Constants + let nicknameMaxCount: Int = 20 + let introMaxCount: Int = 20 + + // MARK: - Dependencies + private let navigationRouter: NavigationRouter + + // MARK: - Published Properties + @Published var nickname: String = "" { + didSet { + if nickname.count > nicknameMaxCount { + nickname = String(nickname.prefix(nicknameMaxCount)) + } + if nicknameCheckStatus == .available || nicknameCheckStatus == .duplicated { + nicknameCheckStatus = .none + } + updateCanComplete() + } + } + + @Published var intro: String = "" { + didSet { + updateCanComplete() + } + } + + @Published var isPublic: Bool = true + @Published var nicknameCheckStatus: NicknameCheckStatus = .none { + didSet { + updateCanComplete() + } + } + @Published var pickedProfileImage: Image? = nil + @Published var canComplete: Bool = false + + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + // 초기 상태 업데이트 + updateCanComplete() + } + + enum NicknameCheckStatus: Equatable { + case none + case checking + case available + case duplicated + } + + var nicknameErrorText: String? { + if nickname.isEmpty { return nil } + if nickname.count > nicknameMaxCount { return "\(nicknameMaxCount)글자 이내로 입력해주세요" } + if nicknameCheckStatus == .duplicated { return "이미 사용중인 아이디입니다." } + return nil + } + + var nicknameFilledHelper: String? { + if nickname.isEmpty { return nil } + if nicknameErrorText != nil { return nil } + + switch nicknameCheckStatus { + case .none: + return nil + case .checking: + return "확인 중이에요" + case .available: + return "사용 가능한 닉네임 입니다." + case .duplicated: + return nil // 에러 메시지로 표시되므로 여기서는 nil + } + } + + var canTryNicknameCheck: Bool { + if nickname.isEmpty { return false } + if nicknameErrorText != nil { return false } + if nicknameCheckStatus == .checking { return false } + return true + } + + var introErrorText: String? { + if intro.isEmpty { return nil } + if intro.count > introMaxCount { return "20자 이내로 입력해주세요." } + return nil + } + + // MARK: - Public Methods + func updateCanCompleteOnFocusChange() { + // 포커스 변경 시에도 업데이트 (View에서 호출) + updateCanComplete() + } + + // MARK: - Private Methods + private func updateCanComplete() { + // 닉네임은 필수이므로 비어있으면 비활성화 + if nickname.isEmpty { + canComplete = false + return + } + // 닉네임 길이 에러가 있으면 비활성화 (중복 에러는 제외) + if nickname.count > nicknameMaxCount { + canComplete = false + return + } + // 닉네임 중복확인이 완료되지 않았으면 비활성화 + if nicknameCheckStatus != .available { + canComplete = false + return + } + // 닉네임 중복확인이 완료된 상태에서, 한줄소개가 20자 초과면 비활성화 + if intro.count > introMaxCount { + canComplete = false + return + } + canComplete = true + } + + func runNicknameDuplicateCheck() { + nicknameCheckStatus = .checking + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + let lowered = self.nickname.lowercased() + if lowered == "trendbox" || lowered == "ckj11" { + self.nicknameCheckStatus = .duplicated + } else { + self.nicknameCheckStatus = .available + } + // 명시적으로 업데이트 호출 (didSet이 호출되지만 확실하게) + self.updateCanComplete() + } + } + + func onProfileImageTapped() { + print("Profile image tapped") + } + + func onCompleteTapped() { + // TODO: 실제 API 호출로 프로필 업데이트 + // 성공 후 이전 화면으로 돌아가기 + navigationRouter.navigateBack() + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift new file mode 100644 index 00000000..7b9e8084 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -0,0 +1,51 @@ +// +// ProfileViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +@MainActor +class ProfileViewModel: ObservableObject { + // MARK: - Mock Data + @Published var username: String = "kiki01" + @Published var displayName: String = "일기러버" + @Published var introText: String = "안녕하세요 일기 러버에요" + @Published var followerCount: Int = 22 + @Published var followingCount: Int = 20 + + // MARK: - State + @Published var month: Date = Date() // 현재 표시 월 + @Published var selectedDate: Date? = Date() // 선택된 날짜 + + // MARK: - Dependencies + private let navigationRouter: NavigationRouter + + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + } + + // MARK: - Actions + func onEditProfileTapped() { + print("Edit profile tapped") + } + + func onSettingsTapped() { + navigationRouter.navigate(to: .settings) + } + + func onFollowerTapped() { + navigationRouter.navigate(to: .followList(mode: .followers)) + } + + func onFollowingTapped() { + navigationRouter.navigate(to: .followList(mode: .followings)) + } + + func onMoreFavoriteCodiTapped() { + navigationRouter.navigate(to: .favoriteCodiList(showHeart: true)) + } +} diff --git a/Codive/Features/Profile/OtherProfile/Presentation/Components/BlockMenuPopup.swift b/Codive/Features/Profile/OtherProfile/Presentation/Components/BlockMenuPopup.swift new file mode 100644 index 00000000..9c7fffa2 --- /dev/null +++ b/Codive/Features/Profile/OtherProfile/Presentation/Components/BlockMenuPopup.swift @@ -0,0 +1,36 @@ +// +// BlockMenuPopup.swift +// Codive +// +// Created by 한태빈 on 1/6/26. +// + +import SwiftUI + +struct BlockMenuPopup: View { + let onBlock: () -> Void + + var body: some View { + Button(action: onBlock) { + HStack(spacing: 8) { + Image("ic_block") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .foregroundStyle(Color("main1")) + + Text("차단하기") + .font(.codive_body2_regular) + .foregroundStyle(Color("Grayscale1")) + } + .padding(.vertical, 8) + .padding(.leading, 16) + .padding(.trailing, 24) + .frame(width: 121, height: 40) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .codiveCardShadow() + } + .buttonStyle(.plain) + } +} diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift new file mode 100644 index 00000000..3ee795d7 --- /dev/null +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -0,0 +1,214 @@ +// +// OtherProfileView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +// MARK: - View +struct OtherProfileView: View { + @ObservedObject private var navigationRouter: NavigationRouter + @StateObject private var viewModel: OtherProfileViewModel + + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + self._viewModel = StateObject(wrappedValue: OtherProfileViewModel(navigationRouter: navigationRouter)) + } + + var body: some View { + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + topBar + + profileSection + .padding(.top, 32) + + Divider() + .padding(.top, 24) + .foregroundStyle(Color.Codive.grayscale7) + + favoriteCodiSection + .padding(.top, 24) + + calendarSection + } + } + + if viewModel.isBlockMenuPresented { + Color.black + .opacity(0.001) + .ignoresSafeArea() + .onTapGesture { + viewModel.dismissBlockMenu() + } + + BlockMenuPopup { + viewModel.onBlockTapped() + } + .padding(.trailing, 20) + .padding(.top, 44) + } + } + .background(Color.white) + } + + // MARK: - Top Bar + private var topBar: some View { + HStack(spacing: 17) { + Button { + viewModel.onBackTapped() + } label: { + Image("back") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + + Spacer(minLength: 0) + + Button { + viewModel.showBlockMenu() + } label: { + Image("more") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 20) + } + + // MARK: - Profile + private var profileSection: some View { + VStack { + Image("CustomProfile") + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + + Text(viewModel.displayName) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.top, 9) + + HStack(spacing: 20) { + Button { + viewModel.onFollowerTapped() + } label: { + HStack(spacing: 6) { + Text("팔로워") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(viewModel.followerCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + + Button { + viewModel.onFollowingTapped() + } label: { + HStack(spacing: 6) { + Text("팔로잉") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(viewModel.followingCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + .padding(.top, 4) + + Text(viewModel.introText) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + .padding(.top, 4) + + followButton + .padding(.top, 16) + } + .frame(maxWidth: .infinity) + } + + private var followButton: some View { + Button { + viewModel.onFollowButtonTapped() + } label: { + Text(viewModel.isFollowing ? "팔로잉" : "팔로우") + .font(.codive_body2_medium) + .foregroundStyle(Color.white) + .frame(width: 76, height: 32) + .background(viewModel.isFollowing ? Color.Codive.main0 : Color.Codive.main4) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + } + + // MARK: - Favorite Codi + private var favoriteCodiSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("최애 코디") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + viewModel.onMoreFavoriteCodiTapped() + } label: { + HStack(spacing: 6) { + Text("더보기") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale3) + Image("go") + .frame(width: 16, height: 16) + .foregroundStyle(Color.Codive.grayscale3) + } + } + } + .padding(.horizontal, 20) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(0..<8, id: \.self) { _ in + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white) + .frame(width: 155, height: 155) + .codiveCardShadow() + } + } + .padding(.top, 12) + } + .padding(.horizontal, 20) + } + } + + // MARK: - Calendar + private var calendarSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("캘린더") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 20) + + CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) + .padding(16) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .codiveCardShadow() + .padding(.horizontal, 20) + .padding(.top, 12) + } + } +} + +#Preview { + OtherProfileView(navigationRouter: NavigationRouter()) +} diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift new file mode 100644 index 00000000..8b23c07f --- /dev/null +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -0,0 +1,66 @@ +// +// OtherProfileViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +@MainActor +class OtherProfileViewModel: ObservableObject { + // MARK: - Mock Data + @Published var displayName: String = "햄스터강아지" + @Published var introText: String = "햄스터가 되고 싶은 강아지입니다" + @Published var followerCount: Int = 22 + @Published var followingCount: Int = 20 + + // MARK: - State + @Published var isFollowing: Bool = false + @Published var month: Date = Date() + @Published var selectedDate: Date? = Date() + @Published var isBlockMenuPresented: Bool = false + + // MARK: - Dependencies + private let navigationRouter: NavigationRouter + + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + } + + // MARK: - Actions + func onBackTapped() { + navigationRouter.navigateBack() + } + + func showBlockMenu() { + isBlockMenuPresented = true + } + + func dismissBlockMenu() { + isBlockMenuPresented = false + } + + func onBlockTapped() { + dismissBlockMenu() + print("Block tapped") + } + + func onFollowerTapped() { + navigationRouter.navigate(to: .followList(mode: .followers)) + } + + func onFollowingTapped() { + navigationRouter.navigate(to: .followList(mode: .followings)) + } + + func onFollowButtonTapped() { + isFollowing.toggle() + print("Follow button tapped. isFollowing: \(isFollowing)") + } + + func onMoreFavoriteCodiTapped() { + navigationRouter.navigate(to: .favoriteCodiList(showHeart: false)) + } +} diff --git a/Codive/Features/Profile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/Presentation/View/ProfileView.swift deleted file mode 100644 index be75c048..00000000 --- a/Codive/Features/Profile/Presentation/View/ProfileView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// ProfileView.swift -// Codive -// -// Created by 황상환 on 9/24/25. -// - -import SwiftUI - -struct ProfileView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - ProfileView() -} diff --git a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift new file mode 100644 index 00000000..c528e435 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +struct CalendarMonthView: View { + @Binding var month: Date + @Binding var selectedDate: Date? + + private let calendar = Calendar.current + private let weekdaySymbols = ["일", "월", "화", "수", "목", "금", "토"] + + init(month: Binding, selectedDate: Binding) { + self._month = month + self._selectedDate = selectedDate + } + + private let cellSpacing: CGFloat = 6 + + private let weekdayCellSize: CGFloat = 40 + private let dayCellWidth: CGFloat = 40 + private let dayCellHeight: CGFloat = 76 + private var gridWidth: CGFloat { (weekdayCellSize * 7) + (cellSpacing * 6) } + + var body: some View { + VStack(spacing: 10) { + header + weekdaysRow + grid + } + .frame(width: gridWidth) // 헤더, 요일, 그리드를 같은 폭으로 고정해서 좌우 정렬 맞춤 + } + + private var header: some View { + HStack(spacing: 0) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + month = calendar.date(byAdding: .month, value: -1, to: month) ?? month + } + } label: { + Image("calendar_left") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.Codive.main1) + .frame(width: weekdayCellSize, height: weekdayCellSize) + } + + Spacer(minLength: 0) + + Text(monthTitle(month)) + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + month = calendar.date(byAdding: .month, value: 1, to: month) ?? month + } + } label: { + Image("calendar_right") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.Codive.main1) + .frame(width: weekdayCellSize, height: weekdayCellSize) // 요일 셀 폭과 맞춤 + } + } + .frame(width: gridWidth, height: weekdayCellSize) + } + + private var weekdaysRow: some View { + HStack(spacing: cellSpacing) { + ForEach(0.. some View { + ZStack { + if item.isPlaceholder { + Color.clear + } else { + let isSelected = isSameDay(item.date, selectedDate) + let weekday = calendar.component(.weekday, from: item.date) // 1=일 ... 7=토 + let isWeekend = (weekday == 1 || weekday == 7) + + Text("\(item.dayNumber)") + .font(.codive_body2_regular) + .foregroundStyle(isSelected ? Color.white : (isWeekend ? Color.Codive.grayscale3 : Color.Codive.grayscale1)) + .frame(width: dayCellWidth, height: dayCellHeight, alignment: .center) // 가운데 정렬 + .background { + if isSelected { + Circle() + .fill(Color.Codive.point1) + .frame(width: 28, height: 28) + } + } + } + } + .frame(width: dayCellWidth, height: dayCellHeight) + .contentShape(Rectangle()) + .onTapGesture { + if !item.isPlaceholder { + selectedDate = item.date + } + } + } + + private func monthTitle(_ date: Date) -> String { + let y = calendar.component(.year, from: date) + let m = calendar.component(.month, from: date) + return "\(y)년 \(m)월" + } + + private func isSameDay(_ a: Date?, _ b: Date?) -> Bool { + guard let a, let b else { return false } + return calendar.isDate(a, inSameDayAs: b) + } + + private func makeDaysForMonth(_ date: Date) -> [CalendarDayItem] { + guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: date)), + let range = calendar.range(of: .day, in: .month, for: firstOfMonth) + else { return [] } + + let firstWeekday = calendar.component(.weekday, from: firstOfMonth) // 1=Sun + let leadingBlanks = max(0, firstWeekday - 1) + + var result: [CalendarDayItem] = [] + result.append(contentsOf: Array(repeating: CalendarDayItem.placeholder, count: leadingBlanks)) + + for day in range { + if let d = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) { + result.append(CalendarDayItem(date: d, dayNumber: day, isPlaceholder: false)) + } + } + + let remainder = result.count % 7 + if remainder != 0 { + result.append(contentsOf: Array(repeating: CalendarDayItem.placeholder, count: 7 - remainder)) + } + + return result + } +} + +struct CalendarDayItem: Hashable { + let date: Date + let dayNumber: Int + let isPlaceholder: Bool + + static var placeholder: CalendarDayItem { + CalendarDayItem(date: Date(), dayNumber: 0, isPlaceholder: true) + } + + init(date: Date, dayNumber: Int, isPlaceholder: Bool) { + self.date = date + self.dayNumber = dayNumber + self.isPlaceholder = isPlaceholder + } +} diff --git a/Codive/Features/Profile/Shared/Presentation/Components/FavoriteCodiListView.swift b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteCodiListView.swift new file mode 100644 index 00000000..6f1bcd26 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Components/FavoriteCodiListView.swift @@ -0,0 +1,94 @@ +// +// FavoriteCodiListView.swift +// Codive +// +// Created by 한태빈 on 1/6/26. +// + +import SwiftUI + +struct FavoriteCodiListView: View { + @ObservedObject private var navigationRouter: NavigationRouter + + let showHeart: Bool + + init(showHeart: Bool, navigationRouter: NavigationRouter) { + self.showHeart = showHeart + self.navigationRouter = navigationRouter + } + + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + private let items: [FavoriteCodiItem] = [ + .init(title: "영화관 데이트"), + .init(title: "미술관 데이트"), + .init(title: "영화관 데이트"), + .init(title: "미술관 데이트"), + .init(title: "영화관 데이트"), + .init(title: "미술관 데이트") + ] + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: "최애 코디", + onBack: { navigationRouter.navigateBack() }, + rightButton: .none + ) + + ScrollView(showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { item in + FavoriteCodiCardView( + title: item.title, + showHeart: showHeart, + isLiked: item.isLiked + ) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 24) + } + } + .background(Color.white) + } +} + +struct FavoriteCodiCardView: View { + let title: String + let showHeart: Bool + let isLiked: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white) + .frame(width: 160, height: 160) + .overlay(alignment: .topTrailing) { + if showHeart { + Image(isLiked ? "heart_on" : "heart_off") + .frame(width: 15, height: 18) + .foregroundStyle(Color.Codive.point1) + .padding(14) + } + } + .codiveCardShadow() + + Text(title) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct FavoriteCodiItem: Identifiable { + let id = UUID() + let title: String + var isLiked: Bool = true +} diff --git a/Codive/Features/Profile/Shared/Presentation/Components/FollowListMode.swift b/Codive/Features/Profile/Shared/Presentation/Components/FollowListMode.swift new file mode 100644 index 00000000..116538a6 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Components/FollowListMode.swift @@ -0,0 +1,18 @@ +// +// FollowListMode.swift +// Codive +// +// Created by 한태빈 on 1/13/26. +import Foundation + +enum FollowListMode: Hashable { + case followers + case followings + + var title: String { + switch self { + case .followers: return "팔로워" + case .followings: return "팔로잉" + } + } +} diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift new file mode 100644 index 00000000..943f74dd --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -0,0 +1,48 @@ +// +// FollowListView.swift +// Codive +// +// Created by 한태빈 on 1/13/26. +// + +import SwiftUI + +struct FollowListView: View { + @ObservedObject private var navigationRouter: NavigationRouter + @StateObject private var viewModel: FollowListViewModel + + init(mode: FollowListMode, navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + _viewModel = StateObject(wrappedValue: FollowListViewModel(mode: mode)) + } + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: viewModel.mode.title, + onBack: { navigationRouter.navigateBack() }, + rightButton: .none + ) + + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.items) { item in + CustomUserRow( + user: item.user, + buttonTitle: item.buttonTitle, + buttonStyle: item.buttonStyle + ) { + viewModel.onTapButton(userId: item.user.userId) + } + .padding(.top, 4) + } + } + .padding(.top, 12) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + } + .background(Color.white) + .navigationBarBackButtonHidden(true) + } +} diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift new file mode 100644 index 00000000..ae90b7e1 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift @@ -0,0 +1,64 @@ +// +// FollowListViewModel.swift +// Codive +// +// Created by 한태빈 on 1/13/26. +// + +import Foundation +import SwiftUI + +final class FollowListViewModel: ObservableObject { + @Published private(set) var items: [FollowRowItem] = [] + let mode: FollowListMode + + init(mode: FollowListMode) { + self.mode = mode + load() + } + + func load() { + // 실제 구현에서는 mode에 따라 API 분기 + + if mode == .followers { + items = [ + .init(user: .init(userId: 1, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: false), + .init(user: .init(userId: 2, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) + ] + } else { + items = [ + .init(user: .init(userId: 3, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true), + .init(user: .init(userId: 4, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) + ] + } + } + + func onTapButton(userId: UserID) { + guard let idx = items.firstIndex(where: { $0.id == userId }) else { return } + + // 공통: 현재 버튼은 follow/following 토글 + // 실제 구현: API 성공 후 반영 + items[idx].isFollowing.toggle() + + // mode가 followings인 경우: + // "팔로잉" 목록에서 언팔로우하면 리스트에서 제거 + if mode == .followings, items[idx].isFollowing == false { + items.remove(at: idx) + } + } +} + +struct FollowRowItem: Identifiable, Hashable { + let user: SimpleUser + var isFollowing: Bool + + var id: UserID { user.userId } + + var buttonTitle: String { + isFollowing ? "팔로잉" : "팔로우" + } + + var buttonStyle: CustomUserRowButtonStyle { + isFollowing ? .secondary : .primary + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Contents.json new file mode 100644 index 00000000..ac6ec471 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Frame 1822.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Frame 1822.png b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Frame 1822.png new file mode 100644 index 00000000..36452a8a Binary files /dev/null and b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Frame 1822.png differ diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Contents.json new file mode 100644 index 00000000..b812fe04 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Frame 1823.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Frame 1823.pdf b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Frame 1823.pdf new file mode 100644 index 00000000..10bbf6a6 Binary files /dev/null and b/Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Frame 1823.pdf differ diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Contents.json new file mode 100644 index 00000000..dfd7fad4 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Frame 1707482060.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Frame 1707482060.pdf b/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Frame 1707482060.pdf new file mode 100644 index 00000000..5f585ccc Binary files /dev/null and b/Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Frame 1707482060.pdf differ diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json index af4ef580..c608154e 100644 --- a/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json +++ b/Codive/Resources/Icons.xcassets/Icon_folder/heart_off_black.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "하트.pdf", + "filename" : "하트.pdf", "idiom" : "universal" } ], diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json new file mode 100644 index 00000000..1216e975 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "프로필.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" "b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" new file mode 100644 index 00000000..61892789 Binary files /dev/null and "b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" differ diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index dfc42cc4..57a418a5 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -32,6 +32,8 @@ enum AppDestination: Hashable, Identifiable { case notification case feedDetail(feedId: Int) case comment(feedId: Int) + case favoriteCodiList(showHeart: Bool) + case followList(mode: FollowListMode) var id: Self { self } @@ -58,6 +60,10 @@ enum AppDestination: Hashable, Identifiable { case .feedDetail, .comment: return true + // Profile Flow + case .favoriteCodiList, .settings, .followList: + return true + // 다른 플로우 전체 화면은 여기에 추가 // case .closetEdit, .feedCreate: // return true @@ -88,6 +94,10 @@ enum AppDestination: Hashable, Identifiable { case .feedDetail, .comment: return false + // Profile Flow - 자체 네비게이션 바 있음 + case .favoriteCodiList, .settings, .followList: + return false + default: return true } diff --git a/Codive/Shared/DesignSystem/Buttons/CustomButton.swift b/Codive/Shared/DesignSystem/Buttons/CustomButton.swift index 7f1ea4bf..c6114942 100644 --- a/Codive/Shared/DesignSystem/Buttons/CustomButton.swift +++ b/Codive/Shared/DesignSystem/Buttons/CustomButton.swift @@ -89,7 +89,7 @@ struct ButtonStyleModifier: ViewModifier { case .fill: content .background(alignment: .center) { - isEnabled ? Color.Codive.main0 : Color.Codive.main3 + isEnabled ? Color.Codive.main0 : Color.Codive.main4 } case .border: content diff --git a/Codive/Shared/DesignSystem/Extensions/Color+Hex.swift b/Codive/Shared/DesignSystem/Extensions/Color+Hex.swift new file mode 100644 index 00000000..d2127894 --- /dev/null +++ b/Codive/Shared/DesignSystem/Extensions/Color+Hex.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let r = Double((int >> 16) & 0xFF) / 255.0 + let g = Double((int >> 8) & 0xFF) / 255.0 + let b = Double(int & 0xFF) / 255.0 + self = Color(red: r, green: g, blue: b) + } +} diff --git a/Codive/Shared/DesignSystem/Extensions/View+Shadow.swift b/Codive/Shared/DesignSystem/Extensions/View+Shadow.swift new file mode 100644 index 00000000..9add749a --- /dev/null +++ b/Codive/Shared/DesignSystem/Extensions/View+Shadow.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension View { + func codiveCardShadow() -> some View { + shadow(color: Color(hex: "#636363").opacity(0.06), radius: 8, x: 0, y: 2) + } +}