Skip to content
Merged
19 changes: 11 additions & 8 deletions GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct ArchiveDetailView: View {
@State private var shredMode: CardShredMode?
@State private var selectedNote: DropNote?
@State private var isEmptyNotesModalPresented = false
@State private var isErrorAlertPresented = false

private let title: String

Expand Down Expand Up @@ -84,12 +85,14 @@ struct ArchiveDetailView: View {
}
.alert(
viewModel.errorMessage ?? "",
isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.errorMessage = nil } }
)
isPresented: $isErrorAlertPresented
) {
Button("확인", role: .cancel) {}
Button("확인", role: .cancel) {
viewModel.errorMessage = nil
}
}
.onChange(of: viewModel.errorMessage) { _, newValue in
isErrorAlertPresented = newValue != nil
}
.task {
await viewModel.load()
Expand Down Expand Up @@ -122,7 +125,7 @@ struct ArchiveDetailView: View {
}
)
}

if let note = selectedNote {
CardDetailView(
viewModel: CardDetailViewModel(
Expand Down Expand Up @@ -151,7 +154,7 @@ struct ArchiveDetailView: View {
.transition(.opacity)
.zIndex(1)
}

if isEmptyNotesModalPresented {
ModalContainerView(isPresented: $isEmptyNotesModalPresented) {
ModalContentView(
Expand All @@ -169,7 +172,7 @@ struct ArchiveDetailView: View {
.animation(.easeOut(duration: 0.12), value: selectedNote?.id)
.hidesTabBar()
}

private func makeShredViewModel(for mode: CardShredMode) -> CardShredViewModel {
let cardRepository = DefaultCardRepository(networkManager: NetworkManager.shared)
switch mode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ final class ArchiveDetailViewModel: ObservableObject {

func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }

do {
Expand Down
24 changes: 15 additions & 9 deletions GAMSS/Sources/Presentation/CardShred/CardShredView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ struct CardShredView: View {
paperArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 27)
.overlay(alignment: .bottom) {
ZStack {
if let toastMessage = viewModel.toastMessage {
ToastView(message: toastMessage)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: viewModel.toastMessage)
}

shredButton
.padding(.horizontal, 18)
Expand All @@ -55,15 +64,6 @@ struct CardShredView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.colorWhite)
.hidesTabBar()
.alert(
viewModel.alertMessage ?? "",
isPresented: Binding(
get: { viewModel.alertMessage != nil },
set: { if !$0 { viewModel.alertMessage = nil } }
)
) {
Button("확인", role: .cancel) {}
}
}

private var powerToggle: some View {
Expand Down Expand Up @@ -139,6 +139,12 @@ struct CardShredView: View {
let succeeded = await viewModel.shred()
if succeeded {
onComplete()
} else {
withAnimation(.easeInOut(duration: 0.45)) {
viewModel.reset()
} completion: {
viewModel.showShredFailureToast()
}
}
}
} else {
Expand Down
20 changes: 18 additions & 2 deletions GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ enum CardShredMode: Hashable, Identifiable {
final class CardShredViewModel: ObservableObject {
@Published private(set) var step = 0
@Published private(set) var isSubmitting = false
@Published var alertMessage: String?
@Published private(set) var toastMessage: String?

private let mode: CardShredMode
private let deleteCardUseCase: DeleteCardUseCase?
private let deleteAllCardUseCase: DeleteAllCardUseCase?
private let requiredSteps = 4
private var toastDismissTask: Task<Void, Never>?

static let shredFailureToastMessage = "파쇄에 실패했어요. 다시 시도해주세요"

init(cardId: Int, deleteCardUseCase: DeleteCardUseCase) {
self.mode = .single(cardId: cardId)
Expand Down Expand Up @@ -55,6 +58,10 @@ final class CardShredViewModel: ObservableObject {
step += 1
}

func reset() {
step = 0
}

@discardableResult
func shred() async -> Bool {
guard isShredEnabled, !isSubmitting else { return false }
Expand All @@ -73,8 +80,17 @@ final class CardShredViewModel: ObservableObject {
}
return true
} catch {
alertMessage = "기록을 파쇄하지 못했어요"
return false
}
}

func showShredFailureToast() {
toastMessage = Self.shredFailureToastMessage
toastDismissTask?.cancel()
toastDismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
self?.toastMessage = nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ struct ConversationListEmptyView: View {
Text("아직 나눈 대화가 없어요")
.typography(.body3Regular)
.foregroundStyle(Color.colorGray500)
.frame(maxWidth: .infinity)
.padding(.top, Spacing.spacing800)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ConversationSearchEmptyView.swift
// GAMSS
//
// Created by 이건준 on 10/1/26.
//

import SwiftUI

/// 검색 결과가 없을 때 리스트 영역에 표시하는 안내 화면.
struct ConversationSearchEmptyView: View {
var body: some View {
VStack(spacing: 0) {
Image(systemName: "magnifyingglass")
.resizable()
.scaledToFit()
.frame(width: 80, height: 80)
.foregroundStyle(Color.colorGray400)
.padding(.bottom, Spacing.spacing300)

Text("찾으시는 결과가 없어요.")
.typography(.subtitle2)
.foregroundStyle(Color.colorGray950)
.padding(.bottom, Spacing.spacing100)

Text("다른 검색어로 다시 찾아보세요.")
.typography(.body5Regular)
.foregroundStyle(Color.colorGray500)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

#Preview {
ConversationSearchEmptyView()
}
127 changes: 73 additions & 54 deletions GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,63 +52,19 @@ struct ConversationListView: View {
.transition(.move(edge: .top).combined(with: .opacity))
}

dateHeader
.padding(.vertical, Spacing.spacing150)
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)

if !viewModel.isLoading && viewModel.displayedConversations.isEmpty {
ConversationListEmptyView()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
if viewModel.isNetworkUnreachable {
NetworkFailureView {
Task { await viewModel.retryAfterNetworkFailure() }
}
} else if isSearchResultEmpty {
ConversationSearchEmptyView()
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)
} else {
VStack(spacing: 0) {
ScrollView {
LazyVStack(spacing: Spacing.spacing100) {
ForEach(Array(viewModel.displayedConversations.enumerated()), id: \.element.id) { index, conversation in
conversationRow(for: conversation)
.onAppear {
Task {
await viewModel.loadMoreIfNeeded(at: index)
}
}
}
dateHeader
.padding(.vertical, Spacing.spacing150)
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)

if viewModel.isLoading && !viewModel.displayedConversations.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.spacing200)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

if viewModel.currentMode == .delete {
Button {
isDeleteConversationPresented = true
} label: {
Text("삭제하기")
.typography(.body3Medium)
.foregroundStyle(
viewModel.isDeleteButtonEnabled
? Color.colorWhite
: Color.colorGray300
)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(
viewModel.isDeleteButtonEnabled
? Color.colorRed
: Color.colorGray075
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.top, Spacing.spacing200)
.padding(.bottom, 10)
.disabled(!viewModel.isDeleteButtonEnabled)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)
conversationListContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Expand Down Expand Up @@ -160,6 +116,69 @@ struct ConversationListView: View {
}
}

private var isSearchResultEmpty: Bool {
viewModel.isSearchExecuted
&& !viewModel.isLoading
&& viewModel.displayedConversations.isEmpty
}

@ViewBuilder
private var conversationListContent: some View {
if !viewModel.isLoading && viewModel.displayedConversations.isEmpty {
ConversationListEmptyView()
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)
} else {
VStack(spacing: 0) {
ScrollView {
LazyVStack(spacing: Spacing.spacing100) {
ForEach(Array(viewModel.displayedConversations.enumerated()), id: \.element.id) { index, conversation in
conversationRow(for: conversation)
.onAppear {
Task {
await viewModel.loadMoreIfNeeded(at: index)
}
}
}

if viewModel.isLoading && !viewModel.displayedConversations.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.spacing200)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

if viewModel.currentMode == .delete {
Button {
isDeleteConversationPresented = true
} label: {
Text("삭제하기")
.typography(.body3Medium)
.foregroundStyle(
viewModel.isDeleteButtonEnabled
? Color.colorWhite
: Color.colorGray300
)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(
viewModel.isDeleteButtonEnabled
? Color.colorRed
: Color.colorGray075
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.top, Spacing.spacing200)
.padding(.bottom, 10)
.disabled(!viewModel.isDeleteButtonEnabled)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, NavigationBarMetrics.horizontalPadding)
}
}

private var dateHeader: some View {
HStack {
Text(ConversationListDateHeaderFormatter.string(from: Date()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class ConversationListViewModel: ObservableObject {
@Published var editedText: String = ""
@Published var isSearching: Bool = false
@Published private(set) var isSearchExecuted = false
@Published private(set) var isNetworkUnreachable = false

@Published private var selectedConversations = Set<Int>()
var isDeleteButtonEnabled: Bool {
Expand Down Expand Up @@ -51,10 +52,22 @@ final class ConversationListViewModel: ObservableObject {
defer { isLoading = false }
do {
conversations = try await getIncompleteConversationsUseCase.execute()
isNetworkUnreachable = false
} catch NetworkError.noConnection {
isNetworkUnreachable = true
} catch {
alertMessage = "채팅방 목록을 불러오지 못했어요"
}
}

/// 네트워크 끊김 화면의 "재시도" 버튼에서 호출한다. 검색 중이었으면 검색을, 아니면 목록 조회를 다시 시도한다.
func retryAfterNetworkFailure() async {
if isSearching, !editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
await searchText()
} else {
await load()
}
}

func isSelected(id: Int) -> Bool {
selectedConversations.contains(id)
Expand Down Expand Up @@ -129,6 +142,9 @@ final class ConversationListViewModel: ObservableObject {
hasMoreSearchResults = page.hasNextPage
searchResults = page.items
isSearchExecuted = true
isNetworkUnreachable = false
} catch NetworkError.noConnection {
isNetworkUnreachable = true
} catch {
alertMessage = error.localizedDescription
}
Expand All @@ -154,6 +170,8 @@ final class ConversationListViewModel: ObservableObject {
searchPage = page.page
hasMoreSearchResults = page.hasNextPage
searchResults.append(contentsOf: page.items)
} catch NetworkError.noConnection {
isNetworkUnreachable = true
} catch {
alertMessage = error.localizedDescription
}
Expand Down
Loading
Loading