从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构

将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 10:16:35 +08:00
parent 0314033a7f
commit 703078352c
127 changed files with 2320 additions and 1465 deletions

View 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()
}
}
}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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(

View File

@ -40,7 +40,7 @@ struct RemoteImage<Placeholder: View>: View {
placeholder()
}
}
.onChange(of: normalizedURLString) { _, _ in
.onChange(of: normalizedURLString) { _ in
didFail = false
}
}

View File

@ -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) {

View File

@ -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) {