Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .claude/rules/git-worktree.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ git worktree add ../Dori-iOS-fix32 fix/32-textfield-validation
git worktree add ../Dori-iOS-develop develop
```

### 생성 직후 필수 체크

- 새 worktree를 만든 직후 `Projects/App/Resources/Common.xcconfig` 파일 존재 여부를 확인한다.
- 파일이 없으면 `~/Desktop/Dori-Workspace/Security_Common/Common.xcconfig`를 복사해서 동일 경로에 둔다.
- 이 파일이 없으면 `Tuist generate`, 앱 런치, 네트워크 설정이 모두 깨질 수 있다.

### 네이밍 규칙

| 브랜치 | 워크트리 폴더명 |
Expand Down
72 changes: 67 additions & 5 deletions Projects/App/Sources/DoriApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ import PlatformKeychain
@main
struct DoriApp: App {
let store: StoreOf<AppFeature>
#if DEBUG
private let debugLaunchRoute: DebugLaunchRoute?
#endif

init() {
#if DEBUG
self.debugLaunchRoute = DebugLaunchRoute(environment: ProcessInfo.processInfo.environment)
#endif

let tokenStore = KeychainAuthTokenStore(service: DoriKeychainKey.serviceID)

// Store 참조를 위한 Box pattern
Expand Down Expand Up @@ -79,11 +86,66 @@ struct DoriApp: App {

var body: some Scene {
WindowGroup {
AppView(store: store)
.preferredColorScheme(.light) // 다크모드 비활성화
.onOpenURL { url in
_ = KakaoSDKHandler.handleOpenURL(url)
}
rootView
.preferredColorScheme(.light)
}
}

@ViewBuilder
private var rootView: some View {
#if DEBUG
if let debugLaunchRoute {
debugLaunchRoute.makeView()
} else {
appView
}
#else
appView
#endif
}

private var appView: some View {
AppView(store: store)
.onOpenURL { url in
_ = KakaoSDKHandler.handleOpenURL(url)
}
}
}

#if DEBUG
private struct DebugLaunchRoute {
private let route: String
private let memo: String

init?(environment: [String: String]) {
guard let route = environment["DORI_DEBUG_ROUTE"] else { return nil }
self.route = route
self.memo = environment["DORI_DEBUG_MEMO"] ?? ""
}

@MainActor
@ViewBuilder
func makeView() -> some View {
switch route {
case "addDoriPage3":
NavigationStack {
AddDoriView(
store: Store(initialState: configuredState) {
AddDoriFeature()
}
)
}
default:
EmptyView()
}
}

@MainActor
private var configuredState: AddDoriFeature.State {
var state = AddDoriFeature.State()
state.currentPage = 2
state.memo = memo
return state
}
}
#endif
128 changes: 128 additions & 0 deletions Projects/Core/DoriDesignSystem/Sources/DoriExpandingTextView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// DoriExpandingTextView.swift
// Dori-iOS
//
// Created by 강동영 on 3/18/26.
//

import SwiftUI
import UIKit

public struct DoriExpandingTextView: View {
@Binding var text: String
@State private var dynamicHeight: CGFloat

private let placeholder: String
private let maxLength: Int
private let minHeight: CGFloat

public init(
_ placeholder: String,
text: Binding<String>,
maxLength: Int = 40,
minHeight: CGFloat = 46
) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.minHeight = minHeight
self._dynamicHeight = State(initialValue: minHeight)
}

public var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 10)
.fill(.doriWhite)

RoundedRectangle(cornerRadius: 10)
.stroke(.grey300, lineWidth: 1)

ExpandingTextViewRepresentable(
text: $text,
dynamicHeight: $dynamicHeight,
maxLength: maxLength,
minHeight: minHeight
)
.frame(height: dynamicHeight)

if text.isEmpty {
Text(placeholder)
.pretendard(.body(.r3))
.foregroundStyle(.grey400)
.padding(.horizontal, 16)
.padding(.vertical, 13)
.allowsHitTesting(false)
}
}
.frame(minHeight: dynamicHeight)
.accessibilityIdentifier("addDori.memoField")
}
}

private struct ExpandingTextViewRepresentable: UIViewRepresentable {
@Binding var text: String
@Binding var dynamicHeight: CGFloat

let maxLength: Int
let minHeight: CGFloat

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = .init(top: 12, left: 16, bottom: 12, right: 16)
textView.textContainer.lineFragmentPadding = 0
textView.font = UIFont(name: "Pretendard-Regular", size: 15) ?? .systemFont(ofSize: 15)
textView.textColor = UIColor(DoriColors.doriBlack.color)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.setContentHuggingPriority(.defaultLow, for: .horizontal)
textView.accessibilityIdentifier = "addDori.memoTextView"
return textView
}

func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text {
uiView.text = String(text.prefix(maxLength))
}

recalculateHeight(for: uiView)
}

private func recalculateHeight(for textView: UITextView) {
let targetSize = CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)
let measuredHeight = max(textView.sizeThatFits(targetSize).height, minHeight)

guard abs(dynamicHeight - measuredHeight) > 0.5 else { return }

DispatchQueue.main.async {
dynamicHeight = measuredHeight
}
}

final class Coordinator: NSObject, UITextViewDelegate {
private var parent: ExpandingTextViewRepresentable

init(_ parent: ExpandingTextViewRepresentable) {
self.parent = parent
}

func textViewDidChange(_ textView: UITextView) {
let currentText = textView.text ?? ""

if textView.markedTextRange == nil, currentText.count > parent.maxLength {
let truncatedText = String(currentText.prefix(parent.maxLength))
textView.text = truncatedText
parent.text = truncatedText
} else {
parent.text = currentText
}

parent.recalculateHeight(for: textView)
}
}
}
62 changes: 60 additions & 2 deletions Projects/Feature/AddDori/Sources/AddDoriView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DoriDesignSystem
public struct AddDoriView: View {
@Bindable var store: StoreOf<AddDoriFeature>
@Environment(\.dismiss) private var dismiss
@State private var isKeyboardVisible = false

public init(store: StoreOf<AddDoriFeature>) {
self.store = store
Expand All @@ -23,6 +24,7 @@ public struct AddDoriView: View {
pageIndicator

pageContent
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.animation(
.easeInOut(duration: 0.3),
value: store.currentPage
Expand All @@ -42,6 +44,15 @@ public struct AddDoriView: View {
}
)
)
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomCTA
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
isKeyboardVisible = false
}
.allowsHitTesting(!store.isDatePickerVisible)

if store.isDatePickerVisible {
Expand All @@ -63,6 +74,53 @@ public struct AddDoriView: View {
}
}
}

@ViewBuilder
private var bottomCTA: some View {
PrimaryButton(title: currentButtonTitle) {
currentButtonAction()
}
.isEnable(isCurrentButtonEnabled)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 20)
.background(.doriWhite)
}

private var currentButtonTitle: String {
switch store.currentPage {
case 0, 1:
return "다음"
case 2:
return "완료"
default:
return "다음"
}
}

private var isCurrentButtonEnabled: Bool {
switch store.currentPage {
case 0:
return store.isPage1Valid
case 1:
return store.isPage2Valid
case 2:
return store.isPage3Valid
default:
return false
}
}

private func currentButtonAction() {
switch store.currentPage {
case 0, 1:
store.send(.nextPageTapped)
case 2:
store.send(.submitTapped)
default:
break
}
}

private var pageIndicator: some View {
HStack {
Expand Down Expand Up @@ -90,13 +148,13 @@ public struct AddDoriView: View {
removal: .move(edge: .leading)
))
case 1:
Page2RelationEventView(store: store)
Page2RelationEventView(store: store, isScrollEnabled: isKeyboardVisible)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
case 2:
Page3AmountDateView(store: store)
Page3AmountDateView(store: store, isScrollEnabled: isKeyboardVisible)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
Expand Down
10 changes: 0 additions & 10 deletions Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,8 @@ struct Page1NameTypeView: View {
searchResultsList
}
}

Spacer()

// 다음 버튼
PrimaryButton(title: "다음") {
store.send(.nextPageTapped)
}
.isEnable(store.isPage1Valid)
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}

private var searchResultsList: some View {
Expand Down Expand Up @@ -192,4 +183,3 @@ struct Page1NameTypeView: View {
)
}
}

Loading
Loading