完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user