Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Codive/Features/Main/View/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct MainTabView: View {
case .feed:
FeedView(viewModel: feedDIContainer.makeFeedViewModel())
case .profile:
ProfileView()
ProfileView(navigationRouter: navigationRouter)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Expand Down Expand Up @@ -136,6 +136,12 @@ struct MainTabView: View {
homeDIContainer.makeEditCategoryView()
case .codiBoard:
homeDIContainer.makeCodiBoardView()
case .favoriteCodiList(let showHeart):
FavoriteCodiListView(showHeart: showHeart, navigationRouter: navigationRouter)
case .settings:
ProfileSettingView(navigationRouter: navigationRouter)
case .followList(let mode):
FollowListView(mode: mode, navigationRouter: navigationRouter)

default:
EmptyView()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//
// UnderlineField.swift
// Codive
//
// Created by 한태빈 on 12/23/25.
//

import SwiftUI

struct UnderlineField<Trailing: View>: View {

let title: String
let requiredTag: String?
@Binding var text: String

let focus: FocusState<ProfileSettingView.Field?>.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<String>,
focus: FocusState<ProfileSettingView.Field?>.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<String>,
focus: FocusState<ProfileSettingView.Field?>.Binding,
focusEquals: ProfileSettingView.Field,
keyboardType: UIKeyboardType
) where Trailing == EmptyView {
self.init(
title: title,
requiredTag: requiredTag,
text: text,
focus: focus,
focusEquals: focusEquals,
keyboardType: keyboardType
) { EmptyView() }
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
titleRow

HStack(spacing: 10) {
ZStack(alignment: .leading) {
// Placeholder 표시: 텍스트가 비어있고 포커스가 없을 때만 표시
if text.isEmpty,
focus.wrappedValue != focusEquals,
let helperEmptyText,
!helperEmptyText.isEmpty,
helperErrorText == nil {
Text(helperEmptyText)
.font(.codive_body3_medium)
.foregroundStyle(Color.Codive.grayscale4)
}

TextField("", text: $text)
.font(.codive_body2_medium)
.foregroundStyle(Color.Codive.grayscale1)
.keyboardType(keyboardType)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.focused(focus, equals: focusEquals)
}

trailing
}
.padding(.bottom, helperErrorText != nil && !(helperErrorText ?? "").isEmpty ? 5 : 0)

Rectangle()
.fill(Color.Codive.grayscale5)
.frame(height: 1)

helperRow
}
}

private var titleRow: some View {
HStack(spacing: 6) {
Text(title)
.font(.codive_body1_medium)
.foregroundStyle(Color.Codive.grayscale1)

if let requiredTag {
Text(requiredTag)
.font(.codive_body3_medium)
.foregroundStyle(Color.Codive.point1)
}

Spacer(minLength: 0)
}
}

private var helperRow: some View {
let isError = !(helperErrorText ?? "").isEmpty

let message: String? = {
// 에러가 있으면 에러 메시지 표시
if isError { return helperErrorText }
if text.isEmpty { return nil } // emptyText는 placeholder로만 표시
return helperFilledText
}()

return Group {
if let message, !message.isEmpty {
Text(message)
.font(.codive_body2_medium)
.foregroundStyle(isError ? Color.Codive.point1 : Color.Codive.grayscale4)
.padding(.top, isError ? 5 : 2)
} else {
Color.clear.frame(height: 0)
}
}
}
}

