ios-projects/AIStudyApp/AIStudyApp/AIStudyAppApp.swift
wangdl 4ba747af46 feat: iOS learning info P0 complete (IOS-INFO-000~017, 038~039)
Models: ReadingMaterialContext, ReadingEventUploadItem, AppContext
Runtime: ReadingRuntimeAdapter, SessionManager, RecoveryService
Reader: MaterialReaderView lifecycle, Heartbeat Timer, Position Adapter
Export: EventMapper, UploadQueue, export→queue→ack flow
Tests: 16 test files covering models, runtime, lifecycle, upload
Docs: ios-learning-info-architecture.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:53:36 +08:00

450 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AuthenticationServices
@main
struct AIStudyAppApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("appAppearance") private var appAppearance = "system"
@StateObject private var authManager = AuthManager()
@Environment(\.scenePhase) private var scenePhase
private var effectiveColorScheme: ColorScheme? {
switch appAppearance {
case "dark": return .dark
case "light": return .light
default: return nil
}
}
var body: some Scene {
WindowGroup {
Group {
switch authManager.session {
case .unknown:
SplashScreen()
case .authenticated:
if hasCompletedOnboarding {
ContentView()
.environmentObject(authManager)
} else {
PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding)
.environmentObject(authManager)
}
case .disabled:
AccountStatusView(
icon: "person.crop.circle.badge.xmark",
title: "账号已被禁用",
message: "您的账号已被管理员禁用,如有疑问请联系客服。",
onBackToLogin: { authManager.session = .unauthenticated }
)
case .deleted:
AccountStatusView(
icon: "person.crop.circle.badge.minus",
title: "账号已注销",
message: "您的账号已成功注销,欢迎随时回来重新注册。",
onBackToLogin: { authManager.session = .unauthenticated }
)
case .expired, .unauthenticated:
PreLoginFlow()
.environmentObject(authManager)
case .authenticating, .refreshing:
SplashScreen()
}
}
.preferredColorScheme(effectiveColorScheme)
.zxToast()
.task {
await authManager.restoreSession()
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
// Pause active session + stop heartbeat
if ReadingRuntimeSessionManager.shared.state == .active {
ReadingRuntimeSessionManager.shared.pause()
}
// Flush events: export enqueue (quick upload in background task)
Task {
let pipeline = ReadingEventUploadPipeline.shared
pipeline.exportAndEnqueue() // pulls from ContextRegistry
await pipeline.flush()
}
case .active:
// Startup recovery: reload stale events + cleanup sessions
RuntimeRecoveryService.run()
// Resume session + reload stale events from Rust
ReadingRuntimeSessionManager.shared.resume()
Task {
ReadingEventUploadPipeline.shared.reloadOnLaunch()
}
case .inactive:
break
@unknown default:
break
}
}
}
}
}
// MARK: - Splash (session restore)
struct SplashScreen: View {
var body: some View {
ZStack {
LinearGradient(colors: [Color(hex: "#0D0D20"), Color(hex: "#0F0F1A"), Color(hex: "#130D20")], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.25), .clear], center: .center, startRadius: 0, endRadius: 140)).frame(width: 280, height: 280).offset(y: -60).allowsHitTesting(false)
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 28).fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)).frame(width: 96, height: 96).overlay(Image(systemName: "brain.head.profile").font(.system(size: 44)).foregroundColor(.white.opacity(0.8))).shadow(color: Color(hex: "#7C6EFA", opacity: 0.5), radius: 40).padding(.bottom, 24)
Text("知习").font(.system(size: 36, weight: .heavy)).tracking(-1).foregroundStyle(LinearGradient(colors: [Color(hex: "#A78BFA"), Color(hex: "#F0F0FF"), Color(hex: "#F97316")], startPoint: .leading, endPoint: .trailing))
ProgressView().tint(Color(hex: "#F0F0FF", opacity: 0.5)).padding(.top, 32)
}
}
}
}
// MARK: - Pre-login flow (Welcome Login)
struct PreLoginFlow: View {
@EnvironmentObject var authManager: AuthManager
@State private var step = 0
var body: some View {
ZStack {
switch step {
case 0:
WelcomePage(onContinue: { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } })
case 1:
LoginPage()
default:
EmptyView()
}
}
}
}
// MARK: - Post-login onboarding (Onboarding GoalSetup)
struct PostLoginOnboardingFlow: View {
@Binding var hasCompletedOnboarding: Bool
@State private var step = 0
var body: some View {
ZStack {
switch step {
case 0:
OnboardingPage { withAnimation { step = 1 } }
case 1:
GoalSetupPage { _ in hasCompletedOnboarding = true }
default:
EmptyView()
}
}
}
}
// MARK: - Welcome
struct WelcomePage: View {
let onContinue: () -> Void
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea()
Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.12), .clear], center: .topTrailing, startRadius: 0, endRadius: 260)).frame(width: 260, height: 260).offset(x: 80, y: -120).allowsHitTesting(false)
VStack { Spacer()
VStack(spacing: 14) {
HStack(spacing: 6) { Image(systemName: "sparkles").font(.system(size: 12)); Text("AI 驱动").font(.system(size: 12, weight: .semibold)) }.foregroundColor(Color.zxAccent).padding(.horizontal, 12).padding(.vertical, 6).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(Capsule())
Text("用 AI 重新定义\n你的学习方式").font(.system(size: 32, weight: .heavy)).tracking(-0.8).lineSpacing(4)
VStack(spacing: 10) {
FeatureRow(icon: "brain.head.profile", title: "主动回忆", desc: "基于间隔重复的智能复习")
FeatureRow(icon: "mic", title: "费曼解释", desc: "用自己的话讲出来")
FeatureRow(icon: "chart.bar", title: "AI 分析", desc: "发现知识薄弱点")
}
}
VStack(spacing: 12) {
Button { onContinue() } label: {
Text("开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20)
}
}.padding(.bottom, 32)
}.padding(.horizontal, 20)
}
}
}
struct FeatureRow: View {
let icon: String; let title: String; let desc: String
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 18)).foregroundColor(Color.zxPurple).frame(width: 40, height: 40).background(Color(hex: "#7C6EFA", opacity: 0.1)).clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }
}.padding(.horizontal, 16).padding(.vertical, 14).background(Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// MARK: - Login (with nonce support for iOS 15+)
struct LoginPage: View {
@EnvironmentObject var authManager: AuthManager
@State private var isLoggingIn = false
@State private var errorMessage: String?
@State private var loginTask: Task<Void, Never>?
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea()
Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.15), .clear], center: .top, startRadius: 0, endRadius: 300)).frame(width: 300, height: 300).offset(y: -80).allowsHitTesting(false)
VStack { Spacer()
VStack(spacing: 32) {
RoundedRectangle(cornerRadius: 28).fill(LinearGradient(colors: [Color(hex: "#7C6EFA"), Color(hex: "#A78BFA"), Color(hex: "#F97316")], startPoint: .topLeading, endPoint: .bottomTrailing)).frame(width: 80, height: 80).overlay(Image(systemName: "brain.head.profile").font(.system(size: 36)).foregroundColor(.white.opacity(0.8))).shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 30).padding(.bottom, -4)
VStack(spacing: 8) {
Text("欢迎使用知习").font(.system(size: 26, weight: .heavy)).tracking(-0.6)
Text("使用 Apple 账号登录以同步学习数据").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
if let error = errorMessage {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 12))
Text(error).font(.system(size: 14))
}
.foregroundColor(Color(hex: "#991B1B"))
.padding(.horizontal, 16).padding(.vertical, 10)
.background(Color(hex: "#FEE2E2"))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
request.nonce = LoginPage.generateNonceHash()
} onCompletion: { result in
handleAppleResult(result)
}
.signInWithAppleButtonStyle(.white)
.frame(height: 54)
.clipShape(RoundedRectangle(cornerRadius: 16))
.disabled(isLoggingIn)
.overlay {
if isLoggingIn {
VStack(spacing: 8) {
ProgressView().tint(.white)
Button("取消") {
loginTask?.cancel()
isLoggingIn = false
errorMessage = nil
}
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.6))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
}.padding(.horizontal, 24).padding(.bottom, 48)
} }
}
// MARK: - Nonce generation (iOS 15+ required)
private static var currentRawNonce: String?
/// SHA256 hash of the nonce, passed to Apple in the request
private static func generateNonceHash() -> String {
let raw = randomNonceString()
currentRawNonce = raw
return sha256(raw)
}
private static func randomNonceString(length: Int = 32) -> String {
precondition(length > 0)
let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
var result = ""
var remainingLength = length
while remainingLength > 0 {
var randoms = [UInt8](repeating: 0, count: 16)
let errorCode = SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms)
if errorCode != errSecSuccess {
fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
}
for random in randoms {
if remainingLength == 0 { continue }
if random < charset.count {
result.append(charset[Int(random)])
remainingLength -= 1
}
}
}
return result
}
private static func sha256(_ input: String) -> String {
let inputData = Data(input.utf8)
let hashedData = sha256Data(inputData)
return hashedData.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Apple result handler
private func handleAppleResult(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let auth):
guard let credential = auth.credential as? ASAuthorizationAppleIDCredential,
let identityToken = credential.identityToken,
let tokenStr = String(data: identityToken, encoding: .utf8) else {
withAnimation { errorMessage = "获取 Apple 身份信息失败" }
return
}
let givenName = credential.fullName?.givenName
let familyName = credential.fullName?.familyName
let authCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }
isLoggingIn = true
errorMessage = nil
loginTask = Task {
do {
try Task.checkCancellation()
let rawNonce = LoginPage.currentRawNonce
LoginPage.currentRawNonce = nil //
let resp = try await AuthService.shared.appleLogin(
identityToken: tokenStr,
authorizationCode: authCode,
givenName: givenName,
familyName: familyName,
nonce: rawNonce
)
try Task.checkCancellation()
await authManager.signIn(resp)
await MainActor.run { isLoggingIn = false }
} catch is CancellationError {
await MainActor.run { isLoggingIn = false }
} catch {
await MainActor.run {
isLoggingIn = false
errorMessage = "登录失败: \(error.localizedDescription)"
}
}
}
case .failure(let error):
if (error as NSError).code != ASAuthorizationError.canceled.rawValue {
withAnimation { errorMessage = "Apple 登录失败: \(error.localizedDescription)" }
}
}
}
}
import CryptoKit
// MARK: - SHA256 helper
private func sha256Data(_ data: Data) -> Data {
let digest = SHA256.hash(data: data)
return Data(digest)
}
// MARK: - Shared UI components
struct ZXTabBtn: View { let t: String; let active: Bool; let a: () -> Void; var body: some View { Button(action: a) { Text(t).font(.system(size: 14, weight: .semibold)).foregroundColor(active ? .white : Color.zxF05).frame(maxWidth: .infinity).frame(height: 36).background(active ? AnyView(ZXGradient.brand) : AnyView(Color.clear)).clipShape(RoundedRectangle(cornerRadius: 9)) } } }
struct ZXInputField: View { let placeholder: String; @Binding var text: String; var isSecure = false; var body: some View { HStack { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } }.font(.system(size: 16)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } }
struct SocialLoginBtn: View { let emoji: String; let text: String; let color: Color; let action: () -> Void; var body: some View { Button(action: action) { HStack(spacing: 10) { Text(emoji).font(.system(size: 18)); Text(text).font(.system(size: 12, weight: .medium)) }.foregroundColor(Color.zxF007).frame(maxWidth: .infinity).frame(height: 52).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)) } } }
// MARK: - Onboarding
struct OnboardingPage: View {
let onContinue: () -> Void
@State private var step = 0
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea()
VStack(spacing: 0) { Spacer()
HStack(spacing: 6) { ForEach(0..<4, id: \.self) { i in RoundedRectangle(cornerRadius: 2).fill(i == step ? AnyShapeStyle(ZXGradient.brand) : AnyShapeStyle(Color(hex: "#FFFFFF", opacity: 0.1))).frame(width: i == step ? 24 : 8, height: 4) } }
VStack(spacing: 12) { Text(titles[step]).font(.system(size: 24, weight: .heavy)).tracking(-0.5); Text(descs[step]).font(.system(size: 14)).foregroundColor(Color.zxF04).lineSpacing(4).multilineTextAlignment(.center) }.padding(.top, 32).padding(.bottom, 40)
Button { if step < 3 { withAnimation { step += 1 } } else { onContinue() } } label: { Text(step < 3 ? "下一步" : "开始使用").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }
Button("跳过") { onContinue() }.font(.system(size: 12)).foregroundColor(Color.zxF03).padding(.top, 12).padding(.bottom, 32)
}.padding(.horizontal, 20)
}
}
}
// MARK: - Account Status (disabled / deleted)
struct AccountStatusView: View {
let icon: String
let title: String
let message: String
let onBackToLogin: () -> Void
var body: some View {
ZStack {
ZXGradient.page.ignoresSafeArea()
VStack(spacing: 24) {
Image(systemName: icon)
.font(.system(size: 56))
.foregroundColor(Color.zxCoral.opacity(0.6))
.padding(.bottom, 8)
Text(title)
.font(.system(size: 22, weight: .heavy))
.foregroundColor(Color.zxF0)
Text(message)
.font(.system(size: 16))
.foregroundColor(Color.zxF04)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 40)
Button {
onBackToLogin()
} label: {
Text("返回首页")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.zxOnPrimary)
.frame(width: 200, height: 48)
.background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.top, 12)
}
}
}
}
// MARK: - GoalSetup
struct GoalSetupPage: View {
let onComplete: (Bool) -> Void
@State private var selectedGoal = ""
let goals = [("🧑‍🎓","备考考试","公考、考研、考证等"),("💼","职业技能","编程、设计、产品等"),("📚","通识学习","扩充知识面"),("🎯","自定义","设定自己的目标")]
@State private var selectedMethod = ""
let methods = ["间隔回忆","费曼技巧","AI 分析"]
@State private var dailyMins = "30 分钟"
let times = ["15 分钟","30 分钟","1 小时","不限制"]
var body: some View {
ZStack { ZXGradient.page.ignoresSafeArea()
VStack(spacing: 0) { Spacer()
Text("设定你的学习目标").font(.system(size: 24, weight: .heavy)).tracking(-0.5).foregroundColor(Color.zxF0).padding(.bottom, 24)
ScrollView { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 10) { Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
ForEach(goals, id: \.1) { g in let sel = selectedGoal == g.1; Button { selectedGoal = g.1 } label: { HStack(spacing: 12) { Text(g.0).font(.system(size: 22)).frame(width: 44, height: 44).background(sel ? Color(hex: "#7C6EFA", opacity: 0.15) : Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 12)); VStack(alignment: .leading, spacing: 2) { Text(g.1).font(.system(size: 16, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0); Text(g.2).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Circle().stroke(sel ? Color.zxPurple : Color(hex: "#FFFFFF", opacity: 0.2), lineWidth: 2).frame(width: 22, height: 22).overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } } }.padding(14).background(sel ? Color(hex: "#7C6EFA", opacity: 0.08) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 16).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 16)) }.foregroundColor(.primary) } }
VStack(alignment: .leading, spacing: 10) { Text("学习方法").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
HStack(spacing: 8) { ForEach(methods, id: \.self) { m in let sel = selectedMethod == m; Button { selectedMethod = m } label: { Text(m).font(.system(size: 14)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).padding(.horizontal, 16).padding(.vertical, 10).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 20).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }.foregroundColor(.primary) } } }
VStack(alignment: .leading, spacing: 10) { Text("每日学习时间").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
HStack(spacing: 8) { ForEach(times, id: \.self) { t in let sel = dailyMins == t; Button { dailyMins = t } label: { Text(t).font(.system(size: 12)).fontWeight(sel ? .semibold : .regular).foregroundColor(sel ? Color.zxPurple : Color.zxF05).frame(maxWidth: .infinity).frame(height: 40).background(sel ? Color(hex: "#7C6EFA", opacity: 0.1) : Color.zxFill003).overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color(hex: "#7C6EFA", opacity: 0.25) : Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundColor(.primary) } } } }
Button { onComplete(true) } label: { Text("开始学习").font(.system(size: 16, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 56).background(ZXGradient.ctaButton).clipShape(RoundedRectangle(cornerRadius: 18)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.4), radius: 20) }.padding(.top, 24).padding(.bottom, 32).padding(.horizontal, 20)
} } }
}
}