从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
75
suixinkan/Core/Design/AppContentUnavailableView.swift
Normal file
75
suixinkan/Core/Design/AppContentUnavailableView.swift
Normal file
@ -0,0 +1,75 @@
|
||||
//
|
||||
// AppContentUnavailableView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// iOS 16 兼容的空状态视图;iOS 17+ 自动使用系统 AppContentUnavailableView。
|
||||
struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, ActionsContent: View>: View {
|
||||
private let label: LabelContent
|
||||
private let description: DescriptionContent
|
||||
private let actions: ActionsContent
|
||||
|
||||
init(
|
||||
@ViewBuilder label: () -> LabelContent,
|
||||
@ViewBuilder description: () -> DescriptionContent,
|
||||
@ViewBuilder actions: () -> ActionsContent
|
||||
) {
|
||||
self.label = label()
|
||||
self.description = description()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
AppContentUnavailableView {
|
||||
label
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
actions
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
label
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
description
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
actions
|
||||
.padding(.top, AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AppMetrics.Spacing.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentUnavailableView where LabelContent == Label<Text, Image>, DescriptionContent == EmptyView, ActionsContent == EmptyView {
|
||||
init(_ title: String, systemImage: String) {
|
||||
self.init {
|
||||
Label(title, systemImage: systemImage)
|
||||
} description: {
|
||||
EmptyView()
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentUnavailableView where LabelContent == Label<Text, Image>, DescriptionContent == Text, ActionsContent == EmptyView {
|
||||
init(_ title: String, systemImage: String, description: Text) {
|
||||
self.init {
|
||||
Label(title, systemImage: systemImage)
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,7 +77,7 @@ final class GlobalLoadingCenter {
|
||||
fileprivate final class GlobalLoadingState: ObservableObject {
|
||||
@Published fileprivate var isVisible = false
|
||||
@Published fileprivate var message = ""
|
||||
fileprivate var activeCount = 0
|
||||
@Published fileprivate var activeCount = 0
|
||||
}
|
||||
|
||||
/// Lottie Loading 动画容器,负责播放主包中的 loading.json。
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 前台定位结果实体,表示一次即时定位得到的坐标和可展示地址。
|
||||
struct ForegroundLocationResult: Equatable {
|
||||
@ -18,11 +18,10 @@ struct ForegroundLocationResult: Equatable {
|
||||
|
||||
/// 前台定位 Provider,只负责当前页面主动请求位置,不缓存、不后台持续定位。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ForegroundLocationProvider: NSObject {
|
||||
@ObservationIgnored private let manager = CLLocationManager()
|
||||
@ObservationIgnored private let geocoder = CLGeocoder()
|
||||
@ObservationIgnored private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
final class ForegroundLocationProvider: NSObject, ObservableObject {
|
||||
private let manager = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
@Published private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// URLSession 抽象协议,用于让网络客户端支持测试替身。
|
||||
protocol URLSessionProtocol {
|
||||
@ -17,13 +17,12 @@ protocol URLSessionProtocol {
|
||||
extension URLSession: URLSessionProtocol {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。
|
||||
final class APIClient {
|
||||
@ObservationIgnored private let session: URLSessionProtocol
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored private let decoder: JSONDecoder
|
||||
@ObservationIgnored private var authTokenProvider: (() -> String?)?
|
||||
private let session: URLSessionProtocol
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private var authTokenProvider: (() -> String?)?
|
||||
|
||||
private let environment: APIEnvironment
|
||||
private let appVersion: String
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。
|
||||
final class PushAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化推送 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。
|
||||
final class ScenicQueueRuntime {
|
||||
private(set) var isMonitoring = false
|
||||
private(set) var lastPollText = "--"
|
||||
private(set) var lastSpokenText = ""
|
||||
private(set) var lastQueueCount = 0
|
||||
private(set) var lastError: String?
|
||||
final class ScenicQueueRuntime: ObservableObject {
|
||||
@Published private(set) var isMonitoring = false
|
||||
@Published private(set) var lastPollText = "--"
|
||||
@Published private(set) var lastSpokenText = ""
|
||||
@Published private(set) var lastQueueCount = 0
|
||||
@Published private(set) var lastError: String?
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private weak var api: (any ScenicQueueServing)?
|
||||
@ObservationIgnored private var userId: String?
|
||||
@ObservationIgnored private var scenicId: Int?
|
||||
@ObservationIgnored private var scenePhase: ScenePhase = .active
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
@ObservationIgnored private var announcementState = ScenicQueueAnnouncementState()
|
||||
@ObservationIgnored private var lastScenicId: Int?
|
||||
@ObservationIgnored private var lastSpotId: Int?
|
||||
@ObservationIgnored private var suspendedByQueueScreen = false
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
private weak var api: (any ScenicQueueServing)?
|
||||
@Published private var userId: String?
|
||||
@Published private var scenicId: Int?
|
||||
@Published private var scenePhase: ScenePhase = .active
|
||||
@Published private var pollTask: Task<Void, Never>?
|
||||
@Published private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
@Published private var announcementState = ScenicQueueAnnouncementState()
|
||||
@Published private var lastScenicId: Int?
|
||||
@Published private var lastSpotId: Int?
|
||||
@Published private var suspendedByQueueScreen = false
|
||||
|
||||
/// 语音播报是否开启。
|
||||
var voiceEnabled: Bool {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。
|
||||
struct AccountSnapshot: Codable, Equatable {
|
||||
@ -41,13 +41,12 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
final class AccountSnapshotStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let key: String
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored private let decoder: JSONDecoder
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
/// 初始化账号快照存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(
|
||||
|
||||
@ -6,14 +6,13 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@Observable
|
||||
/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。
|
||||
final class AppPreferencesStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
@ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||
private let defaults: UserDefaults
|
||||
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||
|
||||
/// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@ -43,4 +42,10 @@ final class AppPreferencesStore {
|
||||
func loadPrivacyAgreementAccepted() -> Bool {
|
||||
defaults.bool(forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
|
||||
/// 清空登录页偏好。
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: lastLoginUsernameKey)
|
||||
defaults.removeObject(forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import Security
|
||||
|
||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
||||
@ -21,11 +21,10 @@ enum SessionTokenStoreError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
||||
final class SessionTokenStore {
|
||||
@ObservationIgnored private let service: String
|
||||
@ObservationIgnored private let account: String
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
/// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。
|
||||
init(
|
||||
|
||||
@ -40,7 +40,7 @@ struct RemoteImage<Placeholder: View>: View {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
.onChange(of: normalizedURLString) { _, _ in
|
||||
.onChange(of: normalizedURLString) { _ in
|
||||
didFail = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if canImport(AlibabaCloudOSS)
|
||||
@ -51,10 +51,9 @@ protocol OSSUploadServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||
final class OSSUploadService {
|
||||
@ObservationIgnored private let configService: any OSSConfigServing
|
||||
private let configService: any OSSConfigServing
|
||||
|
||||
/// 初始化 OSS 上传服务,并注入 STS 配置服务。
|
||||
init(configService: any OSSConfigServing) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。
|
||||
@MainActor
|
||||
@ -16,10 +16,9 @@ protocol OSSConfigServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 上传 API,封装文件上传前需要的服务端配置接口。
|
||||
final class UploadAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化上传 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
Reference in New Issue
Block a user