diff --git a/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/Contents.json b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/Contents.json new file mode 100644 index 0000000..7b9b44e --- /dev/null +++ b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "note_strip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "note_strip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "note_strip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip.png b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip.png new file mode 100644 index 0000000..9436a28 Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip.png differ diff --git a/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@2x.png b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@2x.png new file mode 100644 index 0000000..7166746 Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@2x.png differ diff --git a/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@3x.png b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@3x.png new file mode 100644 index 0000000..88e76a8 Binary files /dev/null and b/GAMSS/Resources/Assets.xcassets/noteStrip.imageset/note_strip@3x.png differ diff --git a/GAMSS/Sources/Core/DateFormatterFactory.swift b/GAMSS/Sources/Core/DateFormatterFactory.swift index 3053bf6..40c0328 100644 --- a/GAMSS/Sources/Core/DateFormatterFactory.swift +++ b/GAMSS/Sources/Core/DateFormatterFactory.swift @@ -21,8 +21,15 @@ enum DateFormatterFactory { return formatter } + /// `yyyy-MM-dd` + static var dateWithHypen: DateFormatter { + let formatter = dateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } + /// `yyyy-MM` - static var dateWithHyphen: DateFormatter { + static var yearMonthWithHypen: DateFormatter { let formatter = dateFormatter() formatter.dateFormat = "yyyy-MM" return formatter diff --git a/GAMSS/Sources/Data/Repository/DefaultCardRepository.swift b/GAMSS/Sources/Data/Repository/DefaultCardRepository.swift index aa18912..44a3530 100644 --- a/GAMSS/Sources/Data/Repository/DefaultCardRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultCardRepository.swift @@ -43,13 +43,13 @@ final class DefaultCardRepository: CardRepository { } func fetchCardsByDate(yearMonth: Date, emotion: Emotion) async throws -> [DailyEmotion] { - let yearMonth = DateFormatterFactory.dateWithHyphen.string(from: yearMonth) + let yearMonth = DateFormatterFactory.yearMonthWithHypen.string(from: yearMonth) let response = try await networkManager.request( CardEndpoint.fetchCardsByDate(yearMonth: yearMonth, emotion: emotion.rawValue), responseType: APIResponse<[FetchCardsByDateResponseDTO]>.self ) return response.data.compactMap { response in - guard let date = DateFormatterFactory.dateWithDot.date(from: response.date) else { + guard let date = DateFormatterFactory.dateWithHypen.date(from: response.date) else { return nil } diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index 453cbe4..bf2475e 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -14,6 +14,7 @@ struct ArchiveDetailView: View { @State private var scene = DropStackScene() @State private var isMonthPickerPresented = false @State private var isDeleteAllModalPresented = false + @State private var selectedNote: DropNote? private let title: String @@ -39,16 +40,25 @@ struct ArchiveDetailView: View { ZStack { Color.colorWhite - GeometryReader { proxy in - SpriteView(scene: scene, options: [.allowsTransparency]) - .onAppear { - scene.scaleMode = .resizeFill - scene.updateSize(proxy.size) - scene.render(notes: viewModel.notes) - } - .onChange(of: proxy.size) { _, newSize in - scene.updateSize(newSize) - } + if viewModel.notes.isEmpty, !viewModel.isLoading { + Text("아직 남겨둔 이야기가 없어요.") + .typography(.body3Regular) + .foregroundStyle(Color.colorGray500) + } else { + GeometryReader { proxy in + SpriteView(scene: scene, options: [.allowsTransparency]) + .onAppear { + scene.scaleMode = .resizeFill + scene.updateSize(proxy.size) + scene.onSelectNote = { note in + selectedNote = note + } + scene.render(notes: viewModel.notes) + } + .onChange(of: proxy.size) { _, newSize in + scene.updateSize(newSize) + } + } } } } @@ -82,6 +92,20 @@ struct ArchiveDetailView: View { } } } + .fullScreenCover(item: $selectedNote) { note in + CardDetailView( + viewModel: CardDetailViewModel( + cardId: note.id, + getCardUseCase: GetCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) + ), + onClose: { + selectedNote = nil + } + ) + .presentationBackground(.clear) + } if isDeleteAllModalPresented { ModalContainerView( isPresented: $isDeleteAllModalPresented diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift index 5097fba..593295a 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift @@ -37,7 +37,7 @@ final class ArchiveDetailViewModel: ObservableObject { do { notes = try await fetchCardsByDateUseCase.execute(yearMonth: selectedMonth).map { dailyEmotion in - DropNote(id: dailyEmotion.conversationId, imageName: "cardFoldStepTwo") + DropNote(id: dailyEmotion.id, imageName: "cardFoldStepTwo") } } catch { notes = [] @@ -47,6 +47,7 @@ final class ArchiveDetailViewModel: ObservableObject { func selectMonth(_ month: Date) async { selectedMonth = month + notes = [] await load() } diff --git a/GAMSS/Sources/Presentation/Archive/Detail/DropStack/DropStackScene.swift b/GAMSS/Sources/Presentation/Archive/Detail/DropStack/DropStackScene.swift index 41d2169..d99bc68 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/DropStack/DropStackScene.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/DropStack/DropStackScene.swift @@ -8,12 +8,15 @@ import SpriteKit final class DropStackScene: SKScene { + var onSelectNote: ((DropNote) -> Void)? + private let floorNodeName = "drop-floor" private let boxNodeName = "drop-note" private let dropSize = CGSize(width: 80, height: 80) private let floorHeight: CGFloat = 12 private var pendingNotes: [DropNote] = [] + private var renderedNotes: [DropNote] = [] private var isAttachedToView = false override func didMove(to view: SKView) { @@ -39,6 +42,7 @@ final class DropStackScene: SKScene { } func render(notes: [DropNote]) { + clear() pendingNotes = notes flushPendingNotesIfNeeded() } @@ -46,11 +50,21 @@ final class DropStackScene: SKScene { func clear() { removeAllActions() pendingNotes = [] + renderedNotes = [] children .filter { $0.name == boxNodeName } .forEach { $0.removeFromParent() } } + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let location = touches.first?.location(in: self), + let id = nodes(at: location).first(where: { $0.name == boxNodeName })?.userData?["id"] as? Int, + let note = renderedNotes.first(where: { $0.id == id }) + else { return } + + onSelectNote?(note) + } + private func flushPendingNotesIfNeeded() { guard isAttachedToView, size.width > 0, size.height > 0, !pendingNotes.isEmpty else { return @@ -58,6 +72,7 @@ final class DropStackScene: SKScene { let notes = pendingNotes pendingNotes = [] + renderedNotes = notes removeAllActions() children @@ -67,7 +82,7 @@ final class DropStackScene: SKScene { for (index, note) in notes.enumerated() { let wait = SKAction.wait(forDuration: 0.07 * Double(index)) let spawn = SKAction.run { [weak self] in - self?.spawnOne(imageName: note.imageName) + self?.spawnOne(note: note) } run(.sequence([wait, spawn])) } @@ -93,9 +108,10 @@ final class DropStackScene: SKScene { addChild(floor) } - private func spawnOne(imageName: String) { - let node = SKSpriteNode(imageNamed: imageName) + private func spawnOne(note: DropNote) { + let node = SKSpriteNode(imageNamed: note.imageName) node.name = boxNodeName + node.userData = ["id": note.id] node.size = dropSize let minX = dropSize.width * 0.5 diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift index 2cf4c76..926b458 100644 --- a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift +++ b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift @@ -13,6 +13,7 @@ struct CardDetailView: View { @StateObject private var viewModel: CardDetailViewModel @StateObject private var conversationHistoryViewModel: ConversationHistoryViewModel @State private var isShowingConversation = false + @State private var isShredPresented = false private let transitionDuration = 0.22 @@ -73,8 +74,6 @@ struct CardDetailView: View { Task { if isLoadFailure { await viewModel.loadCard() - } else { - await discardCard() } } } @@ -84,13 +83,33 @@ struct CardDetailView: View { Button("확인", role: .cancel) {} } } + .fullScreenCover(isPresented: $isShredPresented) { + if let card = viewModel.card { + CardShredView( + viewModel: CardShredViewModel( + cardId: card.id, + deleteCardUseCase: DeleteCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) + ), + stripImageName: "noteStrip", + onBack: { + isShredPresented = false + }, + onComplete: { + isShredPresented = false + onClose() + } + ) + } + } } private var bottomActions: some View { VStack(spacing: Spacing.spacing200) { HStack(spacing: Spacing.spacing050) { OutlineButton(title: "기록 버리기") { - Task { await discardCard() } + isShredPresented = true } .frame(width: 97) @@ -135,21 +154,15 @@ struct CardDetailView: View { isShowingConversation = false } } - - private func discardCard() async { - let succeeded = await viewModel.deleteCard() - if succeeded { - onClose() - } - } } #Preview { CardDetailView( viewModel: CardDetailViewModel( cardId: 1, - getCardUseCase: GetCardUseCase(cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared)), - deleteCardUseCase: DeleteCardUseCase(cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared)) + getCardUseCase: GetCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) ), onClose: {} ) diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailViewModel.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailViewModel.swift index d12393a..b5a0c48 100644 --- a/GAMSS/Sources/Presentation/CardDetail/CardDetailViewModel.swift +++ b/GAMSS/Sources/Presentation/CardDetail/CardDetailViewModel.swift @@ -16,12 +16,10 @@ final class CardDetailViewModel: ObservableObject { private let cardId: Int private let getCardUseCase: GetCardUseCase - private let deleteCardUseCase: DeleteCardUseCase - init(cardId: Int, getCardUseCase: GetCardUseCase, deleteCardUseCase: DeleteCardUseCase) { + init(cardId: Int, getCardUseCase: GetCardUseCase) { self.cardId = cardId self.getCardUseCase = getCardUseCase - self.deleteCardUseCase = deleteCardUseCase } /// 카드가 아직 없고(로딩 실패로 보여줄 게 없음) 알럿이 떠 있으면, 알럿의 "닫기"가 화면 자체를 @@ -39,17 +37,4 @@ final class CardDetailViewModel: ObservableObject { alertMessage = "카드를 불러오지 못했어요" } } - - @discardableResult - func deleteCard() async -> Bool { - isLoading = true - defer { isLoading = false } - do { - try await deleteCardUseCase.execute(cardId: cardId) - return true - } catch { - alertMessage = "카드를 삭제하지 못했어요" - return false - } - } } diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift new file mode 100644 index 0000000..10fb82a --- /dev/null +++ b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift @@ -0,0 +1,205 @@ +// +// CardShredView.swift +// GAMSS +// +// Created by 이건준 on 8/21/26. +// + +import SwiftUI + +struct CardShredView: View { + let onBack: () -> Void + let onComplete: () -> Void + let stripImageName: String + + @StateObject private var viewModel: CardShredViewModel + + private let strips: [ShredStrip] = [ + .init(xRatio: 0.06, width: 36, height: 420), + .init(xRatio: 0.18, width: 32, height: 360), + .init(xRatio: 0.29, width: 38, height: 480), + .init(xRatio: 0.42, width: 34, height: 400), + .init(xRatio: 0.53, width: 40, height: 450), + .init(xRatio: 0.66, width: 33, height: 380), + .init(xRatio: 0.77, width: 37, height: 470), + .init(xRatio: 0.89, width: 31, height: 390) + ] + + init( + viewModel: CardShredViewModel, + stripImageName: String, + onBack: @escaping () -> Void, + onComplete: @escaping () -> Void + ) { + _viewModel = StateObject(wrappedValue: viewModel) + self.stripImageName = stripImageName + self.onBack = onBack + self.onComplete = onComplete + } + + var body: some View { + VStack(spacing: 0) { + header + .padding(.horizontal, 18) + .padding(.vertical, Spacing.spacing400) + + powerToggle + + paperArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 27) + + shredButton + .padding(.horizontal, 18) + .padding(.top, Spacing.spacing300) + .padding(.bottom, 42) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.colorWhite) + .toolbar(.hidden, for: .navigationBar) + .alert( + viewModel.alertMessage ?? "", + isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } } + ) + ) { + Button("확인", role: .cancel) {} + } + } + + private var header: some View { + HStack(spacing: Spacing.spacing200) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .foregroundStyle(Color.colorGray900) + } + + Text("비우기") + .typography(.subtitle2) + .foregroundStyle(Color.colorGray900) + + Spacer() + } + } + + private var powerToggle: some View { + HStack { + HStack(spacing: 0) { + Text("OFF") + .typography(.body4Medium) + .foregroundStyle(viewModel.isPowerOn ? Color.colorGray500 : Color.colorWhite) + .padding(.horizontal, 8) + .frame(maxHeight: .infinity) + .background(viewModel.isPowerOn ? Color.clear : Color.colorApricot) + .clipShape(Capsule()) + + Text("ON") + .typography(.body4Medium) + .foregroundStyle(viewModel.isPowerOn ? Color.colorWhite : Color.colorGray500) + .padding(.horizontal, 8) + .frame(maxHeight: .infinity) + .background(viewModel.isPowerOn ? Color.colorGreen : Color.clear) + .clipShape(Capsule()) + } + .padding(4) + .frame(height: 40) + .background(Color.colorGray300) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(Color.colorGray400, lineWidth: 1) + ) + + Spacer() + + ZStack { + Circle() + .fill(Color.colorGray400) + Circle() + .fill(viewModel.isPowerOn ? Color.colorGreen : Color.colorApricot) + .padding(6) + } + .frame(width: 30, height: 30) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .frame(height: 90) + .background(Color.colorGray100) + .allowsHitTesting(false) + } + + private var paperArea: some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + Color.colorWhite + + ForEach(strips) { strip in + Image(stripImageName) + .resizable() + .scaledToFit() + .frame(width: strip.width, height: strip.height, alignment: .top) + .offset( + x: proxy.size.width * strip.xRatio - strip.width * 0.5, + y: paperOffset(in: proxy.size.height, stripHeight: strip.height) + ) + } + } + .clipped() + } + } + + private var shredButton: some View { + Button { + if viewModel.isShredEnabled { + Task { + let succeeded = await viewModel.shred() + if succeeded { + onComplete() + } + } + } else { + withAnimation(.easeInOut(duration: 0.45)) { + viewModel.advance() + } + } + } label: { + Text("파쇄하기") + .typography(.title5) + .foregroundStyle(Color.colorWhite) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(viewModel.isShredEnabled ? Color.colorApricot : Color.colorGray900) + .clipShape(RoundedRectangle(cornerRadius: Radius.radius200)) + } + .disabled(viewModel.isSubmitting) + .buttonStyle(.plain) + } + + private func paperOffset(in areaHeight: CGFloat, stripHeight: CGFloat) -> CGFloat { + let startY = -stripHeight + 28 + let endY = areaHeight + 20 + return startY + (endY - startY) * viewModel.progress + } +} + +private struct ShredStrip: Identifiable { + let id = UUID() + let xRatio: CGFloat + let width: CGFloat + let height: CGFloat +} + +#Preview { + CardShredView( + viewModel: CardShredViewModel( + cardId: 1, + deleteCardUseCase: DeleteCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) + ), + stripImageName: "noteStrip", + onBack: {}, + onComplete: {} + ) +} diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift b/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift new file mode 100644 index 0000000..074b31a --- /dev/null +++ b/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift @@ -0,0 +1,58 @@ +// +// CardShredViewModel.swift +// GAMSS +// +// Created by 이건준 on 8/21/26. +// + +import Combine +import Foundation + +@MainActor +final class CardShredViewModel: ObservableObject { + @Published private(set) var step = 0 + @Published private(set) var isSubmitting = false + @Published var alertMessage: String? + + private let cardId: Int + private let deleteCardUseCase: DeleteCardUseCase + private let requiredSteps = 4 + + init(cardId: Int, deleteCardUseCase: DeleteCardUseCase) { + self.cardId = cardId + self.deleteCardUseCase = deleteCardUseCase + } + + var isPowerOn: Bool { + step > 0 && step < requiredSteps + } + + var isShredEnabled: Bool { + step >= requiredSteps + } + + var progress: CGFloat { + CGFloat(step) / CGFloat(requiredSteps) + } + + func advance() { + guard step < requiredSteps else { return } + step += 1 + } + + @discardableResult + func shred() async -> Bool { + guard isShredEnabled, !isSubmitting else { return false } + + isSubmitting = true + defer { isSubmitting = false } + + do { + try await deleteCardUseCase.execute(cardId: cardId) + return true + } catch { + alertMessage = "기록을 파쇄하지 못했어요" + return false + } + } +}