完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:51:23 +08:00
parent 6336feff27
commit 5eef31b8da
17 changed files with 181 additions and 124 deletions

View File

@ -44,7 +44,7 @@ final class OSSUploadService {
data: Data, data: Data,
fileName: String, fileName: String,
scenicId: Int, scenicId: Int,
onProgress: @escaping (Int) -> Void onProgress: @escaping @MainActor (Int) -> Void
) async throws -> String { ) async throws -> String {
try await uploadFile( try await uploadFile(
data: data, data: data,
@ -52,7 +52,11 @@ final class OSSUploadService {
fileType: 2, fileType: 2,
scenicId: scenicId, scenicId: scenicId,
moduleType: "scenic_apply", moduleType: "scenic_apply",
onProgress: onProgress onProgress: { progress in
Task { @MainActor in
onProgress(progress)
}
}
) )
} }

View File

@ -59,9 +59,9 @@ final class WeChatManager: NSObject {
shareCompletion = completion shareCompletion = completion
} }
WXApi.send(request) { [weak self] success in WXApi.send(request) { success in
guard !success else { return } guard !success else { return }
Task { @MainActor in Task { @MainActor [weak self] in
self?.finishShare(with: .sendFailed(-1)) self?.finishShare(with: .sendFailed(-1))
} }
} }

View File

@ -170,8 +170,8 @@ final class CloudTransferManager {
fileName: seed.fileName, fileName: seed.fileName,
fileType: seed.fileType, fileType: seed.fileType,
scenicId: scenicIdProvider() scenicId: scenicIdProvider()
) { [weak self] progress in ) { progress in
Task { @MainActor in Task { @MainActor [weak self] in
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return } guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
self.updateUploadProgress(id: id, progress: progress) self.updateUploadProgress(id: id, progress: progress)
} }
@ -220,8 +220,8 @@ final class CloudTransferManager {
let localURL = try await downloader.downloadFile( let localURL = try await downloader.downloadFile(
remoteURL: seed.remoteURL, remoteURL: seed.remoteURL,
fileName: seed.fileName fileName: seed.fileName
) { [weak self] progress in ) { progress in
Task { @MainActor in Task { @MainActor [weak self] in
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return } guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
self.updateDownloadProgress(id: id, progress: progress) self.updateDownloadProgress(id: id, progress: progress)
} }

View File

@ -6,14 +6,15 @@
import Foundation import Foundation
/// Android `GeocodeSearch` /// Android `GeocodeSearch`
@MainActor
final class LocationGeocoder: NSObject, AMapSearchDelegate { final class LocationGeocoder: NSObject, AMapSearchDelegate {
static let shared = LocationGeocoder() nonisolated static let shared = LocationGeocoder()
private var searchAPI: AMapSearchAPI? private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>? private var continuation: CheckedContinuation<String, Never>?
private override init() { nonisolated private override init() {
super.init() super.init()
} }

View File

@ -7,19 +7,19 @@ import CoreLocation
import Foundation import Foundation
/// `AMapLocationManager` Android `LocationProvider` /// `AMapLocationManager` Android `LocationProvider`
@MainActor
final class LocationProvider: NSObject, LocationProviding { final class LocationProvider: NSObject, LocationProviding {
static let shared = LocationProvider() nonisolated static let shared = LocationProvider()
private let permissionManager = CLLocationManager() private let permissionManager = CLLocationManager()
private let geocoder: LocationGeocoder private let geocoder = LocationGeocoder.shared
init(geocoder: LocationGeocoder = .shared) { nonisolated override init() {
self.geocoder = geocoder
super.init() super.init()
permissionManager.desiredAccuracy = kCLLocationAccuracyBest
} }
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot { func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -51,6 +51,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -77,12 +78,14 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String { func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude) await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
} }
/// ///
func ensureLocationPermission() async throws { func ensureLocationPermission() async throws {
permissionManager.desiredAccuracy = kCLLocationAccuracyBest
let initialStatus = permissionManager.authorizationStatus let initialStatus = permissionManager.authorizationStatus
if initialStatus == .notDetermined { if initialStatus == .notDetermined {
permissionManager.requestWhenInUseAuthorization() permissionManager.requestWhenInUseAuthorization()

View File

@ -12,7 +12,7 @@ protocol ScenicApplicationImageUploading: AnyObject {
data: Data, data: Data,
fileName: String, fileName: String,
scenicId: Int, scenicId: Int,
onProgress: @escaping (Int) -> Void onProgress: @escaping @MainActor (Int) -> Void
) async throws -> String ) async throws -> String
} }
@ -247,21 +247,20 @@ final class ScenicApplicationViewModel {
notifyStateChange() notifyStateChange()
do { do {
let progressBatch = batch
let url = try await uploader.uploadScenicApplyImage( let url = try await uploader.uploadScenicApplyImage(
data: data, data: data,
fileName: image.fileName, fileName: image.fileName,
scenicId: scenicId, scenicId: scenicId,
onProgress: { [weak self] progress in onProgress: { [weak self] progress in
Task { @MainActor in
self?.handleUploadProgress( self?.handleUploadProgress(
index: index, index: index,
batch: batch, batch: progressBatch,
currentPosition: currentPosition, currentPosition: currentPosition,
total: total, total: total,
progress: progress progress: progress
) )
} }
}
) )
updateImage(at: index) { item in updateImage(at: index) { item in
var copy = item var copy = item

View File

@ -41,8 +41,67 @@ protocol ScenicQueueTTSManaging: AnyObject {
func estimateAnnouncementDurationMillis(_ text: String) -> Int64 func estimateAnnouncementDurationMillis(_ text: String) -> Int64
} }
/// 线 continuation
@MainActor
private final class SystemVoiceSynthesizer: NSObject, @preconcurrency AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer()
private var currentContinuation: CheckedContinuation<Void, Never>?
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退 /// 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() static let shared = AliyunNLSTTSManager()
private enum LoopKind { private enum LoopKind {
@ -62,7 +121,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
let voicePath: String let voicePath: String
} }
private let synthesizer = AVSpeechSynthesizer() private let systemVoiceSynthesizer = SystemVoiceSynthesizer()
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts") private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS") private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS")
private let appStore: AppStore private let appStore: AppStore
@ -71,15 +130,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
private let streamingPlayer: AliyunPCMStreamingPlaying private let streamingPlayer: AliyunPCMStreamingPlaying
private let workspacePathProvider: () -> String? private let workspacePathProvider: () -> String?
private var activeLoop: LoopKind? private var activeLoop: LoopKind?
private var currentContinuation: CheckedContinuation<Void, Never>?
private var nativeContinuation: CheckedContinuation<Bool, Never>? private var nativeContinuation: CheckedContinuation<Bool, Never>?
private var nlsToken: AliyunNLSToken?
private var lastCredentials: AliyunOSSCredentials?
private var nativeReady = false private var nativeReady = false
private var nativeEngineKind: NativeEngineKind? private var nativeEngineKind: NativeEngineKind?
private var nativeSampleRate: Double private var nativeSampleRate: Double
private var customTextLoopingStorage = false
private(set) var customTextLooping = false var customTextLooping: Bool {
loopQueue.sync { customTextLoopingStorage }
}
var supportsOfflineTTS: Bool { var supportsOfflineTTS: Bool {
bridge.sdkAvailable && offlineResources() != nil bridge.sdkAvailable && offlineResources() != nil
@ -99,24 +158,22 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath
nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate
super.init() super.init()
synthesizer.delegate = self
self.bridge.delegate = self self.bridge.delegate = self
} }
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
guard let api else { return } guard let api else { return }
guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else { guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else {
nativeReady = false await setNativeReady(false)
return return
} }
do { do {
let sts = try await api.aliyunSTS(bucket: "vipsky") let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
let token = try await tokenProvider.createToken(credentials: sts.credentials) let token = try await tokenProvider.createToken(credentials: sts.credentials)
nlsToken = token let isReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials) await setNativeReady(isReady)
} catch { } catch {
nativeReady = false await setNativeReady(false)
} }
} }
@ -125,7 +182,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
loopQueue.async { [weak self] in loopQueue.async { [weak self] in
guard let self else { return } guard let self else { return }
self.activeLoop = .queueCall(queueNo) self.activeLoop = .queueCall(queueNo)
self.customTextLooping = false self.customTextLoopingStorage = false
Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) } Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) }
} }
} }
@ -155,7 +212,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
loopQueue.async { [weak self] in loopQueue.async { [weak self] in
guard let self else { return } guard let self else { return }
self.activeLoop = .custom(trimmed) self.activeLoop = .custom(trimmed)
self.customTextLooping = true self.customTextLoopingStorage = true
Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) } Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) }
} }
return true return true
@ -167,7 +224,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
if case .custom = self.activeLoop { if case .custom = self.activeLoop {
self.activeLoop = nil self.activeLoop = nil
} }
self.customTextLooping = false self.customTextLoopingStorage = false
self.stopSpeaking() self.stopSpeaking()
} }
} }
@ -208,7 +265,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
} }
if case .custom = expected { if case .custom = expected {
customTextLooping = false loopQueue.async { [weak self] in
self?.customTextLoopingStorage = false
}
} }
} }
@ -254,47 +313,26 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
} }
} }
private func speakWithSystemVoice(_ text: String) async { private func setNativeReady(_ isReady: Bool) async {
await MainActor.run {
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
}
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
currentContinuation = continuation nativeQueue.async { [weak self] in
let utterance = AVSpeechUtterance(string: text) self?.nativeReady = isReady
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") continuation.resume()
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86
utterance.volume = 1.0
DispatchQueue.main.async {
self.synthesizer.speak(utterance)
} }
} }
} }
private func speakWithSystemVoice(_ text: String) async {
await systemVoiceSynthesizer.speak(text)
}
private func stopSpeaking() { private func stopSpeaking() {
nativeQueue.async { [weak self] in nativeQueue.async { [weak self] in
self?.cancelNativeSpeakLocked(resumeExisting: true) self?.cancelNativeSpeakLocked(resumeExisting: true)
} }
DispatchQueue.main.async { Task { @MainActor [systemVoiceSynthesizer] in
if self.synthesizer.isSpeaking { systemVoiceSynthesizer.stop()
self.synthesizer.stopSpeaking(at: .immediate)
} }
self.resumeCurrentContinuationIfNeeded()
}
}
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( private func prepareNativeEngineIfAvailable(

View File

@ -554,7 +554,17 @@ private final class MediaPreviewPlaybackButton: UIButton {
private func setupUI() { private func setupUI() {
tintColor = .white tintColor = .white
backgroundColor = UIColor.white.withAlphaComponent(0.08) 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.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.28 layer.shadowOpacity = 0.28

View File

@ -101,8 +101,8 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
case .authorized: case .authorized:
presentScanner() presentScanner()
case .notDetermined: case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in AVCaptureDevice.requestAccess(for: .video) { granted in
Task { @MainActor in Task { @MainActor [weak self] in
if granted { if granted {
self?.presentScanner() self?.presentScanner()
} }

View File

@ -358,28 +358,27 @@ final class MaterialFormViewController: BaseViewController {
} }
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem { private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL) let preview = videoPreview(url: sourceURL)
return MaterialUploadedMediaItem( return MaterialUploadedMediaItem(
data: data, data: data,
thumbnailData: videoThumbnailData(url: sourceURL), thumbnailData: preview.thumbnailData,
fileName: fileName, fileName: fileName,
width: Int(size.width), width: preview.width,
height: Int(size.height) height: preview.height
) )
} }
private func videoDisplaySize(url: URL) -> CGSize { private func videoPreview(url: URL) -> (thumbnailData: Data?, width: Int, height: Int) {
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? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else {
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) return (nil, 0, 0)
}
return (
UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75),
imageRef.width,
imageRef.height
)
} }
} }

View File

@ -1032,7 +1032,7 @@ final class ProfileAddScheduleViewController: BaseViewController {
api: profileAPI api: profileAPI
) )
if success { if success {
await MainActor.run { navigationController?.popViewController(animated: true) } _ = await MainActor.run { navigationController?.popViewController(animated: true) }
} }
} }
} }

View File

@ -417,28 +417,27 @@ final class UploadSampleViewController: SampleBaseViewController {
} }
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> SampleUploadedMediaItem { private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> SampleUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL) let preview = videoPreview(url: sourceURL)
return SampleUploadedMediaItem( return SampleUploadedMediaItem(
data: data, data: data,
thumbnailData: videoThumbnailData(url: sourceURL), thumbnailData: preview.thumbnailData,
fileName: fileName, fileName: fileName,
width: Int(size.width), width: preview.width,
height: Int(size.height) height: preview.height
) )
} }
private func videoDisplaySize(url: URL) -> CGSize { private func videoPreview(url: URL) -> (thumbnailData: Data?, width: Int, height: Int) {
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? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else {
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) return (nil, 0, 0)
}
return (
UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75),
imageRef.width,
imageRef.height
)
} }
} }

View File

@ -469,7 +469,9 @@ final class ScenicQueueSettingsViewController: BaseViewController {
button.backgroundColor = ScenicQueueTokens.bannerBlue button.backgroundColor = ScenicQueueTokens.bannerBlue
button.layer.cornerRadius = ScenicQueueTokens.radiusInput button.layer.cornerRadius = ScenicQueueTokens.radiusInput
button.addTarget(self, action: action, for: .touchUpInside) 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 return button
} }

View File

@ -317,7 +317,7 @@ final class TaskAddViewController: BaseViewController {
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "") let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
viewModel.addUploadPlaceholder( viewModel.addUploadPlaceholder(
fileName: TaskMediaLoader.suggestedFileName( fileName: TaskMediaLoader.suggestedFileName(
from: result.itemProvider, from: result.itemProvider.suggestedName,
fallback: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")" fallback: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")"
), ),
fileType: isImage ? 2 : 1, fileType: isImage ? 2 : 1,
@ -472,8 +472,8 @@ enum TaskMediaLoader {
} }
/// 使 /// 使
static func suggestedFileName(from provider: NSItemProvider, fallback: String) -> String { static func suggestedFileName(from suggestedFileName: String?, fallback: String) -> String {
guard let suggestedName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines), guard let suggestedName = suggestedFileName?.trimmingCharacters(in: .whitespacesAndNewlines),
!suggestedName.isEmpty else { !suggestedName.isEmpty else {
return fallback return fallback
} }
@ -493,7 +493,8 @@ enum TaskMediaLoader {
} }
private static func loadImage(from provider: NSItemProvider) async throws -> Payload { 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 { let identifier = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier } ?? UTType.image.identifier
@ -508,7 +509,7 @@ enum TaskMediaLoader {
} }
let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
let fallback = "image_\(UUID().uuidString).\(ext)" 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:)) let previewData = UIImage(data: data).flatMap(makePreviewData(from:))
continuation.resume(returning: Payload(data: data, fileName: fileName, previewData: previewData)) 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 { private static func loadVideo(from provider: NSItemProvider) async throws -> Payload {
let typeIdentifier = UTType.movie.identifier let typeIdentifier = UTType.movie.identifier
let suggestedName = provider.suggestedName
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
if let error { if let error {
@ -529,7 +531,7 @@ enum TaskMediaLoader {
} }
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
let fallback = "video_\(UUID().uuidString).\(ext)" let fallback = "video_\(UUID().uuidString).\(ext)"
let fileName = suggestedFileName(from: provider, fallback: fallback) let fileName = suggestedFileName(from: suggestedName, fallback: fallback)
continuation.resume(returning: Payload( continuation.resume(returning: Payload(
data: data, data: data,
fileName: fileName, fileName: fileName,

View File

@ -858,6 +858,11 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
) async -> TravelAlbumPhoneAlbumImportItem? { ) async -> TravelAlbumPhoneAlbumImportItem? {
let provider = result.itemProvider let provider = result.itemProvider
let typeIdentifier = preferredImageTypeIdentifier(from: provider) let typeIdentifier = preferredImageTypeIdentifier(from: provider)
let fileName = importFileName(
suggestedName: provider.suggestedName,
typeIdentifier: typeIdentifier,
index: index
)
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
guard let data, !data.isEmpty else { guard let data, !data.isEmpty else {
@ -867,7 +872,7 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
continuation.resume( continuation.resume(
returning: TravelAlbumPhoneAlbumImportItem( returning: TravelAlbumPhoneAlbumImportItem(
data: data, data: data,
fileName: importFileName(provider: provider, typeIdentifier: typeIdentifier, index: index), fileName: fileName,
createdAt: Date() createdAt: Date()
) )
) )
@ -881,9 +886,9 @@ extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
} ?? UTType.image.identifier } ?? 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 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 let baseName = rawName?.isEmpty == false ? rawName! : fallback
guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else { guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else {
return baseName return baseName

View File

@ -83,9 +83,6 @@ final class StatisticsViewModelTests: XCTestCase {
} }
func testUpdateRangeByShortcutAllClearsTimes() async { 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 = """ let listJSON = """
{"code":100000,"msg":"success","data":{"total":0,"data":[]}} {"code":100000,"msg":"success","data":{"total":0,"data":[]}}
""".data(using: .utf8)! """.data(using: .utf8)!
@ -224,7 +221,7 @@ final class StatisticsViewModelTests: XCTestCase {
formatter.calendar = Calendar(identifier: .gregorian) formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN") formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy/MM/dd" formatter.dateFormat = "yyyy/MM/dd"
var calendar = Calendar(identifier: .gregorian) let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.year, .month], from: Date()) let components = calendar.dateComponents([.year, .month], from: Date())
let firstDay = calendar.date(from: components) ?? Date() let firstDay = calendar.date(from: components) ?? Date()
return formatter.string(from: firstDay) return formatter.string(from: firstDay)

View File

@ -1616,9 +1616,7 @@ private final class MockRiskMapLocationProvider: LocationProviding, @unchecked S
private var recordedAccuracies: [CLLocationAccuracy] = [] private var recordedAccuracies: [CLLocationAccuracy] = []
var requestedAccuracies: [CLLocationAccuracy] { var requestedAccuracies: [CLLocationAccuracy] {
lock.lock() lock.withLock { recordedAccuracies }
defer { lock.unlock() }
return recordedAccuracies
} }
init( init(
@ -1639,9 +1637,9 @@ private final class MockRiskMapLocationProvider: LocationProviding, @unchecked S
} }
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
lock.lock() lock.withLock {
recordedAccuracies.append(desiredAccuracy) recordedAccuracies.append(desiredAccuracy)
lock.unlock() }
await onCoordinateRequest?(desiredAccuracy) await onCoordinateRequest?(desiredAccuracy)
switch result { switch result {
case .success(let latitude, let longitude): case .success(let latitude, let longitude):