From f01d426cea0d38b00a61bff85d313c1f308ecd85 Mon Sep 17 00:00:00 2001 From: taebin2 <109895271+taebin2@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:25:46 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#42]=20ProfileView=20=EC=A0=9C=EC=9E=91(1?= =?UTF-8?q?=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/ProfileView.swift | 369 ++++++++++++++++++ .../Presentation/View/ProfileView.swift | 18 - .../Icon_folder/go.imageset/Contents.json | 12 + .../go.imageset/Frame 1707482060.pdf | Bin 0 -> 4541 bytes .../heart_off_black.imageset/Contents.json | 2 +- 5 files changed, 382 insertions(+), 19 deletions(-) create mode 100644 Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift delete mode 100644 Codive/Features/Profile/Presentation/View/ProfileView.swift create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Contents.json create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/go.imageset/Frame 1707482060.pdf 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..11b00201 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -0,0 +1,369 @@ +// +// MyPageMainView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +struct ProfileView: View { + + // MARK: - Mock + private let username: String = "kiki01" + private let displayName: String = "일기러버" + private let introText: String = "안녕하세요 일기 러버에요" + private let followerCount: Int = 22 + private let followingCount: Int = 20 + + // MARK: - State + @State private var month: Date = Date() // 현재 표시 월 + @State private var selectedDate: Date? = Date() // 선택된 날짜(옵션) + + 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) { + Text(username) + .font(.codive_title1) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + } label: { + Image("edit") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + Button { + } 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(displayName) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.top, 9) + + HStack(spacing: 20) { + Button { + // follower tap + } label: { + HStack(spacing: 6) { + Text("팔로워") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(followerCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + + Button { + // following tap + } label: { + HStack(spacing: 6) { + Text("팔로잉") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(followingCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + .padding(.top, 4) + + Text(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(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + // 더보기 + } 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(.horizontal, 20) + .padding(.top, 12) + } + } + } + + // MARK: - Calendar + private var calendarSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("캘린더") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 20) + + CalendarMonthView(month: $month, selectedDate: $selectedDate) + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .codiveCardShadow() + .padding(.horizontal, 20) + } + } +} + +// MARK: - CalendarMonthView (날짜만) +struct CalendarMonthView: View { + @Binding var month: Date + @Binding var selectedDate: Date? + + private let calendar = Calendar.current + private let weekdaySymbols = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + + var body: some View { + VStack(spacing: 10) { + header + weekdaysRow + grid + } + } + + private var header: some View { + HStack(spacing: 12) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + month = calendar.date(byAdding: .month, value: -1, to: month) ?? month + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) + } + + Spacer(minLength: 0) + + Text(monthTitle(month)) + .font(.system(size: 14, weight: .semibold)) + .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(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) + } + } + .padding(.bottom, 4) + } + + private var weekdaysRow: some View { + HStack(spacing: 0) { + ForEach(weekdaySymbols, id: \.self) { w in + Text(w) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.Codive.grayscale4) + .frame(maxWidth: .infinity) + } + } + } + + private var grid: some View { + let days = makeDaysForMonth(month) + + return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 10) { + ForEach(days.indices, id: \.self) { idx in + let item = days[idx] + dayCell(item) + } + } + .padding(.top, 2) + } + + private let dayCellHeight: CGFloat = 54 + private let dayNumberSize: CGFloat = 28 + + private func dayCell(_ item: CalendarDayItem) -> some View { + ZStack { + if item.isPlaceholder { + Color.clear + .frame(height: dayCellHeight) + } else { + let isSelected = isSameDay(item.date, selectedDate) + + // 나중에 사진이 들어갈 영역(지금은 비워둠) + // Image(...) 넣을 땐 이 Color.clear 자리에 깔면 됨 + Color.clear + + Text("\(item.dayNumber)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected ? Color.white : Color.Codive.grayscale3) + .frame(width: dayNumberSize, height: dayNumberSize) + .background { + if isSelected { + Circle().fill(Color.Codive.point1) + } else { + Circle().fill(Color.clear) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(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 + } +} + +// MARK: - Calendar Models +private 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 + } +} + +// MARK: - Shadow + Hex +private 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) + } +} + +private extension View { + func codiveCardShadow() -> some View { + shadow(color: Color(hex: "#636363").opacity(0.06), radius: 8, x: 0, y: 2) + } +} + +#Preview { + ProfileView() +} 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/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 0000000000000000000000000000000000000000..5f585ccc2fa6d1825ebaba5dd8bf7010169033c5 GIT binary patch literal 4541 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 dG Date: Tue, 23 Dec 2025 06:46:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#42]=20OtherProfileView=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=91(1=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/ProfileView.swift | 4 +- .../Presentation/View/OtherProfileView.swift | 205 ++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 11b00201..05ebda75 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -346,7 +346,7 @@ private struct CalendarDayItem: Hashable { } // MARK: - Shadow + Hex -private extension Color { + extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 @@ -358,7 +358,7 @@ private extension Color { } } -private extension View { +extension View { func codiveCardShadow() -> some View { shadow(color: Color(hex: "#636363").opacity(0.06), radius: 8, x: 0, y: 2) } 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..41c2c4e7 --- /dev/null +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -0,0 +1,205 @@ +// +// OtherProfileView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +struct OtherProfileView: View { + + // MARK: - Mock (나중에 API로 교체) + private let username: String = "ham_dog" + private let displayName: String = "햄스터강아지" + private let introText: String = "햄스터가 되고 싶은 강아지입니다" + private let followerCount: Int = 22 + private let followingCount: Int = 20 + + // MARK: - State + @State private var isFollowing: Bool = false + @State private var month: Date = Date() + @State private var selectedDate: Date? = Date() + + 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: 17) { + Button { + // back action + } label: { + Image("back") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + + Text(username) + .font(.codive_title1) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + // more action + } 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(displayName) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.top, 9) + + HStack(spacing: 20) { + Button { + // follower tap + } label: { + HStack(spacing: 6) { + Text("팔로워") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(followerCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + + Button { + // following tap + } label: { + HStack(spacing: 6) { + Text("팔로잉") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + Text("\(followingCount)") + .font(.codive_body1_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + .padding(.top, 4) + + Text(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 { + isFollowing.toggle() + } label: { + Text(isFollowing ? "팔로잉" : "팔로우") + .font(.codive_body2_medium) + .foregroundStyle(Color.white) + .frame(width: 76, height: 32) + .background(isFollowing ? Color.Codive.main0 : Color.Codive.main4) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + } + + // MARK: - Favorite Codi (ProfileView와 동일) + private var favoriteCodiSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("최애 코디") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale1) + + Spacer(minLength: 0) + + Button { + // 더보기 + } 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(.horizontal, 20) + .padding(.top, 12) + } + } + } + + // MARK: - Calendar + private var calendarSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("캘린더") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 20) + + CalendarMonthView(month: $month, selectedDate: $selectedDate) + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .codiveCardShadow() + .padding(.horizontal, 20) + } + } +} +#Preview { + OtherProfileView() +} From d2d2e560b096b7a081c8dc76140a97191e4e8686 Mon Sep 17 00:00:00 2001 From: taebin2 <109895271+taebin2@users.noreply.github.com> Date: Tue, 23 Dec 2025 07:57:23 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#42]=20ProfileSettingView=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=91=20=EB=B0=8F=20=EA=B0=81=20View=20ViewModel=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/UnderlineField.swift | 136 +++++++++++ .../View/ProfileSettingView.swift | 216 +++++++++++++++++ .../Presentation/View/ProfileView.swift | 218 ++---------------- .../ViewModel/ProfileSettingViewModel.swift | 152 ++++++++++++ .../ViewModel/ProfileViewModel.swift | 42 ++++ .../Presentation/View/OtherProfileView.swift | 45 ++-- .../ViewModel/OtherProfileViewModel.swift | 48 ++++ .../Components/CalendarMonthView.swift | 174 ++++++++++++++ .../settingProfile.imageset/Contents.json | 12 + .../\355\224\204\353\241\234\355\225\204.pdf" | Bin 0 -> 11024 bytes .../DesignSystem/Extensions/Color+Hex.swift | 13 ++ .../DesignSystem/Extensions/View+Shadow.swift | 7 + 12 files changed, 832 insertions(+), 231 deletions(-) create mode 100644 Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift create mode 100644 Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift create mode 100644 Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift create mode 100644 Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift create mode 100644 Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift create mode 100644 Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json create mode 100644 "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.pdf" create mode 100644 Codive/Shared/DesignSystem/Extensions/Color+Hex.swift create mode 100644 Codive/Shared/DesignSystem/Extensions/View+Shadow.swift 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..ceb6d26f --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift @@ -0,0 +1,136 @@ +// +// 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) { + TextField("", text: $text) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + .keyboardType(keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .focused(focus, equals: focusEquals) + + trailing + } + + 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 helperEmptyText } + return helperFilledText + }() + + return Group { + if let message, !message.isEmpty { + Text(message) + .font(.codive_body3_medium) + .foregroundStyle(isError ? Color.Codive.point1 : Color.Codive.grayscale4) + .padding(.top, 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..e6faaae0 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -0,0 +1,216 @@ +// +// ProfileSettingView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI +import Combine + +// MARK: - View +struct ProfileSettingView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ProfileSettingViewModel() + + // MARK: - Focus + enum Field: Hashable { + case nickname + case userId + case intro + } + + @FocusState private var focus: Field? + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: "프로필 설정", + onBack: { dismiss() }, + rightButton: .none + ) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + profileImageSection + .padding(.top, 26) + + formSection + .padding(.top, 22) + + completeButton + .padding(.top, 36) + .padding(.bottom, 24) + } + } + } + .background(Color.white) + .navigationBarHidden(true) + } + + // MARK: - Profile Image + 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() + .padding(18) + } + } + } + .frame(width: 100, height: 100) + .clipShape(Circle()) + + Button { + viewModel.onProfileImageTapped() + } label: { + Circle() + .fill(Color.Codive.grayscale1) + .frame(width: 28, height: 28) + .overlay { + Image(systemName: "plus") + .frame(width: 28, height: 28) + } + } + .buttonStyle(.plain) + .offset(x: 6, y: 6) + } + .frame(maxWidth: .infinity) + } + + // MARK: - Form + private var formSection: some View { + VStack(alignment: .leading, spacing: 0) { + + UnderlineField( + title: "닉네임", + requiredTag: "*", + text: $viewModel.nickname, + focus: $focus, + focusEquals: .nickname, + keyboardType: .default + ) + .setHelper( + emptyText: "\(viewModel.nicknameMaxCount)글자 내로 닉네임을 입력해주세요", + filledText: viewModel.nicknameFilledHelper, + errorText: viewModel.nicknameErrorText + ) + .padding(.top, 18) + + UnderlineField( + title: "아이디", + requiredTag: "*", + text: $viewModel.userId, + focus: $focus, + focusEquals: .userId, + keyboardType: .asciiCapable + ) { + Button { + viewModel.runIDDuplicateCheck() + } 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.canTryIDCheck) + .opacity(viewModel.canTryIDCheck ? 1 : 0.4) + } + .setHelper( + emptyText: "대문자, 특수문자 입력 불가, \(viewModel.userIdMaxCount)자 이내", + filledText: viewModel.userIdFilledHelper, + errorText: viewModel.userIdErrorText + ) + .padding(.top, 26) + + UnderlineField( + title: "한줄소개", + requiredTag: nil, + text: $viewModel.intro, + focus: $focus, + focusEquals: .intro, + keyboardType: .default + ) + .setHelper( + emptyText: "\(viewModel.introMaxCount)자 이내로 나를 소개해보세요", + filledText: nil, + errorText: viewModel.introErrorText + ) + .padding(.top, 26) + + privacySection + .padding(.top, 26) + } + .padding(.horizontal, 20) + } + + // MARK: - Privacy + 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.point1.opacity(0.12) : Color.Codive.grayscale7) + } + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isOn ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + // MARK: - Complete + private var completeButton: some View { + CustomButton( + text: "설정 완료", + widthType: .fixed, + isEnabled: viewModel.canComplete + ) { + viewModel.onCompleteTapped() + } + } +} + +#Preview { + ProfileSettingView() +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 05ebda75..732a66d3 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -1,5 +1,5 @@ // -// MyPageMainView.swift +// ProfileView.swift // Codive // // Created by 한태빈 on 12/23/25. @@ -7,18 +7,9 @@ import SwiftUI +// MARK: - View struct ProfileView: View { - - // MARK: - Mock - private let username: String = "kiki01" - private let displayName: String = "일기러버" - private let introText: String = "안녕하세요 일기 러버에요" - private let followerCount: Int = 22 - private let followingCount: Int = 20 - - // MARK: - State - @State private var month: Date = Date() // 현재 표시 월 - @State private var selectedDate: Date? = Date() // 선택된 날짜(옵션) + @StateObject private var viewModel = ProfileViewModel() var body: some View { ScrollView(showsIndicators: false) { @@ -47,13 +38,14 @@ struct ProfileView: View { // MARK: - Top Bar private var topBar: some View { HStack(spacing: 12) { - Text(username) + Text(viewModel.username) .font(.codive_title1) .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) Button { + viewModel.onEditProfileTapped() } label: { Image("edit") .resizable() @@ -62,6 +54,7 @@ struct ProfileView: View { } Button { + viewModel.onSettingsTapped() } label: { Image("setting") .resizable() @@ -81,33 +74,33 @@ struct ProfileView: View { .frame(width: 80, height: 80) .clipShape(Circle()) - Text(displayName) + Text(viewModel.displayName) .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) .padding(.top, 9) HStack(spacing: 20) { Button { - // follower tap + viewModel.onFollowerTapped() } label: { HStack(spacing: 6) { Text("팔로워") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text("\(followerCount)") + Text("\(viewModel.followerCount)") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) } } Button { - // following tap + viewModel.onFollowingTapped() } label: { HStack(spacing: 6) { Text("팔로잉") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text("\(followingCount)") + Text("\(viewModel.followingCount)") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) } @@ -115,7 +108,7 @@ struct ProfileView: View { } .padding(.top, 4) - Text(introText) + Text(viewModel.introText) .font(.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale4) .padding(.top, 4) @@ -134,7 +127,7 @@ struct ProfileView: View { Spacer(minLength: 0) Button { - // 더보기 + viewModel.onMoreFavoriteCodiTapped() } label: { HStack(spacing: 6) { Text("더보기") @@ -171,7 +164,7 @@ struct ProfileView: View { .foregroundStyle(Color.Codive.grayscale1) .padding(.horizontal, 20) - CalendarMonthView(month: $month, selectedDate: $selectedDate) + CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) .padding(16) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) @@ -181,189 +174,6 @@ struct ProfileView: View { } } -// MARK: - CalendarMonthView (날짜만) -struct CalendarMonthView: View { - @Binding var month: Date - @Binding var selectedDate: Date? - - private let calendar = Calendar.current - private let weekdaySymbols = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - - var body: some View { - VStack(spacing: 10) { - header - weekdaysRow - grid - } - } - - private var header: some View { - HStack(spacing: 12) { - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - month = calendar.date(byAdding: .month, value: -1, to: month) ?? month - } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.Codive.grayscale3) - } - - Spacer(minLength: 0) - - Text(monthTitle(month)) - .font(.system(size: 14, weight: .semibold)) - .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(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.Codive.grayscale3) - } - } - .padding(.bottom, 4) - } - - private var weekdaysRow: some View { - HStack(spacing: 0) { - ForEach(weekdaySymbols, id: \.self) { w in - Text(w) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.Codive.grayscale4) - .frame(maxWidth: .infinity) - } - } - } - - private var grid: some View { - let days = makeDaysForMonth(month) - - return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 10) { - ForEach(days.indices, id: \.self) { idx in - let item = days[idx] - dayCell(item) - } - } - .padding(.top, 2) - } - - private let dayCellHeight: CGFloat = 54 - private let dayNumberSize: CGFloat = 28 - - private func dayCell(_ item: CalendarDayItem) -> some View { - ZStack { - if item.isPlaceholder { - Color.clear - .frame(height: dayCellHeight) - } else { - let isSelected = isSameDay(item.date, selectedDate) - - // 나중에 사진이 들어갈 영역(지금은 비워둠) - // Image(...) 넣을 땐 이 Color.clear 자리에 깔면 됨 - Color.clear - - Text("\(item.dayNumber)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(isSelected ? Color.white : Color.Codive.grayscale3) - .frame(width: dayNumberSize, height: dayNumberSize) - .background { - if isSelected { - Circle().fill(Color.Codive.point1) - } else { - Circle().fill(Color.clear) - } - } - } - } - .frame(maxWidth: .infinity) - .frame(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 - } -} - -// MARK: - Calendar Models -private 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 - } -} - -// MARK: - Shadow + Hex - 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) - } -} - -extension View { - func codiveCardShadow() -> some View { - shadow(color: Color(hex: "#636363").opacity(0.06), radius: 8, x: 0, y: 2) - } -} - #Preview { ProfileView() } 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..090a3c6c --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -0,0 +1,152 @@ +// +// ProfileSettingViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI +import Combine + +class ProfileSettingViewModel: ObservableObject { + // MARK: - Constants + let nicknameMaxCount: Int = 6 + let userIdMaxCount: Int = 20 + let introMaxCount: Int = 20 + + // MARK: - Published Properties + @Published var nickname: String = "" { + didSet { + if nickname.count > nicknameMaxCount { + nickname = String(nickname.prefix(nicknameMaxCount)) + } + } + } + + @Published var userId: String = "" { + didSet { + if userId.count > userIdMaxCount { + userId = String(userId.prefix(userIdMaxCount)) + } + // Reset check status if user changes ID + if idCheckStatus == .available || idCheckStatus == .duplicated { + idCheckStatus = .none + } + } + } + + @Published var intro: String = "" { + didSet { + if intro.count > introMaxCount { + intro = String(intro.prefix(introMaxCount)) + } + } + } + + @Published var isPublic: Bool = true + @Published var idCheckStatus: IDCheckStatus = .none + @Published var pickedProfileImage: Image? = nil + + // MARK: - Types + enum IDCheckStatus: Equatable { + case none + case checking + case available + case duplicated + } + + // MARK: - Validation Helpers + var nicknameErrorText: String? { + if nickname.isEmpty { return nil } + if nickname.count > nicknameMaxCount { return "\(nicknameMaxCount)글자 이내로 입력해주세요" } + return nil + } + + var nicknameFilledHelper: String? { + if nickname.isEmpty { return nil } + if nicknameErrorText != nil { return nil } + return "사용 가능한 닉네임 입니다." + } + + var canTryIDCheck: Bool { + if userId.isEmpty { return false } + if userId.count > userIdMaxCount { return false } + if userIdErrorText != nil { return false } + if idCheckStatus == .checking { return false } + return true + } + + var userIdErrorText: String? { + if userId.isEmpty { return nil } + if userId.count > userIdMaxCount { return "\(userIdMaxCount)자 이내로 입력해주세요" } + if containsUppercase(userId) { return "대문자는 사용할 수 없어요" } + if containsSpecialCharacters(userId) { return "특수문자는 사용할 수 없어요" } + return nil + } + + var userIdFilledHelper: String? { + if userId.isEmpty { return nil } + if userIdErrorText != nil { return nil } + + switch idCheckStatus { + case .none: + return nil + case .checking: + return "확인 중이에요" + case .available: + return "사용 가능한 아이디 입니다." + case .duplicated: + return nil + } + } + + var introErrorText: String? { + if intro.isEmpty { return nil } + if intro.count > introMaxCount { return "\(introMaxCount)자 이내로 입력해주세요" } + return nil + } + + var canComplete: Bool { + if nickname.isEmpty { return false } + if nicknameErrorText != nil { return false } + + if userId.isEmpty { return false } + if userIdErrorText != nil { return false } + if idCheckStatus != .available { return false } + + if introErrorText != nil { return false } + return true + } + + // MARK: - Actions + func runIDDuplicateCheck() { + idCheckStatus = .checking + + // Mock API call + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + if self.userId.lowercased() == "trendbox" || self.userId.lowercased() == "ckj11" { + self.idCheckStatus = .duplicated + } else { + self.idCheckStatus = .available + } + } + } + + func onProfileImageTapped() { + print("Profile image tapped") + } + + func onCompleteTapped() { + print("Complete tapped") + } + + // MARK: - Private Helpers + private func containsUppercase(_ s: String) -> Bool { + s.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil + } + + private func containsSpecialCharacters(_ s: String) -> Bool { + let allowed = CharacterSet.alphanumerics + return s.rangeOfCharacter(from: allowed.inverted) != nil + } +} 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..a219f2a9 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -0,0 +1,42 @@ +// +// ProfileViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +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: - Actions + func onEditProfileTapped() { + print("Edit profile tapped") + } + + func onSettingsTapped() { + print("Settings tapped") + } + + func onFollowerTapped() { + print("Follower tapped") + } + + func onFollowingTapped() { + print("Following tapped") + } + + func onMoreFavoriteCodiTapped() { + print("More favorite codi tapped") + } +} diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index 41c2c4e7..c4f5de6d 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -7,19 +7,9 @@ import SwiftUI +// MARK: - View struct OtherProfileView: View { - - // MARK: - Mock (나중에 API로 교체) - private let username: String = "ham_dog" - private let displayName: String = "햄스터강아지" - private let introText: String = "햄스터가 되고 싶은 강아지입니다" - private let followerCount: Int = 22 - private let followingCount: Int = 20 - - // MARK: - State - @State private var isFollowing: Bool = false - @State private var month: Date = Date() - @State private var selectedDate: Date? = Date() + @StateObject private var viewModel = OtherProfileViewModel() var body: some View { ScrollView(showsIndicators: false) { @@ -49,7 +39,7 @@ struct OtherProfileView: View { private var topBar: some View { HStack(spacing: 17) { Button { - // back action + viewModel.onBackTapped() } label: { Image("back") .resizable() @@ -57,14 +47,14 @@ struct OtherProfileView: View { .frame(width: 24, height: 24) } - Text(username) + Text(viewModel.username) .font(.codive_title1) .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) Button { - // more action + viewModel.onMoreTapped() } label: { Image("more") .resizable() @@ -84,33 +74,33 @@ struct OtherProfileView: View { .frame(width: 80, height: 80) .clipShape(Circle()) - Text(displayName) + Text(viewModel.displayName) .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) .padding(.top, 9) HStack(spacing: 20) { Button { - // follower tap + viewModel.onFollowerTapped() } label: { HStack(spacing: 6) { Text("팔로워") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text("\(followerCount)") + Text("\(viewModel.followerCount)") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) } } Button { - // following tap + viewModel.onFollowingTapped() } label: { HStack(spacing: 6) { Text("팔로잉") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text("\(followingCount)") + Text("\(viewModel.followingCount)") .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) } @@ -118,7 +108,7 @@ struct OtherProfileView: View { } .padding(.top, 4) - Text(introText) + Text(viewModel.introText) .font(.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale4) .padding(.top, 4) @@ -131,19 +121,19 @@ struct OtherProfileView: View { private var followButton: some View { Button { - isFollowing.toggle() + viewModel.onFollowButtonTapped() } label: { - Text(isFollowing ? "팔로잉" : "팔로우") + Text(viewModel.isFollowing ? "팔로잉" : "팔로우") .font(.codive_body2_medium) .foregroundStyle(Color.white) .frame(width: 76, height: 32) - .background(isFollowing ? Color.Codive.main0 : Color.Codive.main4) + .background(viewModel.isFollowing ? Color.Codive.main0 : Color.Codive.main4) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) } - // MARK: - Favorite Codi (ProfileView와 동일) + // MARK: - Favorite Codi private var favoriteCodiSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -154,7 +144,7 @@ struct OtherProfileView: View { Spacer(minLength: 0) Button { - // 더보기 + viewModel.onMoreFavoriteCodiTapped() } label: { HStack(spacing: 6) { Text("더보기") @@ -191,7 +181,7 @@ struct OtherProfileView: View { .foregroundStyle(Color.Codive.grayscale1) .padding(.horizontal, 20) - CalendarMonthView(month: $month, selectedDate: $selectedDate) + CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) .padding(16) .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) @@ -200,6 +190,7 @@ struct OtherProfileView: View { } } } + #Preview { OtherProfileView() } 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..0a519415 --- /dev/null +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -0,0 +1,48 @@ +// +// OtherProfileViewModel.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +class OtherProfileViewModel: ObservableObject { + // MARK: - Mock Data + @Published var username: String = "ham_dog" + @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() + + // MARK: - Actions + func onBackTapped() { + print("Back tapped") + } + + func onMoreTapped() { + print("More tapped") + } + + func onFollowerTapped() { + print("Follower tapped") + } + + func onFollowingTapped() { + print("Following tapped") + } + + func onFollowButtonTapped() { + isFollowing.toggle() + print("Follow button tapped. isFollowing: \(isFollowing)") + } + + func onMoreFavoriteCodiTapped() { + print("More favorite codi tapped") + } +} 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..12bd0c01 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -0,0 +1,174 @@ +// +// CalendarMonthView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +struct CalendarMonthView: View { + @Binding var month: Date + @Binding var selectedDate: Date? + + private let calendar = Calendar.current + private let weekdaySymbols = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + + // 이니셜라이저 추가 (public access를 위함) + init(month: Binding, selectedDate: Binding) { + self._month = month + self._selectedDate = selectedDate + } + + var body: some View { + VStack(spacing: 10) { + header + weekdaysRow + grid + } + } + + private var header: some View { + HStack(spacing: 12) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + month = calendar.date(byAdding: .month, value: -1, to: month) ?? month + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) + } + + Spacer(minLength: 0) + + Text(monthTitle(month)) + .font(.system(size: 14, weight: .semibold)) + .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(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) + } + } + .padding(.bottom, 4) + } + + private var weekdaysRow: some View { + HStack(spacing: 0) { + ForEach(weekdaySymbols, id: \.self) { w in + Text(w) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.Codive.grayscale4) + .frame(maxWidth: .infinity) + } + } + } + + private var grid: some View { + let days = makeDaysForMonth(month) + + return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 10) { + ForEach(days.indices, id: \.self) { idx in + let item = days[idx] + dayCell(item) + } + } + .padding(.top, 2) + } + + private let dayCellHeight: CGFloat = 54 + private let dayNumberSize: CGFloat = 28 + + private func dayCell(_ item: CalendarDayItem) -> some View { + ZStack { + if item.isPlaceholder { + Color.clear + .frame(height: dayCellHeight) + } else { + let isSelected = isSameDay(item.date, selectedDate) + Color.clear + + Text("\(item.dayNumber)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected ? Color.white : Color.Codive.grayscale3) + .frame(width: dayNumberSize, height: dayNumberSize) + .background { + if isSelected { + Circle().fill(Color.Codive.point1) + } else { + Circle().fill(Color.clear) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(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 + } +} + +// MARK: - Calendar Models +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/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..add56e78 --- /dev/null +++ b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "프로필.pdf", + "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.pdf" "b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.pdf" new file mode 100644 index 0000000000000000000000000000000000000000..0e77b09f7793d65ea48d12ec3886f3a3e038d1dc GIT binary patch literal 11024 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 Q29X#L4d1+3`=HK$0L{OQ8UO$Q literal 0 HcmV?d00001 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) + } +} From 6dc476d5110a29bae589555bd06c6d0882395518 Mon Sep 17 00:00:00 2001 From: taebin2 <109895271+taebin2@users.noreply.github.com> Date: Tue, 6 Jan 2026 04:48:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#42]=20ProfileView=EC=97=90=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B5=9C=EC=95=A0=EC=BD=94=EB=94=94=20View=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91,=20OtherProfileView=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=ED=8C=9D=EC=97=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 6 +- .../View/ProfileSettingView.swift | 13 ++- .../Presentation/View/ProfileView.swift | 26 +++-- .../ViewModel/ProfileViewModel.swift | 13 ++- .../Components/BlockMenuPopup.swift | 36 +++++++ .../Presentation/View/OtherProfileView.swift | 60 +++++++---- .../ViewModel/OtherProfileViewModel.swift | 25 ++++- .../Components/CalendarMonthView.swift | 89 +++++++++-------- .../Components/FavoriteCodiListView.swift | 94 ++++++++++++++++++ .../calendar_left.imageset/Contents.json | 12 +++ .../calendar_left.imageset/Frame 1822.png | Bin 0 -> 278 bytes .../calendar_right.imageset/Contents.json | 12 +++ .../calendar_right.imageset/Frame 1823.pdf | Bin 0 -> 4549 bytes Codive/Router/AppDestination.swift | 9 ++ 14 files changed, 318 insertions(+), 77 deletions(-) create mode 100644 Codive/Features/Profile/OtherProfile/Presentation/Components/BlockMenuPopup.swift create mode 100644 Codive/Features/Profile/Shared/Presentation/Components/FavoriteCodiListView.swift create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Contents.json create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/calendar_left.imageset/Frame 1822.png create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Contents.json create mode 100644 Codive/Resources/Icons.xcassets/Icon_folder/calendar_right.imageset/Frame 1823.pdf diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 08b25c63..bbe3e23a 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,10 @@ struct MainTabView: View { homeDIContainer.makeEditCategoryView() case .codiBoard: homeDIContainer.makeCodiBoardView() + case .favoriteCodiList(let showHeart): + FavoriteCodiListView(showHeart: showHeart, navigationRouter: navigationRouter) + case .settings: + ProfileSettingView(navigationRouter: navigationRouter) default: EmptyView() diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index e6faaae0..8da9c1a1 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -10,8 +10,13 @@ import Combine // MARK: - View struct ProfileSettingView: View { - @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel = ProfileSettingViewModel() + @ObservedObject private var navigationRouter: NavigationRouter + @StateObject private var viewModel: ProfileSettingViewModel + + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel()) + } // MARK: - Focus enum Field: Hashable { @@ -26,7 +31,7 @@ struct ProfileSettingView: View { VStack(spacing: 0) { CustomNavigationBar( title: "프로필 설정", - onBack: { dismiss() }, + onBack: { navigationRouter.navigateBack() }, rightButton: .none ) @@ -212,5 +217,5 @@ struct ProfileSettingView: View { } #Preview { - ProfileSettingView() + ProfileSettingView(navigationRouter: NavigationRouter()) } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 732a66d3..493e93e6 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -9,7 +9,13 @@ import SwiftUI // MARK: - View struct ProfileView: View { - @StateObject private var viewModel = ProfileViewModel() + @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) { @@ -121,7 +127,7 @@ struct ProfileView: View { VStack(alignment: .leading, spacing: 12) { HStack { Text("최애 코디") - .font(.system(size: 16, weight: .semibold)) + .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) @@ -146,13 +152,19 @@ struct ProfileView: View { ForEach(0..<8, id: \.self) { _ in RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.white) - .frame(width: 155, height: 155) + .frame(width: 160, height: 160) + .overlay(alignment: .topTrailing) { + Image("heart_on") + .frame(width: 15, height: 18) + .foregroundStyle(Color.Codive.point1) + .padding(14) + } .codiveCardShadow() } } - .padding(.horizontal, 20) .padding(.top, 12) } + .padding(.horizontal, 20) } } @@ -160,20 +172,22 @@ struct ProfileView: View { private var calendarSection: some View { VStack(alignment: .leading, spacing: 12) { Text("캘린더") - .font(.system(size: 16, weight: .semibold)) + .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() + ProfileView(navigationRouter: NavigationRouter()) } diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index a219f2a9..27b1d01b 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor class ProfileViewModel: ObservableObject { // MARK: - Mock Data @Published var username: String = "kiki01" @@ -19,13 +20,21 @@ class ProfileViewModel: ObservableObject { @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() { - print("Settings tapped") + navigationRouter.navigate(to: .settings) } func onFollowerTapped() { @@ -37,6 +46,6 @@ class ProfileViewModel: ObservableObject { } func onMoreFavoriteCodiTapped() { - print("More favorite codi tapped") + 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 index c4f5de6d..47188044 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -9,27 +9,47 @@ import SwiftUI // MARK: - View struct OtherProfileView: View { - @StateObject private var viewModel = OtherProfileViewModel() + @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 { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - topBar + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + topBar + + profileSection + .padding(.top, 32) - profileSection - .padding(.top, 32) + Divider() + .padding(.top, 24) + .foregroundStyle(Color.Codive.grayscale7) - Divider() - .padding(.top, 24) - .foregroundStyle(Color.Codive.grayscale7) + favoriteCodiSection + .padding(.top, 24) - favoriteCodiSection - .padding(.top, 24) + calendarSection + } + } - calendarSection - .padding(.top, 40) + if viewModel.isBlockMenuPresented { + Color.black + .opacity(0.001) + .ignoresSafeArea() + .onTapGesture { + viewModel.dismissBlockMenu() + } - Spacer(minLength: 77) + BlockMenuPopup { + viewModel.onBlockTapped() + } + .padding(.trailing, 20) + .padding(.top, 44) } } .background(Color.white) @@ -54,7 +74,7 @@ struct OtherProfileView: View { Spacer(minLength: 0) Button { - viewModel.onMoreTapped() + viewModel.showBlockMenu() } label: { Image("more") .resizable() @@ -138,7 +158,7 @@ struct OtherProfileView: View { VStack(alignment: .leading, spacing: 12) { HStack { Text("최애 코디") - .font(.system(size: 16, weight: .semibold)) + .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) @@ -167,9 +187,9 @@ struct OtherProfileView: View { .codiveCardShadow() } } - .padding(.horizontal, 20) .padding(.top, 12) } + .padding(.horizontal, 20) } } @@ -177,20 +197,22 @@ struct OtherProfileView: View { private var calendarSection: some View { VStack(alignment: .leading, spacing: 12) { Text("캘린더") - .font(.system(size: 16, weight: .semibold)) + .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() + OtherProfileView(navigationRouter: NavigationRouter()) } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift index 0a519415..54ad0d10 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor class OtherProfileViewModel: ObservableObject { // MARK: - Mock Data @Published var username: String = "ham_dog" @@ -19,14 +20,32 @@ class OtherProfileViewModel: ObservableObject { @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() { print("Back tapped") } - func onMoreTapped() { - print("More tapped") + func showBlockMenu() { + isBlockMenuPresented = true + } + + func dismissBlockMenu() { + isBlockMenuPresented = false + } + + func onBlockTapped() { + dismissBlockMenu() + print("Block tapped") } func onFollowerTapped() { @@ -43,6 +62,6 @@ class OtherProfileViewModel: ObservableObject { } func onMoreFavoriteCodiTapped() { - print("More favorite codi tapped") + navigationRouter.navigate(to: .favoriteCodiList(showHeart: false)) } } diff --git a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift index 12bd0c01..c528e435 100644 --- a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -1,10 +1,3 @@ -// -// CalendarMonthView.swift -// Codive -// -// Created by 한태빈 on 12/23/25. -// - import SwiftUI struct CalendarMonthView: View { @@ -12,13 +5,19 @@ struct CalendarMonthView: View { @Binding var selectedDate: Date? private let calendar = Calendar.current - private let weekdaySymbols = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - - // 이니셜라이저 추가 (public access를 위함) + 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) { @@ -26,24 +25,28 @@ struct CalendarMonthView: View { weekdaysRow grid } + .frame(width: gridWidth) // 헤더, 요일, 그리드를 같은 폭으로 고정해서 좌우 정렬 맞춤 } private var header: some View { - HStack(spacing: 12) { + HStack(spacing: 0) { Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { month = calendar.date(byAdding: .month, value: -1, to: month) ?? month } } label: { - Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.Codive.grayscale3) + 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(.system(size: 14, weight: .semibold)) + .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) @@ -53,64 +56,67 @@ struct CalendarMonthView: View { month = calendar.date(byAdding: .month, value: 1, to: month) ?? month } } label: { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.Codive.grayscale3) + Image("calendar_right") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.Codive.main1) + .frame(width: weekdayCellSize, height: weekdayCellSize) // 요일 셀 폭과 맞춤 } } - .padding(.bottom, 4) + .frame(width: gridWidth, height: weekdayCellSize) } private var weekdaysRow: some View { - HStack(spacing: 0) { - ForEach(weekdaySymbols, id: \.self) { w in + HStack(spacing: cellSpacing) { + ForEach(0.. some View { ZStack { if item.isPlaceholder { Color.clear - .frame(height: dayCellHeight) } else { let isSelected = isSameDay(item.date, selectedDate) - Color.clear + let weekday = calendar.component(.weekday, from: item.date) // 1=일 ... 7=토 + let isWeekend = (weekday == 1 || weekday == 7) Text("\(item.dayNumber)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(isSelected ? Color.white : Color.Codive.grayscale3) - .frame(width: dayNumberSize, height: dayNumberSize) + .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) - } else { - Circle().fill(Color.clear) + Circle() + .fill(Color.Codive.point1) + .frame(width: 28, height: 28) } } } } - .frame(maxWidth: .infinity) - .frame(height: dayCellHeight) + .frame(width: dayCellWidth, height: dayCellHeight) .contentShape(Rectangle()) .onTapGesture { if !item.isPlaceholder { @@ -156,7 +162,6 @@ struct CalendarMonthView: View { } } -// MARK: - Calendar Models struct CalendarDayItem: Hashable { let date: Date let dayNumber: Int 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/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 0000000000000000000000000000000000000000..36452a8a26bb434cf3944dddc0af47ba95c1772b GIT binary patch literal 278 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`j)FbFd;%$g$s6l5$8 za(7}_cTVOdki(Mh=E(mGhZIDPee7SxY{7JwLD<*E zh)LR_EAIhMUxIi+L)FILC)8(!^d@~~&|mgny8lV}Lq;FH)}}j$BATSNUTu3l`}P_) zH|8e~-_{;@9xL>N(N(BX$b)5>!{P)!5!SnxBqr^a(cqf&PeLxIt!dTu=2fdJZ=A8^ z>eAa5E%{czPWoFmZ{pjP;qeD$B3PCk@O;25_fIdQo~iGk>jP^Uk=hsMc>gWZ=4p<< UexAE-9?;ngp00i_>zopr0I$4bB>(^b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..10bbf6a6e782080184ed6fac05b4dc01b7a9dbb4 GIT binary patch literal 4549 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 Date: Tue, 13 Jan 2026 05:43:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#42]=20=ED=8C=94=EB=A1=9C=EC=9B=8C=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9A=B0=20View=20=EC=A0=9C=EC=9E=91=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 2 + .../Components/UnderlineField.swift | 37 ++-- .../View/ProfileSettingView.swift | 59 +++---- .../Presentation/View/ProfileView.swift | 3 - .../ViewModel/ProfileSettingViewModel.swift | 165 +++++++++--------- .../ViewModel/ProfileViewModel.swift | 4 +- .../Presentation/View/OtherProfileView.swift | 6 +- .../ViewModel/OtherProfileViewModel.swift | 7 +- .../Components/FollowListMode.swift | 18 ++ .../Presentation/View/FollowListView.swift | 48 +++++ .../Viewmodel/FollowListViewModel.swift | 64 +++++++ .../settingProfile.imageset/Contents.json | 2 +- .../\355\224\204\353\241\234\355\225\204.pdf" | Bin 11024 -> 0 bytes .../\355\224\204\353\241\234\355\225\204.png" | Bin 0 -> 1895 bytes Codive/Router/AppDestination.swift | 5 +- .../DesignSystem/Buttons/CustomButton.swift | 2 +- 16 files changed, 272 insertions(+), 150 deletions(-) create mode 100644 Codive/Features/Profile/Shared/Presentation/Components/FollowListMode.swift create mode 100644 Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift create mode 100644 Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift delete mode 100644 "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.pdf" create mode 100644 "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index bbe3e23a..d5d7d413 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -140,6 +140,8 @@ struct MainTabView: View { 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 index ceb6d26f..ea391deb 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/Components/UnderlineField.swift @@ -67,16 +67,30 @@ struct UnderlineField: View { titleRow HStack(spacing: 10) { - TextField("", text: $text) - .font(.codive_body2_medium) - .foregroundStyle(Color.Codive.grayscale1) - .keyboardType(keyboardType) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .focused(focus, equals: focusEquals) + 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) @@ -104,19 +118,20 @@ struct UnderlineField: View { private var helperRow: some View { let isError = !(helperErrorText ?? "").isEmpty - + let message: String? = { + // 에러가 있으면 에러 메시지 표시 if isError { return helperErrorText } - if text.isEmpty { return helperEmptyText } + if text.isEmpty { return nil } // emptyText는 placeholder로만 표시 return helperFilledText }() return Group { if let message, !message.isEmpty { Text(message) - .font(.codive_body3_medium) + .font(.codive_body2_medium) .foregroundStyle(isError ? Color.Codive.point1 : Color.Codive.grayscale4) - .padding(.top, 2) + .padding(.top, isError ? 5 : 2) } else { Color.clear.frame(height: 0) } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 8da9c1a1..37ebf590 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -8,20 +8,17 @@ import SwiftUI import Combine -// MARK: - View struct ProfileSettingView: View { @ObservedObject private var navigationRouter: NavigationRouter @StateObject private var viewModel: ProfileSettingViewModel init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel()) + self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel(navigationRouter: navigationRouter)) } - // MARK: - Focus enum Field: Hashable { case nickname - case userId case intro } @@ -38,22 +35,24 @@ struct ProfileSettingView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { profileImageSection - .padding(.top, 26) + .padding(.top, 32) formSection - .padding(.top, 22) + .padding(.top, 56) completeButton - .padding(.top, 36) - .padding(.bottom, 24) + .padding(.top, 120) } } } .background(Color.white) .navigationBarHidden(true) + .onChange(of: focus) { _ in + // 포커스 변경 시 canComplete 업데이트 + viewModel.updateCanCompleteOnFocusChange() + } } - // MARK: - Profile Image private var profileImageSection: some View { ZStack(alignment: .bottomTrailing) { Group { @@ -68,7 +67,6 @@ struct ProfileSettingView: View { Image("settingProfile") .resizable() .scaledToFit() - .padding(18) } } } @@ -82,7 +80,7 @@ struct ProfileSettingView: View { .fill(Color.Codive.grayscale1) .frame(width: 28, height: 28) .overlay { - Image(systemName: "plus") + Image("plus") .frame(width: 28, height: 28) } } @@ -92,7 +90,6 @@ struct ProfileSettingView: View { .frame(maxWidth: .infinity) } - // MARK: - Form private var formSection: some View { VStack(alignment: .leading, spacing: 0) { @@ -103,24 +100,9 @@ struct ProfileSettingView: View { focus: $focus, focusEquals: .nickname, keyboardType: .default - ) - .setHelper( - emptyText: "\(viewModel.nicknameMaxCount)글자 내로 닉네임을 입력해주세요", - filledText: viewModel.nicknameFilledHelper, - errorText: viewModel.nicknameErrorText - ) - .padding(.top, 18) - - UnderlineField( - title: "아이디", - requiredTag: "*", - text: $viewModel.userId, - focus: $focus, - focusEquals: .userId, - keyboardType: .asciiCapable ) { Button { - viewModel.runIDDuplicateCheck() + viewModel.runNicknameDuplicateCheck() } label: { Text("중복 확인") .font(.codive_body3_medium) @@ -133,15 +115,15 @@ struct ProfileSettingView: View { } } .buttonStyle(.plain) - .disabled(!viewModel.canTryIDCheck) - .opacity(viewModel.canTryIDCheck ? 1 : 0.4) + .disabled(!viewModel.canTryNicknameCheck) + .opacity(viewModel.canTryNicknameCheck ? 1 : 0.4) } .setHelper( - emptyText: "대문자, 특수문자 입력 불가, \(viewModel.userIdMaxCount)자 이내", - filledText: viewModel.userIdFilledHelper, - errorText: viewModel.userIdErrorText + emptyText: "한글, 소문자, 숫자 조합, 20자 이내", + filledText: viewModel.nicknameFilledHelper, + errorText: viewModel.nicknameErrorText ) - .padding(.top, 26) + .padding(.top, 18) UnderlineField( title: "한줄소개", @@ -152,7 +134,7 @@ struct ProfileSettingView: View { keyboardType: .default ) .setHelper( - emptyText: "\(viewModel.introMaxCount)자 이내로 나를 소개해보세요", + emptyText: "20자 이내로 나를 소개 해보세요.", filledText: nil, errorText: viewModel.introErrorText ) @@ -164,7 +146,6 @@ struct ProfileSettingView: View { .padding(.horizontal, 20) } - // MARK: - Privacy private var privacySection: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { @@ -194,17 +175,16 @@ struct ProfileSettingView: View { .padding(.vertical, 8) .background { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(isOn ? Color.Codive.point1.opacity(0.12) : Color.Codive.grayscale7) + .fill(isOn ? Color.Codive.point4 : Color.Codive.grayscale7) } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(isOn ? Color.Codive.point1 : Color.Codive.grayscale6, lineWidth: 1) + .stroke(isOn ? Color.Codive.point2 : Color.Codive.grayscale6, lineWidth: 1) } } .buttonStyle(.plain) } - // MARK: - Complete private var completeButton: some View { CustomButton( text: "설정 완료", @@ -213,6 +193,7 @@ struct ProfileSettingView: View { ) { viewModel.onCompleteTapped() } + .padding(.horizontal, 20) } } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 493e93e6..81e11399 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -44,9 +44,6 @@ struct ProfileView: View { // MARK: - Top Bar private var topBar: some View { HStack(spacing: 12) { - Text(viewModel.username) - .font(.codive_title1) - .foregroundStyle(Color.Codive.grayscale1) Spacer(minLength: 0) diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift index 090a3c6c..8df50137 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -8,145 +8,146 @@ import SwiftUI import Combine -class ProfileSettingViewModel: ObservableObject { +@MainActor +final class ProfileSettingViewModel: ObservableObject { // MARK: - Constants - let nicknameMaxCount: Int = 6 - let userIdMaxCount: Int = 20 + 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)) } - } - } - - @Published var userId: String = "" { - didSet { - if userId.count > userIdMaxCount { - userId = String(userId.prefix(userIdMaxCount)) - } - // Reset check status if user changes ID - if idCheckStatus == .available || idCheckStatus == .duplicated { - idCheckStatus = .none + if nicknameCheckStatus == .available || nicknameCheckStatus == .duplicated { + nicknameCheckStatus = .none } + updateCanComplete() } } - + @Published var intro: String = "" { didSet { - if intro.count > introMaxCount { - intro = String(intro.prefix(introMaxCount)) - } + updateCanComplete() } } - + @Published var isPublic: Bool = true - @Published var idCheckStatus: IDCheckStatus = .none + @Published var nicknameCheckStatus: NicknameCheckStatus = .none { + didSet { + updateCanComplete() + } + } @Published var pickedProfileImage: Image? = nil - - // MARK: - Types - enum IDCheckStatus: Equatable { + @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 } - - // MARK: - Validation Helpers + 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 } - return "사용 가능한 닉네임 입니다." - } - var canTryIDCheck: Bool { - if userId.isEmpty { return false } - if userId.count > userIdMaxCount { return false } - if userIdErrorText != nil { return false } - if idCheckStatus == .checking { return false } - return true - } - - var userIdErrorText: String? { - if userId.isEmpty { return nil } - if userId.count > userIdMaxCount { return "\(userIdMaxCount)자 이내로 입력해주세요" } - if containsUppercase(userId) { return "대문자는 사용할 수 없어요" } - if containsSpecialCharacters(userId) { return "특수문자는 사용할 수 없어요" } - return nil - } - - var userIdFilledHelper: String? { - if userId.isEmpty { return nil } - if userIdErrorText != nil { return nil } - - switch idCheckStatus { + switch nicknameCheckStatus { case .none: return nil case .checking: return "확인 중이에요" case .available: - return "사용 가능한 아이디 입니다." + return "사용 가능한 닉네임 입니다." case .duplicated: - return nil + 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 "\(introMaxCount)자 이내로 입력해주세요" } + if intro.count > introMaxCount { return "20자 이내로 입력해주세요." } return nil } - var canComplete: Bool { - if nickname.isEmpty { return false } - if nicknameErrorText != nil { return false } - - if userId.isEmpty { return false } - if userIdErrorText != nil { return false } - if idCheckStatus != .available { return false } - - if introErrorText != nil { return false } - return true + // MARK: - Public Methods + func updateCanCompleteOnFocusChange() { + // 포커스 변경 시에도 업데이트 (View에서 호출) + updateCanComplete() } - // MARK: - Actions - func runIDDuplicateCheck() { - idCheckStatus = .checking + // 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 - // Mock API call DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - if self.userId.lowercased() == "trendbox" || self.userId.lowercased() == "ckj11" { - self.idCheckStatus = .duplicated + let lowered = self.nickname.lowercased() + if lowered == "trendbox" || lowered == "ckj11" { + self.nicknameCheckStatus = .duplicated } else { - self.idCheckStatus = .available + self.nicknameCheckStatus = .available } + // 명시적으로 업데이트 호출 (didSet이 호출되지만 확실하게) + self.updateCanComplete() } } - + func onProfileImageTapped() { print("Profile image tapped") } - - func onCompleteTapped() { - print("Complete tapped") - } - - // MARK: - Private Helpers - private func containsUppercase(_ s: String) -> Bool { - s.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil - } - private func containsSpecialCharacters(_ s: String) -> Bool { - let allowed = CharacterSet.alphanumerics - return s.rangeOfCharacter(from: allowed.inverted) != nil + 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 index 27b1d01b..7b9e8084 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -38,11 +38,11 @@ class ProfileViewModel: ObservableObject { } func onFollowerTapped() { - print("Follower tapped") + navigationRouter.navigate(to: .followList(mode: .followers)) } func onFollowingTapped() { - print("Following tapped") + navigationRouter.navigate(to: .followList(mode: .followings)) } func onMoreFavoriteCodiTapped() { diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index 47188044..3ee795d7 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -66,11 +66,7 @@ struct OtherProfileView: View { .scaledToFit() .frame(width: 24, height: 24) } - - Text(viewModel.username) - .font(.codive_title1) - .foregroundStyle(Color.Codive.grayscale1) - + Spacer(minLength: 0) Button { diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift index 54ad0d10..8b23c07f 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -10,7 +10,6 @@ import SwiftUI @MainActor class OtherProfileViewModel: ObservableObject { // MARK: - Mock Data - @Published var username: String = "ham_dog" @Published var displayName: String = "햄스터강아지" @Published var introText: String = "햄스터가 되고 싶은 강아지입니다" @Published var followerCount: Int = 22 @@ -32,7 +31,7 @@ class OtherProfileViewModel: ObservableObject { // MARK: - Actions func onBackTapped() { - print("Back tapped") + navigationRouter.navigateBack() } func showBlockMenu() { @@ -49,11 +48,11 @@ class OtherProfileViewModel: ObservableObject { } func onFollowerTapped() { - print("Follower tapped") + navigationRouter.navigate(to: .followList(mode: .followers)) } func onFollowingTapped() { - print("Following tapped") + navigationRouter.navigate(to: .followList(mode: .followings)) } func onFollowButtonTapped() { 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/settingProfile.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json index add56e78..1216e975 100644 --- a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json +++ b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "프로필.pdf", + "filename" : "프로필.png", "idiom" : "universal" } ], diff --git "a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.pdf" "b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.pdf" deleted file mode 100644 index 0e77b09f7793d65ea48d12ec3886f3a3e038d1dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11024 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 Q29X#L4d1+3`=HK$0L{OQ8UO$Q 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 0000000000000000000000000000000000000000..61892789298e4bfc24ed5cadd6b6b7478511855c GIT binary patch literal 1895 zcmV-t2blPYP)f=>`~0y+{KHe@0;Y)~3D zY>;R+Y>*)DYhK}IW_r4(r>gq(P(P{YnPGsYzIpG}tG_)`&gJFhfG;tZsNe6uy}P@M z@R-Y>P>N(yQatJPdc)h>+f#lxL|Ix|%1ALiQU(hU@nxMW|07p^O!t*1m)j8j3buCCyk zHc7E-nvp!luqZp^2q%KTJ@F^%54z7E7LkO7`Ru}4`eYec1&i?yEv^_K#{bA4ybZF9 zH49mkSTQRs%=XKdFTao_tOz15>QB;>PxuD0<&zB;Hn$)m#vb|4u(e@Dl(__9F;+y3 zMQKINOD3W;?t*D7ND!lxxkPCsND!lv#-hwDh{OyuYl9fmfhgb28dGM8IrUDJ&c=Cng$#&KI zDg_Ba+#0ole5K10%Lro3&!CQ%nJS3%X2uk-Ma*?Nwez*qWraQ}XTWv6E1MryY9Z1x zh$vv2!L(M_L<Th45GzSXUw<~~%`(tLnz^{Rp!4(d{Ca(TO*c0;^!)jA;=!W4 zynp|mUc7igS`jPxJo@^Wg=n`9RQ}=NVJ<`!Z{ED24yvP$2;BKG$7 zYKZ~P&dz9me;>UJq?EB1*CPwjY#k5*mD>ckvbD9f+?6R~ti?5IAu%b$xT&Tfz8oAJ ziK zupGlYDO$(|DWq-yuLHzT8sKB|;6ad4)6s(M3bZM(Ex7J~GLLgXWSB7`h1eEk-dN;_ z$PD!w3yCSvHdsk^FNjum1#D;8$VoJUMXvc(o~xacYde`!m>lNFieu`_jEIPoV!Hxg zy?RATiSiW$q=^WStNMgH&iu;fhieVH&2zg-pLAt*^Y5g~d3k`m@Z~Hzqff zA*{Mw+1Tt@*6A;Yw zA{1B)x>(v4hPC(?F(jpyK&2cV9pzThCiwd(3MOZ#tqFopCdAM*aSitbD}^XcdzR;9 z*u$_PyR1v{2;38jX4>eoXP33qmzmcod7=wqfEMmEg%)|H&dU5v9)L^ef*1zetFH;# z2m{Pjt14%;)ir)+zsiPM0@N`9w+6UwY3|6bV-$v6qDBy50bm#N2EluneB7S|R9 ztRjbsrDfl*oZXOR88*UV35)TVv1i(CTUq>aOl#l(lEbNvjL-}B@N+NiwCS6y*l@z&q7Q| z>FF}OvvfGIl~uU75wx|RV)BUp*_If<`qC*m(G5rJEqYwZr03S^khhbyY!X*+09H(k zNrH|-TUJrT>Ec4MmPI-`$%$1IalYD9HVEDH@$ literal 0 HcmV?d00001 diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index d9fdeb12..57a418a5 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -33,6 +33,7 @@ enum AppDestination: Hashable, Identifiable { case feedDetail(feedId: Int) case comment(feedId: Int) case favoriteCodiList(showHeart: Bool) + case followList(mode: FollowListMode) var id: Self { self } @@ -60,7 +61,7 @@ enum AppDestination: Hashable, Identifiable { return true // Profile Flow - case .favoriteCodiList, .settings: + case .favoriteCodiList, .settings, .followList: return true // 다른 플로우 전체 화면은 여기에 추가 @@ -94,7 +95,7 @@ enum AppDestination: Hashable, Identifiable { return false // Profile Flow - 자체 네비게이션 바 있음 - case .favoriteCodiList, .settings: + case .favoriteCodiList, .settings, .followList: return false default: 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 From f0c1f6f33d9b35f08bf802b5a12333e8c28a72d6 Mon Sep 17 00:00:00 2001 From: taebin2 <109895271+taebin2@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:19:50 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EC=B5=9C=EC=95=A0=EC=BD=94=EB=94=94=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84,=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84,=20=ED=94=84=EB=A1=9C=ED=95=84=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95,=20View,=20Viewmodel=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 2 +- .../View/ProfileSettingView.swift | 4 - .../Presentation/View/ProfileView.swift | 23 ++-- .../ViewModel/ProfileSettingViewModel.swift | 87 ++++++------- .../Presentation/View/OtherProfileView.swift | 16 ++- .../Components/CalendarMonthView.swift | 8 +- .../Presentation/View/FavoriteCodiView.swift | 61 +++++++++ .../Shared/DesignSystem/Views/CodiCard.swift | 123 ++++++++++++++++++ 8 files changed, 255 insertions(+), 69 deletions(-) create mode 100644 Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift create mode 100644 Codive/Shared/DesignSystem/Views/CodiCard.swift diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index d5d7d413..75568b9d 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -137,7 +137,7 @@ struct MainTabView: View { case .codiBoard: homeDIContainer.makeCodiBoardView() case .favoriteCodiList(let showHeart): - FavoriteCodiListView(showHeart: showHeart, navigationRouter: navigationRouter) + FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) case .settings: ProfileSettingView(navigationRouter: navigationRouter) case .followList(let mode): diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 37ebf590..040273e4 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -47,10 +47,6 @@ struct ProfileSettingView: View { } .background(Color.white) .navigationBarHidden(true) - .onChange(of: focus) { _ in - // 포커스 변경 시 canComplete 업데이트 - viewModel.updateCanCompleteOnFocusChange() - } } private var profileImageSection: some View { diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 81e11399..f45a7d4e 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -35,7 +35,7 @@ struct ProfileView: View { calendarSection .padding(.top, 40) - Spacer(minLength: 77) + Spacer(minLength: 40) } } .background(Color.white) @@ -147,16 +147,17 @@ struct ProfileView: View { 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() + CodiCard( + imageURL: URL(string: "https://via.placeholder.com/160/F08080/FFFFFF?text=Date+Look"), + title: nil, + icon: .heart(isSelected: true, onTap: nil), // 항상 하트 on, 타이틀 없음 + cardWidth: 160, + imageSize: 160, + cornerRadius: 16, + iconPadding: 14, + iconSize: 20, + onCardTap: nil + ) } } .padding(.top, 12) diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift index 8df50137..e0093c67 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -21,35 +21,63 @@ final class ProfileSettingViewModel: ObservableObject { @Published var nickname: String = "" { didSet { if nickname.count > nicknameMaxCount { - nickname = String(nickname.prefix(nicknameMaxCount)) + let trimmed = String(nickname.prefix(nicknameMaxCount)) + if trimmed != nickname { + nickname = trimmed + return + } } - if nicknameCheckStatus == .available || nicknameCheckStatus == .duplicated { - nicknameCheckStatus = .none + + if nickname != oldValue { + if nicknameCheckStatus == .available || nicknameCheckStatus == .duplicated { + nicknameCheckStatus = .none + } } - updateCanComplete() } } @Published var intro: String = "" { didSet { - updateCanComplete() + // 20자 제한 처리 + if intro.count > introMaxCount { + let trimmed = String(intro.prefix(introMaxCount)) + // 무한 루프 방지: 값이 실제로 변경된 경우에만 업데이트 + if trimmed != intro { + intro = trimmed + return // didSet이 다시 호출되므로 여기서 종료 + } + } + // 닉네임 중복확인이 완료된 경우에는 canComplete를 절대 변경하지 않음 + // 한줄소개는 선택사항이므로 닉네임 중복확인 완료 후에는 영향 없음 } } @Published var isPublic: Bool = true - @Published var nicknameCheckStatus: NicknameCheckStatus = .none { - didSet { - updateCanComplete() + @Published var nicknameCheckStatus: NicknameCheckStatus = .none + @Published var pickedProfileImage: Image? = nil + + // 닉네임 중복확인이 완료된 경우에는 항상 true를 반환 + var canComplete: Bool { + // 닉네임은 필수이므로 비어있으면 비활성화 + if nickname.isEmpty { + return false } + // 닉네임 길이 에러가 있으면 비활성화 + if nickname.count > nicknameMaxCount { + return false + } + // 닉네임 중복확인이 완료되지 않았으면 비활성화 + if nicknameCheckStatus != .available { + return false + } + // 닉네임 중복확인이 완료된 경우에는 항상 활성화 + // (한줄소개는 선택사항이고, 20자 제한은 입력 단계에서 처리됨) + return true } - @Published var pickedProfileImage: Image? = nil - @Published var canComplete: Bool = false // MARK: - Initializer init(navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter - // 초기 상태 업데이트 - updateCanComplete() } enum NicknameCheckStatus: Equatable { @@ -90,41 +118,12 @@ final class ProfileSettingViewModel: ObservableObject { } var introErrorText: String? { - if intro.isEmpty { return nil } - if intro.count > introMaxCount { return "20자 이내로 입력해주세요." } + // 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 - } + // canComplete는 computed property로 변경되어 더 이상 필요 없음 func runNicknameDuplicateCheck() { nicknameCheckStatus = .checking @@ -136,8 +135,6 @@ final class ProfileSettingViewModel: ObservableObject { } else { self.nicknameCheckStatus = .available } - // 명시적으로 업데이트 호출 (didSet이 호출되지만 확실하게) - self.updateCanComplete() } } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index 3ee795d7..039a180e 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -34,6 +34,9 @@ struct OtherProfileView: View { .padding(.top, 24) calendarSection + .padding(.top, 40) + + Spacer(minLength: 40) } } @@ -177,10 +180,15 @@ struct OtherProfileView: View { 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() + CodiCard( + imageURL: URL(string: "https://via.placeholder.com/155"), + title: nil, + icon: .none, + cardWidth: 155, + imageSize: 155, + cornerRadius: 16, + onCardTap: nil + ) } } .padding(.top, 12) diff --git a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift index c528e435..4682980f 100644 --- a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -12,12 +12,12 @@ struct CalendarMonthView: View { self._selectedDate = selectedDate } - private let cellSpacing: CGFloat = 6 + 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) } + private let dayCellWidth: CGFloat = 40 // 사진 크기: 40 * 57 + private let dayCellHeight: CGFloat = 57 // 사진 크기: 40 * 57 + private var gridWidth: CGFloat { (dayCellWidth * 7) + (cellSpacing * 6) } var body: some View { VStack(spacing: 10) { diff --git a/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift new file mode 100644 index 00000000..4e9e9f15 --- /dev/null +++ b/Codive/Features/Profile/Shared/Presentation/View/FavoriteCodiView.swift @@ -0,0 +1,61 @@ +// +// FavoriteCodiView.swift +// Codive +// +// Created by 한태빈 on 1/15/26. +// + +import SwiftUI + +struct FavoriteCodiView: View { + @ObservedObject private var navigationRouter: NavigationRouter + + let showHeart: Bool + + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 15), + GridItem(.flexible(), spacing: 15) + ] + + init(showHeart: Bool, navigationRouter: NavigationRouter) { + self.showHeart = showHeart + self.navigationRouter = navigationRouter + } + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar( + title: "최애 코디", + onBack: { navigationRouter.navigateBack() }, + rightButton: .none + ) + + ScrollView(showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 32) { + ForEach(0..<8, id: \.self) { idx in + CodiCard( + imageURL: URL(string: "https://via.placeholder.com/160"), + title: idx.isMultiple(of: 2) ? "영화관 데이트" : "미술관 데이트", + icon: showHeart ? .heart(isSelected: true, onTap: nil) : .none, + cardWidth: 160, + imageSize: 160, + cornerRadius: 16, + iconPadding: 14, + iconSize: 20, + onCardTap: nil + ) + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 24) + } + } + .background(Color.white) + .navigationBarHidden(true) + } +} + +#Preview { + FavoriteCodiView(showHeart: true, navigationRouter: NavigationRouter()) +} diff --git a/Codive/Shared/DesignSystem/Views/CodiCard.swift b/Codive/Shared/DesignSystem/Views/CodiCard.swift new file mode 100644 index 00000000..535e690b --- /dev/null +++ b/Codive/Shared/DesignSystem/Views/CodiCard.swift @@ -0,0 +1,123 @@ +// +// LookBookCard.swift +// Codive +// +// Created by 한태빈 on 1/15/26. +// + +import SwiftUI + +struct CodiCard: View { + + enum Icon { + case heart(isSelected: Bool, onTap: (() -> Void)?) + case checkmark(isSelected: Bool, onTap: (() -> Void)?) + case none + } + + let imageURL: URL? + let title: String? + let icon: Icon + + var cardWidth: CGFloat = 160 + var imageSize: CGFloat = 160 + var cornerRadius: CGFloat = 16 + var titleFont: Font = .system(size: 14, weight: .medium) + var titleLineLimit: Int = 1 + var iconPadding: CGFloat = 12 + var iconSize: CGFloat = 20 + + var onCardTap: (() -> Void)? = nil + + init( + imageURL: URL?, + title: String? = nil, + icon: Icon = .none, + cardWidth: CGFloat = 160, + imageSize: CGFloat = 160, + cornerRadius: CGFloat = 16, + titleFont: Font = .system(size: 14, weight: .medium), + titleLineLimit: Int = 1, + iconPadding: CGFloat = 12, + iconSize: CGFloat = 20, + onCardTap: (() -> Void)? = nil + ) { + self.imageURL = imageURL + self.title = title + self.icon = icon + self.cardWidth = cardWidth + self.imageSize = imageSize + self.cornerRadius = cornerRadius + self.titleFont = titleFont + self.titleLineLimit = titleLineLimit + self.iconPadding = iconPadding + self.iconSize = iconSize + self.onCardTap = onCardTap + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topTrailing) { + imageView + .frame(width: imageSize, height: imageSize) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.Codive.grayscale7, lineWidth: 1) + } + .codiveCardShadow() + + iconOverlay + } + .frame(width: imageSize, height: imageSize) + + if let title { + Text(title) + .font(titleFont) + .lineLimit(titleLineLimit) + } + } + .frame(width: cardWidth, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { onCardTap?() } + } + + private var imageView: some View { + AsyncImage(url: imageURL) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + case .failure: + Rectangle().fill(Color(.systemGray3)) + default: + Rectangle().fill(Color(.systemGray5)) + } + } + .clipped() + } + + @ViewBuilder + private var iconOverlay: some View { + switch icon { + case .none: + EmptyView() + case .heart(let isSelected, let onTap): + iconButton(imageName: isSelected ? "heart_on" : "heart_off", onTap: onTap) + case .checkmark(let isSelected, let onTap): + iconButton(imageName: isSelected ? "check_on" : "check_off", onTap: onTap) + } + } + + private func iconButton(imageName: String, onTap: (() -> Void)?) -> some View { + Button { onTap?() } label: { + Image(imageName) + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + .padding(iconPadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +}