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>
450 lines
24 KiB
Swift
450 lines
24 KiB
Swift
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)
|
||
} } }
|
||
}
|
||
}
|