From 8507e7aef8d17d1fa5974202762464b58ce4675c Mon Sep 17 00:00:00 2001 From: rrheon Date: Tue, 11 Aug 2026 11:33:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(share):=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20404=C2=B7=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유하기에서 두 가지가 깨져 있었다. 1) 링크가 열리지 않음 서버 배틀 상세의 shareUrl 이 무관한 도메인(pique.app)으로 내려오고, 클라 폴백도 복수형 /battles/{id} 라 403 — 이중으로 죽은 링크였다. 실존하는 웹 랜딩은 단수형 /battle/{id} 뿐이다. PickeShareURL 을 신설해 picke.store(서브도메인 포함) 링크만 신뢰하고, 아니면 단수형 링크를 직접 만들도록 일원화했다. (서버 shareUrl 도메인 교정은 서버 측 후속 과제) 2) 공유 카드에 아이콘·제목이 빠짐 KFImage 는 ImageRenderer 동기 렌더 시점에 항상 로드 전이라 빈 원으로 캡처된다. RecapView 의 avatarOverride 패턴을 PreVoteView 에 이식해 Kingfisher 로 선로드한 이미지를 주입해 렌더한다. 빠른배틀은 배틀 이미지를 URLSession 으로 받아 첨부하도록 변경. 최종투표(post)는 밝은 제목색이 밝은 스냅샷 배경에 묻혀 보이지 않아 스냅샷에서만 어두운 제목색으로 고정했다. Stage 빌드로 4개 공유 지점(사전투표·최종투표·빠른배틀·Recap) 실기 확인. --- .../Entity/Sources/Share/PickeShareURL.swift | 27 +++++ .../Sources/Main/Reducer/BattleFeature.swift | 32 +++++- .../Sources/Vote/Reducer/PreVoteFeature.swift | 2 +- .../Chat/Sources/Vote/View/PreVoteView.swift | 106 +++++++++++++----- 4 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Share/PickeShareURL.swift diff --git a/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift b/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift new file mode 100644 index 0000000..147142d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift @@ -0,0 +1,27 @@ +// +// PickeShareURL.swift +// Entity +// + +import Foundation + +/// 공유용 웹 랜딩 링크 빌더. +/// 서버 `shareUrl` 이 빈 값이거나 무관한 도메인(pique.app 등)으로 내려오는 사고가 있어, +/// picke.store 링크만 신뢰하고 그 외에는 검증된 랜딩 경로로 대체한다. +public enum PickeShareURL { + private static let trustedHost = "picke.store" + + /// 배틀 공유 랜딩 링크 — 웹 랜딩이 실존하는 경로는 단수형 `/battle/{id}` 다 (`/battles/{id}` 는 403). + public static func battle(id: Int, serverShareUrl: String? = nil) -> String { + if let serverShareUrl, isTrusted(serverShareUrl) { + return serverShareUrl + } + return "https://\(trustedHost)/battle/\(id)" + } + + /// picke.store(서브도메인 포함) 링크만 신뢰. + private static func isTrusted(_ urlString: String) -> Bool { + guard let host = URLComponents(string: urlString)?.host?.lowercased() else { return false } + return host == trustedHost || host.hasSuffix(".\(trustedHost)") + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift index 3426c33..38d901c 100644 --- a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -4,6 +4,7 @@ // import Foundation +import UIKit import ComposableArchitecture import Entity @@ -54,10 +55,12 @@ public struct BattleFeature { public enum AsyncAction: Equatable { case fetchRequested + case prepareShare(text: String, url: String, imageURL: String?) } public enum InnerAction: Equatable { case todayResponse(Result) + case sharePrepared(ShareItem) } nonisolated enum CancelID: Hashable { @@ -126,10 +129,11 @@ extension BattleFeature { ] .filter { !$0.isEmpty } .joined(separator: "\n\n") - var items: [Any] = [text] - if let urlString = battle.imageURL, let url = URL(string: urlString) { items.append(url) } - state.shareItem = ShareItem(items: items) - return .none + return .send(.async(.prepareShare( + text: text, + url: PickeShareURL.battle(id: battleId), + imageURL: battle.imageURL + ))) case let .optionTapped(battleId, optionId): analyticsUseCase.track(.uiAction(action: .quickBattleOption, screen: .quickBattle)) @@ -165,6 +169,22 @@ extension BattleFeature { return await send(.inner(.todayResponse(result))) } .cancellable(id: CancelID.fetchToday, cancelInFlight: true) + + case let .prepareShare(text, url, imageURL): + return .run { send in + var items: [Any] = [text] + if let linkURL = URL(string: url) { + items.append(linkURL) + } + if let imageURL, + let remoteURL = URL(string: imageURL), + let (data, _) = try? await URLSession.shared.data(from: remoteURL), + let image = UIImage(data: data) + { + items.append(image) + } + await send(.inner(.sharePrepared(ShareItem(items: items)))) + } } } @@ -183,6 +203,10 @@ extension BattleFeature { Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)") } return .none + + case let .sharePrepared(item): + state.shareItem = item + return .none } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 198298b..8368891 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -191,7 +191,7 @@ extension PreVoteFeature { let detail = state.battleDetail let battle = state.battle let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" - let url = detail?.shareUrl ?? "https://picke.store/battles/\(state.battleId)" + let url = PickeShareURL.battle(id: state.battleId, serverShareUrl: detail?.shareUrl) let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL let summary = { if let description = detail?.description, !description.isEmpty { return description } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index a5ad926..2b68bf2 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -178,7 +178,7 @@ extension PreVoteView { Spacer() Button { - send(.shareTapped(snapshot: captureCardSnapshot())) + shareWithSnapshot() } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 24, weight: .regular)) @@ -225,11 +225,14 @@ extension PreVoteView { } @ViewBuilder - private func contentSection(_ battle: PreVoteBattle) -> some View { + private func contentSection( + _ battle: PreVoteBattle, + titleColorOverride: Color? = nil + ) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 20) { tagsRow(battle) - titleText(battle) + titleText(battle, colorOverride: titleColorOverride) } summaryText(battle) } @@ -247,10 +250,13 @@ extension PreVoteView { } @ViewBuilder - private func titleText(_ battle: PreVoteBattle) -> some View { + private func titleText( + _ battle: PreVoteBattle, + colorOverride: Color? = nil + ) -> some View { Text([battle.titleLine1, battle.titleLine2].filter { !$0.isEmpty }.joined(separator: "\n")) .pretendardFont(.bold24) - .foregroundStyle(titleColor) + .foregroundStyle(colorOverride ?? titleColor) .kerning(-0.6) .lineSpacing(24 * 0.4) .multilineTextAlignment(.leading) @@ -274,11 +280,14 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private func optionSection(_ battle: PreVoteBattle) -> some View { + private func optionSection( + _ battle: PreVoteBattle, + avatarOverrides: [Int: UIImage] = [:] + ) -> some View { ZStack { HStack(spacing: 8) { - optionCard(battle.leftOption) - optionCard(battle.rightOption) + optionCard(battle.leftOption, avatarOverride: avatarOverrides[battle.leftOption.optionId]) + optionCard(battle.rightOption, avatarOverride: avatarOverrides[battle.rightOption.optionId]) } .frame(maxWidth: .infinity) vsBadge() @@ -286,14 +295,17 @@ extension PreVoteView { } @ViewBuilder - private func optionCard(_ option: PreVoteOption) -> some View { + private func optionCard( + _ option: PreVoteOption, + avatarOverride: UIImage? = nil + ) -> some View { let isSelected = store.selectedOptionId == option.optionId return Button { send(.optionTapped(optionId: option.optionId)) } label: { VStack(spacing: 12) { - avatarView(imageURL: option.imageURL) + avatarView(imageURL: option.imageURL, override: avatarOverride) VStack(spacing: 2) { Text(option.stance) @@ -321,17 +333,27 @@ extension PreVoteView { .buttonStyle(.plain) } - private func avatarView(imageURL: String) -> some View { - KFImage(URL(string: imageURL)) - .placeholder { - SkeletonView() - .frame(width: 28, height: 20) + @ViewBuilder + private func avatarView(imageURL: String, override: UIImage? = nil) -> some View { + Group { + if let override { + // 공유 스냅샷: 사전 로드된 이미지를 동기 렌더. + Image(uiImage: override) + .resizable() + .scaledToFit() + } else { + KFImage(URL(string: imageURL)) + .placeholder { + SkeletonView() + .frame(width: 28, height: 20) + } + .resizable() + .scaledToFit() } - .resizable() - .scaledToFit() - .frame(width: 28, height: 20) - .frame(width: 40, height: 40) - .background(.beige600, in: Circle()) + } + .frame(width: 28, height: 20) + .frame(width: 40, height: 40) + .background(.beige600, in: Circle()) } @ViewBuilder @@ -359,19 +381,53 @@ extension PreVoteView { // MARK: - Share snapshot extension PreVoteView { + /// 공유 트리거 — 옵션 아바타(철학자) 이미지를 먼저 비동기 로드한 뒤 카드 스냅샷을 렌더한다. + /// KFImage 는 ImageRenderer(동기 렌더) 에서 로드 전이라 빈 원으로 캡처되므로, + /// Kingfisher 로 미리 받아 `avatarOverrides` 로 주입해 동기 렌더한다. (RecapView 와 동일 패턴) + private func shareWithSnapshot() { + Task { @MainActor in + let avatars = await loadOptionAvatarImages() + send(.shareTapped(snapshot: captureCardSnapshot(avatarOverrides: avatars))) + } + } + + /// 좌/우 옵션의 원격 아바타를 Kingfisher 로 선로드 (실패한 쪽은 제외 → KFImage 폴백). @MainActor - private func captureCardSnapshot() -> Data? { + private func loadOptionAvatarImages() async -> [Int: UIImage] { + guard let battle = store.battle else { return [:] } + var images: [Int: UIImage] = [:] + for option in [battle.leftOption, battle.rightOption] { + guard let url = URL(string: option.imageURL) else { continue } + let image: UIImage? = await withCheckedContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url) { result in + continuation.resume(returning: try? result.get().image) + } + } + if let image { + images[option.optionId] = image + } + } + return images + } + + @MainActor + private func captureCardSnapshot(avatarOverrides: [Int: UIImage]) -> Data? { guard let battle = store.battle else { return nil } - let renderer = ImageRenderer(content: shareSnapshotCard(battle)) + let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatarOverrides)) renderer.scale = UIScreen.main.scale return renderer.uiImage?.pngData() } @ViewBuilder - private func shareSnapshotCard(_ battle: PreVoteBattle) -> some View { + private func shareSnapshotCard( + _ battle: PreVoteBattle, + avatarOverrides: [Int: UIImage] + ) -> some View { VStack(spacing: PreVoteLayout.contentToOptionSpacing) { - contentSection(battle) - optionSection(battle) + // 스냅샷 배경은 모드와 무관하게 밝은색이라, 사후(post) 화면의 밝은 제목색을 + // 그대로 쓰면 베이지 위 베이지로 묻힌다 → 항상 어두운 제목색으로 고정. + contentSection(battle, titleColorOverride: .neutral500) + optionSection(battle, avatarOverrides: avatarOverrides) } .padding(16) .frame(width: PreVoteLayout.snapshotWidth) From 413989e3cedb01d4c79b9632deaf7c28f84a8a82 Mon Sep 17 00:00:00 2001 From: rrheon Date: Thu, 13 Aug 2026 13:44:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(share):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20=EA=B3=B5=EC=9C=A0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EB=B7=B0=EC=97=90=EC=84=9C=20=EB=B9=BC?= =?UTF-8?q?=EC=84=9C=20ShareUseCase=20=EB=A1=9C=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 받은 지적을 이렇게 반영했다. - "공유 버튼을 TCA 액션으로" → 버튼은 send(.shareTapped) 만 보내고, 나머지 흐름은 전부 리듀서 액션 체인으로 처리 - "아바타 로드 로직을 뷰에서 빼 달라" → 리듀서의 loadShareAvatars 로 이동 - "다운로드는 usecase 에서" → ShareUseCase 를 새로 만들어 이미지 다운로드(loadImageData)와 공유 아이템 조립(makeShareItem)을 담당 - "BattleFeature 쪽도 더 나은 방식으로" → 이펙트 안의 생 URLSession 조립을 없애고 같은 ShareUseCase + ShareContent 경로로 통일 카드 렌더(ImageRenderer)는 SwiftUI 뷰만 그릴 수 있어 뷰에 남겼고, 리듀서가 요청하면 그려서 돌려주는 역할만 한다. Stage 빌드 + 시뮬레이터에서 공유 동작 재확인(링크 picke.store, 카드 아바타 렌더). --- .../UseCase/Sources/Share/ShareUseCase.swift | 74 ++++++++++ .../Sources/Main/Reducer/BattleFeature.swift | 41 ++---- .../Sources/Vote/Reducer/PreVoteFeature.swift | 138 +++++++++++------- .../Chat/Sources/Vote/View/PreVoteView.swift | 40 +---- 4 files changed, 180 insertions(+), 113 deletions(-) create mode 100644 Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift diff --git a/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift b/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift new file mode 100644 index 0000000..531f82f --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift @@ -0,0 +1,74 @@ +// +// ShareUseCase.swift +// UseCase +// + +import Foundation +import UIKit + +import ComposableArchitecture +import Entity + +/// 공유 시트 아이템(본문·링크·이미지)을 조립하는 클라이언트. +public struct ShareUseCase: Sendable { + /// 원격 이미지를 받음 + public var loadImageData: @Sendable (_ urlString: String) async -> Data? + /// `ShareContent` 를 공유 시트에 넘길 아이템 배열로 조립한다. + public var makeShareItem: @Sendable (_ content: ShareContent) async -> ShareItem + + public init( + loadImageData: @escaping @Sendable (_ urlString: String) async -> Data?, + makeShareItem: @escaping @Sendable (_ content: ShareContent) async -> ShareItem + ) { + self.loadImageData = loadImageData + self.makeShareItem = makeShareItem + } +} + +extension ShareUseCase: DependencyKey { + public static let liveValue = ShareUseCase( + loadImageData: loadRemoteImageData, + makeShareItem: { content in + var items: [Any] = [content.displayText] + + if let url = URL(string: content.url) { + items.append(url) + } else { + items.append(content.url) + } + + // 인스타 스토리/게시물 공유를 위해 카드 스냅샷을 우선 포함하고, 없을 때만 썸네일로 폴백. + if let snapshotData = content.snapshotData, let image = UIImage(data: snapshotData) { + items.append(image) + } else if let thumbnailURL = content.thumbnailURL, + let data = await loadRemoteImageData(thumbnailURL), + let image = UIImage(data: data) + { + items.append(image) + } + + return ShareItem(items: items) + } + ) + + public static let testValue = ShareUseCase( + loadImageData: { _ in nil }, + makeShareItem: { ShareItem(items: [$0.displayText]) } + ) + + public static let previewValue = testValue + + private static let loadRemoteImageData: @Sendable (String) async -> Data? = { urlString in + guard let url = URL(string: urlString), + let (data, _) = try? await URLSession.shared.data(from: url) + else { return nil } + return data + } +} + +public extension DependencyValues { + var shareUseCase: ShareUseCase { + get { self[ShareUseCase.self] } + set { self[ShareUseCase.self] = newValue } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift index 38d901c..34e9f13 100644 --- a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -4,7 +4,6 @@ // import Foundation -import UIKit import ComposableArchitecture import Entity @@ -55,7 +54,7 @@ public struct BattleFeature { public enum AsyncAction: Equatable { case fetchRequested - case prepareShare(text: String, url: String, imageURL: String?) + case prepareShare(ShareContent) } public enum InnerAction: Equatable { @@ -69,6 +68,7 @@ public struct BattleFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.analyticsUseCase) private var analyticsUseCase + @Dependency(\.shareUseCase) private var shareUseCase public enum DelegateAction: Equatable { /// 배틀 입장 → 채팅방 진입 @@ -122,18 +122,16 @@ extension BattleFeature { case let .shareTapped(battleId): analyticsUseCase.track(.shareAction(ShareActionData(target: .battle))) guard let battle = state.battles.first(where: { $0.battleId == battleId }) else { return .none } - let text = [ - battle.title, - battle.question, - battle.tags.map { "#\($0)" }.joined(separator: " "), - ] - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - return .send(.async(.prepareShare( - text: text, + let content = ShareContent( + title: battle.title, + summary: battle.question, + hashtags: battle.tags.map { "#\($0)" }, + optionLine: nil, url: PickeShareURL.battle(id: battleId), - imageURL: battle.imageURL - ))) + thumbnailURL: battle.imageURL, + snapshotData: nil + ) + return .send(.async(.prepareShare(content))) case let .optionTapped(battleId, optionId): analyticsUseCase.track(.uiAction(action: .quickBattleOption, screen: .quickBattle)) @@ -170,20 +168,9 @@ extension BattleFeature { } .cancellable(id: CancelID.fetchToday, cancelInFlight: true) - case let .prepareShare(text, url, imageURL): - return .run { send in - var items: [Any] = [text] - if let linkURL = URL(string: url) { - items.append(linkURL) - } - if let imageURL, - let remoteURL = URL(string: imageURL), - let (data, _) = try? await URLSession.shared.data(from: remoteURL), - let image = UIImage(data: data) - { - items.append(image) - } - await send(.inner(.sharePrepared(ShareItem(items: items)))) + case let .prepareShare(content): + return .run { [useCase = shareUseCase] send in + await send(.inner(.sharePrepared(useCase.makeShareItem(content)))) } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 8368891..8462bdd 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -6,7 +6,6 @@ // import Foundation -import UIKit import BattleDomainInterface import CommonDomainInterface @@ -27,6 +26,20 @@ public struct PreVoteFeature { case post } + /// 공유 카드 렌더 요청 — 아바타 선로드가 끝나면 채워지고, 뷰가 이 값으로 카드를 그린다. + public struct ShareSnapshotRequest: Equatable, Identifiable { + public let id: UUID + public let avatarImageData: [Int: Data] + + public init( + id: UUID = UUID(), + avatarImageData: [Int: Data] + ) { + self.id = id + self.avatarImageData = avatarImageData + } + } + @ObservableState public struct State: Equatable { public var battle: PreVoteBattle? @@ -39,6 +52,7 @@ public struct PreVoteFeature { public var detailLoadFailed: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? + public var shareSnapshotRequest: ShareSnapshotRequest? public var battleId: Int = 0 public var voteMode: VoteMode = .pre public var myPerspective: BattlePerspective? @@ -78,7 +92,8 @@ public struct PreVoteFeature { case onAppear case backButtonTapped case retryTapped - case shareTapped(snapshot: Data?) + case shareTapped + case shareSnapshotRendered(Data?) case optionTapped(optionId: Int) case primaryButtonTapped } @@ -87,6 +102,7 @@ public struct PreVoteFeature { case fetchBattleDetail case fetchMyPerspective case deleteMyPerspective(perspectiveId: Int) + case loadShareAvatars case prepareShare(ShareContent) case submitPreVote(battleId: Int, optionId: Int) case submitPostVote(battleId: Int, optionId: Int) @@ -97,6 +113,7 @@ public struct PreVoteFeature { case myPerspectiveResponse(Result) case deleteMyPerspectiveResponse(Result) case preVoteResponse(Result) + case shareAvatarsLoaded([Int: Data]) case sharePrepared(ShareItem) case postVoteResponse(Result) } @@ -121,6 +138,7 @@ public struct PreVoteFeature { case fetchBattleDetail case fetchMyPerspective case deleteMyPerspective + case loadShareAvatars case submitPreVote case submitPostVote } @@ -128,6 +146,7 @@ public struct PreVoteFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase @Dependency(\.analyticsUseCase) private var analyticsUseCase + @Dependency(\.shareUseCase) private var shareUseCase public var body: some Reducer { BindingReducer() @@ -187,39 +206,12 @@ extension PreVoteFeature { case .retryTapped: return .send(.async(.fetchBattleDetail)) - case let .shareTapped(snapshot): - let detail = state.battleDetail - let battle = state.battle - let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" - let url = PickeShareURL.battle(id: state.battleId, serverShareUrl: detail?.shareUrl) - let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL - let summary = { - if let description = detail?.description, !description.isEmpty { return description } - if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } - return battle?.summary ?? "" - }() - let hashtags: [String] = { - if let tags = detail?.categoryTags, !tags.isEmpty { - return tags.map { "#\($0.name)" } - } - return battle?.tags ?? [] - }() - let optionLine: String? = { - guard let left = battle?.leftOption.stance, - let right = battle?.rightOption.stance - else { return nil } - return "🆚 A: \(left) vs B: \(right)" - }() - let content = ShareContent( - title: title, - summary: summary, - hashtags: hashtags, - optionLine: optionLine, - url: url, - thumbnailURL: thumbnailURL, - snapshotData: snapshot - ) - return .send(.async(.prepareShare(content))) + case .shareTapped: + return .send(.async(.loadShareAvatars)) + + case let .shareSnapshotRendered(snapshot): + state.shareSnapshotRequest = nil + return .send(.async(.prepareShare(shareContent(state: state, snapshot: snapshot)))) case let .optionTapped(optionId): state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId @@ -238,6 +230,41 @@ extension PreVoteFeature { } } + /// 배틀 상세를 우선하고 프리뷰(battle) 값으로 폴백해 공유 본문을 조립. + private func shareContent(state: State, snapshot: Data?) -> ShareContent { + let detail = state.battleDetail + let battle = state.battle + let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" + let url = PickeShareURL.battle(id: state.battleId, serverShareUrl: detail?.shareUrl) + let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL + let summary = { + if let description = detail?.description, !description.isEmpty { return description } + if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } + return battle?.summary ?? "" + }() + let hashtags: [String] = { + if let tags = detail?.categoryTags, !tags.isEmpty { + return tags.map { "#\($0.name)" } + } + return battle?.tags ?? [] + }() + let optionLine: String? = { + guard let left = battle?.leftOption.stance, + let right = battle?.rightOption.stance + else { return nil } + return "🆚 A: \(left) vs B: \(right)" + }() + return ShareContent( + title: title, + summary: summary, + hashtags: hashtags, + optionLine: optionLine, + url: url, + thumbnailURL: thumbnailURL, + snapshotData: snapshot + ) + } + private func handleAsyncAction( state: inout State, action: AsyncAction @@ -278,27 +305,26 @@ extension PreVoteFeature { } .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) - case let .prepareShare(content): - return .run { send in - var items: [Any] = [content.displayText] - - if let url = URL(string: content.url) { - items.append(url) - } else { - items.append(content.url) - } - - if let data = content.snapshotData, let image = UIImage(data: data) { - items.append(image) - } else if let thumbnailURL = content.thumbnailURL, - let remoteURL = URL(string: thumbnailURL), - let (data, _) = try? await URLSession.shared.data(from: remoteURL), - let image = UIImage(data: data) - { - items.append(image) + case .loadShareAvatars: + guard let battle = state.battle else { + return .send(.async(.prepareShare(shareContent(state: state, snapshot: nil)))) + } + + let avatars = [battle.leftOption, battle.rightOption].map { ($0.optionId, $0.imageURL) } + + return .run { [useCase = shareUseCase] send in + var imageData: [Int: Data] = [:] + + for (optionId, imageURL) in avatars { + imageData[optionId] = await useCase.loadImageData(imageURL) } + await send(.inner(.shareAvatarsLoaded(imageData))) + } + .cancellable(id: CancelID.loadShareAvatars, cancelInFlight: true) - await send(.inner(.sharePrepared(ShareItem(items: items)))) + case let .prepareShare(content): + return .run { [useCase = shareUseCase] send in + await send(.inner(.sharePrepared(useCase.makeShareItem(content)))) } case let .submitPreVote(battleId, optionId): @@ -382,6 +408,10 @@ extension PreVoteFeature { return .none } + case let .shareAvatarsLoaded(imageData): + state.shareSnapshotRequest = ShareSnapshotRequest(avatarImageData: imageData) + return .none + case let .sharePrepared(item): state.shareItem = item return .none diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 2b68bf2..791e880 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -51,6 +51,10 @@ public struct PreVoteView: View { } } .onAppear { send(.onAppear) } + .onChange(of: store.shareSnapshotRequest) { _, request in + guard let request else { return } + send(.shareSnapshotRendered(captureCardSnapshot(request))) + } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) .presentationDetents([.fraction(0.5)]) @@ -178,7 +182,7 @@ extension PreVoteView { Spacer() Button { - shareWithSnapshot() + send(.shareTapped) } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 24, weight: .regular)) @@ -381,39 +385,11 @@ extension PreVoteView { // MARK: - Share snapshot extension PreVoteView { - /// 공유 트리거 — 옵션 아바타(철학자) 이미지를 먼저 비동기 로드한 뒤 카드 스냅샷을 렌더한다. - /// KFImage 는 ImageRenderer(동기 렌더) 에서 로드 전이라 빈 원으로 캡처되므로, - /// Kingfisher 로 미리 받아 `avatarOverrides` 로 주입해 동기 렌더한다. (RecapView 와 동일 패턴) - private func shareWithSnapshot() { - Task { @MainActor in - let avatars = await loadOptionAvatarImages() - send(.shareTapped(snapshot: captureCardSnapshot(avatarOverrides: avatars))) - } - } - - /// 좌/우 옵션의 원격 아바타를 Kingfisher 로 선로드 (실패한 쪽은 제외 → KFImage 폴백). - @MainActor - private func loadOptionAvatarImages() async -> [Int: UIImage] { - guard let battle = store.battle else { return [:] } - var images: [Int: UIImage] = [:] - for option in [battle.leftOption, battle.rightOption] { - guard let url = URL(string: option.imageURL) else { continue } - let image: UIImage? = await withCheckedContinuation { continuation in - KingfisherManager.shared.retrieveImage(with: url) { result in - continuation.resume(returning: try? result.get().image) - } - } - if let image { - images[option.optionId] = image - } - } - return images - } - @MainActor - private func captureCardSnapshot(avatarOverrides: [Int: UIImage]) -> Data? { + private func captureCardSnapshot(_ request: PreVoteFeature.ShareSnapshotRequest) -> Data? { guard let battle = store.battle else { return nil } - let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatarOverrides)) + let avatars = request.avatarImageData.compactMapValues(UIImage.init(data:)) + let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatars)) renderer.scale = UIScreen.main.scale return renderer.uiImage?.pngData() }