// MARK: - UnderlineField helper setter
extension UnderlineField {
func setHelper(emptyText: String?, filledText: String?, errorText: String?) -> UnderlineField {
var copy = self
copy.helperEmptyText = emptyText
copy.helperFilledText = filledText
copy.helperErrorText = errorText
return copy
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
//
// ProfileSettingView.swift
// Codive
//
// Created by 한태빈 on 12/23/25.
//

import SwiftUI
import Combine

struct ProfileSettingView: View {
@ObservedObject private var navigationRouter: NavigationRouter
@StateObject private var viewModel: ProfileSettingViewModel

init(navigationRouter: NavigationRouter) {
self.navigationRouter = navigationRouter
self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel(navigationRouter: navigationRouter))
}

enum Field: Hashable {
case nickname
case intro
}

@FocusState private var focus: Field?

var body: some View {
VStack(spacing: 0) {
CustomNavigationBar(
title: "프로필 설정",
onBack: { navigationRouter.navigateBack() },
rightButton: .none
)

ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
profileImageSection
.padding(.top, 32)

formSection
.padding(.top, 56)

completeButton
.padding(.top, 120)
}
}
}
.background(Color.white)
.navigationBarHidden(true)
.onChange(of: focus) { _ in
// 포커스 변경 시 canComplete 업데이트
viewModel.updateCanCompleteOnFocusChange()
}
}

private var profileImageSection: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if let pickedProfileImage = viewModel.pickedProfileImage {
pickedProfileImage
.resizable()
.scaledToFill()
} else {
Circle()
.fill(Color.Codive.grayscale6)
.overlay {
Image("settingProfile")
.resizable()
.scaledToFit()
}
}
}
.frame(width: 100, height: 100)
.clipShape(Circle())

Button {
viewModel.onProfileImageTapped()
} label: {
Circle()
.fill(Color.Codive.grayscale1)
.frame(width: 28, height: 28)
.overlay {
Image("plus")
.frame(width: 28, height: 28)
}
}
.buttonStyle(.plain)
.offset(x: 6, y: 6)
}
.frame(maxWidth: .infinity)
}

private var formSection: some View {
VStack(alignment: .leading, spacing: 0) {

UnderlineField(
title: "닉네임",
requiredTag: "*",
text: $viewModel.nickname,
focus: $focus,
focusEquals: .nickname,
keyboardType: .default
) {
Button {
viewModel.runNicknameDuplicateCheck()
} label: {
Text("중복 확인")
.font(.codive_body3_medium)
.foregroundStyle(Color.Codive.grayscale1)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.Codive.grayscale4, lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(!viewModel.canTryNicknameCheck)
.opacity(viewModel.canTryNicknameCheck ? 1 : 0.4)
}
.setHelper(
emptyText: "한글, 소문자, 숫자 조합, 20자 이내",
filledText: viewModel.nicknameFilledHelper,
errorText: viewModel.nicknameErrorText
)
.padding(.top, 18)

UnderlineField(
title: "한줄소개",
requiredTag: nil,
text: $viewModel.intro,
focus: $focus,
focusEquals: .intro,
keyboardType: .default
)
.setHelper(
emptyText: "20자 이내로 나를 소개 해보세요.",
filledText: nil,
errorText: viewModel.introErrorText
)
.padding(.top, 26)

privacySection
.padding(.top, 26)
}
.padding(.horizontal, 20)
}

private var privacySection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Text("계정 공개여부")
.font(.codive_body1_medium)
.foregroundStyle(Color.Codive.grayscale1)

Text("*")
.font(.codive_body3_medium)
.foregroundStyle(Color.Codive.point1)
}

HStack(spacing: 10) {
pillButton(title: "공개", isOn: viewModel.isPublic) { viewModel.isPublic = true }
pillButton(title: "비공개", isOn: !viewModel.isPublic) { viewModel.isPublic = false }
Spacer(minLength: 0)
}
}
}

private func pillButton(title: String, isOn: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.codive_body2_medium)
.foregroundStyle(isOn ? Color.Codive.point1 : Color.Codive.grayscale3)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isOn ? Color.Codive.point4 : Color.Codive.grayscale7)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isOn ? Color.Codive.point2 : Color.Codive.grayscale6, lineWidth: 1)
}
}
.buttonStyle(.plain)
}

private var completeButton: some View {
CustomButton(
text: "설정 완료",
widthType: .fixed,
isEnabled: viewModel.canComplete
) {
viewModel.onCompleteTapped()
}
.padding(.horizontal, 20)
}
}

#Preview {
ProfileSettingView(navigationRouter: NavigationRouter())
}
Loading