From 5eef31b8daa92bba45d26926bba84fb3cbe8d604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 14 Jul 2026 16:51:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=99=AF=E5=8C=BA=E6=8E=92?= =?UTF-8?q?=E9=98=9F=E8=AE=BE=E7=BD=AE=E9=A1=B5=E4=B8=8E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E9=85=8D=E7=BD=AE=E8=BF=81=E7=A7=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor --- suixinkan/Core/Upload/OSSUploadService.swift | 8 +- suixinkan/Core/WeChat/WeChatManager.swift | 4 +- .../Services/CloudTransferManager.swift | 8 +- .../Features/Location/LocationGeocoder.swift | 5 +- .../Features/Location/LocationProvider.swift | 13 +- .../ScenicApplicationViewModel.swift | 19 ++- .../Services/ScenicQueueTTSManager.swift | 132 +++++++++++------- .../Common/MediaPreviewViewController.swift | 12 +- .../CooperationAcquirerViewController.swift | 4 +- .../MaterialFormViewController.swift | 27 ++-- .../ProfileSpaceSettingsViewController.swift | 2 +- .../UploadSampleViewController.swift | 27 ++-- .../ScenicQueueSettingsViewController.swift | 4 +- suixinkan/UI/Task/TaskAddViewController.swift | 14 +- .../WiredCameraTransferViewController.swift | 11 +- suixinkanTests/StatisticsViewModelTests.swift | 5 +- .../WildPhotographerReportTests.swift | 10 +- 17 files changed, 181 insertions(+), 124 deletions(-) diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 0328235..34c3069 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -44,7 +44,7 @@ final class OSSUploadService { data: Data, fileName: String, scenicId: Int, - onProgress: @escaping (Int) -> Void + onProgress: @escaping @MainActor (Int) -> Void ) async throws -> String { try await uploadFile( data: data, @@ -52,7 +52,11 @@ final class OSSUploadService { fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", - onProgress: onProgress + onProgress: { progress in + Task { @MainActor in + onProgress(progress) + } + } ) } diff --git a/suixinkan/Core/WeChat/WeChatManager.swift b/suixinkan/Core/WeChat/WeChatManager.swift index d159c46..d75b7e8 100644 --- a/suixinkan/Core/WeChat/WeChatManager.swift +++ b/suixinkan/Core/WeChat/WeChatManager.swift @@ -59,9 +59,9 @@ final class WeChatManager: NSObject { shareCompletion = completion } - WXApi.send(request) { [weak self] success in + WXApi.send(request) { success in guard !success else { return } - Task { @MainActor in + Task { @MainActor [weak self] in self?.finishShare(with: .sendFailed(-1)) } } diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift index 5a1e280..8470d2f 100644 --- a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift @@ -170,8 +170,8 @@ final class CloudTransferManager { fileName: seed.fileName, fileType: seed.fileType, scenicId: scenicIdProvider() - ) { [weak self] progress in - Task { @MainActor in + ) { progress in + Task { @MainActor [weak self] in guard let self, self.isCurrentOperation(id: id, generation: generation) else { return } self.updateUploadProgress(id: id, progress: progress) } @@ -220,8 +220,8 @@ final class CloudTransferManager { let localURL = try await downloader.downloadFile( remoteURL: seed.remoteURL, fileName: seed.fileName - ) { [weak self] progress in - Task { @MainActor in + ) { progress in + Task { @MainActor [weak self] in guard let self, self.isCurrentOperation(id: id, generation: generation) else { return } self.updateDownloadProgress(id: id, progress: progress) } diff --git a/suixinkan/Features/Location/LocationGeocoder.swift b/suixinkan/Features/Location/LocationGeocoder.swift index 9529bf7..ec5f58a 100644 --- a/suixinkan/Features/Location/LocationGeocoder.swift +++ b/suixinkan/Features/Location/LocationGeocoder.swift @@ -6,14 +6,15 @@ import Foundation /// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。 +@MainActor final class LocationGeocoder: NSObject, AMapSearchDelegate { - static let shared = LocationGeocoder() + nonisolated static let shared = LocationGeocoder() private var searchAPI: AMapSearchAPI? private var continuation: CheckedContinuation? - private override init() { + nonisolated private override init() { super.init() } diff --git a/suixinkan/Features/Location/LocationProvider.swift b/suixinkan/Features/Location/LocationProvider.swift index e872f2d..165831a 100644 --- a/suixinkan/Features/Location/LocationProvider.swift +++ b/suixinkan/Features/Location/LocationProvider.swift @@ -7,19 +7,19 @@ import CoreLocation import Foundation /// 统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。 +@MainActor final class LocationProvider: NSObject, LocationProviding { - static let shared = LocationProvider() + nonisolated static let shared = LocationProvider() private let permissionManager = CLLocationManager() - private let geocoder: LocationGeocoder + private let geocoder = LocationGeocoder.shared - init(geocoder: LocationGeocoder = .shared) { - self.geocoder = geocoder + nonisolated override init() { super.init() - permissionManager.desiredAccuracy = kCLLocationAccuracyBest } + @MainActor func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot { try AMapBootstrap.requireConfigured() try await ensureLocationPermission() @@ -51,6 +51,7 @@ final class LocationProvider: NSObject, LocationProviding { } } + @MainActor func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { try AMapBootstrap.requireConfigured() try await ensureLocationPermission() @@ -77,12 +78,14 @@ final class LocationProvider: NSObject, LocationProviding { } } + @MainActor func reverseGeocode(latitude: Double, longitude: Double) async -> String { await geocoder.reverseGeocode(latitude: latitude, longitude: longitude) } /// 请求系统定位权限,首次弹窗后等待用户响应。 func ensureLocationPermission() async throws { + permissionManager.desiredAccuracy = kCLLocationAccuracyBest let initialStatus = permissionManager.authorizationStatus if initialStatus == .notDetermined { permissionManager.requestWhenInUseAuthorization() diff --git a/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift b/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift index c85ebce..11a4680 100644 --- a/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift +++ b/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift @@ -12,7 +12,7 @@ protocol ScenicApplicationImageUploading: AnyObject { data: Data, fileName: String, scenicId: Int, - onProgress: @escaping (Int) -> Void + onProgress: @escaping @MainActor (Int) -> Void ) async throws -> String } @@ -247,20 +247,19 @@ final class ScenicApplicationViewModel { notifyStateChange() do { + let progressBatch = batch let url = try await uploader.uploadScenicApplyImage( data: data, fileName: image.fileName, scenicId: scenicId, onProgress: { [weak self] progress in - Task { @MainActor in - self?.handleUploadProgress( - index: index, - batch: batch, - currentPosition: currentPosition, - total: total, - progress: progress - ) - } + self?.handleUploadProgress( + index: index, + batch: progressBatch, + currentPosition: currentPosition, + total: total, + progress: progress + ) } ) updateImage(at: index) { item in diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift index 7b1a1de..3d0af4c 100644 --- a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift @@ -41,8 +41,67 @@ protocol ScenicQueueTTSManaging: AnyObject { func estimateAnnouncementDurationMillis(_ text: String) -> Int64 } +/// 主线程隔离的系统语音合成器,统一管理播放状态与异步 continuation。 +@MainActor +private final class SystemVoiceSynthesizer: NSObject, @preconcurrency AVSpeechSynthesizerDelegate { + private let synthesizer = AVSpeechSynthesizer() + private var currentContinuation: CheckedContinuation? + private var currentUtterance: AVSpeechUtterance? + + nonisolated override init() { + super.init() + } + + /// 播放一段中文文本,并在完成或取消后结束等待。 + func speak(_ text: String) async { + synthesizer.delegate = self + finishCurrentSpeech() + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + + await withCheckedContinuation { continuation in + let utterance = AVSpeechUtterance(string: text) + utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") + utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86 + utterance.volume = 1.0 + currentContinuation = continuation + currentUtterance = utterance + synthesizer.speak(utterance) + } + } + + /// 停止当前播放并释放等待中的调用方。 + func stop() { + finishCurrentSpeech() + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + finishCurrentSpeech(matching: utterance) + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + finishCurrentSpeech(matching: utterance) + } + + private func finishCurrentSpeech(matching utterance: AVSpeechUtterance? = nil) { + if let utterance, utterance !== currentUtterance { + return + } + currentUtterance = nil + currentContinuation?.resume() + currentContinuation = nil + } +} + /// 阿里云 NLS TTS 管理器,优先使用 NUI SDK,失败时回退到系统中文语音。 -final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate { +/// +/// 线程安全性:循环状态只在 `loopQueue` 读写,原生引擎状态只在 `nativeQueue` 读写, +/// 系统语音合成由 `SystemVoiceSynthesizer` 的主 Actor 隔离保护。 +final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, @unchecked Sendable { static let shared = AliyunNLSTTSManager() private enum LoopKind { @@ -62,7 +121,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth let voicePath: String } - private let synthesizer = AVSpeechSynthesizer() + private let systemVoiceSynthesizer = SystemVoiceSynthesizer() private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts") private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS") private let appStore: AppStore @@ -71,15 +130,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth private let streamingPlayer: AliyunPCMStreamingPlaying private let workspacePathProvider: () -> String? private var activeLoop: LoopKind? - private var currentContinuation: CheckedContinuation? private var nativeContinuation: CheckedContinuation? - private var nlsToken: AliyunNLSToken? - private var lastCredentials: AliyunOSSCredentials? private var nativeReady = false private var nativeEngineKind: NativeEngineKind? private var nativeSampleRate: Double + private var customTextLoopingStorage = false - private(set) var customTextLooping = false + var customTextLooping: Bool { + loopQueue.sync { customTextLoopingStorage } + } var supportsOfflineTTS: Bool { bridge.sdkAvailable && offlineResources() != nil @@ -99,24 +158,22 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate super.init() - synthesizer.delegate = self self.bridge.delegate = self } func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { guard let api else { return } guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else { - nativeReady = false + await setNativeReady(false) return } do { let sts = try await api.aliyunSTS(bucket: "vipsky") - lastCredentials = sts.credentials let token = try await tokenProvider.createToken(credentials: sts.credentials) - nlsToken = token - nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials) + let isReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials) + await setNativeReady(isReady) } catch { - nativeReady = false + await setNativeReady(false) } } @@ -125,7 +182,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth loopQueue.async { [weak self] in guard let self else { return } self.activeLoop = .queueCall(queueNo) - self.customTextLooping = false + self.customTextLoopingStorage = false Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) } } } @@ -155,7 +212,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth loopQueue.async { [weak self] in guard let self else { return } self.activeLoop = .custom(trimmed) - self.customTextLooping = true + self.customTextLoopingStorage = true Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) } } return true @@ -167,7 +224,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth if case .custom = self.activeLoop { self.activeLoop = nil } - self.customTextLooping = false + self.customTextLoopingStorage = false self.stopSpeaking() } } @@ -208,7 +265,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth try? await Task.sleep(nanoseconds: 1_000_000_000) } if case .custom = expected { - customTextLooping = false + loopQueue.async { [weak self] in + self?.customTextLoopingStorage = false + } } } @@ -254,49 +313,28 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } } - private func speakWithSystemVoice(_ text: String) async { - await MainActor.run { - if synthesizer.isSpeaking { - synthesizer.stopSpeaking(at: .immediate) - } - } + private func setNativeReady(_ isReady: Bool) async { await withCheckedContinuation { continuation in - currentContinuation = continuation - let utterance = AVSpeechUtterance(string: text) - utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") - utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86 - utterance.volume = 1.0 - DispatchQueue.main.async { - self.synthesizer.speak(utterance) + nativeQueue.async { [weak self] in + self?.nativeReady = isReady + continuation.resume() } } } + private func speakWithSystemVoice(_ text: String) async { + await systemVoiceSynthesizer.speak(text) + } + private func stopSpeaking() { nativeQueue.async { [weak self] in self?.cancelNativeSpeakLocked(resumeExisting: true) } - DispatchQueue.main.async { - if self.synthesizer.isSpeaking { - self.synthesizer.stopSpeaking(at: .immediate) - } - self.resumeCurrentContinuationIfNeeded() + Task { @MainActor [systemVoiceSynthesizer] in + systemVoiceSynthesizer.stop() } } - private func resumeCurrentContinuationIfNeeded() { - currentContinuation?.resume() - currentContinuation = nil - } - - func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { - resumeCurrentContinuationIfNeeded() - } - - func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { - resumeCurrentContinuationIfNeeded() - } - private func prepareNativeEngineIfAvailable( token: AliyunNLSToken, credentials: AliyunOSSCredentials diff --git a/suixinkan/UI/Common/MediaPreviewViewController.swift b/suixinkan/UI/Common/MediaPreviewViewController.swift index 38c2065..f80b9b7 100644 --- a/suixinkan/UI/Common/MediaPreviewViewController.swift +++ b/suixinkan/UI/Common/MediaPreviewViewController.swift @@ -554,7 +554,17 @@ private final class MediaPreviewPlaybackButton: UIButton { private func setupUI() { tintColor = .white backgroundColor = UIColor.white.withAlphaComponent(0.08) - adjustsImageWhenHighlighted = false + var buttonConfiguration = UIButton.Configuration.plain() + buttonConfiguration.baseForegroundColor = .white + buttonConfiguration.contentInsets = .zero + configuration = buttonConfiguration + configurationUpdateHandler = { button in + var updatedConfiguration = button.configuration + updatedConfiguration?.baseForegroundColor = .white + button.configuration = updatedConfiguration + button.alpha = 1 + button.imageView?.alpha = 1 + } layer.masksToBounds = false layer.shadowColor = UIColor.black.cgColor layer.shadowOpacity = 0.28 diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift index 2d4450b..6e3ea9a 100644 --- a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -101,8 +101,8 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa case .authorized: presentScanner() case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - Task { @MainActor in + AVCaptureDevice.requestAccess(for: .video) { granted in + Task { @MainActor [weak self] in if granted { self?.presentScanner() } diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index 4c72e74..17dbd4e 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -358,28 +358,27 @@ final class MaterialFormViewController: BaseViewController { } private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem { - let size = videoDisplaySize(url: sourceURL) + let preview = videoPreview(url: sourceURL) return MaterialUploadedMediaItem( data: data, - thumbnailData: videoThumbnailData(url: sourceURL), + thumbnailData: preview.thumbnailData, fileName: fileName, - width: Int(size.width), - height: Int(size.height) + width: preview.width, + height: preview.height ) } - private func videoDisplaySize(url: URL) -> CGSize { - let asset = AVAsset(url: url) - guard let track = asset.tracks(withMediaType: .video).first else { return .zero } - let transformed = track.naturalSize.applying(track.preferredTransform) - return CGSize(width: abs(transformed.width), height: abs(transformed.height)) - } - - private func videoThumbnailData(url: URL) -> Data? { + private func videoPreview(url: URL) -> (thumbnailData: Data?, width: Int, height: Int) { let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) generator.appliesPreferredTrackTransform = true - guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } - return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) + guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { + return (nil, 0, 0) + } + return ( + UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75), + imageRef.width, + imageRef.height + ) } } diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index 26564a2..f7bb96a 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -1032,7 +1032,7 @@ final class ProfileAddScheduleViewController: BaseViewController { api: profileAPI ) if success { - await MainActor.run { navigationController?.popViewController(animated: true) } + _ = await MainActor.run { navigationController?.popViewController(animated: true) } } } } diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift index c2b10e8..3868946 100644 --- a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift +++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift @@ -417,28 +417,27 @@ final class UploadSampleViewController: SampleBaseViewController { } private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> SampleUploadedMediaItem { - let size = videoDisplaySize(url: sourceURL) + let preview = videoPreview(url: sourceURL) return SampleUploadedMediaItem( data: data, - thumbnailData: videoThumbnailData(url: sourceURL), + thumbnailData: preview.thumbnailData, fileName: fileName, - width: Int(size.width), - height: Int(size.height) + width: preview.width, + height: preview.height ) } - private func videoDisplaySize(url: URL) -> CGSize { - let asset = AVAsset(url: url) - guard let track = asset.tracks(withMediaType: .video).first else { return .zero } - let transformed = track.naturalSize.applying(track.preferredTransform) - return CGSize(width: abs(transformed.width), height: abs(transformed.height)) - } - - private func videoThumbnailData(url: URL) -> Data? { + private func videoPreview(url: URL) -> (thumbnailData: Data?, width: Int, height: Int) { let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) generator.appliesPreferredTrackTransform = true - guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } - return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) + guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { + return (nil, 0, 0) + } + return ( + UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75), + imageRef.width, + imageRef.height + ) } } diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift index 3f33230..bc2f9f2 100644 --- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift @@ -469,7 +469,9 @@ final class ScenicQueueSettingsViewController: BaseViewController { button.backgroundColor = ScenicQueueTokens.bannerBlue button.layer.cornerRadius = ScenicQueueTokens.radiusInput button.addTarget(self, action: action, for: .touchUpInside) - button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + ) return button } diff --git a/suixinkan/UI/Task/TaskAddViewController.swift b/suixinkan/UI/Task/TaskAddViewController.swift index 57316e2..71c7c4e 100644 --- a/suixinkan/UI/Task/TaskAddViewController.swift +++ b/suixinkan/UI/Task/TaskAddViewController.swift @@ -317,7 +317,7 @@ final class TaskAddViewController: BaseViewController { let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "") viewModel.addUploadPlaceholder( fileName: TaskMediaLoader.suggestedFileName( - from: result.itemProvider, + from: result.itemProvider.suggestedName, fallback: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")" ), fileType: isImage ? 2 : 1, @@ -472,8 +472,8 @@ enum TaskMediaLoader { } /// 优先使用系统相册提供的原文件名。 - static func suggestedFileName(from provider: NSItemProvider, fallback: String) -> String { - guard let suggestedName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines), + static func suggestedFileName(from suggestedFileName: String?, fallback: String) -> String { + guard let suggestedName = suggestedFileName?.trimmingCharacters(in: .whitespacesAndNewlines), !suggestedName.isEmpty else { return fallback } @@ -493,7 +493,8 @@ enum TaskMediaLoader { } private static func loadImage(from provider: NSItemProvider) async throws -> Payload { - try await withCheckedThrowingContinuation { continuation in + let suggestedName = provider.suggestedName + return try await withCheckedThrowingContinuation { continuation in let identifier = provider.registeredTypeIdentifiers.first { UTType($0)?.conforms(to: .image) == true } ?? UTType.image.identifier @@ -508,7 +509,7 @@ enum TaskMediaLoader { } let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension let fallback = "image_\(UUID().uuidString).\(ext)" - let fileName = suggestedFileName(from: provider, fallback: fallback) + let fileName = suggestedFileName(from: suggestedName, fallback: fallback) let previewData = UIImage(data: data).flatMap(makePreviewData(from:)) continuation.resume(returning: Payload(data: data, fileName: fileName, previewData: previewData)) } @@ -517,6 +518,7 @@ enum TaskMediaLoader { private static func loadVideo(from provider: NSItemProvider) async throws -> Payload { let typeIdentifier = UTType.movie.identifier + let suggestedName = provider.suggestedName return try await withCheckedThrowingContinuation { continuation in provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in if let error { @@ -529,7 +531,7 @@ enum TaskMediaLoader { } let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension let fallback = "video_\(UUID().uuidString).\(ext)" - let fileName = suggestedFileName(from: provider, fallback: fallback) + let fileName = suggestedFileName(from: suggestedName, fallback: fallback) continuation.resume(returning: Payload( data: data, fileName: fileName, diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 403089c..2d8f8c8 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -858,6 +858,11 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { ) async -> TravelAlbumPhoneAlbumImportItem? { let provider = result.itemProvider let typeIdentifier = preferredImageTypeIdentifier(from: provider) + let fileName = importFileName( + suggestedName: provider.suggestedName, + typeIdentifier: typeIdentifier, + index: index + ) return await withCheckedContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in guard let data, !data.isEmpty else { @@ -867,7 +872,7 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { continuation.resume( returning: TravelAlbumPhoneAlbumImportItem( data: data, - fileName: importFileName(provider: provider, typeIdentifier: typeIdentifier, index: index), + fileName: fileName, createdAt: Date() ) ) @@ -881,9 +886,9 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { } ?? UTType.image.identifier } - private static func importFileName(provider: NSItemProvider, typeIdentifier: String, index: Int) -> String { + private static func importFileName(suggestedName: String?, typeIdentifier: String, index: Int) -> String { let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)" - let rawName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawName = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) let baseName = rawName?.isEmpty == false ? rawName! : fallback guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else { return baseName diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift index bb7a3b6..e944f0b 100644 --- a/suixinkanTests/StatisticsViewModelTests.swift +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -83,9 +83,6 @@ final class StatisticsViewModelTests: XCTestCase { } func testUpdateRangeByShortcutAllClearsTimes() async { - let summaryJSON = """ - {"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}} - """.data(using: .utf8)! let listJSON = """ {"code":100000,"msg":"success","data":{"total":0,"data":[]}} """.data(using: .utf8)! @@ -224,7 +221,7 @@ final class StatisticsViewModelTests: XCTestCase { formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy/MM/dd" - var calendar = Calendar(identifier: .gregorian) + let calendar = Calendar(identifier: .gregorian) let components = calendar.dateComponents([.year, .month], from: Date()) let firstDay = calendar.date(from: components) ?? Date() return formatter.string(from: firstDay) diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index dc18695..1b7694b 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -1616,9 +1616,7 @@ private final class MockRiskMapLocationProvider: LocationProviding, @unchecked S private var recordedAccuracies: [CLLocationAccuracy] = [] var requestedAccuracies: [CLLocationAccuracy] { - lock.lock() - defer { lock.unlock() } - return recordedAccuracies + lock.withLock { recordedAccuracies } } init( @@ -1639,9 +1637,9 @@ private final class MockRiskMapLocationProvider: LocationProviding, @unchecked S } func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { - lock.lock() - recordedAccuracies.append(desiredAccuracy) - lock.unlock() + lock.withLock { + recordedAccuracies.append(desiredAccuracy) + } await onCoordinateRequest?(desiredAccuracy) switch result { case .success(let latitude, let longitude):