Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.

Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

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

View File

@ -0,0 +1,261 @@
//
// AppServiceEnvironment.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SwiftUI
private enum EnvironmentServiceDefaults {
static func apiClient() -> APIClient {
APIClient()
}
}
private struct APIClientEnvironmentKey: EnvironmentKey {
static var defaultValue: APIClient { EnvironmentServiceDefaults.apiClient() }
}
private struct UploadAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: UploadAPI { UploadAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct PushAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: PushAPI { PushAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct AccountSnapshotStoreEnvironmentKey: EnvironmentKey {
static var defaultValue: AccountSnapshotStore { AccountSnapshotStore() }
}
private struct AuthSessionCoordinatorEnvironmentKey: EnvironmentKey {
static var defaultValue: AuthSessionCoordinator { AuthSessionCoordinator() }
}
private struct AccountContextAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: AccountContextAPI { AccountContextAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct AssetsAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: AssetsAPI { AssetsAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct AuthAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct InviteAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct LiveAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: LiveAPI { LiveAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct LocationReportAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: LocationReportAPI { LocationReportAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct MessageCenterAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: MessageCenterAPI { MessageCenterAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct OperatingAreaAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: OperatingAreaAPI { OperatingAreaAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct OrdersAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: OrdersAPI { OrdersAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct OSSUploadServiceEnvironmentKey: EnvironmentKey {
static var defaultValue: OSSUploadService {
OSSUploadService(configService: UploadAPI(client: EnvironmentServiceDefaults.apiClient()))
}
}
private struct PaymentAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: PaymentAPI { PaymentAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct PilotCertificationAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: PilotCertificationAPI { PilotCertificationAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ProfileAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ProfileAPI { ProfileAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ProjectAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ProjectAPI { ProjectAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct PunchPointAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: PunchPointAPI { PunchPointAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ScenicPermissionAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ScenicPermissionAPI { ScenicPermissionAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ScenicQueueAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ScenicQueueAPI { ScenicQueueAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ScenicSettlementAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ScenicSettlementAPI { ScenicSettlementAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct ScheduleAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: ScheduleAPI { ScheduleAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct StatisticsAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: StatisticsAPI { StatisticsAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct TaskAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: TaskAPI { TaskAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
private struct WalletAPIEnvironmentKey: EnvironmentKey {
static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) }
}
extension EnvironmentValues {
var apiClient: APIClient {
get { self[APIClientEnvironmentKey.self] }
set { self[APIClientEnvironmentKey.self] = newValue }
}
var uploadAPI: UploadAPI {
get { self[UploadAPIEnvironmentKey.self] }
set { self[UploadAPIEnvironmentKey.self] = newValue }
}
var pushAPI: PushAPI {
get { self[PushAPIEnvironmentKey.self] }
set { self[PushAPIEnvironmentKey.self] = newValue }
}
var accountSnapshotStore: AccountSnapshotStore {
get { self[AccountSnapshotStoreEnvironmentKey.self] }
set { self[AccountSnapshotStoreEnvironmentKey.self] = newValue }
}
var authSessionCoordinator: AuthSessionCoordinator {
get { self[AuthSessionCoordinatorEnvironmentKey.self] }
set { self[AuthSessionCoordinatorEnvironmentKey.self] = newValue }
}
var accountContextAPI: AccountContextAPI {
get { self[AccountContextAPIEnvironmentKey.self] }
set { self[AccountContextAPIEnvironmentKey.self] = newValue }
}
var assetsAPI: AssetsAPI {
get { self[AssetsAPIEnvironmentKey.self] }
set { self[AssetsAPIEnvironmentKey.self] = newValue }
}
var authAPI: AuthAPI {
get { self[AuthAPIEnvironmentKey.self] }
set { self[AuthAPIEnvironmentKey.self] = newValue }
}
var inviteAPI: InviteAPI {
get { self[InviteAPIEnvironmentKey.self] }
set { self[InviteAPIEnvironmentKey.self] = newValue }
}
var liveAPI: LiveAPI {
get { self[LiveAPIEnvironmentKey.self] }
set { self[LiveAPIEnvironmentKey.self] = newValue }
}
var locationReportAPI: LocationReportAPI {
get { self[LocationReportAPIEnvironmentKey.self] }
set { self[LocationReportAPIEnvironmentKey.self] = newValue }
}
var messageCenterAPI: MessageCenterAPI {
get { self[MessageCenterAPIEnvironmentKey.self] }
set { self[MessageCenterAPIEnvironmentKey.self] = newValue }
}
var operatingAreaAPI: OperatingAreaAPI {
get { self[OperatingAreaAPIEnvironmentKey.self] }
set { self[OperatingAreaAPIEnvironmentKey.self] = newValue }
}
var ordersAPI: OrdersAPI {
get { self[OrdersAPIEnvironmentKey.self] }
set { self[OrdersAPIEnvironmentKey.self] = newValue }
}
var ossUploadService: OSSUploadService {
get { self[OSSUploadServiceEnvironmentKey.self] }
set { self[OSSUploadServiceEnvironmentKey.self] = newValue }
}
var paymentAPI: PaymentAPI {
get { self[PaymentAPIEnvironmentKey.self] }
set { self[PaymentAPIEnvironmentKey.self] = newValue }
}
var pilotCertificationAPI: PilotCertificationAPI {
get { self[PilotCertificationAPIEnvironmentKey.self] }
set { self[PilotCertificationAPIEnvironmentKey.self] = newValue }
}
var profileAPI: ProfileAPI {
get { self[ProfileAPIEnvironmentKey.self] }
set { self[ProfileAPIEnvironmentKey.self] = newValue }
}
var projectAPI: ProjectAPI {
get { self[ProjectAPIEnvironmentKey.self] }
set { self[ProjectAPIEnvironmentKey.self] = newValue }
}
var punchPointAPI: PunchPointAPI {
get { self[PunchPointAPIEnvironmentKey.self] }
set { self[PunchPointAPIEnvironmentKey.self] = newValue }
}
var scenicPermissionAPI: ScenicPermissionAPI {
get { self[ScenicPermissionAPIEnvironmentKey.self] }
set { self[ScenicPermissionAPIEnvironmentKey.self] = newValue }
}
var scenicQueueAPI: ScenicQueueAPI {
get { self[ScenicQueueAPIEnvironmentKey.self] }
set { self[ScenicQueueAPIEnvironmentKey.self] = newValue }
}
var scenicSettlementAPI: ScenicSettlementAPI {
get { self[ScenicSettlementAPIEnvironmentKey.self] }
set { self[ScenicSettlementAPIEnvironmentKey.self] = newValue }
}
var scheduleAPI: ScheduleAPI {
get { self[ScheduleAPIEnvironmentKey.self] }
set { self[ScheduleAPIEnvironmentKey.self] = newValue }
}
var statisticsAPI: StatisticsAPI {
get { self[StatisticsAPIEnvironmentKey.self] }
set { self[StatisticsAPIEnvironmentKey.self] = newValue }
}
var taskAPI: TaskAPI {
get { self[TaskAPIEnvironmentKey.self] }
set { self[TaskAPIEnvironmentKey.self] = newValue }
}
var walletAPI: WalletAPI {
get { self[WalletAPIEnvironmentKey.self] }
set { self[WalletAPIEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,22 @@
//
// AppUITestLaunchState.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// UI
enum AppUITestLaunchState {
static let resetArgument = "-suixinkan-ui-tests-reset-state"
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
guard arguments.contains(resetArgument) else { return }
try? SessionTokenStore().clear()
AccountSnapshotStore().clear()
let preferences = AppPreferencesStore()
preferences.clear()
}
}

View File

@ -0,0 +1,31 @@
//
// NavigationCompatibility.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SwiftUI
extension View {
/// iOS 16 compatible replacement for iOS 17's navigationDestination(item:).
func appNavigationDestination<Item, Destination: View>(
item: Binding<Item?>,
@ViewBuilder destination: @escaping (Item) -> Destination
) -> some View {
navigationDestination(
isPresented: Binding(
get: { item.wrappedValue != nil },
set: { isPresented in
if !isPresented {
item.wrappedValue = nil
}
}
)
) {
if let value = item.wrappedValue {
destination(value)
}
}
}
}

View File

@ -5,7 +5,7 @@
// Created by Codex on 2026/6/18.
//
import Observation
import Combine
import SwiftUI
/// Tab
@ -60,10 +60,9 @@ extension OrdersRoute {
}
@MainActor
@Observable
/// NavigationStack Tab
final class RouterPath {
var path: [AppRoute] = []
final class RouterPath: ObservableObject {
@Published var path: [AppRoute] = []
/// Tab
func navigate(to route: AppRoute) {
@ -77,13 +76,12 @@ final class RouterPath {
}
@MainActor
@Observable
/// Tab Tab NavigationStack
final class AppRouter {
var selectedTab: AppTab = .home
var selectedOrdersEntry: OrdersEntry = .storeOrders
private(set) var pendingOrderScanCode: String?
private var routers: [AppTab: RouterPath] = [:]
final class AppRouter: ObservableObject {
@Published var selectedTab: AppTab = .home
@Published var selectedOrdersEntry: OrdersEntry = .storeOrders
@Published private(set) var pendingOrderScanCode: String?
@Published private var routers: [AppTab: RouterPath] = [:]
/// Tab
func router(for tab: AppTab) -> RouterPath {

View File

@ -10,12 +10,12 @@ import SwiftUI
/// App
struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var appSession = AppSession()
@State private var accountContext = AccountContext()
@State private var permissionContext = PermissionContext()
@State private var scenicSpotContext = ScenicSpotContext()
@State private var appRouter = AppRouter()
@State private var toastCenter = ToastCenter()
@StateObject private var appSession = AppSession()
@StateObject private var accountContext = AccountContext()
@StateObject private var permissionContext = PermissionContext()
@StateObject private var scenicSpotContext = ScenicSpotContext()
@StateObject private var appRouter = AppRouter()
@StateObject private var toastCenter = ToastCenter()
@State private var globalLoading = GlobalLoadingCenter()
@State private var snapshotStore: AccountSnapshotStore
@State private var apiClient: APIClient
@ -33,7 +33,7 @@ struct RootView: View {
@State private var scenicSettlementAPI: ScenicSettlementAPI
@State private var messageCenterAPI: MessageCenterAPI
@State private var scenicQueueAPI: ScenicQueueAPI
@State private var scenicQueueRuntime = ScenicQueueRuntime()
@StateObject private var scenicQueueRuntime = ScenicQueueRuntime()
@State private var liveAPI: LiveAPI
@State private var operatingAreaAPI: OperatingAreaAPI
@State private var pilotCertificationAPI: PilotCertificationAPI
@ -98,41 +98,41 @@ struct RootView: View {
rootContent
.globalToastOverlay()
.globalLoadingOverlay(loadingCenter: globalLoading)
.environment(appSession)
.environment(accountContext)
.environment(permissionContext)
.environment(scenicSpotContext)
.environment(appRouter)
.environment(toastCenter)
.environmentObject(appSession)
.environmentObject(accountContext)
.environmentObject(permissionContext)
.environmentObject(scenicSpotContext)
.environmentObject(appRouter)
.environmentObject(toastCenter)
.environment(\.globalLoading, globalLoading)
.environment(snapshotStore)
.environment(apiClient)
.environment(authAPI)
.environment(profileAPI)
.environment(uploadAPI)
.environment(ossUploadService)
.environment(accountContextAPI)
.environment(ordersAPI)
.environment(statisticsAPI)
.environment(paymentAPI)
.environment(walletAPI)
.environment(pushAPI)
.environment(scenicPermissionAPI)
.environment(scenicSettlementAPI)
.environment(messageCenterAPI)
.environment(scenicQueueAPI)
.environment(scenicQueueRuntime)
.environment(liveAPI)
.environment(operatingAreaAPI)
.environment(pilotCertificationAPI)
.environment(taskAPI)
.environment(projectAPI)
.environment(scheduleAPI)
.environment(inviteAPI)
.environment(assetsAPI)
.environment(punchPointAPI)
.environment(locationReportAPI)
.environment(authSessionCoordinator)
.environment(\.accountSnapshotStore, snapshotStore)
.environment(\.apiClient, apiClient)
.environment(\.authAPI, authAPI)
.environment(\.profileAPI, profileAPI)
.environment(\.uploadAPI, uploadAPI)
.environment(\.ossUploadService, ossUploadService)
.environment(\.accountContextAPI, accountContextAPI)
.environment(\.ordersAPI, ordersAPI)
.environment(\.statisticsAPI, statisticsAPI)
.environment(\.paymentAPI, paymentAPI)
.environment(\.walletAPI, walletAPI)
.environment(\.pushAPI, pushAPI)
.environment(\.scenicPermissionAPI, scenicPermissionAPI)
.environment(\.scenicSettlementAPI, scenicSettlementAPI)
.environment(\.messageCenterAPI, messageCenterAPI)
.environment(\.scenicQueueAPI, scenicQueueAPI)
.environmentObject(scenicQueueRuntime)
.environment(\.liveAPI, liveAPI)
.environment(\.operatingAreaAPI, operatingAreaAPI)
.environment(\.pilotCertificationAPI, pilotCertificationAPI)
.environment(\.taskAPI, taskAPI)
.environment(\.projectAPI, projectAPI)
.environment(\.scheduleAPI, scheduleAPI)
.environment(\.inviteAPI, inviteAPI)
.environment(\.assetsAPI, assetsAPI)
.environment(\.punchPointAPI, punchPointAPI)
.environment(\.locationReportAPI, locationReportAPI)
.environment(\.authSessionCoordinator, authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
@ -160,7 +160,7 @@ struct RootView: View {
api: accountContextAPI
)
}
.onChange(of: scenePhase) { _, newPhase in
.onChange(of: scenePhase) { newPhase in
if appSession.isLoggedIn {
scenicQueueRuntime.update(
api: scenicQueueAPI,
@ -172,7 +172,7 @@ struct RootView: View {
scenicQueueRuntime.stop()
}
}
.onChange(of: accountContext.currentScenic?.id) { _, _ in
.onChange(of: accountContext.currentScenic?.id) { _ in
if appSession.isLoggedIn {
scenicQueueRuntime.update(
api: scenicQueueAPI,
@ -182,7 +182,7 @@ struct RootView: View {
)
}
}
.onChange(of: appSession.phase) { _, phase in
.onChange(of: appSession.phase) { phase in
switch phase {
case .loggedIn:
PushNotificationManager.shared.requestAuthorizationAndRegister()

View File

@ -5,7 +5,7 @@
// Created by Codex on 2026/6/18.
//
import Observation
import Combine
///
struct AccountProfile: Codable, Equatable {
@ -58,14 +58,13 @@ struct BusinessScope: Codable, Equatable, Identifiable {
}
@MainActor
@Observable
/// /
final class AccountContext {
private(set) var profile: AccountProfile?
private(set) var scenicScopes: [BusinessScope] = []
private(set) var storeScopes: [BusinessScope] = []
var currentScenic: BusinessScope?
var currentStore: BusinessScope?
final class AccountContext: ObservableObject {
@Published private(set) var profile: AccountProfile?
@Published private(set) var scenicScopes: [BusinessScope] = []
@Published private(set) var storeScopes: [BusinessScope] = []
@Published var currentScenic: BusinessScope?
@Published var currentStore: BusinessScope?
///
func applyLogin(profile: AccountProfile? = nil) {

View File

@ -6,7 +6,7 @@
//
import Foundation
import Observation
import Combine
///
enum AuthPhase: Equatable {
@ -16,11 +16,10 @@ enum AuthPhase: Equatable {
}
@MainActor
@Observable
/// token
final class AppSession {
private(set) var phase: AuthPhase = .loggedOut
private(set) var token: String?
final class AppSession: ObservableObject {
@Published private(set) var phase: AuthPhase = .loggedOut
@Published private(set) var token: String?
var isLoggedIn: Bool {
phase == .loggedIn

View File

@ -6,15 +6,14 @@
//
import Foundation
import Observation
import Combine
@MainActor
@Observable
/// 退
final class AuthSessionCoordinator {
@ObservationIgnored private let tokenStore: SessionTokenStore
@ObservationIgnored private let snapshotStore: AccountSnapshotStore
@ObservationIgnored private let preferencesStore: AppPreferencesStore
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
private let preferencesStore: AppPreferencesStore
/// token
init(

View File

@ -6,15 +6,14 @@
//
import Foundation
import Observation
import Combine
@MainActor
@Observable
/// URI
final class PermissionContext {
private(set) var rolePermissions: [RolePermissionResponse] = []
private(set) var permissionURIs: Set<String> = []
var currentRole: RoleInfo?
final class PermissionContext: ObservableObject {
@Published private(set) var rolePermissions: [RolePermissionResponse] = []
@Published private(set) var permissionURIs: Set<String> = []
@Published var currentRole: RoleInfo?
/// ID
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {

View File

@ -6,7 +6,7 @@
//
import Foundation
import Observation
import Combine
///
enum ScenicSpotLoadState: Equatable {
@ -17,12 +17,11 @@ enum ScenicSpotLoadState: Equatable {
}
@MainActor
@Observable
///
final class ScenicSpotContext {
private(set) var scenicId: Int?
private(set) var spots: [ScenicSpotItem] = []
private(set) var loadState: ScenicSpotLoadState = .idle
final class ScenicSpotContext: ObservableObject {
@Published private(set) var scenicId: Int?
@Published private(set) var spots: [ScenicSpotItem] = []
@Published private(set) var loadState: ScenicSpotLoadState = .idle
/// ID
func reload(scenicId: Int?, api: AccountContextServing) async {

View File

@ -6,14 +6,13 @@
//
import Foundation
import Observation
import Combine
@MainActor
@Observable
/// token
final class SessionBootstrapper {
@ObservationIgnored private let tokenStore: SessionTokenStore
@ObservationIgnored private let snapshotStore: AccountSnapshotStore
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
private var didAttemptRestore = false
/// token

View File

@ -5,17 +5,16 @@
// Created by Codex on 2026/6/18.
//
import Observation
import Combine
import SwiftUI
@MainActor
@Observable
/// Toast
final class ToastCenter {
fileprivate var message: String?
@ObservationIgnored private let autoDismissNanoseconds: UInt64
@ObservationIgnored private var dismissTask: Task<Void, Never>?
@ObservationIgnored private var displayToken: UInt64 = 0
final class ToastCenter: ObservableObject {
@Published fileprivate var message: String?
private let autoDismissNanoseconds: UInt64
@Published private var dismissTask: Task<Void, Never>?
@Published private var displayToken: UInt64 = 0
/// Toast 2.2
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
@ -65,7 +64,7 @@ final class ToastCenter {
/// Toast Toast
private struct GlobalToastOverlay: ViewModifier {
@Environment(ToastCenter.self) private var toastCenter
@EnvironmentObject private var toastCenter: ToastCenter
func body(content: Content) -> some View {
content

View File

@ -12,6 +12,10 @@ import SwiftUI
struct suixinkanApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
AppUITestLaunchState.resetIfNeeded()
}
var body: some Scene {
WindowGroup {
RootView()