-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #218 from ku-ring/feature/kuringbot/UI
[2.2.0] 쿠링봇 UI
- Loading branch information
Showing
31 changed files
with
663 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
129 changes: 129 additions & 0 deletions
129
package-kuring/Sources/Features/BotFeatures/BotFeature.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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() { } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 까지만 질문 가능해요.") | ||
} | ||
.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 { | ||
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() } | ||
) | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
Oops, something went wrong.