Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2.2.0] 쿠링봇 UI #218

Merged
merged 3 commits into from
Aug 22, 2024
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
5 changes: 0 additions & 5 deletions KuringApp/KuringApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,6 @@ struct ContentView: View {
}
}
.tint(Color.Kuring.gray600)

/// 팝업 추가
if PopupView.checkShowPopup() {
PopupView()
}
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions package-kuring/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let package = Package(
/// ```swift
/// import NoticeUI
/// ```
"BotUI",
"NoticeUI",
"SubscriptionUI",
"DepartmentUI",
Expand Down Expand Up @@ -47,6 +48,15 @@ let package = Package(
],
targets: [
// MARK: App Library Dependencies
.target(
name: "BotUI",
dependencies: [
"ColorSet", "BotFeatures",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
],
path: "Sources/UIKit/BotUI",
resources: [.process("Resources")]
),
.target(
name: "NoticeUI",
dependencies: [
Expand Down Expand Up @@ -138,6 +148,14 @@ let package = Package(
),

// MARK: Features
.target(
name: "BotFeatures",
dependencies: [
"Networks",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
],
path: "Sources/Features/BotFeatures"
),
.target(
name: "NoticeFeatures",
dependencies: [
Expand Down
129 changes: 129 additions & 0 deletions package-kuring/Sources/Features/BotFeatures/BotFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// File.swift
//
//
// Created by 최효원 on 8/5/24.
//

import Foundation
import ComposableArchitecture

@Reducer
public struct BotFeature {
@ObservableState
public struct State: Equatable {
public var chatInfo: ChatInfo = .init()
public var chatHistory: [ChatInfo] = []
public var focus: Field? = .question

public struct ChatInfo: Equatable {
public var limit: Int = 2
public var text: String = ""
public var messageType: MessageType = .question
public var chatStatus: ChatStatus = .before

public enum MessageType: String, Equatable {
case question
case answer
}

public enum ChatStatus {
/// 질문 보내기 전
case before
/// 질문 보낸 후
case waiting
/// 답변 완료
case complete
/// 답변 실패
case failure
}

public init(
limit: Int = 2,
text: String = "",
messageType: MessageType = .question,
chatStatus: ChatStatus = .before
) {
self.limit = limit
self.text = text
self.messageType = messageType
self.chatStatus = chatStatus
}
}

public enum Field {
case question
}

public init(
chatInfo: ChatInfo = .init(),
chatHistory: [ChatInfo] = [],
focus: Field? = .question
) {
self.chatInfo = chatInfo
self.chatHistory = chatHistory
self.focus = focus
}
}

public enum Action: BindableAction, Equatable {
case sendMessage
case messageResponse(Result<String, ChatError>)
case updateQuestion(String)
case binding(BindingAction<State>)

public enum ChatError: Error, Equatable {
case serverError(Error)

public static func == (lhs: ChatError, rhs: ChatError) -> Bool {
lgvv marked this conversation as resolved.
Show resolved Hide resolved
switch (lhs, rhs) {
case let (.serverError(lhsError), .serverError(rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
}
}
}
}

public var body: some ReducerOf<Self> {
BindingReducer()

Reduce { state, action in
switch action {
case .binding:
return .none

case .sendMessage:
guard !state.chatInfo.text.isEmpty else { return .none }
state.focus = nil
state.chatInfo.chatStatus = .waiting
return .run { [question = state.chatInfo.text] send in
do {
// let answer = try await chatService.sendQuestion(question)
/// 임시 응답
await send(.messageResponse(.success(question)))
} catch {
await send(.messageResponse(.failure(.serverError(error))))
}
}

case let .messageResponse(.success(answer)):
state.chatInfo.text = answer
state.chatInfo.chatStatus = .complete
state.chatHistory.append(state.chatInfo)
state.chatInfo = .init()
return .none

case let .messageResponse(.failure(error)):
state.chatInfo.chatStatus = .failure
print(error.localizedDescription)
return .none

case let .updateQuestion(question):
state.chatInfo.text = question
return .none
}
}
}

public init() { }
}
177 changes: 177 additions & 0 deletions package-kuring/Sources/UIKit/BotUI/BotView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//
// SwiftUIView.swift
//
//
// Created by 최효원 on 8/5/24.
//

import SwiftUI
import ComposableArchitecture
import ColorSet
import BotFeatures

public struct BotView: View {
@Bindable var store: StoreOf<BotFeature>
@FocusState private var isInputFocused: Bool
@State private var isPopoverVisible = false
@State private var isSendPopupVisible = false
@State private var messageCountRemaining = 2
@State private var chatMessages: [Message] = []

public var body: some View {
ZStack {
Color.Kuring.bg
.ignoresSafeArea()
.onTapGesture {
isInputFocused = false
}

VStack(alignment: .center) {
headerView
chatView
inputView
infoText
}
.padding(.bottom, 16)

if isSendPopupVisible {
sendPopup
.transition(.opacity)
.zIndex(1)
}
}
}

private var headerView: some View {
HStack {
backButton
Spacer()
titleText
Spacer()
infoButton
}
.padding(.horizontal, 18)
}

private var backButton: some View {
Button {
// 뒤로 가기 버튼 동작 구현
} label: {
Image(systemName: "chevron.backward")
.padding()
.frame(width: 20, height: 11)
.foregroundStyle(Color.black)
}
}

private var titleText: some View {
Text("쿠링봇")
.padding()
.font(.system(size: 18, weight: .semibold))
}

private var infoButton: some View {
Button {
isPopoverVisible.toggle()
} label: {
Image("icon_info_circle", bundle: Bundle.bots)
}
.popover(isPresented: $isPopoverVisible, arrowEdge: .top) {
popoverContent
}
}

private var popoverContent: some View {
VStack(spacing: 10) {
Text("• 쿠링봇은 2024년 6월 이후의 공지\n 사항 내용을 기준으로 답변할 수 있\n 어요.")
Text("• 테스트 기간인 관계로 한 달에 2회\n 까지만 질문 가능해요.")
wonniiii marked this conversation as resolved.
Show resolved Hide resolved
}
.lineSpacing(5)
.font(.system(size: 15, weight: .medium))
.padding(20)
.presentationBackground(Color.Kuring.gray100)
.presentationCompactAdaptation(.popover)
}

private var chatView: some View {
if !chatMessages.isEmpty {
AnyView(ChatView(messages: chatMessages))
} else {
AnyView(ChatEmptyView())
}
}

private var inputView: some View {
HStack(alignment: .bottom, spacing: 12) {
TextField("질문을 입력해주세요", text: $store.chatInfo.question.limit(to: 300), axis: .vertical)
.lineLimit(5)
.focused($isInputFocused)
.padding(.horizontal)
.padding(.vertical, 12)
.overlay(RoundedRectangle(cornerRadius: 20).strokeBorder(Color.Kuring.gray200, style: StrokeStyle(lineWidth: 1.0)))
.disabled(messageCountRemaining == 0)

sendButton
}
.padding(.horizontal, 20)
.disabled(messageCountRemaining == 0)
}

private var sendButton: some View {
Button {
isInputFocused = false
isSendPopupVisible = true
} label: {
Image(systemName: "arrow.up.circle.fill")
.resizable()
.foregroundStyle(Color.Kuring.gray400)
.scaledToFit()
.frame(width: 40, height: 40)
}
}

private var infoText: some View {
Text("쿠링봇은 실수를 할 수 있습니다. 중요한 정보를 확인하세요.")
.foregroundStyle(Color.Kuring.caption2)
.font(.system(size: 12, weight: .medium))
.padding(.top, 8)
}

private var sendPopup: some View {
SendPopup(isVisible: $isSendPopupVisible) {
if messageCountRemaining > 0 {
messageCountRemaining -= 1
let userMessage = Message(text: store.chatInfo.question, type: .question, sendCount: messageCountRemaining)
let botResponse = Message(text: "자동 응답입니다.", type: .answer, sendCount: messageCountRemaining)
chatMessages.append(contentsOf: [userMessage, botResponse])
store.chatInfo.question = ""
}
}
}

public init(store: StoreOf<BotFeature>) {
self.store = store
}
}

/// 글자 수 max 판단
extension Binding where Value == String {
lgvv marked this conversation as resolved.
Show resolved Hide resolved
func limit(to maxLength: Int) -> Self {
if self.wrappedValue.count > maxLength {
DispatchQueue.main.async {
self.wrappedValue = String(self.wrappedValue.prefix(maxLength))
}
}
return self
}
}


#Preview {
BotView(
store: Store(
initialState: BotFeature.State(),
reducer: { BotFeature() }
)
)
}
12 changes: 12 additions & 0 deletions package-kuring/Sources/UIKit/BotUI/Bundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// File.swift
//
//
// Created by 최효원 on 8/5/24.
//

import Foundation

extension Bundle {
public static var bots: Bundle { .module }
}
Loading