diff --git a/Projects/DSKit/Sources/Components/PokitListButton.swift b/Projects/DSKit/Sources/Components/PokitListButton.swift index f6becf72..bf1fb69e 100644 --- a/Projects/DSKit/Sources/Components/PokitListButton.swift +++ b/Projects/DSKit/Sources/Components/PokitListButton.swift @@ -37,9 +37,9 @@ public struct PokitListButton: View { VStack(alignment: .leading, spacing: 4) { HStack { switch type { - case let .default(icon), - let .bottomSheet(icon), - let .subText(icon, _): + case let .default(icon, iconColor), + let .bottomSheet(icon, iconColor), + let .subText(icon, iconColor, _): Text(title) .pokitFont(.b1(.m)) .foregroundStyle(.pokit(.text(.secondary))) @@ -49,7 +49,7 @@ public struct PokitListButton: View { Image(icon) .resizable() .frame(width: 24, height: 24) - .foregroundStyle(.pokit(.icon(.primary))) + .foregroundStyle(iconColor) case .toggle: Toggle(isOn: $isOn) { Text(title) @@ -61,7 +61,7 @@ public struct PokitListButton: View { } - if case let .subText(_, subeText) = type { + if case let .subText(_, _, subeText) = type { Text(subeText) .pokitFont(.detail1) .foregroundStyle(.pokit(.text(.tertiary))) @@ -89,9 +89,9 @@ public struct PokitListButton: View { extension PokitListButton { public enum ListButtonType { - case `default`(icon: PokitImage) - case bottomSheet(icon: PokitImage) - case subText(icon: PokitImage, subeText: String) + case `default`(icon: PokitImage, iconColor: Color) + case bottomSheet(icon: PokitImage, iconColor: Color) + case subText(icon: PokitImage, iconColor: Color, subeText: String) case toggle(subeText: String) } @@ -104,13 +104,19 @@ extension PokitListButton { PokitListButton( title: "공지사항", - type: .default(icon: .icon(.arrowRight)), + type: .default( + icon: .icon(.arrowRight), + iconColor: .pokit(.icon(.primary)) + ), action: { } ) PokitListButton( title: "공지사항", - type: .bottomSheet(icon: .icon(.edit)), + type: .bottomSheet( + icon: .icon(.edit), + iconColor: .pokit(.icon(.primary)) + ), action: { } ) @@ -118,6 +124,7 @@ extension PokitListButton { title: "공지사항", type: .subText( icon: .icon(.arrowRight), + iconColor: .pokit(.icon(.primary)), subeText: "포킷에 저장된 링크가 다른 사용자에게 추천됩니다." ), action: { } diff --git a/Projects/DSKit/Sources/Components/PokitPartTextArea.swift b/Projects/DSKit/Sources/Components/PokitPartTextArea.swift index 37f91e25..8f2ec0ac 100644 --- a/Projects/DSKit/Sources/Components/PokitPartTextArea.swift +++ b/Projects/DSKit/Sources/Components/PokitPartTextArea.swift @@ -10,24 +10,26 @@ import SwiftUI public struct PokitPartTextArea: View { @Binding private var text: String - @State private var state: PokitInputStyle.State + @Binding private var state: PokitInputStyle.State private var focusState: FocusState.Binding - + private let baseState: PokitInputStyle.State private let equals: Value private let placeholder: String private let onSubmit: (() -> Void)? public init( text: Binding, - state: PokitInputStyle.State = .default, + state: Binding, + baseState: PokitInputStyle.State = .default, placeholder: String = "내용을 입력해주세요.", focusState: FocusState.Binding, equals: Value, onSubmit: (() -> Void)? = nil ) { self._text = text - self._state = State(initialValue: state) + self._state = state + self.baseState = baseState self.focusState = focusState self.equals = equals self.placeholder = placeholder @@ -47,7 +49,11 @@ public struct PokitPartTextArea: View { .foregroundStyle(.pokit(.text(.primary))) .scrollContentBackground(.hidden) .focused(focusState, equals: equals) - .disabled(state == .disable || state == .readOnly) + .disabled( + state == .disable || + state == .readOnly || + state == .memo(isReadOnly: true) + ) .onSubmit { onSubmit?() } @@ -77,7 +83,7 @@ public struct PokitPartTextArea: View { case .error(message: let message): state = .error(message: message) default: - state = .default + state = baseState } } } diff --git a/Projects/DSKit/Sources/Components/PokitTextArea.swift b/Projects/DSKit/Sources/Components/PokitTextArea.swift index ef8eb530..0a02a2f1 100644 --- a/Projects/DSKit/Sources/Components/PokitTextArea.swift +++ b/Projects/DSKit/Sources/Components/PokitTextArea.swift @@ -15,9 +15,10 @@ public struct PokitTextArea: View { private var focusState: FocusState.Binding + private let baseState: PokitInputStyle.State private let errorMessage: String? private let equals: Value - private let label: String + private let label: String? private let placeholder: String private let info: String? private let maxLetter: Int @@ -25,8 +26,9 @@ public struct PokitTextArea: View { public init( text: Binding, - label: String, + label: String? = nil, state: Binding, + baseState: PokitInputStyle.State = .default, errorMessage: String? = nil, placeholder: String = "내용을 입력해주세요.", info: String? = nil, @@ -38,6 +40,7 @@ public struct PokitTextArea: View { self._text = text self.label = label self._state = state + self.baseState = baseState self.errorMessage = errorMessage self.focusState = focusState self.equals = equals @@ -49,18 +52,20 @@ public struct PokitTextArea: View { public var body: some View { VStack(alignment: .leading, spacing: 0) { - PokitLabel(text: label, size: .large) - .padding(.bottom, 8) + if let label { + PokitLabel(text: label, size: .large) + .padding(.bottom, 8) + } PokitPartTextArea( text: $text, - state: state, + state: $state, + baseState: baseState, placeholder: placeholder, focusState: focusState, equals: equals, onSubmit: onSubmit ) - .onChange(of: focusState.wrappedValue) { onChangedFocuseState($0) } .onChange(of: state) { onChangedState($0) } infoLabel @@ -94,23 +99,31 @@ public struct PokitTextArea: View { Spacer() - Group { - switch state { - case .error: - Text("\(text.count > maxLetter ? maxLetter : text.count)/\(maxLetter)") - .foregroundStyle(.pokit(.text(.error))) - default: - Text("\(text.count > maxLetter ? maxLetter : text.count)/\(maxLetter)") - .foregroundStyle(.pokit(.text(.tertiary))) - } + if state != .memo(isReadOnly: true) && + state != .memo(isReadOnly: false) { + textCount + .pokitBlurReplaceTransition(.pokitDissolve) } - .pokitFont(.detail1) - .contentTransition(.numericText()) - .animation(.pokitDissolve, value: text) } .padding(.top, 4) } + private var textCount: some View { + Group { + switch state { + case .error: + Text("\(text.count > maxLetter ? maxLetter : text.count)/\(maxLetter)") + .foregroundStyle(.pokit(.text(.error))) + default: + Text("\(text.count > maxLetter ? maxLetter : text.count)/\(maxLetter)") + .foregroundStyle(.pokit(.text(.tertiary))) + } + } + .pokitFont(.detail1) + .contentTransition(.numericText()) + .animation(.pokitDissolve, value: text) + } + private func onChangedText(_ newValue: String) { if isMaxLetters { self.text = String(newValue.prefix(maxLetter + 1)) @@ -122,19 +135,6 @@ public struct PokitTextArea: View { state = newValue ? .error(message: "최대 \(maxLetter)자까지 입력가능합니다.") : .active } - private func onChangedFocuseState(_ newValue: Value) { - if newValue == equals { - state = .active - } else { - switch state { - case .error(message: let message): - state = .error(message: message) - default: - state = .default - } - } - } - private func onChangedState(_ newValue: PokitInputStyle.State) { switch newValue { case .error: diff --git a/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift b/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift index 5a39c2e4..41b32aec 100644 --- a/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift +++ b/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift @@ -15,6 +15,7 @@ public enum PokitInputStyle: Equatable { case disable case readOnly case error(message: String) + case memo(isReadOnly: Bool) var infoColor: Color { switch self { @@ -28,23 +29,27 @@ public enum PokitInputStyle: Equatable { var backgroundColor: Color { switch self { case .default, .input, .active, .error: - return .pokit(.bg(.primary)) + return .pokit(.bg(.base)) case .disable: return .pokit(.bg(.disable)) case .readOnly: return .pokit(.bg(.secondary)) + case let .memo(isReadOnly): + return isReadOnly + ? .pokit(.bg(.primary)) + : Color(red: 1, green: 0.96, blue: 0.89) } } var backgroundStrokeColor: Color { switch self { - case .default, .input: + case .input, .memo: return .clear case .active: return .pokit(.border(.brand)) case .disable: return .pokit(.border(.disable)) - case .readOnly: + case .readOnly, .default: return .pokit(.border(.secondary)) case .error: return .pokit(.border(.error)) @@ -53,7 +58,7 @@ public enum PokitInputStyle: Equatable { var iconColor: Color { switch self { - case .default, .readOnly: + case .default, .readOnly, .memo: return .pokit(.icon(.secondary)) case .input, .active: return .pokit(.icon(.primary)) diff --git a/Projects/Domain/Sources/Base/BaseContentDetail.swift b/Projects/Domain/Sources/Base/BaseContentDetail.swift index 9a59096d..8e8fef34 100644 --- a/Projects/Domain/Sources/Base/BaseContentDetail.swift +++ b/Projects/Domain/Sources/Base/BaseContentDetail.swift @@ -12,7 +12,7 @@ public struct BaseContentDetail: Equatable { public let category: BaseCategoryInfo public let title: String public let data: String - public let memo: String + public var memo: String public let createdAt: String public var favorites: Bool? public var alertYn: RemindState diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift index 93861d2d..99e7fe17 100644 --- a/Projects/Domain/Sources/Base/BaseContentItem.swift +++ b/Projects/Domain/Sources/Base/BaseContentItem.swift @@ -14,7 +14,7 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta public let categoryName: String public let categoryId: Int public let title: String - public let memo: String? + public var memo: String? public var thumbNail: String public let data: String public let domain: String diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift index e6f39e3e..6502e96d 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift @@ -40,12 +40,13 @@ public struct ContentDetailFeature { var contentId: Int? { get { domain.contentId } } - + var memo: String = "" var linkTitle: String? = nil var linkImageURL: String? = nil var showAlert: Bool = false - var showLinkPreview = false var showShareSheet: Bool = false + var memoTextAreaState: PokitInputStyle.State = .memo(isReadOnly: true) + var linkPopup: PokitLinkPopup.PopupType? } /// - Action @@ -71,15 +72,13 @@ public struct ContentDetailFeature { case 경고시트_해제 case 링크_공유_완료되었을때 + case 메모포커스_변경되었을때(Bool) } public enum InnerAction: Equatable { - case linkPreview - case 메타데이터_조회_수행(url: URL) - case 메타데이터_조회_반영(title: String?, imageURL: String?) - case URL_유효성_확인 case 컨텐츠_상세_조회_API_반영(content: BaseContentDetail) case 즐겨찾기_API_반영(Bool) + case 링크팝업_활성화(PokitLinkPopup.PopupType) } public enum AsyncAction: Equatable { @@ -87,6 +86,7 @@ public struct ContentDetailFeature { case 즐겨찾기_API(id: Int) case 즐겨찾기_취소_API(id: Int) case 컨텐츠_삭제_API(id: Int) + case 컨텐츠_수정_API } public enum ScopeAction: Equatable { case 없음 } @@ -129,6 +129,7 @@ public struct ContentDetailFeature { /// - Reducer body public var body: some ReducerOf { + BindingReducer(action: \.view) Reduce(self.core) } } @@ -138,11 +139,14 @@ private extension ContentDetailFeature { func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { case .뷰가_나타났을때: - if let content = state.content { - state.domain.content = content - return .send(.inner(.URL_유효성_확인)) - } else if let id = state.domain.contentId { + /// - 나중에 공유 받은 컨텐츠인지 확인해야함 + state.memoTextAreaState = .memo(isReadOnly: false) + + if let id = state.domain.contentId { return .send(.async(.컨텐츠_상세_조회_API(id: id))) + } else if let content = state.domain.content { + state.memo = content.memo + return .none } else { return .none } @@ -176,47 +180,29 @@ private extension ContentDetailFeature { case .경고시트_해제: state.showAlert = false return .none + case let .메모포커스_변경되었을때(isFocused): + guard + !isFocused, + state.memo != state.domain.content?.memo + else { return .none } + let memo = state.memo + state.domain.content?.memo = memo + return .send(.async(.컨텐츠_수정_API)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .메타데이터_조회_수행(url: let url): - return .run { send in - /// - 링크에 대한 메타데이터의 제목 및 썸네일 항목 파싱 - async let title = swiftSoup.parseOGTitle(url) - async let imageURL = swiftSoup.parseOGImageURL(url) - try await send( - .inner(.메타데이터_조회_반영(title: title, imageURL: imageURL)), - animation: .pokitDissolve - ) - } - case let .메타데이터_조회_반영(title: title, imageURL: imageURL): - state.linkTitle = title - state.linkImageURL = imageURL - return .send(.inner(.linkPreview), animation: .pokitDissolve) - case .URL_유효성_확인: - guard let urlString = state.domain.content?.data, - let url = URL(string: urlString) else { - /// 🚨 Error Case [1]: 올바른 링크가 아닐 때 - state.showLinkPreview = false - state.linkTitle = nil - state.linkImageURL = nil - return .none - } - return .send(.inner(.메타데이터_조회_수행(url: url)), animation: .pokitDissolve) case .컨텐츠_상세_조회_API_반영(content: let content): state.domain.content = content - return .merge( - .send(.delegate(.컨텐츠_조회_완료)), - .send(.inner(.URL_유효성_확인)) - ) + state.memo = state.domain.content?.memo ?? "" + return .send(.delegate(.컨텐츠_조회_완료)) case .즐겨찾기_API_반영(let favorite): state.domain.content?.favorites = favorite return .send(.delegate(.즐겨찾기_갱신_완료)) - case .linkPreview: - state.showLinkPreview = true + case let .링크팝업_활성화(type): + state.linkPopup = type return .none } } @@ -232,12 +218,12 @@ private extension ContentDetailFeature { case .즐겨찾기_API(id: let id): return .run { send in let _ = try await contentClient.즐겨찾기("\(id)") - await send(.inner(.즐겨찾기_API_반영(true))) + await send(.inner(.즐겨찾기_API_반영(true)), animation: .pokitDissolve) } case .즐겨찾기_취소_API(id: let id): return .run { send in try await contentClient.즐겨찾기_취소("\(id)") - await send(.inner(.즐겨찾기_API_반영(false))) + await send(.inner(.즐겨찾기_API_반영(false)), animation: .pokitDissolve) } case .컨텐츠_삭제_API(id: let id): return .run { send in @@ -245,6 +231,37 @@ private extension ContentDetailFeature { await send(.delegate(.컨텐츠_삭제_완료)) await dismiss() } + case .컨텐츠_수정_API: + guard + let content = state.domain.content, + let url = URL(string: content.data) + else { return .none } + return .run { send in + let imageURL = try? await swiftSoup.parseOGImageURL(url) + + let request = ContentBaseRequest( + data: content.data, + title: content.title, + categoryId: content.category.categoryId, + memo: content.memo, + alertYn: content.alertYn.rawValue, + thumbNail: imageURL + ) + let _ = try await contentClient.컨텐츠_수정( + contentId: "\(content.id)", + model: request + ) + await send( + .inner(.링크팝업_활성화(.success(title: "메모 수정 완료"))), + animation: .pokitSpring + ) + } catch: { error, send in + guard let errorResponse = error as? ErrorResponse else { return } + await send( + .inner(.링크팝업_활성화(.error(title: errorResponse.message))), + animation: .pokitSpring + ) + } } } diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift index 6cbca8ea..95319266 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift @@ -15,7 +15,8 @@ public struct ContentDetailView: View { /// - Properties @Perception.Bindable public var store: StoreOf - + @FocusState + private var isFocused: Bool /// - Initializer public init(store: StoreOf) { self.store = store @@ -26,17 +27,19 @@ public extension ContentDetailView { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - if let content = store.content { + if let content = store.content, + let favorites = content.favorites { title(content: content) ScrollView { - VStack { - contentLinkPreview(content: content) - .padding(.vertical, 24) + VStack(spacing: 0) { + contentMemo + + Divider() + .foregroundStyle(.pokit(.border(.tertiary))) + + bottomList(favorites: favorites) } } - .overlay(alignment: .bottom) { - bottomToolbar(content: content) - } } else { PokitLoading() } @@ -47,7 +50,14 @@ public extension ContentDetailView { .pokitPresentationBackground() .pokitPresentationCornerRadius() .presentationDragIndicator(.visible) - .presentationDetents([.medium, .large]) + .presentationDetents([.height(588), .large]) + .overlay(alignment: .bottom) { + if store.linkPopup != nil { + PokitLinkPopup(type: $store.linkPopup) + } + } + .dismissKeyboard(focused: $isFocused) + .onChange(of: isFocused) { send(.메모포커스_변경되었을때($0)) } .sheet(isPresented: $store.showAlert) { PokitAlert( "링크를 정말 삭제하시겠습니까?", @@ -124,113 +134,79 @@ private extension ContentDetailView { } } - @ViewBuilder - func contentLinkPreview(content: BaseContentDetail) -> some View { - VStack(spacing: 16) { - if store.showLinkPreview { - PokitLinkPreview( - title: store.linkTitle ?? content.title, - url: content.data, - imageURL: store.linkImageURL ?? "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png" - ) - .pokitBlurReplaceTransition(.pokitDissolve) - } - - contentMemo(content: content) - } - .padding(.horizontal, 20) - } - - @ViewBuilder - func contentMemo(content: BaseContentDetail) -> some View { - let isEmpty = content.memo.isEmpty - - HStack { - VStack { - Group { - if isEmpty { - Text("메모를 작성해보세요.") - .foregroundStyle(.pokit(.text(.tertiary))) - } else { - Text(content.memo) - .foregroundStyle(.pokit(.text(.primary))) - } - } - .pokitFont(.b3(.r)) - .multilineTextAlignment(.leading) - + var contentMemo: some View { + VStack(spacing: 12) { + HStack(alignment: .bottom) { + Text("메모") + .pokitFont(.b1(.m)) + .foregroundStyle(.pokit(.text(.primary))) + .padding(.top, 16) + Spacer() + + Image(.icon(.memo)) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(.pokit(.border(.primary))) } - .padding(16) - - Spacer() - } - .frame(minHeight: 132) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(red: 1, green: 0.96, blue: 0.89)) - } - } - - @ViewBuilder - func favorite(favorites: Bool) -> some View { - Button(action: { send(.즐겨찾기_버튼_눌렀을때, animation: .pokitDissolve) }) { - Image(favorites ? .icon(.starFill) : .icon(.starFill)) - .resizable() - .scaledToFit() - .foregroundStyle(.pokit(.icon(favorites ? .brand : .tertiary))) - .frame(width: 24, height: 24) - } - } - - @ViewBuilder - func bottomToolbar(content: BaseContentDetail) -> some View { - HStack(spacing: 14) { - if let favorites = content.favorites { - favorite(favorites: favorites) - } - - Spacer() - - Group { - toolbarButton( - .icon(.share), - action: { send(.공유_버튼_눌렀을때) } - ) - - toolbarButton( - .icon(.edit), - action: { send(.수정_버튼_눌렀을때) } - ) - - toolbarButton( - .icon(.trash), - action: { send(.삭제_버튼_눌렀을때) } - ) - } - .disabled(store.contentId == nil) - .opacity(store.contentId == nil ? 0 : 1) - } - .padding(.top, 12) - .padding(.bottom, 40) - .padding(.horizontal, 16) - .background(.pokit(.bg(.base))) - .overlay(alignment: .top) { - Divider() - .foregroundStyle(.pokit(.border(.tertiary))) + + PokitTextArea( + text: $store.memo, + state: $store.memoTextAreaState, + baseState: .memo(isReadOnly: false), + placeholder: "메모를 입력해주세요.", + maxLetter: 100, + focusState: $isFocused, + equals: true + ) + .frame(minHeight: 132) } + .padding(.bottom, 24) + .padding(.horizontal, 20) } @ViewBuilder - func toolbarButton( - _ icon: PokitImage, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(icon) - .resizable() - .frame(width: 24, height: 24) - .foregroundStyle(.pokit(.icon(.secondary))) + func bottomList(favorites: Bool) -> some View { + VStack(spacing: 0) { + PokitListButton( + title: "즐겨찾기", + type: .bottomSheet( + icon: favorites + ? .icon(.starFill) + : .icon(.star), + iconColor: favorites + ? .pokit(.icon(.brand)) + : .pokit(.icon(.primary)) + ), + action: { send(.즐겨찾기_버튼_눌렀을때) } + ) + + PokitListButton( + title: "공유하기", + type: .bottomSheet( + icon: .icon(.share), + iconColor: .pokit(.icon(.primary)) + ), + action: { send(.공유_버튼_눌렀을때) } + ) + + PokitListButton( + title: "수정하기", + type: .bottomSheet( + icon: .icon(.edit), + iconColor: .pokit(.icon(.primary)) + ), + action: { send(.수정_버튼_눌렀을때) } + ) + + PokitListButton( + title: "삭제하기", + type: .bottomSheet( + icon: .icon(.trash), + iconColor: .pokit(.icon(.primary)) + ), + action: { send(.삭제_버튼_눌렀을때) } + ) } } } diff --git a/Projects/Util/Sources/Extension/View+Extension.swift b/Projects/Util/Sources/Extension/View+Extension.swift new file mode 100644 index 00000000..d0e7c9b0 --- /dev/null +++ b/Projects/Util/Sources/Extension/View+Extension.swift @@ -0,0 +1,40 @@ +// +// View+Extension.swift +// Util +// +// Created by 김도형 on 12/1/24. +// + +import SwiftUI + +extension View { + public func dismissKeyboard( + focused: FocusState.Binding + ) -> some View { + self + .overlay { + if focused.wrappedValue { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + focused.wrappedValue = false + } + } + } + } + + public func dismissKeyboard( + focused: FocusState.Binding + ) -> some View { + self + .overlay { + if focused.wrappedValue != nil { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + focused.wrappedValue = nil + } + } + } + } +}