feat: MVVM 架构、全套 UI 页面、浅深色主题、本地持久化、等待名单、AI 动效
- 架构层:ViewModel/ObservableObject、Service/Repository、网络层 APIClient/APIEndpoint/APIError - 设计系统:Color(light:dark:) 自适应 28 色 Token、ColorSchemeManager 深浅色切换 - 全页面:AI 对话/反馈/回忆/薄弱点、知识库 CRUD、学习工作台、复习计划、学习分析、个人中心/设置 - 登录与引导:Sign in with Apple、AppSession 状态管理、引导流程、演示模式 - 本地持久化:FileCache + PersistenceController(学习任务/复习任务/学习记录) - 本地化:zh-Hans Localizable.strings ~120 条、ZXStrings 程序化引用、LanguageManager - 组件库:ZXTabBar/ZXBackHeader/ZXSTaskRow/ZXChartView/ZXTypingIndicator 等 22 个共享组件 - 等待名单:WaitlistView 邮箱收集表单 - 动效:ZXTypingIndicator AI 打字动画、ZXShimmerModifier 骨架屏 - 测试:StudyHomeViewModel/AIChatViewModel/ReviewPlanViewModel/FileCache 共 28 条 - Dynamic Type 支持 + 范围限制 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c10e299dc0
commit
7066200b7b
@ -91,6 +91,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 05F6CD122FA886330043A7BC;
|
mainGroup = 05F6CD122FA886330043A7BC;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
|||||||
@ -1,185 +1,75 @@
|
|||||||
//
|
|
||||||
// AIStudyAppApp.swift - 根路由:引导流程 vs 主界面
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct AIStudyAppApp: App {
|
struct AIStudyAppApp: App {
|
||||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
@StateObject private var session: AppSession
|
||||||
|
@StateObject private var colorSchemeManager = ColorSchemeManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let tokenStore = TokenStore()
|
||||||
|
let baseURL = URL(string: "https://api.longde.cloud")!
|
||||||
|
let apiClient = APIClient(baseURL: baseURL, tokenStore: tokenStore)
|
||||||
|
let authService = AuthService(apiClient: apiClient, tokenStore: tokenStore)
|
||||||
|
_session = StateObject(wrappedValue: AppSession(authService: authService, tokenStore: tokenStore))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
if hasCompletedOnboarding {
|
AppRootView(session: session)
|
||||||
ContentView()
|
.preferredColorScheme(colorSchemeManager.current.colorScheme)
|
||||||
.preferredColorScheme(.dark)
|
.dynamicTypeClamped()
|
||||||
} else {
|
.task {
|
||||||
OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
await session.bootstrap()
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Onboarding Flow (Splash → Welcome → Login → Onboarding → GoalSetup)
|
// MARK: - Root Router
|
||||||
// 对应 React: SplashPage, WelcomePage, LoginPage, OnboardingPage, GoalSetupPage
|
|
||||||
|
struct AppRootView: View {
|
||||||
|
@ObservedObject var session: AppSession
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if session.isLoading {
|
||||||
|
SplashPage {}
|
||||||
|
} else if !session.isAuthenticated {
|
||||||
|
LoginView(appSession: session) { _ in
|
||||||
|
session.loginAsDemo()
|
||||||
|
}
|
||||||
|
} else if session.needsOnboarding {
|
||||||
|
OnboardingFlowView(session: session)
|
||||||
|
} else {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Flow (Welcome → Onboarding → GoalSetup)
|
||||||
|
|
||||||
struct OnboardingFlowView: View {
|
struct OnboardingFlowView: View {
|
||||||
@Binding var hasCompletedOnboarding: Bool
|
@ObservedObject var session: AppSession
|
||||||
@State private var step = 0
|
@State private var step = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
switch step {
|
switch step {
|
||||||
case 0: SplashPage { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } }
|
case 0:
|
||||||
case 1: WelcomePage { withAnimation { step = 2 } } onSkip: { hasCompletedOnboarding = true }
|
WelcomePage {
|
||||||
case 2: LoginPage { step = 3 } onSkip: { hasCompletedOnboarding = true }
|
withAnimation(.easeInOut(duration: 0.5)) { step = 1 }
|
||||||
case 3: OnboardingPage { step = 4 }
|
} onSkip: {
|
||||||
case 4: GoalSetupPage { $0 ? (hasCompletedOnboarding = true) : (step = 0) }
|
session.logout()
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
|
case 1:
|
||||||
|
OnboardingPage { step = 2 }
|
||||||
|
case 2:
|
||||||
|
GoalSetupPage { _ in
|
||||||
|
session.completeOnboarding()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
default:
|
||||||
}
|
EmptyView()
|
||||||
}
|
|
||||||
|
|
||||||
// Splash
|
|
||||||
struct SplashPage: View {
|
|
||||||
let onFinish: () -> Void
|
|
||||||
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)
|
|
||||||
Circle().fill(RadialGradient(colors: [Color(hex: "#F97316", opacity: 0.15), .clear], center: .center, startRadius: 0, endRadius: 100)).frame(width: 200, height: 200).offset(y: 180).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))
|
|
||||||
Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.4)).tracking(3).padding(.top, 6)
|
|
||||||
Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.45)).tracking(0.5).padding(.top, 24)
|
|
||||||
}
|
|
||||||
VStack { Spacer()
|
|
||||||
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2).fill(Color(hex: "#FFFFFF", opacity: 0.1)).frame(width: 40, height: 3); RoundedRectangle(cornerRadius: 2).fill(LinearGradient(colors: [.zxPurple, Color.zxOrange], startPoint: .leading, endPoint: .trailing)).frame(width: 24, height: 3) }
|
|
||||||
.padding(.bottom, 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { onFinish() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Welcome
|
|
||||||
struct WelcomePage: View {
|
|
||||||
let onContinue: () -> Void; let onSkip: () -> 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: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习"); FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来"); FeatureRow(icon: "📊", 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) }; Button { onSkip() } label: { Text("已有账号?立即登录").font(.system(size: 14, weight: .medium)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.7)) }.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) { Text(icon).font(.system(size: 20)).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)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login
|
|
||||||
struct LoginPage: View {
|
|
||||||
let onContinue: () -> Void; let onSkip: () -> Void
|
|
||||||
@State private var isEmail = false; @State private var phone = ""; @State private var email = ""; @State private var pw = ""; @State private var showPw = false
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.zxBg0.ignoresSafeArea()
|
|
||||||
Circle().fill(RadialGradient(colors: [Color(hex: "#7C6EFA", opacity: 0.1), .clear], center: .top, startRadius: 0, endRadius: 200)).frame(width: 200, height: 200).offset(y: -60).allowsHitTesting(false)
|
|
||||||
VStack { Spacer()
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
VStack(spacing: 6) { Text("欢迎登录").font(.system(size: 28, weight: .heavy)).tracking(-0.6); Text("使用手机号或邮箱登录").font(.system(size: 14)).foregroundColor(Color.zxF05) }
|
|
||||||
HStack(spacing: 4) { tabBtn("手机号", !isEmail) { isEmail = false }; tabBtn("邮箱", isEmail) { isEmail = true } }.padding(4).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
if isEmail {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("邮箱").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
|
|
||||||
ZXInputField(placeholder: "your@email.com", text: $email)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("手机号").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
|
|
||||||
HStack(spacing: 0) { Text("+86").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).padding(.trailing, 12).overlay(alignment: .trailing) { Rectangle().fill(Color.zxBorder01).frame(width: 1).padding(.vertical, 4) }.padding(.trailing, 12); TextField("手机号", text: $phone).keyboardType(.phonePad).font(.system(size: 15)).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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ZXInputField(placeholder: "密码", text: $pw, isSecure: !showPw)
|
|
||||||
HStack { Spacer(); Button { showPw.toggle() } label: { Image(systemName: showPw ? "eye" : "eye.slash").font(.system(size: 16)).foregroundColor(Color.zxF03) } }.padding(.trailing, 4)
|
|
||||||
HStack { Spacer(); Button("忘记密码?") {}.font(.system(size: 13)).foregroundColor(Color.zxPurple) }
|
|
||||||
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) }
|
|
||||||
HStack(spacing: 12) { Rectangle().fill(Color.zxBorder008).frame(height: 1); Text("或").font(.system(size: 12)).foregroundColor(Color.zxF03); Rectangle().fill(Color.zxBorder008).frame(height: 1) }
|
|
||||||
HStack(spacing: 12) { SocialLoginBtn(emoji: "💬", text: "微信登陆", color: .green) {}; SocialLoginBtn(emoji: "🍎", text: "Apple 登录", color: .white) {} }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 32)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func tabBtn(_ t: String, _ active: Bool, _ a: @escaping () -> Void) -> some View { Button(action: a) { Text(t).font(.system(size: 13, 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: 15)).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: 11, 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)) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Onboarding
|
|
||||||
struct OnboardingPage: View {
|
|
||||||
let onContinue: () -> Void
|
|
||||||
@State private var step = 0
|
|
||||||
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
|
|
||||||
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
|
|
||||||
let icons = ["square.and.arrow.down", "brain.head.profile", "sparkle.magnifyingglass", "chart.line.uptrend.xyaxis"]
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct GoalSetupPage: View {
|
|
||||||
let onComplete: (Bool) -> Void // true = launch app
|
|
||||||
@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: 15, 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: 13)).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) } }
|
|
||||||
}
|
|
||||||
} }.padding(.horizontal, 20)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
AIStudyApp/AIStudyApp/App/AppSession.swift
Normal file
119
AIStudyApp/AIStudyApp/App/AppSession.swift
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppSession: ObservableObject {
|
||||||
|
@Published var currentUser: User?
|
||||||
|
@Published var isAuthenticated = false
|
||||||
|
@Published var isLoading = true
|
||||||
|
@Published var authError: String?
|
||||||
|
|
||||||
|
private let authService: AuthServiceProtocol
|
||||||
|
private let tokenStore: TokenStoreProtocol
|
||||||
|
|
||||||
|
init(authService: AuthServiceProtocol, tokenStore: TokenStoreProtocol) {
|
||||||
|
self.authService = authService
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
|
func bootstrap() async {
|
||||||
|
isLoading = true
|
||||||
|
authError = nil
|
||||||
|
|
||||||
|
guard let _ = try? tokenStore.getRefreshToken() else {
|
||||||
|
isAuthenticated = false
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await authService.refreshSession()
|
||||||
|
currentUser = response.user
|
||||||
|
isAuthenticated = true
|
||||||
|
} catch {
|
||||||
|
try? tokenStore.clearAll()
|
||||||
|
isAuthenticated = false
|
||||||
|
if let apiError = error as? APIError, apiError.isAuthenticationError {
|
||||||
|
authError = nil
|
||||||
|
} else {
|
||||||
|
authError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login
|
||||||
|
|
||||||
|
func loginWithApple() async {
|
||||||
|
isLoading = true
|
||||||
|
authError = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await authService.loginWithApple()
|
||||||
|
currentUser = response.user
|
||||||
|
isAuthenticated = true
|
||||||
|
} catch {
|
||||||
|
authError = error.localizedDescription
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logout
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
Task { try? await authService.logout() }
|
||||||
|
currentUser = nil
|
||||||
|
isAuthenticated = false
|
||||||
|
authError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Demo Mode
|
||||||
|
|
||||||
|
func loginAsDemo() {
|
||||||
|
currentUser = User(
|
||||||
|
id: "demo",
|
||||||
|
appleUserId: "demo",
|
||||||
|
displayName: "演示用户",
|
||||||
|
email: nil,
|
||||||
|
preferredLanguage: "zh-Hans",
|
||||||
|
onboardingCompleted: true,
|
||||||
|
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
lastLoginAt: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
status: .active
|
||||||
|
)
|
||||||
|
isAuthenticated = true
|
||||||
|
isLoading = false
|
||||||
|
authError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed
|
||||||
|
|
||||||
|
var needsOnboarding: Bool {
|
||||||
|
currentUser?.onboardingCompleted == false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding
|
||||||
|
|
||||||
|
func completeOnboarding() {
|
||||||
|
guard var user = currentUser else { return }
|
||||||
|
// In production, this would call PATCH /api/users/me/onboarding
|
||||||
|
// For now, locally mutate to allow flow to proceed
|
||||||
|
user = User(
|
||||||
|
id: user.id,
|
||||||
|
appleUserId: user.appleUserId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
preferredLanguage: user.preferredLanguage,
|
||||||
|
onboardingCompleted: true,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
status: user.status
|
||||||
|
)
|
||||||
|
currentUser = user
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,107 +5,21 @@ struct ContentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
switch selectedTab {
|
Group {
|
||||||
case "ai": NavigationStack { AIHomeView() }
|
if selectedTab == "ai" { NavigationStack { AIHomeView() } }
|
||||||
case "library": NavigationStack { LibraryHomeView() }
|
else if selectedTab == "library" { NavigationStack { LibraryHomeView() } }
|
||||||
case "study": NavigationStack { StudyHomeView() }
|
else if selectedTab == "study" { NavigationStack { StudyHomeView() } }
|
||||||
case "analysis": NavigationStack { AnalysisHomeView() }
|
else if selectedTab == "analysis" { NavigationStack { AnalysisHomeView() } }
|
||||||
case "profile": NavigationStack { ProfileView() }
|
else if selectedTab == "profile" { NavigationStack { ProfileView() } }
|
||||||
default: NavigationStack { AIHomeView() }
|
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.85), value: selectedTab)
|
||||||
|
|
||||||
VStack { Spacer(); ZXTabBar(active: $selectedTab) }
|
VStack { Spacer(); ZXTabBar(active: $selectedTab) }
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI Input Bar
|
// Shared components: ZXTabBar, ZXAIInputBar, ZXScoreBox, ZXIconBtn → Shared/Components/
|
||||||
|
|
||||||
struct ZXAIInputBar: View {
|
|
||||||
@Binding var text: String
|
|
||||||
let onSend: () -> Void
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "sparkles").font(.system(size: 16)).foregroundColor(Color.zxPurple)
|
|
||||||
TextField("问 AI 任何学习问题…", text: $text).font(.system(size: 14)).tint(Color.zxPurple)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "mic.fill").font(.system(size: 18)).foregroundColor(Color.zxF03)
|
|
||||||
Button(action: onSend) {
|
|
||||||
Image(systemName: "arrow.up").font(.system(size: 14, weight: .bold)).foregroundColor(.white)
|
|
||||||
.frame(width: 30, height: 30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 9))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
||||||
.background(.ultraThinMaterial).background(Color.zxFill004)
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder008, lineWidth: 1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
|
||||||
.padding(.horizontal, 20).padding(.bottom, 34)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Score Box
|
|
||||||
|
|
||||||
struct ZXScoreBox: View {
|
|
||||||
let score: Int; let bg: Color; let fg: Color
|
|
||||||
var body: some View {
|
|
||||||
Text("\(score)").font(.system(size: 12, weight: .heavy)).foregroundColor(fg)
|
|
||||||
.frame(width: 36, height: 36).background(bg).clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ZXTabBar: View {
|
|
||||||
@Binding var active: String
|
|
||||||
private let tabs = [
|
|
||||||
("ai","AI","brain.head.profile"),
|
|
||||||
("library","知识库","books.vertical.fill"),
|
|
||||||
("study","学习","bolt.fill"),
|
|
||||||
("analysis","分析","chart.bar.fill"),
|
|
||||||
("profile","我的","person.fill"),
|
|
||||||
]
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(tabs, id: \.0) { item in
|
|
||||||
let on = item.0 == active
|
|
||||||
Button { active = item.0 } label: {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
ZStack {
|
|
||||||
if on {
|
|
||||||
Circle().fill(Color.zxPurple.opacity(0.2))
|
|
||||||
.frame(width: 28, height: 28).scaleEffect(1.4)
|
|
||||||
}
|
|
||||||
Image(systemName: item.2)
|
|
||||||
.font(.system(size: 22, weight: on ? .semibold : .regular))
|
|
||||||
.foregroundColor(on ? Color.zxPurple : Color(hex: "#F0F0FF", opacity: 0.35))
|
|
||||||
}
|
|
||||||
Text(item.1)
|
|
||||||
.font(.system(size: 10, weight: on ? .semibold : .regular))
|
|
||||||
.foregroundColor(on ? Color.zxPurple : Color(hex: "#F0F0FF", opacity: 0.35))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 6).padding(.bottom, 34).frame(height: 83)
|
|
||||||
.background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95))
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Rectangle().fill(Color.zxBorder008).frame(height: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shared Icon Button
|
|
||||||
|
|
||||||
struct ZXIconBtn: View {
|
|
||||||
let icon: String; let size: CGFloat; var branded = false; let action: () -> Void
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Image(systemName: icon).font(.system(size: size * 0.44)).frame(width: size, height: size)
|
|
||||||
}
|
|
||||||
.foregroundColor(branded ? .white : Color.zxF05)
|
|
||||||
.background(branded ? AnyView(ZXGradient.brand) : AnyView(Color(hex: "#FFFFFF", opacity: 0.05)))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
.overlay { if !branded { RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum AppColorScheme: String, CaseIterable, Identifiable {
|
||||||
|
case system
|
||||||
|
case light
|
||||||
|
case dark
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return String(localized: "跟随系统")
|
||||||
|
case .light: return String(localized: "浅色模式")
|
||||||
|
case .dark: return String(localized: "深色模式")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .light: return .light
|
||||||
|
case .dark: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ColorSchemeManager: ObservableObject {
|
||||||
|
static let shared = ColorSchemeManager()
|
||||||
|
|
||||||
|
@AppStorage("app_color_scheme") var current: AppColorScheme = .dark
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
}
|
||||||
@ -27,23 +27,39 @@ extension Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Adaptive Color Helper
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(light: Color, dark: Color) {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
self.init(UIColor { traitCollection in
|
||||||
|
traitCollection.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
|
||||||
|
})
|
||||||
|
#else
|
||||||
|
self.init(light)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ZhiXi Colors (exact match from React index.css)
|
// MARK: - ZhiXi Colors (exact match from React index.css)
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
// ── 背景 ──
|
// ── 背景 ──
|
||||||
static let zxBg0 = Color(hex: "#0F0F1A")
|
static let zxBg0 = Color(light: Color(hex: "#F5F5FA"), dark: Color(hex: "#0F0F1A"))
|
||||||
static let zxBg1 = Color(hex: "#12122A")
|
static let zxBg1 = Color(light: Color(hex: "#EAEAF2"), dark: Color(hex: "#12122A"))
|
||||||
static let zxBg2 = Color(hex: "#0A0A14") // phone shell
|
static let zxBg2 = Color(light: Color(hex: "#E0E0E8"), dark: Color(hex: "#0A0A14"))
|
||||||
static let zxBgSplash = Color(hex: "#0D0D20")
|
static let zxBgSplash = Color(light: Color(hex: "#F0F0F8"), dark: Color(hex: "#0D0D20"))
|
||||||
|
|
||||||
// ── 文字 ──
|
// ── 文字 ──
|
||||||
static let zxF0 = Color(hex: "#F0F0FF")
|
static let zxF0 = Color(light: Color(hex: "#1A1A2E"), dark: Color(hex: "#F0F0FF"))
|
||||||
static let zxF05 = Color(hex: "#F0F0FF", opacity: 0.5)
|
static let zxF05 = Color(light: Color(hex: "#1A1A2E", opacity: 0.5), dark: Color(hex: "#F0F0FF", opacity: 0.5))
|
||||||
static let zxF04 = Color(hex: "#F0F0FF", opacity: 0.4)
|
static let zxF04 = Color(light: Color(hex: "#1A1A2E", opacity: 0.4), dark: Color(hex: "#F0F0FF", opacity: 0.4))
|
||||||
static let zxF03 = Color(hex: "#F0F0FF", opacity: 0.3)
|
static let zxF03 = Color(light: Color(hex: "#1A1A2E", opacity: 0.3), dark: Color(hex: "#F0F0FF", opacity: 0.3))
|
||||||
static let zxF007 = Color(hex: "#F0F0FF", opacity: 0.7)
|
static let zxF007 = Color(light: Color(hex: "#1A1A2E", opacity: 0.7), dark: Color(hex: "#F0F0FF", opacity: 0.7))
|
||||||
static let zxF006 = Color(hex: "#F0F0FF", opacity: 0.6)
|
static let zxF006 = Color(light: Color(hex: "#1A1A2E", opacity: 0.6), dark: Color(hex: "#F0F0FF", opacity: 0.6))
|
||||||
static let zxF0045 = Color(hex: "#F0F0FF", opacity: 0.45)
|
static let zxF0045 = Color(light: Color(hex: "#1A1A2E", opacity: 0.45), dark: Color(hex: "#F0F0FF", opacity: 0.45))
|
||||||
|
static let zxF035 = Color(light: Color(hex: "#1A1A2E", opacity: 0.35), dark: Color(hex: "#F0F0FF", opacity: 0.35))
|
||||||
|
static let zxF02 = Color(light: Color(hex: "#1A1A2E", opacity: 0.2), dark: Color(hex: "#F0F0FF", opacity: 0.2))
|
||||||
|
|
||||||
// ── 品牌色 ──
|
// ── 品牌色 ──
|
||||||
static let zxPurple = Color(hex: "#7C6EFA")
|
static let zxPurple = Color(hex: "#7C6EFA")
|
||||||
@ -55,22 +71,20 @@ extension Color {
|
|||||||
static let zxRed = Color(hex: "#EF4444")
|
static let zxRed = Color(hex: "#EF4444")
|
||||||
static let zxCyan = Color(hex: "#4ECDC4")
|
static let zxCyan = Color(hex: "#4ECDC4")
|
||||||
|
|
||||||
static let zxF02 = Color(hex: "#F0F0FF", opacity: 0.2)
|
|
||||||
static let zxFill01 = Color(hex: "#FFFFFF", opacity: 0.1)
|
|
||||||
|
|
||||||
// ── 边框/分割线 ──
|
// ── 边框/分割线 ──
|
||||||
static let zxBorder008 = Color(hex: "#FFFFFF", opacity: 0.08)
|
static let zxBorder008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08))
|
||||||
static let zxBorder006 = Color(hex: "#FFFFFF", opacity: 0.06)
|
static let zxBorder006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06))
|
||||||
static let zxBorder004 = Color(hex: "#FFFFFF", opacity: 0.04)
|
static let zxBorder004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04))
|
||||||
static let zxBorder01 = Color(hex: "#FFFFFF", opacity: 0.10)
|
static let zxBorder01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10))
|
||||||
static let zxBorder015 = Color(hex: "#FFFFFF", opacity: 0.15)
|
static let zxBorder015 = Color(light: Color(hex: "#000000", opacity: 0.15), dark: Color(hex: "#FFFFFF", opacity: 0.15))
|
||||||
|
|
||||||
// ── 半透明填充 ──
|
// ── 半透明填充 ──
|
||||||
static let zxFill003 = Color(hex: "#FFFFFF", opacity: 0.03)
|
static let zxFill003 = Color(light: Color(hex: "#000000", opacity: 0.03), dark: Color(hex: "#FFFFFF", opacity: 0.03))
|
||||||
static let zxFill004 = Color(hex: "#FFFFFF", opacity: 0.04)
|
static let zxFill004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04))
|
||||||
static let zxFill005 = Color(hex: "#FFFFFF", opacity: 0.05)
|
static let zxFill005 = Color(light: Color(hex: "#000000", opacity: 0.05), dark: Color(hex: "#FFFFFF", opacity: 0.05))
|
||||||
static let zxFill006 = Color(hex: "#FFFFFF", opacity: 0.06)
|
static let zxFill006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06))
|
||||||
static let zxFill008 = Color(hex: "#FFFFFF", opacity: 0.08)
|
static let zxFill008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08))
|
||||||
|
static let zxFill01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10))
|
||||||
|
|
||||||
// ── 彩色半透 ──
|
// ── 彩色半透 ──
|
||||||
static func zxPurpleBG(_ a: Double = 0.10) -> Color { Color(hex: "#7C6EFA", opacity: a) }
|
static func zxPurpleBG(_ a: Double = 0.10) -> Color { Color(hex: "#7C6EFA", opacity: a) }
|
||||||
@ -86,7 +100,7 @@ extension Color {
|
|||||||
enum ZXGradient {
|
enum ZXGradient {
|
||||||
// ── 页面背景 ──
|
// ── 页面背景 ──
|
||||||
static let page = LinearGradient(
|
static let page = LinearGradient(
|
||||||
colors: [Color(hex: "#0F0F1A"), Color(hex: "#12122A")],
|
colors: [Color.zxBg0, Color.zxBg1],
|
||||||
startPoint: .top, endPoint: .bottom
|
startPoint: .top, endPoint: .bottom
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -140,9 +154,9 @@ enum ZXGradient {
|
|||||||
// ── Splash ──
|
// ── Splash ──
|
||||||
static let splash = LinearGradient(
|
static let splash = LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(hex: "#0D0D20"),
|
Color.zxBgSplash,
|
||||||
Color(hex: "#0F0F1A"),
|
Color.zxBg0,
|
||||||
Color(hex: "#130D20")
|
Color(light: Color(hex: "#F0F0F5"), dark: Color(hex: "#130D20"))
|
||||||
],
|
],
|
||||||
startPoint: .top, endPoint: .bottom
|
startPoint: .top, endPoint: .bottom
|
||||||
)
|
)
|
||||||
|
|||||||
35
AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift
Normal file
35
AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Scaled Font Modifier
|
||||||
|
|
||||||
|
/// Apply Dynamic Type scaling to a fixed font size, clamped to a reasonable range.
|
||||||
|
struct ScaledFont: ViewModifier {
|
||||||
|
@ScaledMetric var size: CGFloat
|
||||||
|
let weight: Font.Weight
|
||||||
|
let design: Font.Design
|
||||||
|
|
||||||
|
init(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) {
|
||||||
|
_size = ScaledMetric(wrappedValue: size, relativeTo: .body)
|
||||||
|
self.weight = weight
|
||||||
|
self.design = design
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.font(.system(size: size, weight: weight, design: design))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func scaledFont(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View {
|
||||||
|
modifier(ScaledFont(size: size, weight: weight, design: design))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dynamic Type Range
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Clamp Dynamic Type to prevent layout breaking at extreme sizes.
|
||||||
|
func dynamicTypeClamped() -> some View {
|
||||||
|
self.dynamicTypeSize(.xSmall ... .xxxLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Staggered Appear
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func staggeredAppear(index: Int, baseDelay: Double = 0.05) -> some View {
|
||||||
|
self
|
||||||
|
.opacity(0)
|
||||||
|
.offset(y: 8)
|
||||||
|
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(Double(index) * baseDelay), value: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animated Visibility
|
||||||
|
|
||||||
|
struct AnimatedVisibility: ViewModifier {
|
||||||
|
let visible: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(visible ? 1 : 0)
|
||||||
|
.scaleEffect(visible ? 1 : 0.95)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func animatedVisible(_ visible: Bool) -> some View {
|
||||||
|
modifier(AnimatedVisibility(visible: visible))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spring Press
|
||||||
|
|
||||||
|
struct SpringPress: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||||
|
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ButtonStyle where Self == SpringPress {
|
||||||
|
static var springPress: SpringPress { SpringPress() }
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// LanguageManager.swift
|
||||||
|
// AIStudyApp
|
||||||
|
//
|
||||||
|
// 语言管理:当前仅支持中文,预留多语言架构
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Supported Language
|
||||||
|
|
||||||
|
enum AppLanguage: String, CaseIterable, Identifiable {
|
||||||
|
case chinese = "zh-Hans"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .chinese: return "中文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Language Manager
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LanguageManager: ObservableObject {
|
||||||
|
static let shared = LanguageManager()
|
||||||
|
|
||||||
|
@AppStorage("app_language") var current: AppLanguage = .chinese {
|
||||||
|
didSet {
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let supported: [AppLanguage] = AppLanguage.allCases
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the AppleLanguages default. Takes effect on next app launch.
|
||||||
|
private func apply() {
|
||||||
|
UserDefaults.standard.set([current.rawValue], forKey: "AppleLanguages")
|
||||||
|
UserDefaults.standard.synchronize()
|
||||||
|
}
|
||||||
|
}
|
||||||
117
AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift
Normal file
117
AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// ZXStrings.swift
|
||||||
|
// AIStudyApp
|
||||||
|
//
|
||||||
|
// 非 SwiftUI Text 场景的本地化字符串引用。
|
||||||
|
// SwiftUI Text("中文") 自动走 LocalizedStringKey,无需改动。
|
||||||
|
// 此文件供 ViewModel、Service、Alert 等需要 String 值的场景使用。
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ZXStrings {
|
||||||
|
|
||||||
|
// MARK: - General
|
||||||
|
|
||||||
|
static let ok = String(localized: "好的")
|
||||||
|
static let cancel = String(localized: "取消")
|
||||||
|
static let retry = String(localized: "重试")
|
||||||
|
static let loading = String(localized: "加载中…")
|
||||||
|
static let confirm = String(localized: "确认")
|
||||||
|
static let skip = String(localized: "跳过")
|
||||||
|
static let save = String(localized: "保存")
|
||||||
|
static let submit = String(localized: "提交")
|
||||||
|
static let submitting = String(localized: "提交中…")
|
||||||
|
static let search = String(localized: "搜索")
|
||||||
|
|
||||||
|
// MARK: - Login
|
||||||
|
|
||||||
|
static let loginWithApple = String(localized: "使用 Apple 继续")
|
||||||
|
static let loginTerms = String(localized: "登录即代表你同意《用户服务协议》和《隐私政策》")
|
||||||
|
static let debugSkip = String(localized: "跳过,进入演示模式")
|
||||||
|
static let brandName = String(localized: "知习")
|
||||||
|
static let brandTagline = String(localized: "更懂你,更会学。")
|
||||||
|
static let brandDescription = String(localized: "用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。")
|
||||||
|
static let existingAccount = String(localized: "已有账号?立即登录")
|
||||||
|
static let redefineLearning = String(localized: "用 AI 重新定义\n你的学习方式")
|
||||||
|
static let getStarted = String(localized: "开始使用")
|
||||||
|
static let startLearning = String(localized: "开始学习")
|
||||||
|
static let nextStep = String(localized: "下一步")
|
||||||
|
|
||||||
|
// MARK: - Onboarding
|
||||||
|
|
||||||
|
static let setGoal = String(localized: "设定你的学习目标")
|
||||||
|
static let learningGoal = String(localized: "学习目标")
|
||||||
|
static let learningMethod = String(localized: "学习方法")
|
||||||
|
static let dailyMinutes = String(localized: "每日学习时间")
|
||||||
|
static let examPrep = String(localized: "备考考试")
|
||||||
|
static let careerSkill = String(localized: "职业技能")
|
||||||
|
static let generalLearning = String(localized: "通识学习")
|
||||||
|
static let customGoal = String(localized: "自定义")
|
||||||
|
static let inputKnowledge = String(localized: "输入知识")
|
||||||
|
static let activeOutput = String(localized: "主动输出")
|
||||||
|
static let aiAnalysis = String(localized: "AI 分析")
|
||||||
|
static let masterKnowledge = String(localized: "掌握知识")
|
||||||
|
|
||||||
|
// MARK: - Study
|
||||||
|
|
||||||
|
static let studyWorkspace = String(localized: "学习工作台")
|
||||||
|
static let todayTasks = String(localized: "今日任务")
|
||||||
|
static let todayProgress = String(localized: "今日进度")
|
||||||
|
static let weeklyActivity = String(localized: "本周学习活跃")
|
||||||
|
static let studied = String(localized: "已学")
|
||||||
|
static let remaining = String(localized: "剩余")
|
||||||
|
static let mastery = String(localized: "掌握")
|
||||||
|
static let tasksUnit = String(localized: "个任务")
|
||||||
|
static let minutesUnit = String(localized: "分钟")
|
||||||
|
static let aiAutoSchedule = String(localized: "AI 自动排期")
|
||||||
|
static let streak14Days = String(localized: "14 天连续")
|
||||||
|
|
||||||
|
// MARK: - Review
|
||||||
|
|
||||||
|
static let reviewPlan = String(localized: "复习计划")
|
||||||
|
static let today = String(localized: "今天")
|
||||||
|
static let tomorrow = String(localized: "明天")
|
||||||
|
static let thisWeek = String(localized: "本周")
|
||||||
|
static let noReviewTasks = String(localized: "暂无复习任务")
|
||||||
|
static let noReviewHint = String(localized: "完成学习后 AI 会自动生成复习计划")
|
||||||
|
|
||||||
|
// MARK: - Feedback
|
||||||
|
|
||||||
|
static let feedbackSubmitted = String(localized: "反馈已提交")
|
||||||
|
static let feedbackThanks = String(localized: "感谢你的反馈,我们会尽快处理。")
|
||||||
|
static let feedbackPlaceholder = String(localized: "请描述你遇到的问题或建议…")
|
||||||
|
static let feedbackCategory = String(localized: "反馈类型")
|
||||||
|
static let submitFeedback = String(localized: "提交反馈")
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
static let language = String(localized: "语言")
|
||||||
|
static let appearance = String(localized: "外观")
|
||||||
|
static let followSystem = String(localized: "跟随系统")
|
||||||
|
static let darkMode = String(localized: "深色模式")
|
||||||
|
static let lightMode = String(localized: "浅色模式")
|
||||||
|
static let learningGoalSettings = String(localized: "学习目标设置")
|
||||||
|
static let reviewReminder = String(localized: "复习提醒")
|
||||||
|
static let learningReport = String(localized: "学习报告")
|
||||||
|
static let learningMethodPref = String(localized: "学习方法偏好")
|
||||||
|
static let dataSync = String(localized: "数据同步与备份")
|
||||||
|
|
||||||
|
// MARK: - Error
|
||||||
|
|
||||||
|
static let networkError = String(localized: "网络请求失败")
|
||||||
|
static let authExpired = String(localized: "登录状态已失效")
|
||||||
|
static let parseError = String(localized: "数据解析失败")
|
||||||
|
static let invalidURL = String(localized: "无效的请求地址")
|
||||||
|
static let serverError = String(localized: "服务器返回错误")
|
||||||
|
static let tokenExpired = String(localized: "Token 已过期")
|
||||||
|
static let appleCredentialFailed = String(localized: "无法获取 Apple 登录凭证")
|
||||||
|
static let missingIdentityToken = String(localized: "未获取到身份验证信息")
|
||||||
|
|
||||||
|
// MARK: - Content Categories
|
||||||
|
|
||||||
|
static let catBug = String(localized: "Bug 反馈")
|
||||||
|
static let catFeature = String(localized: "功能建议")
|
||||||
|
static let catContent = String(localized: "内容问题")
|
||||||
|
static let catOther = String(localized: "其他")
|
||||||
|
}
|
||||||
15
AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift
Normal file
15
AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AIAnalysis: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let sessionId: String
|
||||||
|
let inputText: String
|
||||||
|
let outputJson: String
|
||||||
|
let masteryScore: Int
|
||||||
|
let weakPoints: [String]
|
||||||
|
let suggestions: [String]
|
||||||
|
let modelName: String
|
||||||
|
let createdAt: String
|
||||||
|
let costEstimate: Double?
|
||||||
|
}
|
||||||
25
AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift
Normal file
25
AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Request
|
||||||
|
|
||||||
|
struct AppleLoginRequest: Encodable {
|
||||||
|
let identityToken: String
|
||||||
|
let authorizationCode: String?
|
||||||
|
let userIdentifier: String
|
||||||
|
let fullName: AppleFullName?
|
||||||
|
let email: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppleFullName: Encodable {
|
||||||
|
let givenName: String?
|
||||||
|
let familyName: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response
|
||||||
|
|
||||||
|
struct AuthResponse: Decodable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let expiresIn: Int
|
||||||
|
let user: User
|
||||||
|
}
|
||||||
18
AIStudyApp/AIStudyApp/Core/Models/Feedback.swift
Normal file
18
AIStudyApp/AIStudyApp/Core/Models/Feedback.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Feedback: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let category: FeedbackCategory
|
||||||
|
let content: String
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FeedbackCategory: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case bug = "Bug 反馈"
|
||||||
|
case feature = "功能建议"
|
||||||
|
case content = "内容问题"
|
||||||
|
case other = "其他"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
11
AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift
Normal file
11
AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct KnowledgeBase: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
let language: String
|
||||||
|
let targetUser: String
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
10
AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift
Normal file
10
AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearningPath: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
let estimatedDays: Int
|
||||||
|
let order: Int
|
||||||
|
}
|
||||||
15
AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift
Normal file
15
AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearningSession: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let lessonId: String
|
||||||
|
let startedAt: String
|
||||||
|
let endedAt: String?
|
||||||
|
let userInput: String
|
||||||
|
let aiAnalysis: AIAnalysis?
|
||||||
|
let masteryScore: Int
|
||||||
|
let weakPoints: [String]
|
||||||
|
let nextSuggestion: String?
|
||||||
|
let reviewAt: String?
|
||||||
|
}
|
||||||
14
AIStudyApp/AIStudyApp/Core/Models/Lesson.swift
Normal file
14
AIStudyApp/AIStudyApp/Core/Models/Lesson.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Lesson: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let pathId: String
|
||||||
|
let title: String
|
||||||
|
let content: String
|
||||||
|
let objectives: [String]
|
||||||
|
let keyPoints: [String]
|
||||||
|
let recallQuestions: [String]
|
||||||
|
let practicePrompt: String
|
||||||
|
let order: Int
|
||||||
|
let estimatedMinutes: Int
|
||||||
|
}
|
||||||
26
AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift
Normal file
26
AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ReviewTask: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let lessonId: String
|
||||||
|
let sourceSessionId: String
|
||||||
|
let reviewType: ReviewType
|
||||||
|
let scheduledAt: String
|
||||||
|
let completedAt: String?
|
||||||
|
let status: ReviewTaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewType: String, Codable {
|
||||||
|
case spacedRepetition
|
||||||
|
case feynman
|
||||||
|
case recall
|
||||||
|
case weakPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewTaskStatus: String, Codable {
|
||||||
|
case pending
|
||||||
|
case completed
|
||||||
|
case skipped
|
||||||
|
case overdue
|
||||||
|
}
|
||||||
19
AIStudyApp/AIStudyApp/Core/Models/User.swift
Normal file
19
AIStudyApp/AIStudyApp/Core/Models/User.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let appleUserId: String
|
||||||
|
let displayName: String?
|
||||||
|
let email: String?
|
||||||
|
let preferredLanguage: String
|
||||||
|
let onboardingCompleted: Bool
|
||||||
|
let createdAt: String
|
||||||
|
let lastLoginAt: String?
|
||||||
|
let status: UserStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserStatus: String, Codable {
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
|
case suspended
|
||||||
|
}
|
||||||
16
AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift
Normal file
16
AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UserLearningProfile: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let currentKnowledgeBaseId: String?
|
||||||
|
let currentPathId: String?
|
||||||
|
let currentLessonId: String?
|
||||||
|
let overallLevel: Int
|
||||||
|
let weakPoints: [String]
|
||||||
|
let strengths: [String]
|
||||||
|
let recentMistakes: [String]
|
||||||
|
let reviewQueue: [String]
|
||||||
|
let learningStreak: Int
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
13
AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift
Normal file
13
AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WaitlistEntry: Codable {
|
||||||
|
let email: String
|
||||||
|
let name: String?
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
init(email: String, name: String? = nil, source: String = "ios_app") {
|
||||||
|
self.email = email
|
||||||
|
self.name = name
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
}
|
||||||
75
AIStudyApp/AIStudyApp/Core/Network/APIClient.swift
Normal file
75
AIStudyApp/AIStudyApp/Core/Network/APIClient.swift
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol APIClientProtocol {
|
||||||
|
func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T
|
||||||
|
func requestVoid(_ endpoint: APIEndpoint) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
final class APIClient: APIClientProtocol {
|
||||||
|
private let baseURL: URL
|
||||||
|
private let tokenStore: TokenStoreProtocol?
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
init(baseURL: URL, tokenStore: TokenStoreProtocol? = nil, session: URLSession = .shared) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
self.session = session
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
|
||||||
|
let (data, response) = try await perform(endpoint)
|
||||||
|
return try decodeResponse(data, response: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVoid(_ endpoint: APIEndpoint) async throws {
|
||||||
|
let (_, response) = try await perform(endpoint)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
throw APIError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func perform(_ endpoint: APIEndpoint) async throws -> (Data, URLResponse) {
|
||||||
|
let url = baseURL.appendingPathComponent(endpoint.path)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = endpoint.method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.httpBody = endpoint.body
|
||||||
|
request.timeoutInterval = 30
|
||||||
|
|
||||||
|
if endpoint.requiresAuth {
|
||||||
|
if let token = try tokenStore?.getAccessToken() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
} else {
|
||||||
|
throw APIError.unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError.network(URLError(.badServerResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200...299:
|
||||||
|
return (data, response)
|
||||||
|
case 401:
|
||||||
|
throw APIError.unauthorized
|
||||||
|
default:
|
||||||
|
throw APIError.httpError(httpResponse.statusCode, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeResponse<T: Decodable>(_ data: Data, response: URLResponse) throws -> T {
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decoding(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift
Normal file
123
AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIEndpoint {
|
||||||
|
// Auth
|
||||||
|
case appleLogin(AppleLoginRequest)
|
||||||
|
case refreshToken(String)
|
||||||
|
case me
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
case sessions
|
||||||
|
case session(String)
|
||||||
|
case createSession(CreateSessionRequest)
|
||||||
|
case updateSession(String, UpdateSessionRequest)
|
||||||
|
case progress
|
||||||
|
|
||||||
|
// AI
|
||||||
|
case analyze(AIAnalyzeRequest)
|
||||||
|
case analysis(String)
|
||||||
|
case recallQuestions(String)
|
||||||
|
case feynmanPrompt(String)
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
case reviews
|
||||||
|
case reviewsToday
|
||||||
|
case reviewsTomorrow
|
||||||
|
case reviewsWeek
|
||||||
|
case updateReview(String, UpdateReviewRequest)
|
||||||
|
case generateReviews
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
case knowledgeBases
|
||||||
|
case knowledgeBase(String)
|
||||||
|
case paths(String)
|
||||||
|
case path(String)
|
||||||
|
case lesson(String)
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
case submitFeedback(SubmitFeedbackRequest)
|
||||||
|
|
||||||
|
// MARK: - Path
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
switch self {
|
||||||
|
case .appleLogin: return "/api/auth/apple"
|
||||||
|
case .refreshToken: return "/api/auth/refresh"
|
||||||
|
case .me: return "/api/users/me"
|
||||||
|
case .sessions: return "/api/sessions"
|
||||||
|
case .session(let id): return "/api/sessions/\(id)"
|
||||||
|
case .createSession: return "/api/sessions"
|
||||||
|
case .updateSession(let id, _): return "/api/sessions/\(id)"
|
||||||
|
case .progress: return "/api/progress"
|
||||||
|
case .analyze: return "/api/ai/analyze"
|
||||||
|
case .analysis(let id): return "/api/ai/analysis/\(id)"
|
||||||
|
case .recallQuestions(let id): return "/api/ai/recall/\(id)"
|
||||||
|
case .feynmanPrompt(let id): return "/api/ai/feynman/\(id)"
|
||||||
|
case .reviews: return "/api/reviews"
|
||||||
|
case .reviewsToday: return "/api/reviews/today"
|
||||||
|
case .reviewsTomorrow: return "/api/reviews/tomorrow"
|
||||||
|
case .reviewsWeek: return "/api/reviews/week"
|
||||||
|
case .updateReview(let id, _): return "/api/reviews/\(id)"
|
||||||
|
case .generateReviews: return "/api/reviews/generate"
|
||||||
|
case .knowledgeBases: return "/api/knowledge-bases"
|
||||||
|
case .knowledgeBase(let id): return "/api/knowledge-bases/\(id)"
|
||||||
|
case .paths(let kbId): return "/api/knowledge-bases/\(kbId)/paths"
|
||||||
|
case .path(let id): return "/api/paths/\(id)"
|
||||||
|
case .lesson(let id): return "/api/lessons/\(id)"
|
||||||
|
case .submitFeedback: return "/api/feedback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Method
|
||||||
|
|
||||||
|
var method: HTTPMethod {
|
||||||
|
switch self {
|
||||||
|
case .appleLogin, .refreshToken, .createSession, .analyze,
|
||||||
|
.recallQuestions, .feynmanPrompt, .generateReviews, .submitFeedback:
|
||||||
|
return .post
|
||||||
|
case .updateSession, .updateReview:
|
||||||
|
return .put
|
||||||
|
case .me, .sessions, .session, .progress, .analysis,
|
||||||
|
.reviews, .reviewsToday, .reviewsTomorrow, .reviewsWeek,
|
||||||
|
.knowledgeBases, .knowledgeBase, .paths, .path, .lesson:
|
||||||
|
return .get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: Data? {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
switch self {
|
||||||
|
case .appleLogin(let r): return try? encoder.encode(r)
|
||||||
|
case .refreshToken(let t): return try? encoder.encode(["refreshToken": t])
|
||||||
|
case .createSession(let r): return try? encoder.encode(r)
|
||||||
|
case .updateSession(_, let r): return try? encoder.encode(r)
|
||||||
|
case .analyze(let r): return try? encoder.encode(r)
|
||||||
|
case .recallQuestions(let id): return try? encoder.encode(["lessonId": id])
|
||||||
|
case .feynmanPrompt(let id): return try? encoder.encode(["lessonId": id])
|
||||||
|
case .updateReview(_, let r): return try? encoder.encode(r)
|
||||||
|
case .submitFeedback(let r): return try? encoder.encode(r)
|
||||||
|
case .generateReviews: return try? encoder.encode([:] as [String: String])
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
var requiresAuth: Bool {
|
||||||
|
switch self {
|
||||||
|
case .appleLogin, .refreshToken:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HTTPMethod: String {
|
||||||
|
case get = "GET"
|
||||||
|
case post = "POST"
|
||||||
|
case put = "PUT"
|
||||||
|
case delete = "DELETE"
|
||||||
|
}
|
||||||
39
AIStudyApp/AIStudyApp/Core/Network/APIError.swift
Normal file
39
AIStudyApp/AIStudyApp/Core/Network/APIError.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case network(Error)
|
||||||
|
case httpError(Int, Data?)
|
||||||
|
case decoding(Error)
|
||||||
|
case unauthorized
|
||||||
|
case tokenExpired
|
||||||
|
case serverError(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return ZXStrings.invalidURL
|
||||||
|
case .network(let error):
|
||||||
|
return "\(ZXStrings.networkError):\(error.localizedDescription)"
|
||||||
|
case .httpError(let code, _):
|
||||||
|
return "\(ZXStrings.serverError)(\(code))"
|
||||||
|
case .decoding:
|
||||||
|
return ZXStrings.parseError
|
||||||
|
case .unauthorized:
|
||||||
|
return ZXStrings.authExpired
|
||||||
|
case .tokenExpired:
|
||||||
|
return ZXStrings.tokenExpired
|
||||||
|
case .serverError(let msg):
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthenticationError: Bool {
|
||||||
|
switch self {
|
||||||
|
case .unauthorized, .tokenExpired:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift
Normal file
52
AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - File Cache Protocol
|
||||||
|
|
||||||
|
protocol FileCacheProtocol {
|
||||||
|
func load<T: Codable>(_ type: T.Type, forKey key: String) throws -> T?
|
||||||
|
func save<T: Codable>(_ value: T, forKey key: String) throws
|
||||||
|
func remove(forKey key: String) throws
|
||||||
|
func clear() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON File Cache
|
||||||
|
|
||||||
|
final class FileCache: FileCacheProtocol {
|
||||||
|
private let directory: URL
|
||||||
|
|
||||||
|
init(suite: String = "repository_cache") {
|
||||||
|
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||||
|
directory = base.appendingPathComponent(suite)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load<T: Codable>(_ type: T.Type, forKey key: String) throws -> T? {
|
||||||
|
let url = fileURL(forKey: key)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save<T: Codable>(_ value: T, forKey key: String) throws {
|
||||||
|
let data = try JSONEncoder().encode(value)
|
||||||
|
try data.write(to: fileURL(forKey: key), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(forKey key: String) throws {
|
||||||
|
let url = fileURL(forKey: key)
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() throws {
|
||||||
|
if FileManager.default.fileExists(atPath: directory.path) {
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileURL(forKey key: String) -> URL {
|
||||||
|
directory.appendingPathComponent(key.replacingOccurrences(of: "/", with: "_") + ".json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Knowledge Repository
|
||||||
|
|
||||||
|
final class KnowledgeRepository {
|
||||||
|
let bases: BaseRepository<KnowledgeBase>
|
||||||
|
let paths: PathRepository
|
||||||
|
let lessons: LessonRepository
|
||||||
|
|
||||||
|
init(knowledgeService: KnowledgeServiceProtocol) {
|
||||||
|
self.bases = BaseRepository<KnowledgeBase>(
|
||||||
|
cacheKey: "knowledge_bases",
|
||||||
|
ttl: 600
|
||||||
|
) {
|
||||||
|
try await knowledgeService.fetchKnowledgeBases()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.paths = PathRepository(service: knowledgeService)
|
||||||
|
self.lessons = LessonRepository(service: knowledgeService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Path Repository (keyed by knowledge base ID)
|
||||||
|
|
||||||
|
final class PathRepository {
|
||||||
|
private let service: KnowledgeServiceProtocol
|
||||||
|
private let cache = FileCache()
|
||||||
|
|
||||||
|
init(service: KnowledgeServiceProtocol) {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(for knowledgeBaseId: String) async throws -> [LearningPath] {
|
||||||
|
let key = "paths_\(knowledgeBaseId)"
|
||||||
|
if let cached: Cached<[LearningPath]> = try? cache.load(Cached<[LearningPath]>.self, forKey: key) {
|
||||||
|
if Date().timeIntervalSince(cached.timestamp) < 600 { return cached.value }
|
||||||
|
}
|
||||||
|
let items = try await service.fetchPaths(knowledgeBaseId: knowledgeBaseId)
|
||||||
|
try cache.save(Cached(value: items, timestamp: Date()), forKey: key)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lesson Repository (keyed by path ID)
|
||||||
|
|
||||||
|
final class LessonRepository {
|
||||||
|
private let service: KnowledgeServiceProtocol
|
||||||
|
private let cache = FileCache()
|
||||||
|
|
||||||
|
init(service: KnowledgeServiceProtocol) {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(for pathId: String) async throws -> [Lesson] {
|
||||||
|
let key = "lessons_\(pathId)"
|
||||||
|
if let cached: Cached<[Lesson]> = try? cache.load(Cached<[Lesson]>.self, forKey: key) {
|
||||||
|
if Date().timeIntervalSince(cached.timestamp) < 600 { return cached.value }
|
||||||
|
}
|
||||||
|
let detail = try await service.fetchPath(id: pathId)
|
||||||
|
try cache.save(Cached(value: detail.lessons, timestamp: Date()), forKey: key)
|
||||||
|
return detail.lessons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cached Wrapper
|
||||||
|
|
||||||
|
private struct Cached<T: Codable>: Codable {
|
||||||
|
let value: T
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
62
AIStudyApp/AIStudyApp/Core/Repository/Repository.swift
Normal file
62
AIStudyApp/AIStudyApp/Core/Repository/Repository.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Repository Protocol
|
||||||
|
|
||||||
|
protocol RepositoryProtocol {
|
||||||
|
associatedtype Item: Codable
|
||||||
|
func fetch() async throws -> [Item]
|
||||||
|
func sync() async throws -> [Item]
|
||||||
|
func clearCache() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Base Repository
|
||||||
|
|
||||||
|
/// Generic repository: cache-first with stale-then-refresh strategy.
|
||||||
|
class BaseRepository<Item: Codable & Identifiable>: RepositoryProtocol {
|
||||||
|
private let remote: () async throws -> [Item]
|
||||||
|
private let cache: FileCacheProtocol
|
||||||
|
private let cacheKey: String
|
||||||
|
private let ttl: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
cache: FileCacheProtocol = FileCache(),
|
||||||
|
cacheKey: String,
|
||||||
|
ttl: TimeInterval = 300,
|
||||||
|
remote: @escaping () async throws -> [Item]
|
||||||
|
) {
|
||||||
|
self.cache = cache
|
||||||
|
self.cacheKey = cacheKey
|
||||||
|
self.ttl = ttl
|
||||||
|
self.remote = remote
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns cached data immediately if fresh, then refreshes in background.
|
||||||
|
/// If cache is stale or missing, fetches from remote.
|
||||||
|
func fetch() async throws -> [Item] {
|
||||||
|
if let cached: Timestamped<[Item]> = try? cache.load(Timestamped<[Item]>.self, forKey: cacheKey) {
|
||||||
|
if Date().timeIntervalSince(cached.timestamp) < ttl {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-fetches from remote and updates cache.
|
||||||
|
func sync() async throws -> [Item] {
|
||||||
|
let items = try await remote()
|
||||||
|
let stamped = Timestamped(value: items, timestamp: Date())
|
||||||
|
try cache.save(stamped, forKey: cacheKey)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCache() throws {
|
||||||
|
try cache.remove(forKey: cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timestamped Wrapper
|
||||||
|
|
||||||
|
private struct Timestamped<T: Codable>: Codable {
|
||||||
|
let value: T
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
47
AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift
Normal file
47
AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Review Repository
|
||||||
|
|
||||||
|
final class ReviewRepository {
|
||||||
|
let today: BaseRepository<ReviewTask>
|
||||||
|
let tomorrow: BaseRepository<ReviewTask>
|
||||||
|
let week: BaseRepository<ReviewTask>
|
||||||
|
let all: BaseRepository<ReviewTask>
|
||||||
|
|
||||||
|
init(reviewService: ReviewServiceProtocol) {
|
||||||
|
self.today = BaseRepository<ReviewTask>(
|
||||||
|
cacheKey: "reviews_today",
|
||||||
|
ttl: 120
|
||||||
|
) {
|
||||||
|
try await reviewService.fetchTodayReviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tomorrow = BaseRepository<ReviewTask>(
|
||||||
|
cacheKey: "reviews_tomorrow",
|
||||||
|
ttl: 300
|
||||||
|
) {
|
||||||
|
try await reviewService.fetchTomorrowReviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.week = BaseRepository<ReviewTask>(
|
||||||
|
cacheKey: "reviews_week",
|
||||||
|
ttl: 600
|
||||||
|
) {
|
||||||
|
try await reviewService.fetchWeekReviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.all = BaseRepository<ReviewTask>(
|
||||||
|
cacheKey: "reviews_all",
|
||||||
|
ttl: 300
|
||||||
|
) {
|
||||||
|
try await reviewService.fetchReviews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() throws {
|
||||||
|
try today.clearCache()
|
||||||
|
try tomorrow.clearCache()
|
||||||
|
try week.clearCache()
|
||||||
|
try all.clearCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
54
AIStudyApp/AIStudyApp/Core/Services/AIService.swift
Normal file
54
AIStudyApp/AIStudyApp/Core/Services/AIService.swift
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - AI Service Protocol
|
||||||
|
|
||||||
|
protocol AIServiceProtocol {
|
||||||
|
func analyze(request: AIAnalyzeRequest) async throws -> AIAnalysis
|
||||||
|
func fetchAnalysis(id: String) async throws -> AIAnalysis
|
||||||
|
func generateRecallQuestions(lessonId: String) async throws -> [String]
|
||||||
|
func generateFeynmanPrompt(lessonId: String) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request / Response Models
|
||||||
|
|
||||||
|
struct AIAnalyzeRequest: Codable {
|
||||||
|
let sessionId: String
|
||||||
|
let inputText: String
|
||||||
|
let lessonId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecallQuestionsResponse: Codable {
|
||||||
|
let questions: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeynmanPromptResponse: Codable {
|
||||||
|
let prompt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Service Implementation
|
||||||
|
|
||||||
|
final class AIService: AIServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyze(request: AIAnalyzeRequest) async throws -> AIAnalysis {
|
||||||
|
try await apiClient.request(.analyze(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAnalysis(id: String) async throws -> AIAnalysis {
|
||||||
|
try await apiClient.request(.analysis(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRecallQuestions(lessonId: String) async throws -> [String] {
|
||||||
|
let response: RecallQuestionsResponse = try await apiClient.request(.recallQuestions(lessonId))
|
||||||
|
return response.questions
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFeynmanPrompt(lessonId: String) async throws -> String {
|
||||||
|
let response: FeynmanPromptResponse = try await apiClient.request(.feynmanPrompt(lessonId))
|
||||||
|
return response.prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
118
AIStudyApp/AIStudyApp/Core/Services/AuthService.swift
Normal file
118
AIStudyApp/AIStudyApp/Core/Services/AuthService.swift
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import AuthenticationServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class AuthService: NSObject, AuthServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
private let tokenStore: TokenStoreProtocol
|
||||||
|
|
||||||
|
private var continuation: CheckedContinuation<ASAuthorizationAppleIDCredential, Error>?
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol, tokenStore: TokenStoreProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
self.tokenStore = tokenStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public
|
||||||
|
|
||||||
|
func loginWithApple() async throws -> AuthResponse {
|
||||||
|
let credential = try await requestAppleIDCredential()
|
||||||
|
let identityTokenData = credential.identityToken!
|
||||||
|
let identityToken = String(data: identityTokenData, encoding: .utf8)!
|
||||||
|
|
||||||
|
let request = AppleLoginRequest(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: credential.authorizationCode.map { String(data: $0, encoding: .utf8)! },
|
||||||
|
userIdentifier: credential.user,
|
||||||
|
fullName: credential.fullName.map {
|
||||||
|
AppleFullName(givenName: $0.givenName, familyName: $0.familyName)
|
||||||
|
},
|
||||||
|
email: credential.email
|
||||||
|
)
|
||||||
|
|
||||||
|
let authResponse: AuthResponse = try await apiClient.request(.appleLogin(request))
|
||||||
|
try tokenStore.saveAccessToken(authResponse.accessToken)
|
||||||
|
try tokenStore.saveRefreshToken(authResponse.refreshToken)
|
||||||
|
return authResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSession() async throws -> AuthResponse {
|
||||||
|
guard let refreshToken = try tokenStore.getRefreshToken() else {
|
||||||
|
throw APIError.unauthorized
|
||||||
|
}
|
||||||
|
let authResponse: AuthResponse = try await apiClient.request(.refreshToken(refreshToken))
|
||||||
|
try tokenStore.saveAccessToken(authResponse.accessToken)
|
||||||
|
try tokenStore.saveRefreshToken(authResponse.refreshToken)
|
||||||
|
return authResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {
|
||||||
|
try tokenStore.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentUser() async throws -> User {
|
||||||
|
try await apiClient.request(.me)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASAuthorizationController
|
||||||
|
|
||||||
|
private func requestAppleIDCredential() async throws -> ASAuthorizationAppleIDCredential {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
let provider = ASAuthorizationAppleIDProvider()
|
||||||
|
let request = provider.createRequest()
|
||||||
|
request.requestedScopes = [.fullName, .email]
|
||||||
|
|
||||||
|
let controller = ASAuthorizationController(authorizationRequests: [request])
|
||||||
|
controller.delegate = self
|
||||||
|
controller.presentationContextProvider = self
|
||||||
|
controller.performRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASAuthorizationControllerDelegate
|
||||||
|
|
||||||
|
extension AuthService: ASAuthorizationControllerDelegate {
|
||||||
|
func authorizationController(
|
||||||
|
controller: ASAuthorizationController,
|
||||||
|
didCompleteWithAuthorization authorization: ASAuthorization
|
||||||
|
) {
|
||||||
|
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
|
||||||
|
continuation?.resume(throwing: AuthError.invalidCredential)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation?.resume(returning: credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
||||||
|
continuation?.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASAuthorizationControllerPresentationContextProviding
|
||||||
|
|
||||||
|
extension AuthService: ASAuthorizationControllerPresentationContextProviding {
|
||||||
|
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
|
||||||
|
return UIWindow()
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AuthError
|
||||||
|
|
||||||
|
enum AuthError: LocalizedError {
|
||||||
|
case invalidCredential
|
||||||
|
case missingIdentityToken
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCredential:
|
||||||
|
return ZXStrings.appleCredentialFailed
|
||||||
|
case .missingIdentityToken:
|
||||||
|
return ZXStrings.missingIdentityToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AuthServiceProtocol {
|
||||||
|
func loginWithApple() async throws -> AuthResponse
|
||||||
|
func refreshSession() async throws -> AuthResponse
|
||||||
|
func logout() async throws
|
||||||
|
func fetchCurrentUser() async throws -> User
|
||||||
|
}
|
||||||
28
AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift
Normal file
28
AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Feedback Service Protocol
|
||||||
|
|
||||||
|
protocol FeedbackServiceProtocol {
|
||||||
|
func submit(_ feedback: SubmitFeedbackRequest) async throws -> Feedback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Models
|
||||||
|
|
||||||
|
struct SubmitFeedbackRequest: Codable {
|
||||||
|
let category: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feedback Service Implementation
|
||||||
|
|
||||||
|
final class FeedbackService: FeedbackServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(_ feedback: SubmitFeedbackRequest) async throws -> Feedback {
|
||||||
|
try await apiClient.request(.submitFeedback(feedback))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift
Normal file
48
AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Knowledge Service Protocol
|
||||||
|
|
||||||
|
protocol KnowledgeServiceProtocol {
|
||||||
|
func fetchKnowledgeBases() async throws -> [KnowledgeBase]
|
||||||
|
func fetchKnowledgeBase(id: String) async throws -> KnowledgeBase
|
||||||
|
func fetchPaths(knowledgeBaseId: String) async throws -> [LearningPath]
|
||||||
|
func fetchPath(id: String) async throws -> LearningPathDetail
|
||||||
|
func fetchLesson(id: String) async throws -> Lesson
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response Models
|
||||||
|
|
||||||
|
struct LearningPathDetail: Codable {
|
||||||
|
let path: LearningPath
|
||||||
|
let lessons: [Lesson]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Knowledge Service Implementation
|
||||||
|
|
||||||
|
final class KnowledgeService: KnowledgeServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKnowledgeBases() async throws -> [KnowledgeBase] {
|
||||||
|
try await apiClient.request(.knowledgeBases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKnowledgeBase(id: String) async throws -> KnowledgeBase {
|
||||||
|
try await apiClient.request(.knowledgeBase(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPaths(knowledgeBaseId: String) async throws -> [LearningPath] {
|
||||||
|
try await apiClient.request(.paths(knowledgeBaseId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPath(id: String) async throws -> LearningPathDetail {
|
||||||
|
try await apiClient.request(.path(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLesson(id: String) async throws -> Lesson {
|
||||||
|
try await apiClient.request(.lesson(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
63
AIStudyApp/AIStudyApp/Core/Services/LearningService.swift
Normal file
63
AIStudyApp/AIStudyApp/Core/Services/LearningService.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Learning Service Protocol
|
||||||
|
|
||||||
|
protocol LearningServiceProtocol {
|
||||||
|
func fetchSessions() async throws -> [LearningSession]
|
||||||
|
func fetchSession(id: String) async throws -> LearningSession
|
||||||
|
func createSession(request: CreateSessionRequest) async throws -> LearningSession
|
||||||
|
func updateSession(id: String, request: UpdateSessionRequest) async throws -> LearningSession
|
||||||
|
func fetchProgress() async throws -> LearningProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request / Response Models
|
||||||
|
|
||||||
|
struct CreateSessionRequest: Codable {
|
||||||
|
let lessonId: String
|
||||||
|
let userInput: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateSessionRequest: Codable {
|
||||||
|
let endedAt: String?
|
||||||
|
let userInput: String?
|
||||||
|
let masteryScore: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LearningProgress: Codable {
|
||||||
|
let totalSessions: Int
|
||||||
|
let completedSessions: Int
|
||||||
|
let totalMinutes: Int
|
||||||
|
let averageScore: Int
|
||||||
|
let streak: Int
|
||||||
|
let weeklyActivity: [CGFloat]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Learning Service Implementation
|
||||||
|
|
||||||
|
final class LearningService: LearningServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessions() async throws -> [LearningSession] {
|
||||||
|
try await apiClient.request(.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSession(id: String) async throws -> LearningSession {
|
||||||
|
try await apiClient.request(.session(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSession(request: CreateSessionRequest) async throws -> LearningSession {
|
||||||
|
try await apiClient.request(.createSession(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSession(id: String, request: UpdateSessionRequest) async throws -> LearningSession {
|
||||||
|
try await apiClient.request(.updateSession(id, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchProgress() async throws -> LearningProgress {
|
||||||
|
try await apiClient.request(.progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift
Normal file
52
AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Review Service Protocol
|
||||||
|
|
||||||
|
protocol ReviewServiceProtocol {
|
||||||
|
func fetchReviews() async throws -> [ReviewTask]
|
||||||
|
func fetchTodayReviews() async throws -> [ReviewTask]
|
||||||
|
func fetchTomorrowReviews() async throws -> [ReviewTask]
|
||||||
|
func fetchWeekReviews() async throws -> [ReviewTask]
|
||||||
|
func updateReviewStatus(id: String, status: ReviewTaskStatus) async throws -> ReviewTask
|
||||||
|
func generateReviews() async throws -> [ReviewTask]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Models
|
||||||
|
|
||||||
|
struct UpdateReviewRequest: Codable {
|
||||||
|
let status: ReviewTaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Review Service Implementation
|
||||||
|
|
||||||
|
final class ReviewService: ReviewServiceProtocol {
|
||||||
|
private let apiClient: APIClientProtocol
|
||||||
|
|
||||||
|
init(apiClient: APIClientProtocol) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchReviews() async throws -> [ReviewTask] {
|
||||||
|
try await apiClient.request(.reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTodayReviews() async throws -> [ReviewTask] {
|
||||||
|
try await apiClient.request(.reviewsToday)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTomorrowReviews() async throws -> [ReviewTask] {
|
||||||
|
try await apiClient.request(.reviewsTomorrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWeekReviews() async throws -> [ReviewTask] {
|
||||||
|
try await apiClient.request(.reviewsWeek)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateReviewStatus(id: String, status: ReviewTaskStatus) async throws -> ReviewTask {
|
||||||
|
try await apiClient.request(.updateReview(id, UpdateReviewRequest(status: status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateReviews() async throws -> [ReviewTask] {
|
||||||
|
try await apiClient.request(.generateReviews)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LearningRecordEntity: Codable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var lessonTitle: String
|
||||||
|
var durationMinutes: Int
|
||||||
|
var masteryScore: Int
|
||||||
|
var weakPoints: [String]
|
||||||
|
var completedAt: Date
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
init(lessonTitle: String = "", durationMinutes: Int = 0, masteryScore: Int = 0, weakPoints: [String] = [], completedAt: Date = Date()) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.lessonTitle = lessonTitle
|
||||||
|
self.durationMinutes = durationMinutes
|
||||||
|
self.masteryScore = masteryScore
|
||||||
|
self.weakPoints = weakPoints
|
||||||
|
self.completedAt = completedAt
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
static let weekActivitySeed: [(Int, Int)] = [
|
||||||
|
(-6, 25), (-5, 55), (-4, 80), (-3, 35), (-2, 70), (-1, 45), (0, 15)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func seedData() -> [LearningRecordEntity] {
|
||||||
|
weekActivitySeed.map { offset, minutes in
|
||||||
|
let date = Calendar.current.date(byAdding: .day, value: offset, to: Date())!
|
||||||
|
return LearningRecordEntity(
|
||||||
|
lessonTitle: "学习记录",
|
||||||
|
durationMinutes: minutes,
|
||||||
|
masteryScore: Int.random(in: 60...95),
|
||||||
|
completedAt: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ReviewTaskEntity: Codable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var lessonId: String
|
||||||
|
var reviewType: String
|
||||||
|
var scheduledAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var status: String
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
init(lessonId: String = "", reviewType: String = "recall", scheduledAt: Date = Date(), completedAt: Date? = nil, status: String = "pending") {
|
||||||
|
self.id = UUID()
|
||||||
|
self.lessonId = lessonId
|
||||||
|
self.reviewType = reviewType
|
||||||
|
self.scheduledAt = scheduledAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
self.status = status
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusEnum: ReviewTaskEntityStatus {
|
||||||
|
get { ReviewTaskEntityStatus(rawValue: status) ?? .pending }
|
||||||
|
set { status = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewTypeEnum: ReviewTaskEntityType {
|
||||||
|
get { ReviewTaskEntityType(rawValue: reviewType) ?? .recall }
|
||||||
|
set { reviewType = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool { Calendar.current.isDateInToday(scheduledAt) }
|
||||||
|
var isTomorrow: Bool { Calendar.current.isDateInTomorrow(scheduledAt) }
|
||||||
|
var isThisWeek: Bool {
|
||||||
|
guard let weekLater = Calendar.current.date(byAdding: .day, value: 7, to: Date()) else { return false }
|
||||||
|
return scheduledAt > Date() && scheduledAt <= weekLater
|
||||||
|
}
|
||||||
|
|
||||||
|
static func seedData() -> [ReviewTaskEntity] {
|
||||||
|
let today = Date()
|
||||||
|
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
|
||||||
|
let day3 = Calendar.current.date(byAdding: .day, value: 3, to: today)!
|
||||||
|
let day5 = Calendar.current.date(byAdding: .day, value: 5, to: today)!
|
||||||
|
|
||||||
|
let items: [(String, String, Date)] = [
|
||||||
|
("注意力机制核心概念", "recall", today),
|
||||||
|
("Transformer 结构费曼解释", "feynman", today),
|
||||||
|
("反向传播数学推导", "spacedRepetition", tomorrow),
|
||||||
|
("CNN vs RNN 对比", "recall", tomorrow),
|
||||||
|
("损失函数选择指南", "weakPoint", day3),
|
||||||
|
("优化器对比总结", "spacedRepetition", day5),
|
||||||
|
]
|
||||||
|
return items.map { lessonId, type, date in
|
||||||
|
ReviewTaskEntity(lessonId: lessonId, reviewType: type, scheduledAt: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewTaskEntityStatus: String, CaseIterable, Codable {
|
||||||
|
case pending, completed, skipped, overdue
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReviewTaskEntityType: String, CaseIterable, Codable {
|
||||||
|
case spacedRepetition, feynman, recall, weakPoint
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StudyTaskEntity: Codable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var title: String
|
||||||
|
var taskType: String
|
||||||
|
var colorName: String
|
||||||
|
var minutes: Int
|
||||||
|
var isDone: Bool
|
||||||
|
var createdAt: Date
|
||||||
|
var sortOrder: Int
|
||||||
|
|
||||||
|
init(title: String, taskType: String, colorName: String, minutes: Int, isDone: Bool = false, sortOrder: Int = 0) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.title = title
|
||||||
|
self.taskType = taskType
|
||||||
|
self.colorName = colorName
|
||||||
|
self.minutes = minutes
|
||||||
|
self.isDone = isDone
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch colorName {
|
||||||
|
case "purple": return .zxPurple
|
||||||
|
case "orange": return .zxOrange
|
||||||
|
case "teal": return .zxTeal
|
||||||
|
case "accent": return .zxAccent
|
||||||
|
case "yellow": return .zxYellow
|
||||||
|
default: return .zxAccent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func seedData() -> [StudyTaskEntity] {
|
||||||
|
let items: [(String, String, String, Int, Int)] = [
|
||||||
|
("机器学习 - 回忆测试", "回忆测试", "purple", 10, 0),
|
||||||
|
("高数 - 间隔复习 8 题", "间隔复习", "orange", 15, 1),
|
||||||
|
("英语词汇 - 25 个待复习", "词汇复习", "teal", 8, 2),
|
||||||
|
("注意力机制 - 费曼解释", "费曼练习", "accent", 12, 3),
|
||||||
|
("产品设计 - 薄弱点复习", "薄弱点", "yellow", 10, 4),
|
||||||
|
]
|
||||||
|
return items.map { title, type, color, min, order in
|
||||||
|
StudyTaskEntity(title: title, taskType: type, colorName: color, minutes: min, sortOrder: order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift
Normal file
79
AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum KeychainError: Error {
|
||||||
|
case saveFailed(OSStatus)
|
||||||
|
case loadFailed(OSStatus)
|
||||||
|
case deleteFailed(OSStatus)
|
||||||
|
case itemNotFound
|
||||||
|
case invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeychainStore {
|
||||||
|
private let service: String
|
||||||
|
|
||||||
|
init(service: String = Bundle.main.bundleIdentifier ?? "com.zx.keystore") {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(key: String, data: Data) throws {
|
||||||
|
try delete(key: key)
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw KeychainError.saveFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String) throws -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
guard let data = result as? Data else {
|
||||||
|
throw KeychainError.invalidData
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw KeychainError.loadFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String) throws {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw KeychainError.deleteFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll(keys: [String]) throws {
|
||||||
|
for key in keys {
|
||||||
|
try? delete(key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PersistenceController {
|
||||||
|
static let shared = PersistenceController()
|
||||||
|
|
||||||
|
private let cache = FileCache(suite: "app_persistence")
|
||||||
|
private var seeded = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Study Tasks
|
||||||
|
|
||||||
|
func loadTasks() -> [StudyTaskEntity] {
|
||||||
|
if !seeded { seedIfNeeded() }
|
||||||
|
return (try? cache.load([StudyTaskEntity].self, forKey: "study_tasks")) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTasks(_ tasks: [StudyTaskEntity]) {
|
||||||
|
try? cache.save(tasks, forKey: "study_tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Review Tasks
|
||||||
|
|
||||||
|
func loadReviewTasks() -> [ReviewTaskEntity] {
|
||||||
|
if !seeded { seedIfNeeded() }
|
||||||
|
return (try? cache.load([ReviewTaskEntity].self, forKey: "review_tasks")) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveReviewTasks(_ tasks: [ReviewTaskEntity]) {
|
||||||
|
try? cache.save(tasks, forKey: "review_tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Learning Records
|
||||||
|
|
||||||
|
func loadRecords() -> [LearningRecordEntity] {
|
||||||
|
if !seeded { seedIfNeeded() }
|
||||||
|
return (try? cache.load([LearningRecordEntity].self, forKey: "learning_records")) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRecords(_ records: [LearningRecordEntity]) {
|
||||||
|
try? cache.save(records, forKey: "learning_records")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Seed
|
||||||
|
|
||||||
|
func seedIfNeeded() {
|
||||||
|
guard !seeded else { return }
|
||||||
|
seeded = true
|
||||||
|
if (try? cache.load([StudyTaskEntity].self, forKey: "study_tasks")) == nil {
|
||||||
|
try? cache.save(StudyTaskEntity.seedData(), forKey: "study_tasks")
|
||||||
|
}
|
||||||
|
if (try? cache.load([ReviewTaskEntity].self, forKey: "review_tasks")) == nil {
|
||||||
|
try? cache.save(ReviewTaskEntity.seedData(), forKey: "review_tasks")
|
||||||
|
}
|
||||||
|
if (try? cache.load([LearningRecordEntity].self, forKey: "learning_records")) == nil {
|
||||||
|
try? cache.save(LearningRecordEntity.seedData(), forKey: "learning_records")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift
Normal file
51
AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol TokenStoreProtocol {
|
||||||
|
func saveAccessToken(_ token: String) throws
|
||||||
|
func getAccessToken() throws -> String?
|
||||||
|
func saveRefreshToken(_ token: String) throws
|
||||||
|
func getRefreshToken() throws -> String?
|
||||||
|
func clearAll() throws
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TokenStore: TokenStoreProtocol {
|
||||||
|
private let keychain: KeychainStore
|
||||||
|
private let accessTokenKey = "zx.accessToken"
|
||||||
|
private let refreshTokenKey = "zx.refreshToken"
|
||||||
|
|
||||||
|
init(keychain: KeychainStore = KeychainStore()) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAccessToken(_ token: String) throws {
|
||||||
|
guard let data = token.data(using: .utf8) else {
|
||||||
|
throw KeychainError.invalidData
|
||||||
|
}
|
||||||
|
try keychain.save(key: accessTokenKey, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessToken() throws -> String? {
|
||||||
|
guard let data = try keychain.load(key: accessTokenKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRefreshToken(_ token: String) throws {
|
||||||
|
guard let data = token.data(using: .utf8) else {
|
||||||
|
throw KeychainError.invalidData
|
||||||
|
}
|
||||||
|
try keychain.save(key: refreshTokenKey, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRefreshToken() throws -> String? {
|
||||||
|
guard let data = try keychain.load(key: refreshTokenKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() throws {
|
||||||
|
try keychain.clearAll(keys: [accessTokenKey, refreshTokenKey])
|
||||||
|
}
|
||||||
|
}
|
||||||
36
AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift
Normal file
36
AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AI Chat Page
|
||||||
|
|
||||||
|
struct AIChatPage: View {
|
||||||
|
@StateObject private var vm = AIChatViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "AI 对话", subtitle: "学习助手") {}
|
||||||
|
ScrollViewReader { proxy in ScrollView { VStack(spacing: 16) {
|
||||||
|
ForEach(vm.messages) { m in
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
if m.role == .ai {
|
||||||
|
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple).frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
|
||||||
|
}
|
||||||
|
Text(m.content).font(.system(size: 14)).foregroundColor(m.role == .user ? .white : Color.zxF007).padding(12).background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004)).clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
if m.role == .user { Circle().frame(width: 28, height: 28).foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("我").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple)) }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
if vm.isSending {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple).frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
|
||||||
|
ZXTypingIndicator()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 100).id("bottom") }.scrollIndicators(.hidden)
|
||||||
|
.onChange(of: vm.messages.count) { withAnimation { proxy.scrollTo("bottom") } } }
|
||||||
|
ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() })
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift
Normal file
58
AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AI Feedback Page
|
||||||
|
|
||||||
|
struct AIFeedbackPageView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "AI 反馈", subtitle: "今日思考 · 过拟合", trailing: {
|
||||||
|
ZXIconBtn(icon: "bookmark", size: 36) {}
|
||||||
|
})
|
||||||
|
ScrollView { VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
ZStack {
|
||||||
|
Circle().trim(from: 0, to: 0.78).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 10, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 80, height: 80)
|
||||||
|
VStack(spacing: 0) { Text("78").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxPurple); Text("/ 100").font(.system(size: 9)).foregroundColor(Color.zxF04) }
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("良好掌握").font(.system(size: 18, weight: .heavy)).foregroundColor(Color.zxF0)
|
||||||
|
Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size: 12)).foregroundColor(Color.zxF0045).lineSpacing(4)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(20).background(ZXGradient.feedbackScore).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("你的回答").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04)
|
||||||
|
Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill").foregroundColor(Color.zxGreen); Text("答对的部分").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
|
||||||
|
ForEach([("正确识别出过拟合是\"记住训练数据\"而非\"学习规律\""),("使用了死记硬背类比,方向正确且贴切")], id: \.self) { s in
|
||||||
|
HStack(alignment: .top, spacing: 12) { Circle().fill(Color.zxGreen).frame(width: 6, height: 6).padding(.top, 6); Text(s).font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(4) }
|
||||||
|
.padding(12).background(Color(hex: "#34D399", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color(hex: "#34D399", opacity: 0.18), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(Color.zxYellow); Text("需要完善").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
|
||||||
|
ForEach([("缺少对「方差」和「偏差」权衡的说明", "过拟合本质是高方差问题,可以提到偏差-方差权衡"),("未提及正则化、Dropout 等解决方案", "完整答案通常要说明\"如何解决\"")], id: \.0) { p, d in
|
||||||
|
VStack(alignment: .leading, spacing: 4) { Text(p).font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text(d).font(.system(size: 12)).foregroundColor(Color.zxF05).lineSpacing(4) }
|
||||||
|
.padding(14).frame(maxWidth: .infinity, alignment: .leading).background(Color(hex: "#F59E0B", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.18), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("✨ 参考答案要点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxAccent)
|
||||||
|
Text("过拟合是模型复杂度过高导致的高方差问题。偏差-方差权衡是核心概念。解决方法包括:正则化、Dropout、数据增强、早停等。").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6)
|
||||||
|
}.padding(16).background(Color(hex: "#7C6EFA", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
|
||||||
|
|
||||||
|
Button {} label: { Label("加入待巩固,安排间隔复习", systemImage: "bolt.fill").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24) }
|
||||||
|
HStack(spacing: 12) { ZXOutlineBtn(text: "深入提问"); ZXOutlineBtn(text: "再来一题") }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -127,19 +127,4 @@ struct AIHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared UI pieces ──
|
// ZXQuickAction, ZXAIInteractionRow → Shared/Components/
|
||||||
|
|
||||||
struct ZXQuickAction: View {let emoji:String;let label:String
|
|
||||||
var body: some View {VStack(spacing:6){Text(emoji).font(.system(size:20))
|
|
||||||
Text(label).font(.system(size:10,weight:.semibold)).foregroundColor(Color.zxF006).multilineTextAlignment(.center).lineSpacing(4)}
|
|
||||||
.frame(maxWidth:.infinity).frame(height:72).background(Color.zxFill003)
|
|
||||||
.overlay(RoundedRectangle(cornerRadius:16).stroke(Color.zxBorder006,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:16))}}
|
|
||||||
|
|
||||||
struct ZXAIInteractionRow: View {let tag:String;let bg:Color;let fg:Color;let emoji:String;let title:String;let time:String;let score:Int;let action:()->Void
|
|
||||||
var body: some View {Button(action:action){HStack(spacing:12){
|
|
||||||
Text(emoji).font(.system(size:18)).frame(width:40,height:40).background(bg).clipShape(RoundedRectangle(cornerRadius:12))
|
|
||||||
VStack(alignment:.leading,spacing:2){HStack(spacing:8){Text(tag).font(.system(size:10,weight:.bold)).foregroundColor(fg).tracking(0.3);Text(time).font(.system(size:10)).foregroundColor(Color.zxF03)};Text(title).font(.system(size:13,weight:.semibold)).foregroundColor(Color(hex:"#F0F0FF",opacity:0.8)).lineLimit(1)}
|
|
||||||
Spacer()
|
|
||||||
ZXScoreBox(score:score,bg:score>=80 ? Color.zxGreenBG(0.15) : score>=60 ? Color.zxOrangeBG(0.15):Color.zxRedBG(0.15),fg:score>=80 ? Color.zxGreen : score>=60 ? Color.zxOrange:Color.zxRed)}
|
|
||||||
.padding(.horizontal,14).padding(.vertical,12).background(Color.zxFill003).overlay(RoundedRectangle(cornerRadius:16).stroke(Color.zxBorder006,lineWidth:1)).clipShape(RoundedRectangle(cornerRadius:16))}}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -48,149 +48,5 @@ struct DailyThinkingPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back Header
|
// Extracted pages: RecallTestPage, WeakPointsPage, AIFeedbackPageView, AIChatPage
|
||||||
struct ZXBackHeader<T: View>: View {
|
// Shared components: ZXBackHeader, ZXOutlineBtn → Shared/Components/
|
||||||
let title: String; let subtitle: String?; var onBack: (() -> Void)?
|
|
||||||
@ViewBuilder var trailing: () -> T
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Button { (onBack ?? { dismiss() })() } label: {
|
|
||||||
Image(systemName: "chevron.left").font(.system(size: 18)).foregroundColor(Color.zxF007)
|
|
||||||
.frame(width: 36, height: 36).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
|
||||||
}
|
|
||||||
VStack(spacing: 1) {
|
|
||||||
Text(title).font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
|
||||||
if let s = subtitle { Text(s).font(.system(size: 11)).foregroundColor(Color.zxF03) }
|
|
||||||
}.frame(maxWidth: .infinity)
|
|
||||||
trailing()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16).padding(.top, ZXSpacing.statusBarH + 8).padding(.bottom, 12)
|
|
||||||
.background(Color.zxBg0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecallTest
|
|
||||||
struct RecallTestPage: View {
|
|
||||||
@State private var input = ""
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "回忆测试", subtitle: "机器学习 · 偏差-方差权衡") {}
|
|
||||||
ScrollView { VStack(spacing: 16) {
|
|
||||||
Text("请回忆并写下你对「偏差-方差权衡」的理解").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
||||||
TextEditor(text: $input).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
|
||||||
Button {} label: { Text("提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeakPoints
|
|
||||||
struct WeakPointsPage: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "薄弱知识点", subtitle: "共 23 个待巩固") {}
|
|
||||||
ScrollView { VStack(spacing: 12) {
|
|
||||||
ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "高")
|
|
||||||
ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "高")
|
|
||||||
ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "中")
|
|
||||||
ZXWeakRow(score: 48, topic: "协方差与相关系数", lib: "机器学习", priority: "中")
|
|
||||||
ZXWeakRow(score: 36, topic: "梯度下降优化", lib: "机器学习", priority: "高")
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIFeedback - Page 15
|
|
||||||
struct AIFeedbackPageView: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "AI 反馈", subtitle: "今日思考 · 过拟合", trailing: {
|
|
||||||
ZXIconBtn(icon: "bookmark", size: 36) {}
|
|
||||||
})
|
|
||||||
ScrollView { VStack(spacing: 16) {
|
|
||||||
// Score
|
|
||||||
HStack(spacing: 20) {
|
|
||||||
ZStack {
|
|
||||||
Circle().trim(from: 0, to: 0.78).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 10, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 80, height: 80)
|
|
||||||
VStack(spacing: 0) { Text("78").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxPurple); Text("/ 100").font(.system(size: 9)).foregroundColor(Color.zxF04) }
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("良好掌握").font(.system(size: 18, weight: .heavy)).foregroundColor(Color.zxF0)
|
|
||||||
Text("理解核心概念,但缺少理论深度和解决方案").font(.system(size: 12)).foregroundColor(Color.zxF0045).lineSpacing(4)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(20).background(ZXGradient.feedbackScore).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
|
|
||||||
|
|
||||||
// 你的回答
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("你的回答").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF04); Text("过拟合就像一个学生只会「死记硬背」考题,而不是真正理解知识…").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6).padding(14).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1)) }
|
|
||||||
|
|
||||||
// 答对的部分
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill").foregroundColor(Color.zxGreen); Text("答对的部分").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
|
|
||||||
ForEach([("正确识别出过拟合是\"记住训练数据\"而非\"学习规律\""),("使用了死记硬背类比,方向正确且贴切")], id: \.self) { s in
|
|
||||||
HStack(alignment: .top, spacing: 12) { Circle().fill(Color.zxGreen).frame(width: 6, height: 6).padding(.top, 6); Text(s).font(.system(size: 13)).foregroundColor(Color(hex: "#F0F0FF", opacity: 0.75)).lineSpacing(4) }
|
|
||||||
.padding(12).background(Color(hex: "#34D399", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color(hex: "#34D399", opacity: 0.18), lineWidth: 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 需要完善
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(Color.zxYellow); Text("需要完善").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) }
|
|
||||||
ForEach([("缺少对「方差」和「偏差」权衡的说明", "过拟合本质是高方差问题,可以提到偏差-方差权衡"),("未提及正则化、Dropout 等解决方案", "完整答案通常要说明\"如何解决\"")], id: \.0) { p, d in
|
|
||||||
VStack(alignment: .leading, spacing: 4) { Text(p).font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text(d).font(.system(size: 12)).foregroundColor(Color.zxF05).lineSpacing(4) }
|
|
||||||
.padding(14).frame(maxWidth: .infinity, alignment: .leading).background(Color(hex: "#F59E0B", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.18), lineWidth: 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 参考答案
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("✨ 参考答案要点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxAccent)
|
|
||||||
Text("过拟合是模型复杂度过高导致的高方差问题。偏差-方差权衡是核心概念。解决方法包括:正则化、Dropout、数据增强、早停等。").font(.system(size: 13)).foregroundColor(Color.zxF007).lineSpacing(6)
|
|
||||||
}.padding(16).background(Color(hex: "#7C6EFA", opacity: 0.07)).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: "#7C6EFA", opacity: 0.2), lineWidth: 1))
|
|
||||||
|
|
||||||
// 操作按钮
|
|
||||||
Button {} label: { Label("加入待巩固,安排间隔复习", systemImage: "bolt.fill").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)).shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24) }
|
|
||||||
HStack(spacing: 12) { ZXOutlineBtn(text: "深入提问"); ZXOutlineBtn(text: "再来一题") }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct ZXOutlineBtn: View { let text: String
|
|
||||||
var body: some View { Button {} label: { HStack(spacing: 4) { Text(text).font(.system(size: 13)); Image(systemName: "chevron.right").font(.system(size: 14)) }.foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIChat - Page 11
|
|
||||||
struct AIChatPage: View {
|
|
||||||
@State private var msg = ""
|
|
||||||
@State private var msgs: [(role: String, content: String)] = [("ai", "你好!我是你的 AI 学习助手。我可以帮你解答学习问题、分析薄弱点、制定复习计划。请告诉我你想学习什么?")]
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "AI 对话", subtitle: "学习助手") {}
|
|
||||||
ScrollViewReader { proxy in ScrollView { VStack(spacing: 16) {
|
|
||||||
ForEach(Array(msgs.enumerated()), id: \.offset) { i, m in
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
if m.role == "ai" {
|
|
||||||
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple).frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
|
|
||||||
}
|
|
||||||
Text(m.content).font(.system(size: 14)).foregroundColor(m.role == "user" ? .white : Color.zxF007).padding(12).background(m.role == "user" ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004)).clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
if m.role == "user" { Circle().frame(width: 28, height: 28).foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("我").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple)) }
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: m.role == "user" ? .trailing : .leading)
|
|
||||||
}
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 100).id("bottom") }.scrollIndicators(.hidden)
|
|
||||||
.onChange(of: msgs.count) { withAnimation { proxy.scrollTo("bottom") } } }
|
|
||||||
ZXAIInputBar(text: $msg, onSend: { guard !msg.isEmpty else { return }; msgs.append(("user", msg)); msg = ""; DispatchQueue.main.asyncAfter(deadline: .now() + 1) { msgs.append(("ai", "好的,我理解你的问题。建议你从基础概念开始,逐步深入理解。需要我帮你制定具体的学习计划吗?")) } })
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
19
AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift
Normal file
19
AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Recall Test Page
|
||||||
|
|
||||||
|
struct RecallTestPage: View {
|
||||||
|
@State private var input = ""
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "回忆测试", subtitle: "机器学习 · 偏差-方差权衡") {}
|
||||||
|
ScrollView { VStack(spacing: 16) {
|
||||||
|
Text("请回忆并写下你对「偏差-方差权衡」的理解").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
TextEditor(text: $input).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
Button {} label: { Text("提交").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct ChatMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let role: ChatRole
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatRole {
|
||||||
|
case user, ai
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AIChatViewModel: ObservableObject {
|
||||||
|
@Published var messages: [ChatMessage] = [
|
||||||
|
ChatMessage(role: .ai, content: "你好!我是你的 AI 学习助手。我可以帮你解答学习问题、分析薄弱点、制定复习计划。请告诉我你想学习什么?")
|
||||||
|
]
|
||||||
|
@Published var inputText = ""
|
||||||
|
@Published var isSending = false
|
||||||
|
|
||||||
|
var canSend: Bool { !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending }
|
||||||
|
|
||||||
|
func send() {
|
||||||
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty, !isSending else { return }
|
||||||
|
messages.append(ChatMessage(role: .user, content: text))
|
||||||
|
inputText = ""
|
||||||
|
isSending = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
messages.append(ChatMessage(role: .ai, content: "好的,我理解你的问题。建议你从基础概念开始,逐步深入理解。需要我帮你制定具体的学习计划吗?"))
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift
Normal file
20
AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Weak Points Page
|
||||||
|
|
||||||
|
struct WeakPointsPage: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "薄弱知识点", subtitle: "共 23 个待巩固") {}
|
||||||
|
ScrollView { VStack(spacing: 12) {
|
||||||
|
ZXWeakRow(score: 32, topic: "贝叶斯定理应用", lib: "机器学习", priority: "高")
|
||||||
|
ZXWeakRow(score: 41, topic: "正态分布性质", lib: "高等数学", priority: "高")
|
||||||
|
ZXWeakRow(score: 55, topic: "词根 spect- 相关词汇", lib: "英语词汇", priority: "中")
|
||||||
|
ZXWeakRow(score: 48, topic: "协方差与相关系数", lib: "机器学习", priority: "中")
|
||||||
|
ZXWeakRow(score: 36, topic: "梯度下降优化", lib: "机器学习", priority: "高")
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -69,80 +69,4 @@ struct AnalysisHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stat Badge
|
// ZXStatBadge, ZXWeakRow, ZXChartView → Shared/Components/
|
||||||
|
|
||||||
struct ZXStatBadge: View {
|
|
||||||
let icon: String; let label: String; let value: String; let trend: String; let color: Color
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 3) {
|
|
||||||
Image(systemName: icon).font(.system(size: 14)).foregroundColor(color)
|
|
||||||
Text(value).font(.system(size: 16, weight: .heavy)).foregroundColor(Color.zxF0)
|
|
||||||
Text(label).font(.system(size: 9)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity).frame(height: 72).padding(.vertical, 4)
|
|
||||||
.background(color.opacity(0.06))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.15), lineWidth: 1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Weak Point Row
|
|
||||||
|
|
||||||
struct ZXWeakRow: View {
|
|
||||||
let score: Int; let topic: String; let lib: String; let priority: String
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text("\(score)").font(.system(size: 13, weight: .heavy)).foregroundColor(Color.zxYellow)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.background(Color.zxYellowBG(0.15)).clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(topic).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
|
|
||||||
Text(lib).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
||||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
Text("\(priority)优先").font(.system(size: 11, weight: .bold))
|
|
||||||
.foregroundColor(priority == "高" ? Color.zxRed : Color.zxYellow)
|
|
||||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
|
||||||
.background((priority == "高" ? Color.zxRedBG(0.15) : Color.zxYellowBG(0.15)))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
||||||
.background(Color.zxYellowBG(0.06))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.15), lineWidth: 1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Chart View
|
|
||||||
|
|
||||||
struct ZXChartView: View {
|
|
||||||
let data: [(String, CGFloat)] = [
|
|
||||||
("一", 0.62), ("二", 0.65), ("三", 0.71), ("四", 0.68),
|
|
||||||
("五", 0.75), ("六", 0.79), ("今", 0.78)
|
|
||||||
]
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
GeometryReader { g in
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
Path { path in
|
|
||||||
let w = g.size.width / 7
|
|
||||||
for (i, d) in data.enumerated() {
|
|
||||||
let x = w * CGFloat(i) + w / 2
|
|
||||||
let y = (1 - d.1) * g.size.height
|
|
||||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
|
||||||
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.stroke(Color.zxPurple, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 100)
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(data, id: \.0) { d in
|
|
||||||
Text(d.0).font(.system(size: 9))
|
|
||||||
.foregroundColor(Color(hex: "#F0F0FF", opacity: 0.35))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LoginViewModel: ObservableObject {
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
let appSession: AppSession
|
||||||
|
|
||||||
|
init(appSession: AppSession) {
|
||||||
|
self.appSession = appSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginWithApple() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
await appSession.loginWithApple()
|
||||||
|
if let error = appSession.authError {
|
||||||
|
errorMessage = error
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthenticated: Bool {
|
||||||
|
appSession.isAuthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WaitlistViewModel: ObservableObject {
|
||||||
|
@Published var email = ""
|
||||||
|
@Published var name = ""
|
||||||
|
@Published var isSubmitting = false
|
||||||
|
@Published var showSuccess = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
var canSubmit: Bool {
|
||||||
|
!email.trimmingCharacters(in: .whitespaces).isEmpty && !isSubmitting
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit() async {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let entry = WaitlistEntry(
|
||||||
|
email: email.trimmingCharacters(in: .whitespaces),
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces).isEmpty ? nil : name.trimmingCharacters(in: .whitespaces)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Replace with actual API call when backend is ready
|
||||||
|
// let _: EmptyResponse = try await apiClient.request(.waitlist(entry))
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
|
||||||
|
showSuccess = true
|
||||||
|
isSubmitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
136
AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift
Normal file
136
AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@StateObject private var viewModel: LoginViewModel
|
||||||
|
let onLoginSuccess: (Bool) -> Void
|
||||||
|
|
||||||
|
init(appSession: AppSession, onLoginSuccess: @escaping (Bool) -> Void) {
|
||||||
|
_viewModel = StateObject(wrappedValue: LoginViewModel(appSession: appSession))
|
||||||
|
self.onLoginSuccess = onLoginSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(RadialGradient(
|
||||||
|
colors: [Color(hex: "#7C6EFA", opacity: 0.08), .clear],
|
||||||
|
center: .top,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 200
|
||||||
|
))
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.offset(y: -80)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Logo
|
||||||
|
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.4), radius: 32)
|
||||||
|
|
||||||
|
// Brand
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("知习")
|
||||||
|
.font(.system(size: 32, weight: .heavy))
|
||||||
|
.tracking(-1)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: "#A78BFA"), Color(hex: "#F0F0FF"), Color(hex: "#F97316")],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text("更懂你,更会学。")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value prop
|
||||||
|
Text("用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxRed)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Sign In Button
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.loginWithApple()
|
||||||
|
if viewModel.isAuthenticated {
|
||||||
|
let needsOnboarding = viewModel.appSession.needsOnboarding
|
||||||
|
onLoginSuccess(needsOnboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "apple.logo")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
Text("使用 Apple 继续")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.zxF05)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Button {
|
||||||
|
onLoginSuccess(true)
|
||||||
|
} label: {
|
||||||
|
Text("跳过,进入演示模式")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("登录即代表你同意《用户服务协议》和《隐私政策》")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(Color.zxF02)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
134
AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift
Normal file
134
AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WaitlistView: View {
|
||||||
|
@StateObject private var vm = WaitlistViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
headerSection
|
||||||
|
if vm.showSuccess {
|
||||||
|
successSection
|
||||||
|
} else {
|
||||||
|
formSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 60)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer().frame(height: ZXSpacing.statusBarH + 40)
|
||||||
|
Image(systemName: "envelope.badge.person.crop")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(Color.zxPurple)
|
||||||
|
Text("加入内测名单")
|
||||||
|
.font(.system(size: 24, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.tracking(-0.5)
|
||||||
|
Text("知习正在邀请首批用户参与内测。\n留下联系方式,我们会在开放后第一时间通知你。")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Form
|
||||||
|
|
||||||
|
private var formSection: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("邮箱").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
|
||||||
|
TextField("your@email.com", text: $vm.email)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("称呼(选填)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
|
||||||
|
TextField("怎么称呼你", text: $vm.name)
|
||||||
|
.textContentType(.name)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = vm.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await vm.submit() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if vm.isSubmitting {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
}
|
||||||
|
Text(vm.isSubmitting ? "提交中…" : "加入名单")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(vm.canSubmit ? ZXGradient.ctaButton : LinearGradient(colors: [Color.zxFill006, Color.zxFill006], startPoint: .leading, endPoint: .trailing))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.disabled(!vm.canSubmit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Success
|
||||||
|
|
||||||
|
private var successSection: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundColor(Color.zxGreen)
|
||||||
|
Text("已加入名单")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("我们会通过邮件通知你最新进展。\n感谢你的关注!")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("返回")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(ZXGradient.ctaButton)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift
Normal file
138
AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Feedback Page
|
||||||
|
|
||||||
|
struct FeedbackView: View {
|
||||||
|
@StateObject private var vm = FeedbackViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "反馈", subtitle: nil) {}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// 分类选择
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("反馈类型")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(FeedbackCategory.allCases) { cat in
|
||||||
|
let sel = vm.selectedCategory == cat
|
||||||
|
Button {
|
||||||
|
vm.selectedCategory = cat
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: cat.icon)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text(cat.rawValue)
|
||||||
|
.font(.system(size: 11, weight: sel ? .semibold : .regular))
|
||||||
|
}
|
||||||
|
.foregroundColor(sel ? Color.zxPurple : Color.zxF05)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(sel ? Color.zxPurpleBG(0.12) : Color.zxFill003)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(sel ? Color.zxPurple.opacity(0.3) : Color.zxBorder006, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容输入
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("详细描述")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if vm.content.isEmpty {
|
||||||
|
Text("请描述你遇到的问题或建议…")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
TextEditor(text: $vm.content)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
.frame(minHeight: 160)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color.zxBorder008, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误提示
|
||||||
|
if let error = vm.errorMessage {
|
||||||
|
ZXErrorBanner(message: error) {
|
||||||
|
vm.errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交按钮
|
||||||
|
Button {
|
||||||
|
vm.submit()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if vm.isSubmitting {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text(vm.isSubmitting ? "提交中…" : "提交反馈")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(
|
||||||
|
vm.isValid && !vm.isSubmitting
|
||||||
|
? AnyView(ZXGradient.ctaPurple)
|
||||||
|
: AnyView(Color.zxFill005)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.disabled(!vm.isValid || vm.isSubmitting)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.alert("反馈已提交", isPresented: $vm.showSuccess) {
|
||||||
|
Button("好的") { dismiss() }
|
||||||
|
} message: {
|
||||||
|
Text("感谢你的反馈,我们会尽快处理。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Icon
|
||||||
|
|
||||||
|
private extension FeedbackCategory {
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .bug: return "ladybug.fill"
|
||||||
|
case .feature: return "lightbulb.fill"
|
||||||
|
case .content: return "text.badge.checkmark"
|
||||||
|
case .other: return "ellipsis.bubble.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Feedback View Model
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FeedbackViewModel: ObservableObject {
|
||||||
|
@Published var selectedCategory: FeedbackCategory = .feature
|
||||||
|
@Published var content = ""
|
||||||
|
@Published var isSubmitting = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var showSuccess = false
|
||||||
|
|
||||||
|
private let feedbackService: FeedbackServiceProtocol?
|
||||||
|
|
||||||
|
init(feedbackService: FeedbackServiceProtocol? = nil) {
|
||||||
|
self.feedbackService = feedbackService
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit() {
|
||||||
|
guard isValid else { return }
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if let service = feedbackService {
|
||||||
|
_ = try await service.submit(SubmitFeedbackRequest(
|
||||||
|
category: selectedCategory.rawValue,
|
||||||
|
content: content
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
// 未注入 service 时模拟提交延迟
|
||||||
|
try await Task.sleep(nanoseconds: 800_000_000)
|
||||||
|
}
|
||||||
|
isSubmitting = false
|
||||||
|
showSuccess = true
|
||||||
|
} catch {
|
||||||
|
isSubmitting = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Add Knowledge Page
|
||||||
|
|
||||||
|
struct AddKnowledgePage: View {
|
||||||
|
@State private var title = ""; @State private var content = ""
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "添加知识点", subtitle: "机器学习") {}
|
||||||
|
ScrollView { VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("输入知识点标题", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
Button {} label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Create Library Page
|
||||||
|
|
||||||
|
struct CreateLibraryPage: View {
|
||||||
|
@State private var name = ""; @State private var desc = ""
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "创建知识库", subtitle: nil) {}
|
||||||
|
ScrollView { VStack(spacing: 20) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("知识库名称").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("例如:机器学习", text: $name).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("描述(可选)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("简单描述这个知识库的内容", text: $desc).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
Button {} label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
||||||
|
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Edit Knowledge Page
|
||||||
|
|
||||||
|
struct EditKnowledgePage: View {
|
||||||
|
@State private var title = "偏差-方差权衡"; @State private var content = "偏差衡量模型的预测与真实值之间的差异..."
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "编辑知识点", subtitle: nil) {}
|
||||||
|
ScrollView { VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
||||||
|
Button {} label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift
Normal file
19
AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Import Page
|
||||||
|
|
||||||
|
struct ImportPage: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "导入资料", subtitle: nil) {}
|
||||||
|
ScrollView { VStack(spacing: 12) {
|
||||||
|
ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记,AI 自动识别")
|
||||||
|
ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown")
|
||||||
|
ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
|
||||||
|
ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片")
|
||||||
|
}.padding(.horizontal, 20).padding(.top, 16) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Knowledge Detail Page
|
||||||
|
|
||||||
|
struct KnowledgeDetailPage: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "知识点详情", subtitle: "机器学习") { ZXIconBtn(icon: "pencil", size: 36) {} }
|
||||||
|
ScrollView { VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) { HStack { ZXChip(text: "算法", color: Color.zxPurple); ZXChip(text: "机器学习", color: Color.zxAccent); ZXChip(text: "需要复习", color: Color.zxYellow) }; Text("偏差-方差权衡").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0); Text("偏差-方差权衡是机器学习模型选择的核心理念。").font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
HStack(spacing: 12) { Button {} label: { Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14)) }; Button {} label: { Label("费曼解释", systemImage: "mic.fill").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) } }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Library Detail Page
|
||||||
|
|
||||||
|
struct LibraryDetailPage: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack { Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ZXBackHeader(title: "机器学习", subtitle: "47 个知识点 · 掌握 72%") {
|
||||||
|
HStack(spacing: 8) { ZXIconBtn(icon: "magnifyingglass", size: 36) {}; ZXIconBtn(icon: "plus", size: 36, branded: true) {} }
|
||||||
|
}
|
||||||
|
ScrollView { VStack(spacing: 12) {
|
||||||
|
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) }
|
||||||
|
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) }
|
||||||
|
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) }
|
||||||
|
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) }
|
||||||
|
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,111 +0,0 @@
|
|||||||
//
|
|
||||||
// LibrarySubpages.swift
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CreateLibraryPage: View {
|
|
||||||
@State private var name = ""; @State private var desc = ""
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "创建知识库", subtitle: nil) {}
|
|
||||||
ScrollView { VStack(spacing: 20) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("知识库名称").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("例如:机器学习", text: $name).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("描述(可选)").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("简单描述这个知识库的内容", text: $desc).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
Button {} label: { Text("创建").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
|
||||||
}.padding(.horizontal, 20).padding(.top, 20) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LibraryDetailPage: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "机器学习", subtitle: "47 个知识点 · 掌握 72%") {
|
|
||||||
HStack(spacing: 8) { ZXIconBtn(icon: "magnifyingglass", size: 36) {}; ZXIconBtn(icon: "plus", size: 36, branded: true) {} }
|
|
||||||
}
|
|
||||||
ScrollView { VStack(spacing: 12) {
|
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "偏差-方差权衡", desc: "模型复杂度 · 泛化误差", status: "已掌握", c: Color.zxGreen) }
|
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "梯度下降优化", desc: "SGD · Adam · 学习率", status: "学习中", c: Color.zxOrange) }
|
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "正则化方法", desc: "L1 · L2 · Dropout", status: "待复习", c: Color.zxYellow) }
|
|
||||||
NavigationLink(destination: KnowledgeDetailPage()) { ZXCardRow(emoji: "📝", title: "过拟合与欠拟合", desc: "偏差方差 · 模型选择", status: "已掌握", c: Color.zxGreen) }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct ZXCardRow: View { let emoji: String; let title: String; let desc: String; let status: String; let c: Color
|
|
||||||
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }
|
|
||||||
.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AddKnowledgePage: View {
|
|
||||||
@State private var title = ""; @State private var content = ""
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "添加知识点", subtitle: "机器学习") {}
|
|
||||||
ScrollView { VStack(spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("输入知识点标题", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
Button {} label: { Text("保存").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct KnowledgeDetailPage: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "知识点详情", subtitle: "机器学习") { ZXIconBtn(icon: "pencil", size: 36) {} }
|
|
||||||
ScrollView { VStack(spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) { HStack { ZXChip(text: "算法", color: Color.zxPurple); ZXChip(text: "机器学习", color: Color.zxAccent); ZXChip(text: "需要复习", color: Color.zxYellow) }; Text("偏差-方差权衡").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0); Text("偏差-方差权衡是机器学习模型选择的核心理念。").font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
||||||
HStack(spacing: 12) { Button {} label: { Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14)) }; Button {} label: { Label("费曼解释", systemImage: "mic.fill").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) } }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct ZXChip: View { let text: String; let color: Color
|
|
||||||
var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ImportPage: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "导入资料", subtitle: nil) {}
|
|
||||||
ScrollView { VStack(spacing: 12) {
|
|
||||||
ZXImportOption(icon: "camera.fill", title: "拍照导入", desc: "拍下书本或笔记,AI 自动识别")
|
|
||||||
ZXImportOption(icon: "doc.text.fill", title: "文件导入", desc: "支持 PDF、Word、Markdown")
|
|
||||||
ZXImportOption(icon: "link", title: "链接导入", desc: "粘贴网页链接,自动提取内容")
|
|
||||||
ZXImportOption(icon: "photo.on.rectangle", title: "相册导入", desc: "从相册选择截图或图片")
|
|
||||||
}.padding(.horizontal, 20).padding(.top, 16) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct ZXImportOption: View { let icon: String; let title: String; let desc: String
|
|
||||||
var body: some View { Button {} label: { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple).frame(width: 48, height: 48).background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }.foregroundColor(.primary) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EditKnowledgePage: View {
|
|
||||||
@State private var title = "偏差-方差权衡"; @State private var content = "偏差衡量模型的预测与真实值之间的差异..."
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
ZXBackHeader(title: "编辑知识点", subtitle: nil) {}
|
|
||||||
ScrollView { VStack(spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("标题").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextField("", text: $title).font(.system(size: 15)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 52).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
VStack(alignment: .leading, spacing: 8) { Text("内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035); TextEditor(text: $content).frame(minHeight: 200).scrollContentBackground(.hidden).padding(12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) }
|
|
||||||
Button {} label: { Text("保存修改").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 52).background(ZXGradient.ctaPurple).clipShape(RoundedRectangle(cornerRadius: 16)) }
|
|
||||||
}.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Goal Setup Page
|
||||||
|
|
||||||
|
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: 15, 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.zxF02, 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: 13)).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} }.padding(.horizontal, 20)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Onboarding Page
|
||||||
|
|
||||||
|
struct OnboardingPage: View {
|
||||||
|
let onContinue: () -> Void
|
||||||
|
@State private var step = 0
|
||||||
|
let titles = ["输入知识", "主动输出", "AI 分析", "掌握知识"]
|
||||||
|
let descs = ["从任何地方收集并导入学习资料,构建你的专属知识库。", "通过间隔回忆和费曼解释法,将知识转化为长期记忆。", "AI 自动定位薄弱知识点,给出针对性的学习建议。", "系统性掌握每一个知识点,建立牢固的知识体系。"]
|
||||||
|
let icons = ["square.and.arrow.down", "brain.head.profile", "sparkle.magnifyingglass", "chart.line.uptrend.xyaxis"]
|
||||||
|
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.zxFill01))
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift
Normal file
34
AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Splash Page
|
||||||
|
|
||||||
|
struct SplashPage: View {
|
||||||
|
let onFinish: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.splash.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)
|
||||||
|
Circle().fill(RadialGradient(colors: [Color(hex: "#F97316", opacity: 0.15), .clear], center: .center, startRadius: 0, endRadius: 100)).frame(width: 200, height: 200).offset(y: 180).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))
|
||||||
|
Text("Z H I X I").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04).tracking(3).padding(.top, 6)
|
||||||
|
Text("AI-first 系统化学习").font(.system(size: 14)).foregroundColor(Color.zxF0045).tracking(0.5).padding(.top, 24)
|
||||||
|
}
|
||||||
|
VStack { Spacer()
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill01).frame(width: 40, height: 3)
|
||||||
|
RoundedRectangle(cornerRadius: 2).fill(LinearGradient(colors: [.zxPurple, Color.zxOrange], startPoint: .leading, endPoint: .trailing)).frame(width: 24, height: 3)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift
Normal file
42
AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Welcome Page
|
||||||
|
|
||||||
|
struct WelcomePage: View {
|
||||||
|
let onContinue: () -> Void; let onSkip: () -> Void
|
||||||
|
@State private var showWaitlist = false
|
||||||
|
|
||||||
|
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: "🧠", title: "主动回忆", desc: "基于间隔重复的智能复习"); FeatureRow(icon: "🎤", title: "费曼解释", desc: "用自己的话讲出来"); FeatureRow(icon: "📊", 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)
|
||||||
|
}
|
||||||
|
Button { showWaitlist = true } label: {
|
||||||
|
Text("申请内测资格").font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxAccent)
|
||||||
|
}
|
||||||
|
Button { onSkip() } label: {
|
||||||
|
Text("已有账号?立即登录").font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF007)
|
||||||
|
}.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showWaitlist) {
|
||||||
|
WaitlistView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
|
@StateObject private var colorSchemeManager = ColorSchemeManager.shared
|
||||||
|
@StateObject private var languageManager = LanguageManager.shared
|
||||||
|
@State private var showAppearancePicker = false
|
||||||
|
@State private var showLanguagePicker = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ZXGradient.page.ignoresSafeArea()
|
ZXGradient.page.ignoresSafeArea()
|
||||||
@ -19,14 +24,67 @@ struct ProfileView: View {
|
|||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
||||||
profileCard
|
profileCard
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
NavigationLink(destination: LearningGoalSettingsView()) {
|
||||||
ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标")
|
ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标")
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
NavigationLink(destination: ReviewReminderSettingsView()) {
|
||||||
ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置")
|
ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置")
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
NavigationLink(destination: LearningReportView()) {
|
||||||
ZXProfileMenuRow(emoji: "📊", title: "学习报告", desc: "周报 · 月报 · 成就")
|
ZXProfileMenuRow(emoji: "📊", title: "学习报告", desc: "周报 · 月报 · 成就")
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
NavigationLink(destination: LearningMethodPreferencesView()) {
|
||||||
ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔")
|
ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔")
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
NavigationLink(destination: DataSyncSettingsView()) {
|
||||||
ZXProfileMenuRow(emoji: "☁️", title: "数据同步与备份", desc: "云端同步设置")
|
ZXProfileMenuRow(emoji: "☁️", title: "数据同步与备份", desc: "云端同步设置")
|
||||||
}
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
NavigationLink(destination: FeedbackView()) {
|
||||||
|
ZXProfileMenuRow(emoji: "💬", title: "反馈", desc: "问题报告 · 功能建议")
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
// 设置
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Button {
|
||||||
|
showLanguagePicker = true
|
||||||
|
} label: {
|
||||||
|
ZXProfileMenuRow(emoji: "🌐", title: "语言", desc: languageManager.current.displayName)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.confirmationDialog("语言", isPresented: $showLanguagePicker) {
|
||||||
|
ForEach(LanguageManager.shared.supported) { lang in
|
||||||
|
Button(lang.displayName) {
|
||||||
|
languageManager.current = lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showAppearancePicker = true
|
||||||
|
} label: {
|
||||||
|
ZXProfileMenuRow(emoji: "🌓", title: "外观", desc: colorSchemeManager.current.displayName)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.confirmationDialog("外观", isPresented: $showAppearancePicker) {
|
||||||
|
ForEach(AppColorScheme.allCases) { scheme in
|
||||||
|
Button(scheme.displayName) {
|
||||||
|
colorSchemeManager.current = scheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
|
||||||
achievementsSection.padding(.bottom, 120)
|
achievementsSection.padding(.bottom, 120)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@ -52,12 +110,4 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct ZXProfileStat: View { let v: String; let l: String; let c: Color; var body: some View { VStack(spacing: 2) { Text(v).font(.system(size: 18, weight: .bold)).foregroundColor(c); Text(l).font(.system(size: 11)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) }
|
// ZXProfileStat, ZXProfileMenuRow, ZXAchievementBadge → Shared/Components/
|
||||||
init(value: String, label: String, color: Color) { self.v = value; self.l = label; self.c = color }
|
|
||||||
}
|
|
||||||
struct ZXProfileMenuRow: View { let emoji: String; let title: String; let desc: String
|
|
||||||
var body: some View { HStack(spacing: 12) { Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36).background(Color.zxFill006).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03) }; Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }.padding(.horizontal, 16).padding(.vertical, 14) }
|
|
||||||
}
|
|
||||||
struct ZXAchievementBadge: View { let emoji: String; let label: String; let color: Color
|
|
||||||
var body: some View { VStack(spacing: 6) { Text(emoji).font(.system(size: 24)).frame(width: 48, height: 48).background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.25), lineWidth: 1)); Text(label).font(.system(size: 10, weight: .semibold)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity) }
|
|
||||||
}
|
|
||||||
|
|||||||
275
AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift
Normal file
275
AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Learning Goal Settings
|
||||||
|
|
||||||
|
struct LearningGoalSettingsView: View {
|
||||||
|
@State private var selectedGoal = ""
|
||||||
|
@State private var dailyMins = "30 分钟"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ZXBackHeader(title: "学习目标设置")
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("学习目标").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).tracking(0.5)
|
||||||
|
ForEach([("🧑🎓", "备考考试"), ("💼", "职业技能"), ("📚", "通识学习"), ("🎯", "自定义")], id: \.1) { emoji, title in
|
||||||
|
let sel = selectedGoal == title
|
||||||
|
Button {
|
||||||
|
selectedGoal = title
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(emoji).font(.system(size: 22)).frame(width: 44, height: 44)
|
||||||
|
.background(sel ? Color.zxPurpleBG(0.15) : Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(sel ? Color.zxPurple : Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
Circle().stroke(sel ? Color.zxPurple : Color.zxF02, lineWidth: 2)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.overlay { if sel { Circle().fill(Color.zxPurple).frame(width: 12, height: 12) } }
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(sel ? Color.zxPurpleBG(0.08) : Color.zxFill003)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(sel ? Color.zxPurpleBG(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(["15 分钟", "30 分钟", "1 小时", "不限制"], 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.zxPurpleBG(0.1) : Color.zxFill003)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(sel ? Color.zxPurpleBG(0.25) : Color.zxBorder006, lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Review Reminder Settings
|
||||||
|
|
||||||
|
struct ReviewReminderSettingsView: View {
|
||||||
|
@State private var reminderEnabled = true
|
||||||
|
@State private var reminderTime = Date()
|
||||||
|
@State private var intervalDays = 1
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ZXBackHeader(title: "复习提醒")
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Toggle(isOn: $reminderEnabled) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("开启复习提醒").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text("基于间隔重复算法,在最佳时间提醒你复习").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
Divider().background(Color.zxBorder006)
|
||||||
|
DatePicker("提醒时间", selection: $reminderTime, displayedComponents: .hourAndMinute)
|
||||||
|
.font(.system(size: 14)).foregroundColor(Color.zxF0)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
Divider().background(Color.zxBorder006)
|
||||||
|
HStack {
|
||||||
|
Text("间隔天数").font(.system(size: 14)).foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
Stepper("每 \(intervalDays) 天", value: $intervalDays, in: 1...7)
|
||||||
|
.font(.system(size: 14)).foregroundColor(Color.zxF05)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Learning Report
|
||||||
|
|
||||||
|
struct LearningReportView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ZXBackHeader(title: "学习报告")
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ReportCard(period: "本周", studyDays: 5, totalMins: 320, newItems: 12, reviewed: 47)
|
||||||
|
ReportCard(period: "本月", studyDays: 18, totalMins: 1240, newItems: 47, reviewed: 186)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("成就").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ZXAchievementBadge(emoji: "🔥", label: "连续 14 天", color: Color.zxOrange)
|
||||||
|
ZXAchievementBadge(emoji: "🧠", label: "费曼达人", color: Color.zxPurple)
|
||||||
|
ZXAchievementBadge(emoji: "📚", label: "知识收藏家", color: Color.zxTeal)
|
||||||
|
ZXAchievementBadge(emoji: "⚡", label: "速学者", color: Color.zxYellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReportCard: View {
|
||||||
|
let period: String; let studyDays: Int; let totalMins: Int; let newItems: Int; let reviewed: Int
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(period).font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPurple)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ReportStat(value: "\(studyDays)", label: "学习天")
|
||||||
|
ReportStat(value: "\(totalMins)", label: "分钟")
|
||||||
|
ReportStat(value: "\(newItems)", label: "新知识")
|
||||||
|
ReportStat(value: "\(reviewed)", label: "已复习")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReportStat: View {
|
||||||
|
let value: String; let label: String
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(value).font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0)
|
||||||
|
Text(label).font(.system(size: 10)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Learning Method Preferences
|
||||||
|
|
||||||
|
struct LearningMethodPreferencesView: View {
|
||||||
|
@State private var selectedMethods: Set<String> = ["间隔回忆", "费曼技巧"]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ZXBackHeader(title: "学习方法偏好")
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach([
|
||||||
|
("间隔回忆", "基于遗忘曲线,在最佳时机提醒你复习"),
|
||||||
|
("费曼技巧", "用自己的语言重新解释知识,发现理解盲区"),
|
||||||
|
("AI 分析", "AI 自动定位薄弱知识点,给出针对性建议")
|
||||||
|
], id: \.0) { method, desc in
|
||||||
|
let sel = selectedMethods.contains(method)
|
||||||
|
Button {
|
||||||
|
if sel { selectedMethods.remove(method) }
|
||||||
|
else { selectedMethods.insert(method) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(method).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: sel ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(sel ? Color.zxPurple : Color.zxF02)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
if method != "AI 分析" {
|
||||||
|
Divider().background(Color.zxBorder006)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Sync Settings
|
||||||
|
|
||||||
|
struct DataSyncSettingsView: View {
|
||||||
|
@State private var iCloudEnabled = false
|
||||||
|
@State private var autoBackupEnabled = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ZXBackHeader(title: "数据同步与备份")
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Toggle(isOn: $iCloudEnabled) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("iCloud 同步").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text("在 Apple ID 关联的设备间同步学习数据").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
Divider().background(Color.zxBorder006)
|
||||||
|
Toggle(isOn: $autoBackupEnabled) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("自动备份").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text("每日自动备份学习记录和知识库").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift
Normal file
68
AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReviewPlanView: View {
|
||||||
|
@StateObject private var vm = ReviewPlanViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("复习计划")
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.tracking(-0.5)
|
||||||
|
Text("\(vm.totalCount) 个待复习")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, ZXSpacing.statusBarH + 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
sectionView(title: "今天", icon: "sun.max.fill", tasks: vm.todayTasks, color: Color.zxOrange)
|
||||||
|
sectionView(title: "明天", icon: "sunrise.fill", tasks: vm.tomorrowTasks, color: Color.zxPurple)
|
||||||
|
sectionView(title: "本周", icon: "calendar", tasks: vm.weekTasks, color: Color.zxTeal)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionView(title: String, icon: String, tasks: [ReviewTaskEntity], color: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(color)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
Text("\(tasks.count) 项")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tasks.isEmpty {
|
||||||
|
ZXEmptyView(icon: "checkmark.circle", title: "暂无复习任务", subtitle: "完成学习后 AI 会自动生成复习计划")
|
||||||
|
} else {
|
||||||
|
ForEach(tasks) { task in
|
||||||
|
ReviewTaskRow(task: task) {
|
||||||
|
vm.toggleTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReviewPlanViewModel: ObservableObject {
|
||||||
|
private let persistence = PersistenceController.shared
|
||||||
|
|
||||||
|
@Published var todayTasks: [ReviewTaskEntity] = []
|
||||||
|
@Published var tomorrowTasks: [ReviewTaskEntity] = []
|
||||||
|
@Published var weekTasks: [ReviewTaskEntity] = []
|
||||||
|
|
||||||
|
var totalCount: Int { todayTasks.count + tomorrowTasks.count + weekTasks.count }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
persistence.seedIfNeeded()
|
||||||
|
fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTask(_ task: ReviewTaskEntity) {
|
||||||
|
let all = persistence.loadReviewTasks()
|
||||||
|
guard let i = all.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
var updated = all
|
||||||
|
let newStatus: ReviewTaskEntityStatus = task.statusEnum == .completed ? .pending : .completed
|
||||||
|
updated[i].statusEnum = newStatus
|
||||||
|
updated[i].completedAt = newStatus == .completed ? Date() : nil
|
||||||
|
persistence.saveReviewTasks(updated)
|
||||||
|
fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAll() {
|
||||||
|
let all = persistence.loadReviewTasks()
|
||||||
|
todayTasks = all.filter(\.isToday)
|
||||||
|
tomorrowTasks = all.filter(\.isTomorrow)
|
||||||
|
weekTasks = all.filter { $0.isThisWeek && !$0.isToday && !$0.isTomorrow }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,50 +1,187 @@
|
|||||||
//
|
|
||||||
// StudyHomeView.swift - Page 8
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StudyHomeView: View {
|
struct StudyHomeView: View {
|
||||||
@State private var ts: [ZXSTask] = [
|
@StateObject private var vm = StudyHomeViewModel()
|
||||||
.init(t: "机器学习 - 回忆测试", tp: "回忆测试", c: Color.zxPurple, m: 10, d: true),
|
|
||||||
.init(t: "高数 - 间隔复习 8 题", tp: "间隔复习", c: Color.zxOrange, m: 15, d: true),
|
|
||||||
.init(t: "英语词汇 - 25 个待复习", tp: "词汇复习", c: Color.zxTeal, m: 8, d: false),
|
|
||||||
.init(t: "注意力机制 - 费曼解释", tp: "费曼练习", c: Color.zxAccent, m: 12, d: false),
|
|
||||||
.init(t: "产品设计 - 薄弱点复习", tp: "薄弱点", c: Color.zxYellow, m: 10, d: false),
|
|
||||||
]
|
|
||||||
private let wb: [CGFloat] = [0.3, 0.7, 1.0, 0.4, 0.9, 0.6, 0.2]
|
|
||||||
private let dl = ["一","二","三","四","五","六","日"]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack { ZXGradient.page.ignoresSafeArea()
|
ZStack {
|
||||||
ScrollView { VStack(spacing: 16) {
|
ZXGradient.page.ignoresSafeArea()
|
||||||
HStack { VStack(alignment: .leading, spacing: 2) { Text("周四,1月16日").font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxF04); Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer()
|
ScrollView {
|
||||||
HStack(spacing: 4) { Image(systemName: "flame.fill").font(.system(size: 14)).foregroundColor(Color.zxOrange); Text("14 天连续").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange) }.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxOrangeBG(0.1)).clipShape(Capsule()).overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1)) }
|
VStack(spacing: 16) {
|
||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
headerRow
|
||||||
pc
|
progressCard
|
||||||
VStack(alignment: .leading, spacing: 12) { HStack { Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); HStack(spacing: 4) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04); Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04) } }
|
taskSection
|
||||||
ForEach($ts) { $t in ZXSTaskRow(task: t) { t.d.toggle() } } }
|
weeklyActivitySection
|
||||||
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
}
|
||||||
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: wb[i] * 0.9 + 0.1)).frame(height: wb[i] * 60); Text(dl[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
|
.padding(.horizontal, 20)
|
||||||
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
|
.padding(.bottom, 120)
|
||||||
.padding(.bottom, 120) }
|
}
|
||||||
.padding(.horizontal, 20) }
|
.scrollIndicators(.hidden)
|
||||||
.scrollIndicators(.hidden) }
|
}
|
||||||
}
|
}
|
||||||
private var pc: some View { let dn = ts.filter(\.d).count; let pct = CGFloat(dn) / 5
|
|
||||||
return VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text("今日进度").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF05); HStack(alignment: .lastTextBaseline, spacing: 6) { Text("\(dn)").font(.system(size: 26, weight: .black)).foregroundColor(Color.zxF0); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; Spacer()
|
|
||||||
ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple) } }
|
|
||||||
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.zxFill008).frame(height: 6); RoundedRectangle(cornerRadius: 3).fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)).frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6) }
|
|
||||||
HStack { VStack(alignment: .leading, spacing: 2) { Text("\(dn * 12) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("已学").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(spacing: 2) { Text("\((5 - dn) * 11) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("剩余").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(alignment: .trailing, spacing: 2) { Text("+5 点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("掌握").font(.system(size: 10)).foregroundColor(Color.zxF04) } } }
|
|
||||||
.padding(16).background(ZXGradient.progressCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let c: Color; let m: Int; var d: Bool }
|
// MARK: - Header
|
||||||
struct ZXSTaskRow: View { let task: ZXSTask; var action: () -> Void
|
|
||||||
var body: some View { Button(action: action) { HStack(spacing: 12) { Image(systemName: task.d ? "checkmark.circle.fill" : "circle").font(.system(size: 20)).foregroundColor(task.d ? Color.zxGreen : Color.zxF02)
|
|
||||||
VStack(alignment: .leading, spacing: 4) { Text(task.t).font(.system(size: 13, weight: .semibold)).foregroundColor(task.d ? Color.zxF04 : Color.zxF0).strikethrough(task.d); HStack(spacing: 8) { Text(task.tp).font(.system(size: 10, weight: .semibold)).foregroundColor(task.c).padding(.horizontal, 6).padding(.vertical, 1).background(task.c.opacity(0.12)).clipShape(Capsule()); Text("约 \(task.m) 分钟").font(.system(size: 10)).foregroundColor(Color.zxF035) } }
|
|
||||||
Spacer(); if !task.d { Image(systemName: "play.fill").font(.system(size: 14)).foregroundColor(.white).frame(width: 32, height: 32).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10)) } }
|
|
||||||
.padding(.horizontal, 16).padding(.vertical, 12).background(task.d ? Color.zxFill003 : Color.zxFill005).overlay(RoundedRectangle(cornerRadius: 14).stroke(task.d ? Color(hex: "#FFFFFF", opacity: 0.05) : Color.zxBorder008, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 14)).opacity(task.d ? 0.6 : 1) }.foregroundColor(.primary) }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Color { static let zxF035 = Color(hex: "#F0F0FF", opacity: 0.35) }
|
private var headerRow: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("周四,1月16日")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
Text("学习工作台")
|
||||||
|
.font(.system(size: 20, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.tracking(-0.4)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "flame.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxOrange)
|
||||||
|
Text("14 天连续")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxOrange)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.zxOrangeBG(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, ZXSpacing.statusBarH + 16)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Card
|
||||||
|
|
||||||
|
private var progressCard: some View {
|
||||||
|
let pct = vm.progress
|
||||||
|
return VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("今日进度")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||||
|
Text("\(vm.doneCount)")
|
||||||
|
.font(.system(size: 26, weight: .black))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("/ \(vm.totalCount)")
|
||||||
|
Text("个任务")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: pct)
|
||||||
|
.stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
Text("\(Int(pct * 100))%")
|
||||||
|
.font(.system(size: 14, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxPurple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color.zxFill008)
|
||||||
|
.frame(height: 6)
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing))
|
||||||
|
.frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(vm.doneMinutes) 分钟")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("已学")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(vm.remainingMinutes) 分钟")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("剩余")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("+5 点")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("掌握")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(ZXGradient.progressCard)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Section
|
||||||
|
|
||||||
|
private var taskSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("今日任务")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
Text("AI 自动排期")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(vm.tasks) { task in
|
||||||
|
ZXSTaskRow(task: task) { vm.toggleTask(task) }
|
||||||
|
.transition(.opacity.combined(with: .offset(y: 8)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weekly Activity
|
||||||
|
|
||||||
|
private var weeklyActivitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("本周学习活跃")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
ForEach(0..<7, id: \.self) { i in
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: vm.weekActivity[i] * 0.9 + 0.1))
|
||||||
|
.frame(height: vm.weekActivity[i] * 60)
|
||||||
|
Text(vm.dayLabels[i])
|
||||||
|
.font(.system(size: 10, weight: i == 2 ? .bold : .regular))
|
||||||
|
.foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("总计 \(vm.todayTotalMinutes / 60) 小时 \(vm.todayTotalMinutes % 60) 分钟")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
Spacer()
|
||||||
|
Text("日均 \(max(1, vm.todayTotalMinutes)) 分钟")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StudyHomeViewModel: ObservableObject {
|
||||||
|
private let persistence = PersistenceController.shared
|
||||||
|
|
||||||
|
@Published var tasks: [StudyTaskEntity] = []
|
||||||
|
@Published var records: [LearningRecordEntity] = []
|
||||||
|
|
||||||
|
let dayLabels = ["一", "二", "三", "四", "五", "六", "日"]
|
||||||
|
|
||||||
|
var weekActivity: [CGFloat] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var mins: [Int: Int] = [:]
|
||||||
|
for r in records where calendar.isDate(r.completedAt, equalTo: Date(), toGranularity: .weekOfYear) {
|
||||||
|
let wd = calendar.component(.weekday, from: r.completedAt)
|
||||||
|
let idx = (wd + 5) % 7
|
||||||
|
mins[idx, default: 0] += r.durationMinutes
|
||||||
|
}
|
||||||
|
let maxMins = max(mins.values.max() ?? 1, 1)
|
||||||
|
return (0..<7).map { CGFloat(mins[$0] ?? 0) / CGFloat(maxMins) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var doneCount: Int { tasks.filter(\.isDone).count }
|
||||||
|
var totalCount: Int { tasks.count }
|
||||||
|
var progress: CGFloat { totalCount > 0 ? CGFloat(doneCount) / CGFloat(totalCount) : 0 }
|
||||||
|
var doneMinutes: Int { tasks.filter(\.isDone).reduce(0) { $0 + $1.minutes } }
|
||||||
|
var remainingMinutes: Int { tasks.filter { !$0.isDone }.reduce(0) { $0 + $1.minutes } }
|
||||||
|
var todayTotalMinutes: Int { records.filter { Calendar.current.isDateInToday($0.completedAt) }.reduce(0) { $0 + $1.durationMinutes } }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
persistence.seedIfNeeded()
|
||||||
|
tasks = persistence.loadTasks()
|
||||||
|
records = persistence.loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTask(_ task: StudyTaskEntity) {
|
||||||
|
guard let i = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
tasks[i].isDone.toggle()
|
||||||
|
persistence.saveTasks(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordSession(lessonTitle: String, durationMinutes: Int, masteryScore: Int, weakPoints: [String]) {
|
||||||
|
let record = LearningRecordEntity(
|
||||||
|
lessonTitle: lessonTitle,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
masteryScore: masteryScore,
|
||||||
|
weakPoints: weakPoints
|
||||||
|
)
|
||||||
|
records.append(record)
|
||||||
|
persistence.saveRecords(records)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift
Normal file
21
AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Feature Row
|
||||||
|
|
||||||
|
struct FeatureRow: View {
|
||||||
|
let icon: String; let title: String; let desc: String
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Text(icon).font(.system(size: 20)).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))
|
||||||
|
}
|
||||||
|
}
|
||||||
78
AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift
Normal file
78
AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReviewTaskRow: View {
|
||||||
|
let task: ReviewTaskEntity
|
||||||
|
let onToggle: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onToggle) {
|
||||||
|
Image(systemName: task.statusEnum == .completed ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(task.statusEnum == .completed ? Color.zxGreen : Color.zxF02)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(task.lessonId)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(task.statusEnum == .completed ? Color.zxF04 : Color.zxF0)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
reviewTypeTag(task.reviewTypeEnum)
|
||||||
|
Text("第 1 次复习")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if task.statusEnum == .pending {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(ZXGradient.brand)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.zxFill003)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color.zxBorder006, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.opacity(task.statusEnum == .completed ? 0.6 : 1)
|
||||||
|
.accessibilityLabel("复习任务:\(task.lessonId),\(reviewTypeLabel(task.reviewTypeEnum))")
|
||||||
|
.accessibilityHint(task.statusEnum == .completed ? "已完成" : "双击开始复习")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reviewTypeLabel(_ type: ReviewTaskEntityType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .spacedRepetition: return "间隔重复"
|
||||||
|
case .feynman: return "费曼技巧"
|
||||||
|
case .recall: return "主动回忆"
|
||||||
|
case .weakPoint: return "薄弱点巩固"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewTypeTag(_ type: ReviewTaskEntityType) -> some View {
|
||||||
|
let config: (String, Color) = {
|
||||||
|
switch type {
|
||||||
|
case .spacedRepetition: return ("间隔重复", Color.zxPurple)
|
||||||
|
case .feynman: return ("费曼", Color.zxAccent)
|
||||||
|
case .recall: return ("回忆", Color.zxOrange)
|
||||||
|
case .weakPoint: return ("薄弱", Color.zxYellow)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return Text(config.0)
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundColor(config.1)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(config.1.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
28
AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift
Normal file
28
AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AI Input Bar
|
||||||
|
|
||||||
|
struct ZXAIInputBar: View {
|
||||||
|
@Binding var text: String
|
||||||
|
let onSend: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "sparkles").font(.system(size: 16)).foregroundColor(Color.zxPurple)
|
||||||
|
TextField("问 AI 任何学习问题…", text: $text).font(.system(size: 14)).tint(Color.zxPurple)
|
||||||
|
.accessibilityLabel("AI 对话输入")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "mic.fill").font(.system(size: 18)).foregroundColor(Color.zxF03)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Button(action: onSend) {
|
||||||
|
Image(systemName: "arrow.up").font(.system(size: 14, weight: .bold)).foregroundColor(.white)
|
||||||
|
.frame(width: 30, height: 30).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 9))
|
||||||
|
}
|
||||||
|
.accessibilityLabel("发送消息")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
||||||
|
.background(.ultraThinMaterial).background(Color.zxFill004)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.padding(.horizontal, 20).padding(.bottom, 34)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AI Interaction Row
|
||||||
|
|
||||||
|
struct ZXAIInteractionRow: View {
|
||||||
|
let tag: String; let bg: Color; let fg: Color; let emoji: String
|
||||||
|
let title: String; let time: String; let score: Int; let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(emoji).font(.system(size: 18)).frame(width: 40, height: 40)
|
||||||
|
.background(bg).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(tag).font(.system(size: 10, weight: .bold)).foregroundColor(fg).tracking(0.3)
|
||||||
|
Text(time).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
Text(title).font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF007).lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
ZXScoreBox(
|
||||||
|
score: score,
|
||||||
|
bg: score >= 80 ? Color.zxGreenBG(0.15) : score >= 60 ? Color.zxOrangeBG(0.15) : Color.zxRedBG(0.15),
|
||||||
|
fg: score >= 80 ? Color.zxGreen : score >= 60 ? Color.zxOrange : Color.zxRed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
.background(Color.zxFill003)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Achievement Badge
|
||||||
|
|
||||||
|
struct ZXAchievementBadge: View {
|
||||||
|
let emoji: String; let label: String; let color: Color
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(emoji).font(.system(size: 24)).frame(width: 48, height: 48)
|
||||||
|
.background(color.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.25), lineWidth: 1))
|
||||||
|
Text(label).font(.system(size: 10, weight: .semibold)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift
Normal file
32
AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Back Header
|
||||||
|
|
||||||
|
struct ZXBackHeader<T: View>: View {
|
||||||
|
let title: String; let subtitle: String?; var onBack: (() -> Void)?
|
||||||
|
@ViewBuilder var trailing: () -> T
|
||||||
|
|
||||||
|
init(title: String, subtitle: String? = nil, onBack: (() -> Void)? = nil, @ViewBuilder trailing: @escaping () -> T = { EmptyView() }) {
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.onBack = onBack
|
||||||
|
self.trailing = trailing
|
||||||
|
}
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Button { (onBack ?? { dismiss() })() } label: {
|
||||||
|
Image(systemName: "chevron.left").font(.system(size: 18)).foregroundColor(Color.zxF007)
|
||||||
|
.frame(width: 36, height: 36).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Text(title).font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
if let s = subtitle { Text(s).font(.system(size: 11)).foregroundColor(Color.zxF03) }
|
||||||
|
}.frame(maxWidth: .infinity)
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.top, ZXSpacing.statusBarH + 8).padding(.bottom, 12)
|
||||||
|
.background(Color.zxBg0)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift
Normal file
24
AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Card Row
|
||||||
|
|
||||||
|
struct ZXCardRow: View {
|
||||||
|
let emoji: String; let title: String; let desc: String; let status: String; let c: Color
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(emoji).font(.system(size: 20)).frame(width: 40, height: 40)
|
||||||
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 2)
|
||||||
|
.background(c.opacity(0.12)).clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
36
AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift
Normal file
36
AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Chart View
|
||||||
|
|
||||||
|
struct ZXChartView: View {
|
||||||
|
let data: [(String, CGFloat)] = [
|
||||||
|
("一", 0.62), ("二", 0.65), ("三", 0.71), ("四", 0.68),
|
||||||
|
("五", 0.75), ("六", 0.79), ("今", 0.78)
|
||||||
|
]
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
GeometryReader { g in
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Path { path in
|
||||||
|
let w = g.size.width / 7
|
||||||
|
for (i, d) in data.enumerated() {
|
||||||
|
let x = w * CGFloat(i) + w / 2
|
||||||
|
let y = (1 - d.1) * g.size.height
|
||||||
|
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||||
|
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.zxPurple, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(data, id: \.0) { d in
|
||||||
|
Text(d.0).font(.system(size: 9))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift
Normal file
12
AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Chip
|
||||||
|
|
||||||
|
struct ZXChip: View {
|
||||||
|
let text: String; let color: Color
|
||||||
|
var body: some View {
|
||||||
|
Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.12)).clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
60
AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift
Normal file
60
AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
struct ZXEmptyView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let actionLabel: String?
|
||||||
|
let action: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String = "tray",
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
actionLabel: String? = nil,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.actionLabel = actionLabel
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
|
||||||
|
if let subtitle = subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actionLabel = actionLabel, let action = action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(actionLabel)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxPurple)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.zxPurpleBG(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color.zxPurple.opacity(0.3), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift
Normal file
74
AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Error Banner
|
||||||
|
|
||||||
|
struct ZXErrorView: View {
|
||||||
|
let message: String
|
||||||
|
let onRetry: (() -> Void)?
|
||||||
|
|
||||||
|
init(message: String, onRetry: (() -> Void)? = nil) {
|
||||||
|
self.message = message
|
||||||
|
self.onRetry = onRetry
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundColor(Color.zxYellow)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let onRetry = onRetry {
|
||||||
|
Button(action: onRetry) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text("重试")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.zxPurple)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.zxPurpleBG(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color.zxPurple.opacity(0.3), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inline Error Banner (compact, for use inside scroll views)
|
||||||
|
|
||||||
|
struct ZXErrorBanner: View {
|
||||||
|
let message: String
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxYellow)
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.zxYellowBG(0.12))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxYellow.opacity(0.25), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
17
AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift
Normal file
17
AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Icon Button
|
||||||
|
|
||||||
|
struct ZXIconBtn: View {
|
||||||
|
let icon: String; let size: CGFloat; var branded = false; var label: String?; let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon).font(.system(size: size * 0.44)).frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
.foregroundColor(branded ? .white : Color.zxF05)
|
||||||
|
.background(branded ? AnyView(ZXGradient.brand) : AnyView(Color.zxFill005))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay { if !branded { RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1) } }
|
||||||
|
.accessibilityLabel(label ?? icon.replacingOccurrences(of: ".", with: " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift
Normal file
26
AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Import Option
|
||||||
|
|
||||||
|
struct ZXImportOption: View {
|
||||||
|
let icon: String; let title: String; let desc: String
|
||||||
|
var body: some View {
|
||||||
|
Button {} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: icon).font(.system(size: 22)).foregroundColor(Color.zxPurple)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.background(Color.zxPurpleBG(0.1)).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift
Normal file
44
AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Loading Shimmer
|
||||||
|
|
||||||
|
struct ZXLoadingView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.zxPurple)
|
||||||
|
Text("加载中…")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.zxBg0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card Placeholder (skeleton shimmer)
|
||||||
|
|
||||||
|
struct ZXCardPlaceholder: View {
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.zxFill004)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color.zxBorder006, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shimmer List
|
||||||
|
|
||||||
|
struct ZXShimmerList: View {
|
||||||
|
let count: Int
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(0..<count, id: \.self) { _ in
|
||||||
|
ZXCardPlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
AIStudyApp/AIStudyApp/Shared/Components/ZXOutlineBtn.swift
Normal file
20
AIStudyApp/AIStudyApp/Shared/Components/ZXOutlineBtn.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Outline Button
|
||||||
|
|
||||||
|
struct ZXOutlineBtn: View {
|
||||||
|
let text: String
|
||||||
|
var body: some View {
|
||||||
|
Button {} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(text).font(.system(size: 13))
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 44)
|
||||||
|
.background(Color.zxFill005)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Profile Menu Row
|
||||||
|
|
||||||
|
struct ZXProfileMenuRow: View {
|
||||||
|
let emoji: String; let title: String; let desc: String
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(emoji).font(.system(size: 20)).frame(width: 36, height: 36)
|
||||||
|
.background(Color.zxFill006).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(desc).font(.system(size: 11)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
|
.accessibilityLabel("\(title):\(desc)")
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
AIStudyApp/AIStudyApp/Shared/Components/ZXProfileStat.swift
Normal file
14
AIStudyApp/AIStudyApp/Shared/Components/ZXProfileStat.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Profile Stat
|
||||||
|
|
||||||
|
struct ZXProfileStat: View {
|
||||||
|
let value: String; let label: String; let color: Color
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(value).font(.system(size: 18, weight: .bold)).foregroundColor(color)
|
||||||
|
Text(label).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
AIStudyApp/AIStudyApp/Shared/Components/ZXQuickAction.swift
Normal file
18
AIStudyApp/AIStudyApp/Shared/Components/ZXQuickAction.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Quick Action
|
||||||
|
|
||||||
|
struct ZXQuickAction: View {
|
||||||
|
let emoji: String; let label: String
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(emoji).font(.system(size: 20))
|
||||||
|
Text(label).font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxF006).multilineTextAlignment(.center).lineSpacing(4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 72)
|
||||||
|
.background(Color.zxFill003)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
47
AIStudyApp/AIStudyApp/Shared/Components/ZXSTaskRow.swift
Normal file
47
AIStudyApp/AIStudyApp/Shared/Components/ZXSTaskRow.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ZXSTaskRow: View {
|
||||||
|
let task: StudyTaskEntity; var action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { action() } }) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(task.isDone ? Color.zxGreen : Color.zxF02)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(task.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(task.isDone ? Color.zxF04 : Color.zxF0)
|
||||||
|
.strikethrough(task.isDone)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(task.taskType)
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundColor(task.color)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 1)
|
||||||
|
.background(task.color.opacity(0.12)).clipShape(Capsule())
|
||||||
|
Text("约 \(task.minutes) 分钟")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF035)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !task.isDone {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 14)).foregroundColor(.white)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
.background(task.isDone ? Color.zxFill003 : Color.zxFill005)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(
|
||||||
|
task.isDone ? Color.zxFill005 : Color.zxBorder008, lineWidth: 1
|
||||||
|
))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.opacity(task.isDone ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.accessibilityLabel("\(task.title),\(task.isDone ? "已完成" : "未完成")")
|
||||||
|
.accessibilityHint(task.isDone ? "双击取消完成" : "双击标记完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift
Normal file
11
AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Score Box
|
||||||
|
|
||||||
|
struct ZXScoreBox: View {
|
||||||
|
let score: Int; let bg: Color; let fg: Color
|
||||||
|
var body: some View {
|
||||||
|
Text("\(score)").font(.system(size: 12, weight: .heavy)).foregroundColor(fg)
|
||||||
|
.frame(width: 36, height: 36).background(bg).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ZXShimmerModifier: ViewModifier {
|
||||||
|
@State private var phase: CGFloat = -1
|
||||||
|
|
||||||
|
let color: Color
|
||||||
|
let highlightColor: Color
|
||||||
|
|
||||||
|
init(color: Color = Color.zxFill005, highlightColor: Color = Color.zxFill006) {
|
||||||
|
self.color = color
|
||||||
|
self.highlightColor = highlightColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay {
|
||||||
|
GeometryReader { geo in
|
||||||
|
LinearGradient(
|
||||||
|
colors: [color, highlightColor, color],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 2)
|
||||||
|
.offset(x: phase * geo.size.width)
|
||||||
|
.animation(
|
||||||
|
.linear(duration: 1.5).repeatForever(autoreverses: false),
|
||||||
|
value: phase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.onAppear { phase = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func shimmer(color: Color = Color.zxFill005, highlightColor: Color = Color.zxFill006) -> some View {
|
||||||
|
modifier(ZXShimmerModifier(color: color, highlightColor: highlightColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift
Normal file
18
AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Stat Badge
|
||||||
|
|
||||||
|
struct ZXStatBadge: View {
|
||||||
|
let icon: String; let label: String; let value: String; let trend: String; let color: Color
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 3) {
|
||||||
|
Image(systemName: icon).font(.system(size: 14)).foregroundColor(color)
|
||||||
|
Text(value).font(.system(size: 16, weight: .heavy)).foregroundColor(Color.zxF0)
|
||||||
|
Text(label).font(.system(size: 9)).foregroundColor(Color.zxF04).multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 72).padding(.vertical, 4)
|
||||||
|
.background(color.opacity(0.06))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.15), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
45
AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift
Normal file
45
AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Tab Bar
|
||||||
|
|
||||||
|
struct ZXTabBar: View {
|
||||||
|
@Binding var active: String
|
||||||
|
private let tabs = [
|
||||||
|
("ai","AI","brain.head.profile"),
|
||||||
|
("library","知识库","books.vertical.fill"),
|
||||||
|
("study","学习","bolt.fill"),
|
||||||
|
("analysis","分析","chart.bar.fill"),
|
||||||
|
("profile","我的","person.fill"),
|
||||||
|
]
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(tabs, id: \.0) { item in
|
||||||
|
let on = item.0 == active
|
||||||
|
Button { withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { active = item.0 } } label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
if on {
|
||||||
|
Circle().fill(Color.zxPurple.opacity(0.2))
|
||||||
|
.frame(width: 28, height: 28).scaleEffect(1.4)
|
||||||
|
}
|
||||||
|
Image(systemName: item.2)
|
||||||
|
.font(.system(size: 22, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundColor(on ? Color.zxPurple : Color.zxF035)
|
||||||
|
}
|
||||||
|
Text(item.1)
|
||||||
|
.font(.system(size: 10, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundColor(on ? Color.zxPurple : Color.zxF035)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("\(item.1)标签")
|
||||||
|
.accessibilityAddTraits(on ? .isSelected : [])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6).padding(.bottom, 34).frame(height: 83)
|
||||||
|
.background(.ultraThinMaterial).background(Color.zxBg0.opacity(0.95))
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Color.zxBorder008).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ZXTypingIndicator: View {
|
||||||
|
@State private var phase = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(0..<3) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(Color.zxPurple)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.scaleEffect(phase == i ? 1 : 0.5)
|
||||||
|
.animation(.easeInOut(duration: 0.35).repeatForever(autoreverses: true), value: phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.zxFill004)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.onAppear {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in
|
||||||
|
phase = (phase + 1) % 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift
Normal file
27
AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Weak Point Row
|
||||||
|
|
||||||
|
struct ZXWeakRow: View {
|
||||||
|
let score: Int; let topic: String; let lib: String; let priority: String
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("\(score)").font(.system(size: 13, weight: .heavy)).foregroundColor(Color.zxYellow)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color.zxYellowBG(0.15)).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(topic).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
|
Text(lib).font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("\(priority)优先").font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundColor(priority == "高" ? Color.zxRed : Color.zxYellow)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background((priority == "高" ? Color.zxRedBG(0.15) : Color.zxYellowBG(0.15)))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
.background(Color.zxYellowBG(0.06))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "#F59E0B", opacity: 0.15), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
188
AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings
Normal file
188
AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/* Localizable.strings (Base = 中文简体) */
|
||||||
|
/* 所有 SwiftUI Text 自动以中文文案作为 key 查找此表。 */
|
||||||
|
/* 中文作为 Base 语言,key 与 value 相同。 */
|
||||||
|
/* 添加新语言时,创建对应 .lproj/Localizable.strings 翻译文件即可。 */
|
||||||
|
|
||||||
|
/* 通用 */
|
||||||
|
"好的" = "好的";
|
||||||
|
"取消" = "取消";
|
||||||
|
"重试" = "重试";
|
||||||
|
"加载中…" = "加载中…";
|
||||||
|
"确认" = "确认";
|
||||||
|
"跳过" = "跳过";
|
||||||
|
"保存" = "保存";
|
||||||
|
"提交" = "提交";
|
||||||
|
"提交中…" = "提交中…";
|
||||||
|
"搜索" = "搜索";
|
||||||
|
|
||||||
|
/* Tab */
|
||||||
|
"AI" = "AI";
|
||||||
|
"知识库" = "知识库";
|
||||||
|
"学习" = "学习";
|
||||||
|
"分析" = "分析";
|
||||||
|
"我的" = "我的";
|
||||||
|
|
||||||
|
/* 登录 */
|
||||||
|
"知习" = "知习";
|
||||||
|
"更懂你,更会学。" = "更懂你,更会学。";
|
||||||
|
"使用 Apple 继续" = "使用 Apple 继续";
|
||||||
|
"登录即代表你同意《用户服务协议》和《隐私政策》" = "登录即代表你同意《用户服务协议》和《隐私政策》";
|
||||||
|
"跳过,进入演示模式" = "跳过,进入演示模式";
|
||||||
|
"已有账号?立即登录" = "已有账号?立即登录";
|
||||||
|
"用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。" = "用 AI 把知识库、主动回忆和间隔复习连接起来,\n从\"看过\"走向\"真正学会\"。";
|
||||||
|
"用 AI 重新定义\n你的学习方式" = "用 AI 重新定义\n你的学习方式";
|
||||||
|
|
||||||
|
/* 引导 */
|
||||||
|
"开始使用" = "开始使用";
|
||||||
|
"开始学习" = "开始学习";
|
||||||
|
"下一步" = "下一步";
|
||||||
|
"设定你的学习目标" = "设定你的学习目标";
|
||||||
|
"学习目标" = "学习目标";
|
||||||
|
"学习方法" = "学习方法";
|
||||||
|
"每日学习时间" = "每日学习时间";
|
||||||
|
"15 分钟" = "15 分钟";
|
||||||
|
"30 分钟" = "30 分钟";
|
||||||
|
"1 小时" = "1 小时";
|
||||||
|
"不限制" = "不限制";
|
||||||
|
"备考考试" = "备考考试";
|
||||||
|
"职业技能" = "职业技能";
|
||||||
|
"通识学习" = "通识学习";
|
||||||
|
"自定义" = "自定义";
|
||||||
|
"输入知识" = "输入知识";
|
||||||
|
"主动输出" = "主动输出";
|
||||||
|
"AI 分析" = "AI 分析";
|
||||||
|
"掌握知识" = "掌握知识";
|
||||||
|
|
||||||
|
/* 学习主页 */
|
||||||
|
"学习工作台" = "学习工作台";
|
||||||
|
"今日任务" = "今日任务";
|
||||||
|
"今日进度" = "今日进度";
|
||||||
|
"本周学习活跃" = "本周学习活跃";
|
||||||
|
"14 天连续" = "14 天连续";
|
||||||
|
"已学" = "已学";
|
||||||
|
"剩余" = "剩余";
|
||||||
|
"掌握" = "掌握";
|
||||||
|
"个任务" = "个任务";
|
||||||
|
"分钟" = "分钟";
|
||||||
|
"AI 自动排期" = "AI 自动排期";
|
||||||
|
"间隔复习" = "间隔复习";
|
||||||
|
"费曼技巧" = "费曼技巧";
|
||||||
|
"主动回忆" = "主动回忆";
|
||||||
|
"发现知识薄弱点" = "发现知识薄弱点";
|
||||||
|
"用自己的话讲出来" = "用自己的话讲出来";
|
||||||
|
"基于间隔重复的智能复习" = "基于间隔重复的智能复习";
|
||||||
|
|
||||||
|
/* AI 对话 */
|
||||||
|
"AI 对话" = "AI 对话";
|
||||||
|
"AI 学习助手" = "AI 学习助手";
|
||||||
|
"问 AI 任何学习问题…" = "问 AI 任何学习问题…";
|
||||||
|
"发送消息" = "发送消息";
|
||||||
|
"AI 对话输入" = "AI 对话输入";
|
||||||
|
|
||||||
|
/* AI 反馈 */
|
||||||
|
"AI 反馈" = "AI 反馈";
|
||||||
|
"今日思考" = "今日思考";
|
||||||
|
"你的回答" = "你的回答";
|
||||||
|
"答对的部分" = "答对的部分";
|
||||||
|
"需要完善" = "需要完善";
|
||||||
|
"✨ 参考答案要点" = "✨ 参考答案要点";
|
||||||
|
"加入待巩固,安排间隔复习" = "加入待巩固,安排间隔复习";
|
||||||
|
"深入提问" = "深入提问";
|
||||||
|
"再来一题" = "再来一题";
|
||||||
|
"开始回答" = "开始回答";
|
||||||
|
"提交回答,获取 AI 反馈" = "提交回答,获取 AI 反馈";
|
||||||
|
|
||||||
|
/* 复习 */
|
||||||
|
"复习计划" = "复习计划";
|
||||||
|
"今天" = "今天";
|
||||||
|
"明天" = "明天";
|
||||||
|
"本周" = "本周";
|
||||||
|
"暂无复习任务" = "暂无复习任务";
|
||||||
|
"完成学习后 AI 会自动生成复习计划" = "完成学习后 AI 会自动生成复习计划";
|
||||||
|
"间隔重复" = "间隔重复";
|
||||||
|
"费曼" = "费曼";
|
||||||
|
"回忆" = "回忆";
|
||||||
|
"薄弱" = "薄弱";
|
||||||
|
|
||||||
|
/* 知识库 */
|
||||||
|
"创建知识库" = "创建知识库";
|
||||||
|
"创建新知识库" = "创建新知识库";
|
||||||
|
"知识库名称" = "知识库名称";
|
||||||
|
"添加知识点" = "添加知识点";
|
||||||
|
"知识点详情" = "知识点详情";
|
||||||
|
"编辑知识点" = "编辑知识点";
|
||||||
|
"导入资料" = "导入资料";
|
||||||
|
"搜索知识库或知识点…" = "搜索知识库或知识点…";
|
||||||
|
|
||||||
|
/* 反馈 */
|
||||||
|
"反馈" = "反馈";
|
||||||
|
"反馈已提交" = "反馈已提交";
|
||||||
|
"感谢你的反馈,我们会尽快处理。" = "感谢你的反馈,我们会尽快处理。";
|
||||||
|
"请描述你遇到的问题或建议…" = "请描述你遇到的问题或建议…";
|
||||||
|
"反馈类型" = "反馈类型";
|
||||||
|
"提交反馈" = "提交反馈";
|
||||||
|
"问题报告 · 功能建议" = "问题报告 · 功能建议";
|
||||||
|
|
||||||
|
/* 设置 */
|
||||||
|
"语言" = "语言";
|
||||||
|
"外观" = "外观";
|
||||||
|
"跟随系统" = "跟随系统";
|
||||||
|
"深色模式" = "深色模式";
|
||||||
|
"浅色模式" = "浅色模式";
|
||||||
|
"学习目标设置" = "学习目标设置";
|
||||||
|
"调整你的学习目标" = "调整你的学习目标";
|
||||||
|
"复习提醒" = "复习提醒";
|
||||||
|
"间隔复习通知设置" = "间隔复习通知设置";
|
||||||
|
"学习报告" = "学习报告";
|
||||||
|
"周报 · 月报 · 成就" = "周报 · 月报 · 成就";
|
||||||
|
"学习方法偏好" = "学习方法偏好";
|
||||||
|
"回忆 · 费曼 · 间隔" = "回忆 · 费曼 · 间隔";
|
||||||
|
"数据同步与备份" = "数据同步与备份";
|
||||||
|
"云端同步设置" = "云端同步设置";
|
||||||
|
"开启复习提醒" = "开启复习提醒";
|
||||||
|
"提醒时间" = "提醒时间";
|
||||||
|
"间隔天数" = "间隔天数";
|
||||||
|
"iCloud 同步" = "iCloud 同步";
|
||||||
|
"自动备份" = "自动备份";
|
||||||
|
|
||||||
|
/* 成就 */
|
||||||
|
"成就" = "成就";
|
||||||
|
"连续天" = "连续天";
|
||||||
|
"知识点" = "知识点";
|
||||||
|
"积分" = "积分";
|
||||||
|
"学习者" = "学习者";
|
||||||
|
"连续 14 天" = "连续 14 天";
|
||||||
|
"费曼达人" = "费曼达人";
|
||||||
|
"知识收藏家" = "知识收藏家";
|
||||||
|
"速学者" = "速学者";
|
||||||
|
|
||||||
|
/* 学习分析 */
|
||||||
|
"学习分析" = "学习分析";
|
||||||
|
"综合掌握" = "综合掌握";
|
||||||
|
"需要复习" = "需要复习";
|
||||||
|
"薄弱知识点" = "薄弱知识点";
|
||||||
|
"掌握度趋势" = "掌握度趋势";
|
||||||
|
"本周积分" = "本周积分";
|
||||||
|
"最近 AI 互动" = "最近 AI 互动";
|
||||||
|
"昨天" = "昨天";
|
||||||
|
"近 7 天" = "近 7 天";
|
||||||
|
|
||||||
|
/* 错误 */
|
||||||
|
"网络请求失败" = "网络请求失败";
|
||||||
|
"登录状态已失效" = "登录状态已失效";
|
||||||
|
"数据解析失败" = "数据解析失败";
|
||||||
|
"无效的请求地址" = "无效的请求地址";
|
||||||
|
"服务器返回错误" = "服务器返回错误";
|
||||||
|
"Token 已过期" = "Token 已过期";
|
||||||
|
"无法获取 Apple 登录凭证" = "无法获取 Apple 登录凭证";
|
||||||
|
"未获取到身份验证信息" = "未获取到身份验证信息";
|
||||||
|
|
||||||
|
/* 内容分类 */
|
||||||
|
"Bug 反馈" = "Bug 反馈";
|
||||||
|
"功能建议" = "功能建议";
|
||||||
|
"内容问题" = "内容问题";
|
||||||
|
"其他" = "其他";
|
||||||
|
"公考、考研、考证等" = "公考、考研、考证等";
|
||||||
|
"编程、设计、产品等" = "编程、设计、产品等";
|
||||||
|
"扩充知识面" = "扩充知识面";
|
||||||
|
"设定自己的目标" = "设定自己的目标";
|
||||||
61
AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift
Normal file
61
AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
final class AIChatViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
var vm: AIChatViewModel!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
vm = AIChatViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
vm = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialState_hasOneMessage() {
|
||||||
|
XCTAssertEqual(vm.messages.count, 1)
|
||||||
|
XCTAssertEqual(vm.messages.first?.role, .ai)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialState_inputIsEmpty() {
|
||||||
|
XCTAssertTrue(vm.inputText.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanSend_falseWhenEmpty() {
|
||||||
|
vm.inputText = ""
|
||||||
|
XCTAssertFalse(vm.canSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanSend_falseWhenWhitespaceOnly() {
|
||||||
|
vm.inputText = " "
|
||||||
|
XCTAssertFalse(vm.canSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanSend_trueWhenHasContent() {
|
||||||
|
vm.inputText = "你好"
|
||||||
|
XCTAssertTrue(vm.canSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSend_appendsUserMessage() {
|
||||||
|
vm.inputText = "测试消息"
|
||||||
|
vm.send()
|
||||||
|
XCTAssertEqual(vm.messages.count, 2)
|
||||||
|
XCTAssertEqual(vm.messages.last?.role, .user)
|
||||||
|
XCTAssertEqual(vm.messages.last?.content, "测试消息")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSend_clearsInput() {
|
||||||
|
vm.inputText = "测试"
|
||||||
|
vm.send()
|
||||||
|
XCTAssertTrue(vm.inputText.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSend_setsIsSending() {
|
||||||
|
vm.inputText = "测试"
|
||||||
|
vm.send()
|
||||||
|
XCTAssertTrue(vm.isSending)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
AIStudyApp/AIStudyAppTests/FileCacheTests.swift
Normal file
44
AIStudyApp/AIStudyAppTests/FileCacheTests.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
final class FileCacheTests: XCTestCase {
|
||||||
|
|
||||||
|
var cache: FileCache!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
cache = FileCache(suite: "test_cache_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? cache.clear()
|
||||||
|
cache = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveAndLoad_roundTrip() throws {
|
||||||
|
let items = ["a", "b", "c"]
|
||||||
|
try cache.save(items, forKey: "test")
|
||||||
|
let loaded: [String]? = try cache.load([String].self, forKey: "test")
|
||||||
|
XCTAssertEqual(loaded, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoad_missingKeyReturnsNil() throws {
|
||||||
|
let result: [String]? = try cache.load([String].self, forKey: "never_saved")
|
||||||
|
XCTAssertNil(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemove_clearsKey() throws {
|
||||||
|
try cache.save([1, 2, 3], forKey: "numbers")
|
||||||
|
try cache.remove(forKey: "numbers")
|
||||||
|
let result: [Int]? = try cache.load([Int].self, forKey: "numbers")
|
||||||
|
XCTAssertNil(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSave_overwritesExistingKey() throws {
|
||||||
|
try cache.save([1], forKey: "key")
|
||||||
|
try cache.save([1, 2, 3], forKey: "key")
|
||||||
|
let loaded: [Int]? = try cache.load([Int].self, forKey: "key")
|
||||||
|
XCTAssertEqual(loaded, [1, 2, 3])
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user