From 8f369867b23a936d2a49b12150eb50357d582304 Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Sun, 7 Sep 2025 13:55:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B2=E7=9B=92=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=89=93=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/Models/BlindModels.swift | 87 ++++-- wake/Utils/ApiClient/BlindBoxApi.swift | 98 +++---- wake/Utils/NetworkService.swift | 37 +++ wake/Utils/Router.swift | 6 +- wake/View/Blind/ContentView.swift | 390 +++++++++++++++---------- wake/View/OnBoarding/UserInfo.swift | 2 +- 6 files changed, 385 insertions(+), 235 deletions(-) diff --git a/wake/Models/BlindModels.swift b/wake/Models/BlindModels.swift index 76412f5..c3faf16 100644 --- a/wake/Models/BlindModels.swift +++ b/wake/Models/BlindModels.swift @@ -68,19 +68,37 @@ struct BlindCount: Codable { // MARK: - Blind Box Data struct BlindBoxData: Codable { - let id: Int64 + let id: String let boxCode: String - let userId: Int64 + let userId: String let name: String let boxType: String let features: String? - let url: String? + let resultFile: FileInfo? let status: String let workflowInstanceId: String? - // 视频生成时间 let videoGenerateTime: String? let createTime: String - let description: String? + let coverFile: FileInfo? + let description: String + + // 添加计算属性以获取Int64值 + var idValue: Int64 { Int64(id) ?? 0 } + var userIdValue: Int64 { Int64(userId) ?? 0 } + + struct FileInfo: Codable { + let id: String + let fileName: String? + let url: String? + let metadata: [String: String]? + + enum CodingKeys: String, CodingKey { + case id + case fileName = "file_name" + case url + case metadata + } + } enum CodingKeys: String, CodingKey { case id @@ -89,43 +107,68 @@ struct BlindBoxData: Codable { case name case boxType = "box_type" case features - case url + case resultFile = "result_file" case status case workflowInstanceId = "workflow_instance_id" case videoGenerateTime = "video_generate_time" case createTime = "create_time" + case coverFile = "cover_file" case description } - init(id: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?, url: String?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, description: String?) { + init(id: String, boxCode: String, userId: String, name: String, boxType: String, features: String?, resultFile: FileInfo?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, coverFile: FileInfo?, description: String) { self.id = id self.boxCode = boxCode self.userId = userId self.name = name self.boxType = boxType self.features = features - self.url = url + self.resultFile = resultFile self.status = status self.workflowInstanceId = workflowInstanceId self.videoGenerateTime = videoGenerateTime self.createTime = createTime + self.coverFile = coverFile self.description = description } init(from listItem: BlindList) { - self.init( - id: Int64(listItem.id) ?? 0, - boxCode: listItem.boxCode, - userId: Int64(listItem.userId) ?? 0, - name: listItem.name, - boxType: listItem.boxType, - features: listItem.features, - url: listItem.resultFile?.url, - status: listItem.status, - workflowInstanceId: listItem.workflowInstanceId, - videoGenerateTime: listItem.videoGenerateTime, - createTime: listItem.createTime, - description: listItem.description - ) + self.id = listItem.id + self.boxCode = listItem.boxCode + self.userId = listItem.userId + self.name = listItem.name + self.boxType = listItem.boxType + self.features = listItem.features + + // 转换FileInfo类型 + if let resultFileInfo = listItem.resultFile { + self.resultFile = FileInfo( + id: resultFileInfo.id, + fileName: resultFileInfo.fileName, + url: resultFileInfo.url, + metadata: resultFileInfo.metadata + ) + } else { + self.resultFile = nil + } + + self.status = listItem.status + self.workflowInstanceId = listItem.workflowInstanceId + self.videoGenerateTime = listItem.videoGenerateTime + self.createTime = listItem.createTime + + // 转换coverFile的FileInfo类型 + if let coverFileInfo = listItem.coverFile { + self.coverFile = FileInfo( + id: coverFileInfo.id, + fileName: coverFileInfo.fileName, + url: coverFileInfo.url, + metadata: coverFileInfo.metadata + ) + } else { + self.coverFile = nil + } + + self.description = listItem.description ?? "" } } diff --git a/wake/Utils/ApiClient/BlindBoxApi.swift b/wake/Utils/ApiClient/BlindBoxApi.swift index 2d13cb4..7e9ab8b 100644 --- a/wake/Utils/ApiClient/BlindBoxApi.swift +++ b/wake/Utils/ApiClient/BlindBoxApi.swift @@ -14,57 +14,7 @@ import Foundation // MARK: - Generate Blind Box Response Model struct GenerateBlindBoxResponse: Codable { let code: Int - let data: BlindBoxDataWrapper? - - struct BlindBoxDataWrapper: Codable { - let id: String - let boxCode: String - let userId: String - let name: String - let boxType: String - let features: String? - let resultFile: FileInfo? - let status: String - let workflowInstanceId: String? - let videoGenerateTime: String? - let createTime: String - let coverFile: FileInfo? - let description: String - - // 添加计算属性以获取Int64值 - var idValue: Int64 { Int64(id) ?? 0 } - var userIdValue: Int64 { Int64(userId) ?? 0 } - - struct FileInfo: Codable { - let id: String - let fileName: String? - let url: String? - let metadata: [String: String]? - - enum CodingKeys: String, CodingKey { - case id - case fileName = "file_name" - case url - case metadata - } - } - - enum CodingKeys: String, CodingKey { - case id - case boxCode = "box_code" - case userId = "user_id" - case name - case boxType = "box_type" - case features - case resultFile = "result_file" - case status - case workflowInstanceId = "workflow_instance_id" - case videoGenerateTime = "video_generate_time" - case createTime = "create_time" - case coverFile = "cover_file" - case description - } - } + let data: BlindBoxData? } // MARK: - Blind Box API Client @@ -81,7 +31,7 @@ class BlindBoxApi { func generateBlindBox( boxType: String, materialIds: [String], - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { // 将Codable结构体转换为字典 let parameters: [String: Any] = [ @@ -108,4 +58,48 @@ class BlindBoxApi { } ) } + + /// 获取盲盒信息 + /// - Parameters: + /// - boxId: 盲盒ID + /// - completion: 完成回调,返回盲盒数据或错误 + func getBlindBox( + boxId: String, + completion: @escaping (Result) -> Void + ) { + let path = "/blind_box/query/\(boxId)" + + NetworkService.shared.getWithToken( + path: path, + completion: { (result: Result) in + DispatchQueue.main.async { + switch result { + case .success(let response): + if response.code == 0 { + completion(.success(response.data)) + } else { + completion(.failure(NetworkError.serverError("服务器返回错误码: \(response.code)"))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + ) + } + + /// 使用 async/await 获取盲盒信息 + /// - Parameter boxId: 盲盒ID + /// - Returns: 盲盒数据 + @available(iOS 13.0, *) + func getBlindBox(boxId: String) async throws -> BlindBoxData? { + let path = "/blind_box/query/\(boxId)" + let response: GenerateBlindBoxResponse = try await NetworkService.shared.getWithToken(path: path) + + if response.code == 0 { + return response.data + } else { + throw NetworkError.serverError("服务器返回错误码: \(response.code)") + } + } } \ No newline at end of file diff --git a/wake/Utils/NetworkService.swift b/wake/Utils/NetworkService.swift index d4d8d4a..75eb87c 100644 --- a/wake/Utils/NetworkService.swift +++ b/wake/Utils/NetworkService.swift @@ -108,6 +108,43 @@ extension NetworkService: NetworkServiceProtocol { } } +// MARK: - Async/Await Extensions +extension NetworkService { + /// 使用 async/await 的 GET 请求(带Token) + public func getWithToken( + path: String, + parameters: [String: Any]? = nil + ) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + getWithToken(path: path, parameters: parameters) { (result: Result) in + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// 使用 async/await 的 POST 请求(带Token) + public func postWithToken( + path: String, + parameters: [String: Any] + ) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + postWithToken(path: path, parameters: parameters) { (result: Result) in + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } +} + public enum NetworkError: Error { case invalidURL case noData diff --git a/wake/Utils/Router.swift b/wake/Utils/Router.swift index 609229b..a33b5e6 100644 --- a/wake/Utils/Router.swift +++ b/wake/Utils/Router.swift @@ -7,7 +7,7 @@ enum AppRoute: Hashable { case feedbackView case feedbackDetail(type: FeedbackView.FeedbackType) case mediaUpload - case blindBox(mediaType: BlindBoxMediaType) + case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil) case memories case subscribe @@ -31,8 +31,8 @@ enum AppRoute: Hashable { FeedbackDetailView(feedbackType: type) case .mediaUpload: MediaUploadView() - case .blindBox(let mediaType): - BlindBoxView(mediaType: mediaType) + case .blindBox(let mediaType, let blindBoxId): + BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId) case .blindOutcome(let media, let time, let description): BlindOutcomeView(media: media, time: time, description: description) case .memories: diff --git a/wake/View/Blind/ContentView.swift b/wake/View/Blind/ContentView.swift index 20a9558..724ba8e 100644 --- a/wake/View/Blind/ContentView.swift +++ b/wake/View/Blind/ContentView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import AVKit +import Foundation // 添加通知名称 extension Notification.Name { @@ -69,6 +70,8 @@ struct AVPlayerController: UIViewControllerRepresentable { struct BlindBoxView: View { let mediaType: BlindBoxMediaType + let currentBoxId: String? + @State private var showModal = false // 控制用户资料弹窗显示 @State private var showSettings = false // 控制设置页面显示 @State private var isMember = false // 是否是会员 @@ -78,7 +81,7 @@ struct BlindBoxView: View { @State private var blindCount: BlindCount? = nil @State private var blindList: [BlindList] = [] // Changed to array // 生成盲盒 - @State private var blindGenerate : BlindBoxData? + @State private var blindGenerate: BlindBoxData? @State private var showLottieAnimation = true // 轮询接口 @State private var isPolling = false @@ -106,8 +109,9 @@ struct BlindBoxView: View { // 查询数据 - 简单查询 @Query private var login: [Login] - init(mediaType: BlindBoxMediaType) { + init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { self.mediaType = mediaType + self.currentBoxId = blindBoxId } // 倒计时 @@ -143,88 +147,138 @@ struct BlindBoxView: View { } } - private func loadMedia() { + private func loadBlindBox() async { print("loadMedia called with mediaType: \(mediaType)") - switch mediaType { - case .video: - loadVideo() - currentBoxType = "Video" - startPolling() - case .image: - loadImage() - currentBoxType = "Image" - startPolling() - case .all: - print("Loading all content...") - // 检查盲盒列表,如果不存在First/Second盲盒,则跳转到对应的页面重新触发新手引导 - NetworkService.shared.get( - path: "/blind_boxs/query", - parameters: nil - ) { (result: Result, NetworkError>) in - DispatchQueue.main.async { - switch result { - case .success(let response): - if response.data.count == 0 { - // 跳转到新手引导-First盲盒页面 - print("❌ 没有盲盒,跳转到新手引导-First盲盒页面") - // return - } - if response.data.count == 1 && response.data[0].boxType == "First" { - // 跳转到新手引导-Second盲盒页面 - print("❌ 只有First盲盒,跳转到新手引导-Second盲盒页面") - // return - } + if self.currentBoxId != nil { + print("指定监听某盲盒结果: ", self.currentBoxId! as Any) + // 启动轮询查询盲盒状态 + await pollingToQuerySingleBox() + } + + // switch mediaType { + // case .video: + // loadVideo() + // currentBoxType = "Video" + // startPolling() + // case .image: + // loadImage() + // currentBoxType = "Image" + // startPolling() + // case .all: + // print("Loading all content...") + // // 检查盲盒列表,如果不存在First/Second盲盒,则跳转到对应的页面重新触发新手引导 + // // 注意:这部分代码仍使用传统的闭包方式,因为NetworkService.shared.get不支持async/await + // NetworkService.shared.get( + // path: "/blind_boxs/query", + // parameters: nil + // ) { (result: Result, NetworkError>) in + // DispatchQueue.main.async { + // switch result { + // case .success(let response): + // if response.data.count == 0 { + // // 跳转到新手引导-First盲盒页面 + // print("❌ 没有盲盒,跳转到新手引导-First盲盒页面") + // // return + // } + // if response.data.count == 1 && response.data[0].boxType == "First" { + // // 跳转到新手引导-Second盲盒页面 + // print("❌ 只有First盲盒,跳转到新手引导-Second盲盒页面") + // // return + // } - self.blindList = response.data ?? [] - // 如果列表为空数组 设置盲盒状态为none - if self.blindList.isEmpty { - self.animationPhase = .none - } - print("✅ 成功获取 \(self.blindList.count) 个盲盒") - case .failure(let error): - self.blindList = [] - self.animationPhase = .none - print("❌ 获取盲盒列表失败:", error.localizedDescription) - } - } - } + // self.blindList = response.data ?? [] + // // 如果列表为空数组 设置盲盒状态为none + // if self.blindList.isEmpty { + // self.animationPhase = .none + // } + // print("✅ 成功获取 \(self.blindList.count) 个盲盒") + // case .failure(let error): + // self.blindList = [] + // self.animationPhase = .none + // print("❌ 获取盲盒列表失败:", error.localizedDescription) + // } + // } + // } - // 会员信息 - NetworkService.shared.get( - path: "/membership/personal-center-info", - parameters: nil - ) { (result: Result) in - DispatchQueue.main.async { - switch result { - case .success(let response): - self.memberProfile = response.data - self.isMember = response.data.membershipLevel == "Pioneer" - self.memberDate = response.data.membershipEndAt ?? "" - print("✅ 成功获取会员信息:", response.data) - print("✅ 用户ID:", response.data.userInfo.userId) - case .failure(let error): - print("❌ 获取会员信息失败:", error) - } - } - } - // 盲盒数量 - NetworkService.shared.get( - path: "/blind_box/available/quantity", - parameters: nil - ) { (result: Result, NetworkError>) in - DispatchQueue.main.async { - switch result { - case .success(let response): - self.blindCount = response.data - print("✅ 成功获取盲盒数量:", response.data) - case .failure(let error): - print("❌ 获取数量失败:", error) - } - } - } - } + // // // 会员信息 + // // NetworkService.shared.get( + // // path: "/membership/personal-center-info", + // // parameters: nil + // // ) { (result: Result) in + // // DispatchQueue.main.async { + // // switch result { + // // case .success(let response): + // // self.memberProfile = response.data + // // self.isMember = response.data.membershipLevel == "Pioneer" + // // self.memberDate = response.data.membershipEndAt ?? "" + // // print("✅ 成功获取会员信息:", response.data) + // // print("✅ 用户ID:", response.data.userInfo.userId) + // // case .failure(let error): + // // print("❌ 获取会员信息失败:", error) + // // } + // // } + // // } + // // // 盲盒数量 + // // NetworkService.shared.get( + // // path: "/blind_box/available/quantity", + // // parameters: nil + // // ) { (result: Result, NetworkError>) in + // // DispatchQueue.main.async { + // // switch result { + // // case .success(let response): + // // self.blindCount = response.data + // // print("✅ 成功获取盲盒数量:", response.data) + // // case .failure(let error): + // // print("❌ 获取数量失败:", error) + // // } + // // } + // // } + // } } + + private func pollingToQuerySingleBox() async { + stopPolling() + isPolling = true + + // 轮询查询盲盒状态,直到状态为Unopened + while isPolling { + do { + let blindBoxData = try await BlindBoxApi.shared.getBlindBox(boxId: self.currentBoxId!) + + // 更新UI + if let data = blindBoxData { + self.blindGenerate = data + + // 根据盲盒类型设置媒体URL + if mediaType == .video { + self.videoURL = data.resultFile?.url ?? "" + } else if mediaType == .image { + self.imageURL = data.resultFile?.url ?? "" + } + + print("✅ 成功获取盲盒数据: \(data.name), 状态: \(data.status)") + + // 检查状态是否为Unopened,如果是则停止轮询 + if data.status == "Unopened" { + print("✅ 盲盒已准备就绪,停止轮询") + stopPolling() + break + } + } + + // 等待2秒后继续轮询 + try await Task.sleep(nanoseconds: 2_000_000_000) + } catch { + print("❌ 获取盲盒数据失败: \(error)") + // 处理错误情况 + self.animationPhase = .none + stopPolling() + break + } + } + } + // 轮询接口 private func startPolling() { stopPolling() @@ -244,52 +298,54 @@ struct BlindBoxView: View { return } - NetworkService.shared.postWithToken( - path: "/blind_box/generate/mock", - parameters: ["box_type": currentBoxType] - ) { (result: Result, NetworkError>) in - DispatchQueue.main.async { - switch result { - case .success(let response): - let data = response.data - self.blindGenerate = data - print("当前盲盒状态: \(data.status)") - // 更新显示数据 - if self.mediaType == .all, let firstItem = self.blindList.first { - self.displayData = BlindBoxData(from: firstItem) - } else { - self.displayData = data - } - - // 发送状态变更通知 - NotificationCenter.default.post( - name: .blindBoxStatusChanged, - object: nil, - userInfo: ["status": data.status] - ) - - if data.status != "Preparing" { - self.stopPolling() - print("✅ 盲盒准备就绪,状态: \(data.status)") - if self.mediaType == .video { - self.videoURL = data.url ?? "" - } else if self.mediaType == .image { - self.imageURL = data.url ?? "" - } - } else { - self.pollingTimer = Timer.scheduledTimer( - withTimeInterval: 2.0, - repeats: false - ) { _ in - self.checkBlindBoxStatus() - } - } - case .failure(let error): - print("❌ 获取盲盒状态失败: \(error.localizedDescription)") - self.stopPolling() - } - } - } +// NetworkService.shared.postWithToken( +// path: "/blind_box/generate/mock", +// parameters: ["box_type": currentBoxType] +// ) { (result: Result) in +// DispatchQueue.main.async { +// switch result { +// case .success(let response): +// let data = response.data +// self.blindGenerate = data +// print("当前盲盒状态: \(data?.status ?? "Unknown")") +// // 更新显示数据 +// if self.mediaType == .all, let firstItem = self.blindList.first { +// self.displayData = BlindBoxData(from: firstItem) +// } else { +// self.displayData = data +// } +// +// // 发送状态变更通知 +// if let status = data?.status { +// NotificationCenter.default.post( +// name: .blindBoxStatusChanged, +// object: nil, +// userInfo: ["status": status] +// ) +// } +// +// if data?.status != "Preparing" { +// self.stopPolling() +// print("✅ 盲盒准备就绪,状态: \(data?.status ?? "Unknown")") +// if self.mediaType == .video { +// self.videoURL = data?.resultFile?.url ?? "" +// } else if self.mediaType == .image { +// self.imageURL = data?.resultFile?.url ?? "" +// } +// } else { +// self.pollingTimer = Timer.scheduledTimer( +// withTimeInterval: 2.0, +// repeats: false +// ) { _ in +// self.checkBlindBoxStatus() +// } +// } +// case .failure(let error): +// print("❌ 获取盲盒状态失败: \(error.localizedDescription)") +// self.stopPolling() +// } +// } +// } } private func loadImage() { @@ -409,40 +465,45 @@ struct BlindBoxView: View { .onAppear { print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 Current thread: \(Thread.current)") + + + // 初始化显示数据 - if mediaType == .all, let firstItem = blindList.first { - displayData = BlindBoxData(from: firstItem) - } else { - displayData = blindGenerate - } + // if mediaType == .all, let firstItem = blindList.first { + // displayData = BlindBoxData(from: firstItem) + // } else { + // displayData = blindGenerate + // } // 添加盲盒状态变化监听 - NotificationCenter.default.addObserver( - forName: .blindBoxStatusChanged, - object: nil, - queue: .main - ) { notification in - if let status = notification.userInfo?["status"] as? String { - switch status { - case "Preparing": - withAnimation { - self.animationPhase = .loading - } - case "Unopened": - withAnimation { - self.animationPhase = .ready - } - default: - // 其他状态不处理 - withAnimation { - self.animationPhase = .ready - } - break - } - } - } + // NotificationCenter.default.addObserver( + // forName: .blindBoxStatusChanged, + // object: nil, + // queue: .main + // ) { notification in + // if let status = notification.userInfo?["status"] as? String { + // switch status { + // case "Preparing": + // withAnimation { + // self.animationPhase = .loading + // } + // case "Unopened": + // withAnimation { + // self.animationPhase = .ready + // } + // default: + // // 其他状态不处理 + // withAnimation { + // self.animationPhase = .ready + // } + // break + // } + // } + // } // 调用接口 - loadMedia() + Task { + await loadBlindBox() + } } .onDisappear { stopPolling() @@ -722,10 +783,10 @@ struct BlindBoxView: View { if !showScalingOverlay && !showMedia { VStack(alignment: .leading, spacing: 8) { // 从变量blindGenerate中获取description - Text(blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn") + Text(blindGenerate?.name ?? "Some box") .font(Typography.font(for: .body, family: .quicksandBold)) .foregroundColor(Color.themeTextMessageMain) - Text(blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation") + Text(blindGenerate?.description ?? "") .font(.system(size: 14)) .foregroundColor(Color.themeTextMessageMain) } @@ -876,6 +937,21 @@ struct BlindBoxView: View { } } +// 预览第一个盲盒 +#Preview("First Blind Box") { + BlindBoxView(mediaType: .image, blindBoxId: "7370140297747107840") + .onAppear { + // 仅在Preview中设置模拟令牌(不要在生产代码中使用) + #if DEBUG + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + // 设置模拟令牌用于Preview + let previewToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNzAwMTY5NzMzODE1NzA1NjAsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTc3NyIsImV4cCI6MTc1Nzc1Mzc3NH0.tZ8p5sW4KX6HFoJpJN0e4VmJOAGhTrYD2yTwQwilKpufzqOAfXX4vpGYBurgBIcHj2KmXKX2PQMOeeAtvAypDA" + let _ = KeychainHelper.saveAccessToken(previewToken) + print("🔑 Preview token set for testing") + } + #endif + } +} // struct TransparentVideoPlayer: UIViewRepresentable { // func makeUIView(context: Context) -> UIView { // let view = UIView() diff --git a/wake/View/OnBoarding/UserInfo.swift b/wake/View/OnBoarding/UserInfo.swift index c864d11..18d1202 100644 --- a/wake/View/OnBoarding/UserInfo.swift +++ b/wake/View/OnBoarding/UserInfo.swift @@ -198,7 +198,7 @@ struct UserInfo: View { case .success(let blindBoxData): print("✅ 盲盒生成成功: \(blindBoxData?.id ?? "0")") // 导航到首页盲盒等待用户开启第一个盲盒 - Router.shared.navigate(to: .blindBox(mediaType: .image)) + Router.shared.navigate(to: .blindBox(mediaType: .image, blindBoxId: blindBoxData?.id ?? "0")) case .failure(let error): print("❌ 盲盒生成失败: \(error.localizedDescription)") }