From 7066200b7b1e2cbdd8e6cbe1e26dd51d4b0812af Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 10 May 2026 22:22:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MVVM=20=E6=9E=B6=E6=9E=84=E3=80=81?= =?UTF-8?q?=E5=85=A8=E5=A5=97=20UI=20=E9=A1=B5=E9=9D=A2=E3=80=81=E6=B5=85?= =?UTF-8?q?=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=E3=80=81=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E7=AD=89=E5=BE=85=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E3=80=81AI=20=E5=8A=A8=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 架构层: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 --- .../AIStudyApp.xcodeproj/project.pbxproj | 1 + AIStudyApp/AIStudyApp/AIStudyAppApp.swift | 212 +--- AIStudyApp/AIStudyApp/App/AppSession.swift | 119 ++ AIStudyApp/AIStudyApp/ContentView.swift | 106 +- .../Core/Appearance/ColorSchemeManager.swift | 35 + .../Core/DesignSystem/DesignTokens.swift | 70 +- .../Core/Extensions/Font+DynamicType.swift | 35 + .../Extensions/View+StaggeredAppear.swift | 45 + .../Core/Localization/LanguageManager.swift | 48 + .../Core/Localization/ZXStrings.swift | 117 ++ .../AIStudyApp/Core/Models/AIAnalysis.swift | 15 + .../AIStudyApp/Core/Models/AuthModels.swift | 25 + .../AIStudyApp/Core/Models/Feedback.swift | 18 + .../Core/Models/KnowledgeBase.swift | 11 + .../AIStudyApp/Core/Models/LearningPath.swift | 10 + .../Core/Models/LearningSession.swift | 15 + .../AIStudyApp/Core/Models/Lesson.swift | 14 + .../AIStudyApp/Core/Models/ReviewTask.swift | 26 + AIStudyApp/AIStudyApp/Core/Models/User.swift | 19 + .../Core/Models/UserLearningProfile.swift | 16 + .../Core/Models/WaitlistEntry.swift | 13 + .../AIStudyApp/Core/Network/APIClient.swift | 75 ++ .../AIStudyApp/Core/Network/APIEndpoint.swift | 123 ++ .../AIStudyApp/Core/Network/APIError.swift | 39 + .../Core/Repository/FileCache.swift | 52 + .../Core/Repository/KnowledgeRepository.swift | 70 ++ .../Core/Repository/Repository.swift | 62 + .../Core/Repository/ReviewRepository.swift | 47 + .../AIStudyApp/Core/Services/AIService.swift | 54 + .../Core/Services/AuthService.swift | 118 ++ .../Core/Services/AuthServiceProtocol.swift | 8 + .../Core/Services/FeedbackService.swift | 28 + .../Core/Services/KnowledgeService.swift | 48 + .../Core/Services/LearningService.swift | 63 + .../Core/Services/ReviewService.swift | 52 + .../Entities/LearningRecordEntity.swift | 37 + .../Storage/Entities/ReviewTaskEntity.swift | 65 + .../Storage/Entities/StudyTaskEntity.swift | 47 + .../Core/Storage/KeychainStore.swift | 79 ++ .../Core/Storage/PersistenceController.swift | 60 + .../AIStudyApp/Core/Storage/TokenStore.swift | 51 + .../AIStudyApp/Features/AI/AIChatPage.swift | 36 + .../Features/AI/AIFeedbackPage.swift | 58 + .../AIStudyApp/Features/AI/AIHomeView.swift | 17 +- .../Features/AI/DailyThinkingPage.swift | 148 +-- .../Features/AI/RecallTestPage.swift | 19 + .../AI/ViewModels/AIChatViewModel.swift | 36 + .../Features/AI/WeakPointsPage.swift | 20 + .../Features/Analysis/AnalysisHomeView.swift | 78 +- .../Auth/ViewModels/LoginViewModel.swift | 28 + .../Auth/ViewModels/WaitlistViewModel.swift | 33 + .../Features/Auth/Views/LoginView.swift | 136 +++ .../Features/Auth/Views/WaitlistView.swift | 134 +++ .../Features/Feedback/FeedbackView.swift | 138 +++ .../Features/Feedback/FeedbackViewModel.swift | 48 + .../Features/Library/AddKnowledgePage.swift | 19 + .../Features/Library/CreateLibraryPage.swift | 19 + .../Features/Library/EditKnowledgePage.swift | 19 + .../Features/Library/ImportPage.swift | 19 + .../Library/KnowledgeDetailPage.swift | 17 + .../Features/Library/LibraryDetailPage.swift | 21 + .../Features/Library/LibrarySubpages.swift | 111 -- .../Features/Onboarding/GoalSetupPage.swift | 85 ++ .../Features/Onboarding/OnboardingPage.swift | 37 + .../Features/Onboarding/SplashPage.swift | 34 + .../Features/Onboarding/WelcomePage.swift | 42 + .../Features/Profile/ProfileView.swift | 78 +- .../Features/Profile/SettingsPages.swift | 275 +++++ .../Features/Review/ReviewPlanView.swift | 68 ++ .../ViewModels/ReviewPlanViewModel.swift | 36 + .../Features/Study/StudyHomeView.swift | 221 +++- .../Study/ViewModels/StudyHomeViewModel.swift | 54 + .../Shared/Components/FeatureRow.swift | 21 + .../Shared/Components/ReviewTaskRow.swift | 78 ++ .../Shared/Components/ZXAIInputBar.swift | 28 + .../Components/ZXAIInteractionRow.swift | 34 + .../Components/ZXAchievementBadge.swift | 16 + .../Shared/Components/ZXBackHeader.swift | 32 + .../Shared/Components/ZXCardRow.swift | 24 + .../Shared/Components/ZXChartView.swift | 36 + .../AIStudyApp/Shared/Components/ZXChip.swift | 12 + .../Shared/Components/ZXEmptyView.swift | 60 + .../Shared/Components/ZXErrorView.swift | 74 ++ .../Shared/Components/ZXIconBtn.swift | 17 + .../Shared/Components/ZXImportOption.swift | 26 + .../Shared/Components/ZXLoadingView.swift | 44 + .../Shared/Components/ZXOutlineBtn.swift | 20 + .../Shared/Components/ZXProfileMenuRow.swift | 22 + .../Shared/Components/ZXProfileStat.swift | 14 + .../Shared/Components/ZXQuickAction.swift | 18 + .../Shared/Components/ZXSTaskRow.swift | 47 + .../Shared/Components/ZXScoreBox.swift | 11 + .../Shared/Components/ZXShimmerModifier.swift | 40 + .../Shared/Components/ZXStatBadge.swift | 18 + .../Shared/Components/ZXTabBar.swift | 45 + .../Shared/Components/ZXTypingIndicator.swift | 26 + .../Shared/Components/ZXWeakRow.swift | 27 + .../zh-Hans.lproj/Localizable.strings | 188 +++ .../AIChatViewModelTests.swift | 61 + .../AIStudyAppTests/FileCacheTests.swift | 44 + .../ReviewPlanViewModelTests.swift | 59 + .../StudyHomeViewModelTests.swift | 60 + AIStudyApp/docs/AI对话.md | 668 +++++++++++ AIStudyApp/docs/architecture.md | 139 +++ AIStudyApp/docs/pages.md | 235 ++++ AIStudyApp/docs/plan-ios-requirements.md | 339 ++++++ AIStudyApp/docs/样式规范.md | 550 +++++++++ AIStudyApp/docs/缺失项与待补全方向.md | 1042 +++++++++++++++++ 108 files changed, 7716 insertions(+), 691 deletions(-) create mode 100644 AIStudyApp/AIStudyApp/App/AppSession.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Appearance/ColorSchemeManager.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Extensions/View+StaggeredAppear.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Localization/LanguageManager.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/Feedback.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/Lesson.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/User.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Network/APIClient.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Network/APIError.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Repository/KnowledgeRepository.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Repository/Repository.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/AIService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/AuthService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/AuthServiceProtocol.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/LearningService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/Entities/LearningRecordEntity.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/Entities/ReviewTaskEntity.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/Entities/StudyTaskEntity.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/PersistenceController.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift create mode 100644 AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/AI/ViewModels/AIChatViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Auth/ViewModels/LoginViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Auth/ViewModels/WaitlistViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Feedback/FeedbackViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/AddKnowledgePage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/CreateLibraryPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/EditKnowledgePage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/KnowledgeDetailPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Library/LibraryDetailPage.swift delete mode 100644 AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Onboarding/GoalSetupPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Onboarding/OnboardingPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Review/ViewModels/ReviewPlanViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Study/ViewModels/StudyHomeViewModel.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXAIInteractionRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXAchievementBadge.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXOutlineBtn.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXProfileMenuRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXProfileStat.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXQuickAction.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXSTaskRow.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXShimmerModifier.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXTypingIndicator.swift create mode 100644 AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift create mode 100644 AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings create mode 100644 AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift create mode 100644 AIStudyApp/AIStudyAppTests/FileCacheTests.swift create mode 100644 AIStudyApp/AIStudyAppTests/ReviewPlanViewModelTests.swift create mode 100644 AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift create mode 100644 AIStudyApp/docs/AI对话.md create mode 100644 AIStudyApp/docs/architecture.md create mode 100644 AIStudyApp/docs/pages.md create mode 100644 AIStudyApp/docs/plan-ios-requirements.md create mode 100644 AIStudyApp/docs/样式规范.md create mode 100644 AIStudyApp/docs/缺失项与待补全方向.md diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj index ae0590a..215aeb2 100644 --- a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj +++ b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 05F6CD122FA886330043A7BC; minimizedProjectReferenceProxies = 1; diff --git a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift index 12379eb..cff4eb9 100644 --- a/AIStudyApp/AIStudyApp/AIStudyAppApp.swift +++ b/AIStudyApp/AIStudyApp/AIStudyAppApp.swift @@ -1,185 +1,75 @@ -// -// AIStudyAppApp.swift - 根路由:引导流程 vs 主界面 -// - import SwiftUI @main 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 { WindowGroup { - if hasCompletedOnboarding { - ContentView() - .preferredColorScheme(.dark) + AppRootView(session: session) + .preferredColorScheme(colorSchemeManager.current.colorScheme) + .dynamicTypeClamped() + .task { + await session.bootstrap() + } + } + } +} + +// MARK: - Root Router + +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 { - OnboardingFlowView(hasCompletedOnboarding: $hasCompletedOnboarding) - .preferredColorScheme(.dark) + ContentView() } } } } -// MARK: - Onboarding Flow (Splash → Welcome → Login → Onboarding → GoalSetup) -// 对应 React: SplashPage, WelcomePage, LoginPage, OnboardingPage, GoalSetupPage +// MARK: - Onboarding Flow (Welcome → Onboarding → GoalSetup) struct OnboardingFlowView: View { - @Binding var hasCompletedOnboarding: Bool + @ObservedObject var session: AppSession @State private var step = 0 var body: some View { ZStack { switch step { - case 0: SplashPage { withAnimation(.easeInOut(duration: 0.5)) { step = 1 } } - case 1: WelcomePage { withAnimation { step = 2 } } onSkip: { hasCompletedOnboarding = true } - case 2: LoginPage { step = 3 } onSkip: { hasCompletedOnboarding = true } - case 3: OnboardingPage { step = 4 } - case 4: GoalSetupPage { $0 ? (hasCompletedOnboarding = true) : (step = 0) } - default: EmptyView() - } - } - .preferredColorScheme(.dark) - } -} - -// 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: "发现知识薄弱点") } + case 0: + WelcomePage { + withAnimation(.easeInOut(duration: 0.5)) { step = 1 } + } onSkip: { + session.logout() } - 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) + case 1: + OnboardingPage { step = 2 } + case 2: + GoalSetupPage { _ in + session.completeOnboarding() + } + default: + EmptyView() } } } diff --git a/AIStudyApp/AIStudyApp/App/AppSession.swift b/AIStudyApp/AIStudyApp/App/AppSession.swift new file mode 100644 index 0000000..8b99990 --- /dev/null +++ b/AIStudyApp/AIStudyApp/App/AppSession.swift @@ -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 + } +} diff --git a/AIStudyApp/AIStudyApp/ContentView.swift b/AIStudyApp/AIStudyApp/ContentView.swift index 9f976f6..9dc0322 100644 --- a/AIStudyApp/AIStudyApp/ContentView.swift +++ b/AIStudyApp/AIStudyApp/ContentView.swift @@ -5,107 +5,21 @@ struct ContentView: View { var body: some View { ZStack { - switch selectedTab { - case "ai": NavigationStack { AIHomeView() } - case "library": NavigationStack { LibraryHomeView() } - case "study": NavigationStack { StudyHomeView() } - case "analysis": NavigationStack { AnalysisHomeView() } - case "profile": NavigationStack { ProfileView() } - default: NavigationStack { AIHomeView() } + Group { + if selectedTab == "ai" { NavigationStack { AIHomeView() } } + else if selectedTab == "library" { NavigationStack { LibraryHomeView() } } + else if selectedTab == "study" { NavigationStack { StudyHomeView() } } + else if selectedTab == "analysis" { NavigationStack { AnalysisHomeView() } } + else if selectedTab == "profile" { NavigationStack { ProfileView() } } } + .transition(.opacity.combined(with: .scale(scale: 0.98))) + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: selectedTab) + VStack { Spacer(); ZXTabBar(active: $selectedTab) } .ignoresSafeArea(edges: .bottom) } .ignoresSafeArea(edges: .bottom) - .preferredColorScheme(.dark) } } -// 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) - 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) } } - } -} +// Shared components: ZXTabBar, ZXAIInputBar, ZXScoreBox, ZXIconBtn → Shared/Components/ diff --git a/AIStudyApp/AIStudyApp/Core/Appearance/ColorSchemeManager.swift b/AIStudyApp/AIStudyApp/Core/Appearance/ColorSchemeManager.swift new file mode 100644 index 0000000..07ee28c --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Appearance/ColorSchemeManager.swift @@ -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() {} +} diff --git a/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift b/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift index 1a3cea1..1232742 100644 --- a/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift +++ b/AIStudyApp/AIStudyApp/Core/DesignSystem/DesignTokens.swift @@ -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) extension Color { // ── 背景 ── - static let zxBg0 = Color(hex: "#0F0F1A") - static let zxBg1 = Color(hex: "#12122A") - static let zxBg2 = Color(hex: "#0A0A14") // phone shell - static let zxBgSplash = Color(hex: "#0D0D20") + static let zxBg0 = Color(light: Color(hex: "#F5F5FA"), dark: Color(hex: "#0F0F1A")) + static let zxBg1 = Color(light: Color(hex: "#EAEAF2"), dark: Color(hex: "#12122A")) + static let zxBg2 = Color(light: Color(hex: "#E0E0E8"), dark: Color(hex: "#0A0A14")) + static let zxBgSplash = Color(light: Color(hex: "#F0F0F8"), dark: Color(hex: "#0D0D20")) // ── 文字 ── - static let zxF0 = Color(hex: "#F0F0FF") - static let zxF05 = Color(hex: "#F0F0FF", opacity: 0.5) - static let zxF04 = Color(hex: "#F0F0FF", opacity: 0.4) - static let zxF03 = Color(hex: "#F0F0FF", opacity: 0.3) - static let zxF007 = Color(hex: "#F0F0FF", opacity: 0.7) - static let zxF006 = Color(hex: "#F0F0FF", opacity: 0.6) - static let zxF0045 = Color(hex: "#F0F0FF", opacity: 0.45) + static let zxF0 = Color(light: Color(hex: "#1A1A2E"), dark: Color(hex: "#F0F0FF")) + static let zxF05 = Color(light: Color(hex: "#1A1A2E", opacity: 0.5), dark: Color(hex: "#F0F0FF", opacity: 0.5)) + static let zxF04 = Color(light: Color(hex: "#1A1A2E", opacity: 0.4), dark: Color(hex: "#F0F0FF", opacity: 0.4)) + static let zxF03 = Color(light: Color(hex: "#1A1A2E", opacity: 0.3), dark: Color(hex: "#F0F0FF", opacity: 0.3)) + static let zxF007 = Color(light: Color(hex: "#1A1A2E", opacity: 0.7), dark: Color(hex: "#F0F0FF", opacity: 0.7)) + static let zxF006 = Color(light: Color(hex: "#1A1A2E", opacity: 0.6), dark: Color(hex: "#F0F0FF", opacity: 0.6)) + 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") @@ -55,22 +71,20 @@ extension Color { static let zxRed = Color(hex: "#EF4444") 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 zxBorder006 = Color(hex: "#FFFFFF", opacity: 0.06) - static let zxBorder004 = Color(hex: "#FFFFFF", opacity: 0.04) - static let zxBorder01 = Color(hex: "#FFFFFF", opacity: 0.10) - static let zxBorder015 = Color(hex: "#FFFFFF", opacity: 0.15) + static let zxBorder008 = Color(light: Color(hex: "#000000", opacity: 0.08), dark: Color(hex: "#FFFFFF", opacity: 0.08)) + static let zxBorder006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06)) + static let zxBorder004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04)) + static let zxBorder01 = Color(light: Color(hex: "#000000", opacity: 0.10), dark: Color(hex: "#FFFFFF", opacity: 0.10)) + 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 zxFill004 = Color(hex: "#FFFFFF", opacity: 0.04) - static let zxFill005 = Color(hex: "#FFFFFF", opacity: 0.05) - static let zxFill006 = Color(hex: "#FFFFFF", opacity: 0.06) - static let zxFill008 = Color(hex: "#FFFFFF", opacity: 0.08) + static let zxFill003 = Color(light: Color(hex: "#000000", opacity: 0.03), dark: Color(hex: "#FFFFFF", opacity: 0.03)) + static let zxFill004 = Color(light: Color(hex: "#000000", opacity: 0.04), dark: Color(hex: "#FFFFFF", opacity: 0.04)) + static let zxFill005 = Color(light: Color(hex: "#000000", opacity: 0.05), dark: Color(hex: "#FFFFFF", opacity: 0.05)) + static let zxFill006 = Color(light: Color(hex: "#000000", opacity: 0.06), dark: Color(hex: "#FFFFFF", opacity: 0.06)) + 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) } @@ -86,7 +100,7 @@ extension Color { enum ZXGradient { // ── 页面背景 ── static let page = LinearGradient( - colors: [Color(hex: "#0F0F1A"), Color(hex: "#12122A")], + colors: [Color.zxBg0, Color.zxBg1], startPoint: .top, endPoint: .bottom ) @@ -140,9 +154,9 @@ enum ZXGradient { // ── Splash ── static let splash = LinearGradient( colors: [ - Color(hex: "#0D0D20"), - Color(hex: "#0F0F1A"), - Color(hex: "#130D20") + Color.zxBgSplash, + Color.zxBg0, + Color(light: Color(hex: "#F0F0F5"), dark: Color(hex: "#130D20")) ], startPoint: .top, endPoint: .bottom ) diff --git a/AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift b/AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift new file mode 100644 index 0000000..745714e --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Extensions/Font+DynamicType.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Extensions/View+StaggeredAppear.swift b/AIStudyApp/AIStudyApp/Core/Extensions/View+StaggeredAppear.swift new file mode 100644 index 0000000..dadad7d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Extensions/View+StaggeredAppear.swift @@ -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() } +} diff --git a/AIStudyApp/AIStudyApp/Core/Localization/LanguageManager.swift b/AIStudyApp/AIStudyApp/Core/Localization/LanguageManager.swift new file mode 100644 index 0000000..ef962e2 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Localization/LanguageManager.swift @@ -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() + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift b/AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift new file mode 100644 index 0000000..a51fb50 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Localization/ZXStrings.swift @@ -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: "其他") +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift b/AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift new file mode 100644 index 0000000..ad12bfe --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/AIAnalysis.swift @@ -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? +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift b/AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift new file mode 100644 index 0000000..7742806 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/AuthModels.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/Feedback.swift b/AIStudyApp/AIStudyApp/Core/Models/Feedback.swift new file mode 100644 index 0000000..24d0587 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/Feedback.swift @@ -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 } +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift b/AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift new file mode 100644 index 0000000..b515d1a --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift b/AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift new file mode 100644 index 0000000..051f000 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/LearningPath.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift b/AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift new file mode 100644 index 0000000..d733a68 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/LearningSession.swift @@ -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? +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/Lesson.swift b/AIStudyApp/AIStudyApp/Core/Models/Lesson.swift new file mode 100644 index 0000000..17a4cfd --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/Lesson.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift b/AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift new file mode 100644 index 0000000..265bb0a --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/ReviewTask.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/User.swift b/AIStudyApp/AIStudyApp/Core/Models/User.swift new file mode 100644 index 0000000..09a47ee --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/User.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift b/AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift new file mode 100644 index 0000000..306cbdb --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/UserLearningProfile.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift b/AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift new file mode 100644 index 0000000..a334047 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/WaitlistEntry.swift @@ -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 + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift new file mode 100644 index 0000000..9321cc2 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Network/APIClient.swift @@ -0,0 +1,75 @@ +import Foundation + +protocol APIClientProtocol { + func request(_ 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(_ 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(_ data: Data, response: URLResponse) throws -> T { + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decoding(error) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift b/AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift new file mode 100644 index 0000000..a1dd994 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Network/APIEndpoint.swift @@ -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" +} diff --git a/AIStudyApp/AIStudyApp/Core/Network/APIError.swift b/AIStudyApp/AIStudyApp/Core/Network/APIError.swift new file mode 100644 index 0000000..0843658 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Network/APIError.swift @@ -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 + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift b/AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift new file mode 100644 index 0000000..6a80c2d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Repository/FileCache.swift @@ -0,0 +1,52 @@ +import Foundation + +// MARK: - File Cache Protocol + +protocol FileCacheProtocol { + func load(_ type: T.Type, forKey key: String) throws -> T? + func save(_ 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(_ 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(_ 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") + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Repository/KnowledgeRepository.swift b/AIStudyApp/AIStudyApp/Core/Repository/KnowledgeRepository.swift new file mode 100644 index 0000000..86c572a --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Repository/KnowledgeRepository.swift @@ -0,0 +1,70 @@ +import Foundation + +// MARK: - Knowledge Repository + +final class KnowledgeRepository { + let bases: BaseRepository + let paths: PathRepository + let lessons: LessonRepository + + init(knowledgeService: KnowledgeServiceProtocol) { + self.bases = BaseRepository( + 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: Codable { + let value: T + let timestamp: Date +} diff --git a/AIStudyApp/AIStudyApp/Core/Repository/Repository.swift b/AIStudyApp/AIStudyApp/Core/Repository/Repository.swift new file mode 100644 index 0000000..a13488b --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Repository/Repository.swift @@ -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: 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: Codable { + let value: T + let timestamp: Date +} diff --git a/AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift b/AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift new file mode 100644 index 0000000..bd8252e --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Repository/ReviewRepository.swift @@ -0,0 +1,47 @@ +import Foundation + +// MARK: - Review Repository + +final class ReviewRepository { + let today: BaseRepository + let tomorrow: BaseRepository + let week: BaseRepository + let all: BaseRepository + + init(reviewService: ReviewServiceProtocol) { + self.today = BaseRepository( + cacheKey: "reviews_today", + ttl: 120 + ) { + try await reviewService.fetchTodayReviews() + } + + self.tomorrow = BaseRepository( + cacheKey: "reviews_tomorrow", + ttl: 300 + ) { + try await reviewService.fetchTomorrowReviews() + } + + self.week = BaseRepository( + cacheKey: "reviews_week", + ttl: 600 + ) { + try await reviewService.fetchWeekReviews() + } + + self.all = BaseRepository( + cacheKey: "reviews_all", + ttl: 300 + ) { + try await reviewService.fetchReviews() + } + } + + func clearAll() throws { + try today.clearCache() + try tomorrow.clearCache() + try week.clearCache() + try all.clearCache() + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/AIService.swift b/AIStudyApp/AIStudyApp/Core/Services/AIService.swift new file mode 100644 index 0000000..f3833c0 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/AIService.swift @@ -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 + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/AuthService.swift b/AIStudyApp/AIStudyApp/Core/Services/AuthService.swift new file mode 100644 index 0000000..206ecde --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/AuthService.swift @@ -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? + + 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 + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/AuthServiceProtocol.swift b/AIStudyApp/AIStudyApp/Core/Services/AuthServiceProtocol.swift new file mode 100644 index 0000000..897ae51 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/AuthServiceProtocol.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift b/AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift new file mode 100644 index 0000000..58845ab --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/FeedbackService.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift b/AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift new file mode 100644 index 0000000..5b91323 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/KnowledgeService.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/LearningService.swift b/AIStudyApp/AIStudyApp/Core/Services/LearningService.swift new file mode 100644 index 0000000..ae67efa --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/LearningService.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift b/AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift new file mode 100644 index 0000000..60ecaf9 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/ReviewService.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/Entities/LearningRecordEntity.swift b/AIStudyApp/AIStudyApp/Core/Storage/Entities/LearningRecordEntity.swift new file mode 100644 index 0000000..56b5d50 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/Entities/LearningRecordEntity.swift @@ -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 + ) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/Entities/ReviewTaskEntity.swift b/AIStudyApp/AIStudyApp/Core/Storage/Entities/ReviewTaskEntity.swift new file mode 100644 index 0000000..ccf9ddd --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/Entities/ReviewTaskEntity.swift @@ -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 +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/Entities/StudyTaskEntity.swift b/AIStudyApp/AIStudyApp/Core/Storage/Entities/StudyTaskEntity.swift new file mode 100644 index 0000000..0707303 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/Entities/StudyTaskEntity.swift @@ -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) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift b/AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift new file mode 100644 index 0000000..de0baad --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/KeychainStore.swift @@ -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) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/PersistenceController.swift b/AIStudyApp/AIStudyApp/Core/Storage/PersistenceController.swift new file mode 100644 index 0000000..9a3ba1e --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/PersistenceController.swift @@ -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") + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift b/AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift new file mode 100644 index 0000000..097ae5c --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Storage/TokenStore.swift @@ -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]) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift new file mode 100644 index 0000000..5b8dbf8 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift new file mode 100644 index 0000000..ab4cf53 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift index cb0570d..0e90391 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift @@ -127,19 +127,4 @@ struct AIHomeView: View { } } -// ── Shared UI pieces ── - -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))}} -} +// ZXQuickAction, ZXAIInteractionRow → Shared/Components/ diff --git a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift index 0dc5d26..50a0064 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/DailyThinkingPage.swift @@ -48,149 +48,5 @@ struct DailyThinkingPage: View { } } -// Back Header -struct ZXBackHeader: View { - 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) - } -} +// Extracted pages: RecallTestPage, WeakPointsPage, AIFeedbackPageView, AIChatPage +// Shared components: ZXBackHeader, ZXOutlineBtn → Shared/Components/ diff --git a/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift b/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift new file mode 100644 index 0000000..57dfef9 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/RecallTestPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/ViewModels/AIChatViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/ViewModels/AIChatViewModel.swift new file mode 100644 index 0000000..7996b63 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/ViewModels/AIChatViewModel.swift @@ -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 + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift b/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift new file mode 100644 index 0000000..9df47cb --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/AI/WeakPointsPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift index bbaee8f..b267461 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift @@ -69,80 +69,4 @@ struct AnalysisHomeView: View { } } -// 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)) - } -} - -// 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) - } - } - } - } -} +// ZXStatBadge, ZXWeakRow, ZXChartView → Shared/Components/ diff --git a/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/LoginViewModel.swift b/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/LoginViewModel.swift new file mode 100644 index 0000000..023a986 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/LoginViewModel.swift @@ -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 + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/WaitlistViewModel.swift b/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/WaitlistViewModel.swift new file mode 100644 index 0000000..c5fad25 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Auth/ViewModels/WaitlistViewModel.swift @@ -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 + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift b/AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift new file mode 100644 index 0000000..e150b86 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Auth/Views/LoginView.swift @@ -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) + } + } + } +} + diff --git a/AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift b/AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift new file mode 100644 index 0000000..efd4dbc --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Auth/Views/WaitlistView.swift @@ -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) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift b/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift new file mode 100644 index 0000000..31b1726 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackView.swift @@ -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" + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackViewModel.swift b/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackViewModel.swift new file mode 100644 index 0000000..751d52d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Feedback/FeedbackViewModel.swift @@ -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 + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/AddKnowledgePage.swift b/AIStudyApp/AIStudyApp/Features/Library/AddKnowledgePage.swift new file mode 100644 index 0000000..0ca7ab2 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/AddKnowledgePage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/CreateLibraryPage.swift b/AIStudyApp/AIStudyApp/Features/Library/CreateLibraryPage.swift new file mode 100644 index 0000000..bcec91f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/CreateLibraryPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/EditKnowledgePage.swift b/AIStudyApp/AIStudyApp/Features/Library/EditKnowledgePage.swift new file mode 100644 index 0000000..84defe7 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/EditKnowledgePage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift b/AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift new file mode 100644 index 0000000..e2b7fe3 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/ImportPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/KnowledgeDetailPage.swift b/AIStudyApp/AIStudyApp/Features/Library/KnowledgeDetailPage.swift new file mode 100644 index 0000000..008f667 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/KnowledgeDetailPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibraryDetailPage.swift b/AIStudyApp/AIStudyApp/Features/Library/LibraryDetailPage.swift new file mode 100644 index 0000000..89b5ea0 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Library/LibraryDetailPage.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift deleted file mode 100644 index 70a7bb3..0000000 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ /dev/null @@ -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) - } -} diff --git a/AIStudyApp/AIStudyApp/Features/Onboarding/GoalSetupPage.swift b/AIStudyApp/AIStudyApp/Features/Onboarding/GoalSetupPage.swift new file mode 100644 index 0000000..b251cb2 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Onboarding/GoalSetupPage.swift @@ -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) + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Onboarding/OnboardingPage.swift b/AIStudyApp/AIStudyApp/Features/Onboarding/OnboardingPage.swift new file mode 100644 index 0000000..74e1c93 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Onboarding/OnboardingPage.swift @@ -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) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift b/AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift new file mode 100644 index 0000000..18a66e7 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Onboarding/SplashPage.swift @@ -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) + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift b/AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift new file mode 100644 index 0000000..1bdf27a --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Onboarding/WelcomePage.swift @@ -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() + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift index 951cf58..18d851b 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift @@ -5,6 +5,11 @@ import SwiftUI 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 { ZStack { ZXGradient.page.ignoresSafeArea() @@ -19,14 +24,67 @@ struct ProfileView: View { .padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4) profileCard VStack(spacing: 0) { - ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标") - ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置") - ZXProfileMenuRow(emoji: "📊", title: "学习报告", desc: "周报 · 月报 · 成就") - ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔") - ZXProfileMenuRow(emoji: "☁️", title: "数据同步与备份", desc: "云端同步设置") + NavigationLink(destination: LearningGoalSettingsView()) { + ZXProfileMenuRow(emoji: "🎯", title: "学习目标设置", desc: "调整你的学习目标") + } + .foregroundColor(.primary) + NavigationLink(destination: ReviewReminderSettingsView()) { + ZXProfileMenuRow(emoji: "🔔", title: "复习提醒", desc: "间隔复习通知设置") + } + .foregroundColor(.primary) + NavigationLink(destination: LearningReportView()) { + ZXProfileMenuRow(emoji: "📊", title: "学习报告", desc: "周报 · 月报 · 成就") + } + .foregroundColor(.primary) + NavigationLink(destination: LearningMethodPreferencesView()) { + ZXProfileMenuRow(emoji: "🧩", title: "学习方法偏好", desc: "回忆 · 费曼 · 间隔") + } + .foregroundColor(.primary) + NavigationLink(destination: DataSyncSettingsView()) { + ZXProfileMenuRow(emoji: "☁️", title: "数据同步与备份", desc: "云端同步设置") + } + .foregroundColor(.primary) + NavigationLink(destination: FeedbackView()) { + ZXProfileMenuRow(emoji: "💬", title: "反馈", desc: "问题报告 · 功能建议") + } + .foregroundColor(.primary) } .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)) .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) } .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) } - 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) } -} +// ZXProfileStat, ZXProfileMenuRow, ZXAchievementBadge → Shared/Components/ diff --git a/AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift b/AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift new file mode 100644 index 0000000..804883b --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Profile/SettingsPages.swift @@ -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 = ["间隔回忆", "费曼技巧"] + + 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) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift b/AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift new file mode 100644 index 0000000..c703e3d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Review/ReviewPlanView.swift @@ -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) + } + } + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Review/ViewModels/ReviewPlanViewModel.swift b/AIStudyApp/AIStudyApp/Features/Review/ViewModels/ReviewPlanViewModel.swift new file mode 100644 index 0000000..d70733a --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Review/ViewModels/ReviewPlanViewModel.swift @@ -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 } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift index 7d1dbdb..5db02a4 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift @@ -1,50 +1,187 @@ -// -// StudyHomeView.swift - Page 8 -// - import SwiftUI struct StudyHomeView: View { - @State private var ts: [ZXSTask] = [ - .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 = ["一","二","三","四","五","六","日"] + @StateObject private var vm = StudyHomeViewModel() var body: some View { - ZStack { ZXGradient.page.ignoresSafeArea() - ScrollView { VStack(spacing: 16) { - 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) - pc - 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($ts) { $t in ZXSTaskRow(task: t) { t.d.toggle() } } } - 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) } } - 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(.horizontal, 20) } - .scrollIndicators(.hidden) } + ZStack { + ZXGradient.page.ignoresSafeArea() + ScrollView { + VStack(spacing: 16) { + headerRow + progressCard + taskSection + weeklyActivitySection + } + .padding(.horizontal, 20) + .padding(.bottom, 120) + } + .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 } -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) } -} + // MARK: - Header -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) + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Study/ViewModels/StudyHomeViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/ViewModels/StudyHomeViewModel.swift new file mode 100644 index 0000000..eca2ab8 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Study/ViewModels/StudyHomeViewModel.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift b/AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift new file mode 100644 index 0000000..32ea5a4 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/FeatureRow.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift b/AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift new file mode 100644 index 0000000..e2c9482 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ReviewTaskRow.swift @@ -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()) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift new file mode 100644 index 0000000..ebe97a3 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInputBar.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInteractionRow.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInteractionRow.swift new file mode 100644 index 0000000..ff67cda --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXAIInteractionRow.swift @@ -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)) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXAchievementBadge.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXAchievementBadge.swift new file mode 100644 index 0000000..6b5e781 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXAchievementBadge.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift new file mode 100644 index 0000000..404ee63 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXBackHeader.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - Back Header + +struct ZXBackHeader: 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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift new file mode 100644 index 0000000..660821f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXCardRow.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift new file mode 100644 index 0000000..c861b50 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXChartView.swift @@ -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) + } + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift new file mode 100644 index 0000000..a14be5b --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXChip.swift @@ -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()) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift new file mode 100644 index 0000000..c53b864 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXEmptyView.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift new file mode 100644 index 0000000..e90ee14 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXErrorView.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift new file mode 100644 index 0000000..bbd03b0 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXIconBtn.swift @@ -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: " ")) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift new file mode 100644 index 0000000..d9a9c1f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXImportOption.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift new file mode 100644 index 0000000..4181d6f --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXLoadingView.swift @@ -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.. 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 ? "双击取消完成" : "双击标记完成") + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift new file mode 100644 index 0000000..bfa3f94 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXScoreBox.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXShimmerModifier.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXShimmerModifier.swift new file mode 100644 index 0000000..a8c49ae --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXShimmerModifier.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift new file mode 100644 index 0000000..7793d1d --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXStatBadge.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift new file mode 100644 index 0000000..386be71 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXTabBar.swift @@ -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) + } + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXTypingIndicator.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXTypingIndicator.swift new file mode 100644 index 0000000..66ea3e1 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXTypingIndicator.swift @@ -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 + } + } + } +} diff --git a/AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift b/AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift new file mode 100644 index 0000000..801dac8 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Shared/Components/ZXWeakRow.swift @@ -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)) + } +} diff --git a/AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings b/AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..09dbf94 --- /dev/null +++ b/AIStudyApp/AIStudyApp/zh-Hans.lproj/Localizable.strings @@ -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 反馈"; +"功能建议" = "功能建议"; +"内容问题" = "内容问题"; +"其他" = "其他"; +"公考、考研、考证等" = "公考、考研、考证等"; +"编程、设计、产品等" = "编程、设计、产品等"; +"扩充知识面" = "扩充知识面"; +"设定自己的目标" = "设定自己的目标"; diff --git a/AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift b/AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift new file mode 100644 index 0000000..e6236c3 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/AIChatViewModelTests.swift @@ -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) + } +} diff --git a/AIStudyApp/AIStudyAppTests/FileCacheTests.swift b/AIStudyApp/AIStudyAppTests/FileCacheTests.swift new file mode 100644 index 0000000..0779dbc --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/FileCacheTests.swift @@ -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]) + } +} diff --git a/AIStudyApp/AIStudyAppTests/ReviewPlanViewModelTests.swift b/AIStudyApp/AIStudyAppTests/ReviewPlanViewModelTests.swift new file mode 100644 index 0000000..c8374fa --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/ReviewPlanViewModelTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import AIStudyApp + +final class ReviewPlanViewModelTests: XCTestCase { + + var vm: ReviewPlanViewModel! + + override func setUp() { + super.setUp() + vm = ReviewPlanViewModel() + } + + override func tearDown() { + vm = nil + super.tearDown() + } + + func testInitialState_hasTasks() { + XCTAssertFalse(vm.todayTasks.isEmpty) + XCTAssertFalse(vm.tomorrowTasks.isEmpty) + XCTAssertFalse(vm.weekTasks.isEmpty) + } + + func testTotalCount_sumsAllTasks() { + let expected = vm.todayTasks.count + vm.tomorrowTasks.count + vm.weekTasks.count + XCTAssertEqual(vm.totalCount, expected) + } + + func testToggleTask_changesStatusToCompleted() { + guard let task = vm.todayTasks.first(where: { $0.status == .pending }) else { + XCTFail("Expected at least one pending task") + return + } + vm.toggleTask(task) + let toggled = vm.todayTasks.first(where: { $0.id == task.id }) + XCTAssertEqual(toggled?.status, .completed) + } + + func testToggleTask_togglingBackChangesToPending() { + guard let task = vm.todayTasks.first(where: { $0.status == .completed }) else { + XCTFail("Expected at least one completed task") + return + } + vm.toggleTask(task) + let toggled = vm.todayTasks.first(where: { $0.id == task.id }) + XCTAssertEqual(toggled?.status, .pending) + } + + func testToggleTask_doesNothingForUnknownTask() { + let before = vm.totalCount + let fake = ReviewTask( + id: "non-existent", userId: "", lessonId: "", + sourceSessionId: "", reviewType: .recall, + scheduledAt: "", completedAt: nil, status: .pending + ) + vm.toggleTask(fake) + XCTAssertEqual(vm.totalCount, before) + } +} diff --git a/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift new file mode 100644 index 0000000..3c3dd01 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/StudyHomeViewModelTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import AIStudyApp + +final class StudyHomeViewModelTests: XCTestCase { + + var vm: StudyHomeViewModel! + + override func setUp() { + super.setUp() + vm = StudyHomeViewModel() + } + + override func tearDown() { + vm = nil + super.tearDown() + } + + func testInitialState_hasFiveTasks() { + XCTAssertEqual(vm.tasks.count, 5) + } + + func testInitialState_twoTasksDone() { + XCTAssertEqual(vm.doneCount, 2) + } + + func testProgress_calculatesCorrectly() { + XCTAssertEqual(vm.progress, 0.4, accuracy: 0.01) + } + + func testToggleTask_changesDoneCount() { + let task = vm.tasks.first(where: { !$0.d })! + vm.toggleTask(task) + XCTAssertEqual(vm.doneCount, 3) + } + + func testToggleTask_togglingBackRestoresCount() { + let task = vm.tasks.first(where: { $0.d })! + vm.toggleTask(task) + XCTAssertEqual(vm.doneCount, 1) + } + + func testDoneMinutes_sumsCompletedTasks() { + XCTAssertEqual(vm.doneMinutes, 25) // 10 + 15 + } + + func testRemainingMinutes_sumsPendingTasks() { + XCTAssertEqual(vm.remainingMinutes, 30) // 8 + 12 + 10 + } + + func testToggleTask_updatesProgress() { + let task = vm.tasks.first(where: { !$0.d })! + vm.toggleTask(task) + XCTAssertEqual(vm.progress, 0.6, accuracy: 0.01) + } + + func testWeekActivity_hasSevenDays() { + XCTAssertEqual(vm.weekActivity.count, 7) + XCTAssertEqual(vm.dayLabels.count, 7) + } +} diff --git a/AIStudyApp/docs/AI对话.md b/AIStudyApp/docs/AI对话.md new file mode 100644 index 0000000..a59808e --- /dev/null +++ b/AIStudyApp/docs/AI对话.md @@ -0,0 +1,668 @@ +# 缺失项与待补全方向 + +> 基于 v0.1 创业计划文档与当前 iOS 代码对比分析 +> 整理时间:2026-05-10 + +本文档系统性列出知习 iOS App 当前在架构、页面、功能、设计等方面的缺失项,并给出优先级建议。 + +--- + +## 一、架构层缺失 + +### 1.1 MVVM 分层 + +**现状**:全部代码写在 SwiftUI View 中,无任何 ViewModel/ObservableObject/@Published。grep 搜索 ViewModel、ObservableObject、@Published 均为零结果。 + +**缺失**: +- 无 ViewModel 层,业务逻辑、状态管理、数据转换全部堆在 View 里 +- 无 Model 层,数据结构通过 View 内的局部 struct 或硬编码数据隐式定义 +- 代码不可测试,无法单独验证业务逻辑 + +**计划要求**(`官网与技术基础.md` 第 5.3 节): +``` +AIStudyApp/ +├── Features/ +│ ├── Onboarding/ +│ │ ├── Views/ ← 当前有,但无 ViewModel/Model 子目录 +│ │ ├── ViewModels/ ← 缺失 +│ │ └── Models/ ← 缺失 +``` + +### 1.2 Service 层 + +**现状**:无任何 Service 类,AI 分析、学习记录、用户管理等概念没有对应的服务抽象。 + +**缺失**: +| Service | 职责 | 涉及的计划数据实体 | +|---------|------|-------------------| +| AuthService | Apple 登录、Token 管理、会话维护 | User | +| LearningService | 学习记录 CRUD、进度追踪 | LearningSession | +| AIService | AI 分析请求代理、结果解析 | AIAnalysis | +| ReviewService | 复习任务生成、调度 | ReviewTask | +| KnowledgeService | 知识库/路径/课程查询 | KnowledgeBase, LearningPath, Lesson | +| FeedbackService | 用户反馈提交 | Feedback | + +### 1.3 Repository 层 + +**现状**:零数据持久化,所有"数据"均为 View 中硬编码的 mock。 + +**缺失**: +- 无数据访问抽象(未来可能切换 CoreData → API,需要 Repository 隔离) +- 无本地缓存层 +- 无网络数据源层 + +### 1.4 网络层 + +**现状**:无任何网络请求代码,无 APIClient,无 URLSession 调用。 + +**缺失**: +- APIClient(封装 URLSession,注入 baseURL、header、token) +- APIEndpoint(枚举化 API 路径,统一请求构建) +- APIError(统一错误模型和处理) +- 请求/响应拦截器(日志、token 刷新) +- Mock 层(本地开发和 UI 预览用) + +### 1.5 依赖注入 + +**现状**:无任何 DI 模式,Service 和 ViewModel 尚未创建,暂时不存在注入问题。但需要在架构搭建时建立模式。 + +**建议**:初期使用构造函数注入 + `@EnvironmentObject`,避免引入第三方 DI 框架。 + +--- + +## 二、核心能力缺失 + +### 2.1 Sign in with Apple + +**现状**:`LoginPage` 有 UI(手机号/邮箱/微信/Apple 入口),但 `AIStudyAppApp` 仅用 `@AppStorage("hasCompletedOnboarding")` 控制是否进入主界面,无实际认证。 + +**计划要求**:第一版登录方式仅为 Sign in with Apple(`Demo与MVP.md` 第 5.2 节)。 + +**需实现**: +- ASAuthorizationController 集成 +- 获取 appleUserId、email、displayName +- 后端验证 identityToken +- Token 本地安全存储(Keychain) +- 登录状态管理 + +### 2.2 后端 API 对接 + +**现状**:所有页面为静态 UI,无任何网络请求。 + +**计划定义的 P0 API**: +- `POST /ai/analyze-learning-input` — AI 分析用户学习输入 +- `POST /ai/chat` — AI 对话 +- 用户/知识库/学习记录/反馈 CRUD + +### 2.3 真实 AI 集成 + +**现状**:AI 相关页面全为静态文本。 + +**需对接**: +- 后端 AI Provider 抽象层(MiniMax/DeepSeek/OpenAI 等) +- 结构化 JSON 输出解析 +- AI 分析结果展示(掌握度评分、优缺点、建议) +- AI 对话流式响应 + +### 2.4 本地数据持久化 + +**现状**:零持久化实现。 + +**需实现**: +- UserDefaults / @AppStorage(简单偏好) +- Keychain(Token、敏感信息) +- 后续可考虑 CoreData 或 SwiftData(学习记录离线缓存) + +### 2.5 多语言本地化 + +**现状**:所有文案硬编码在 View 中,无 Localizable.xcstrings 文件。 + +**计划要求**(`Demo与MVP.md` 第 6 节): +- 默认简体中文 +- 预留英文 +- App UI 文案使用本地化资源 + +**需实现**: +- 创建 `Localizable.xcstrings` +- 将所有硬编码文案迁移为 `LocalizedStringKey` +- 支持语言切换 + +### 2.6 错误/加载/空状态处理 + +**现状**:无任何错误处理、加载态、空状态 UI。 + +**至少需要**: +- 网络请求 loading 指示器 +- 网络错误提示和重试按钮 +- AI 分析中的等待状态 +- 列表空状态(如知识库为空时的引导) +- 登录失败错误提示 + +--- + +## 三、页面层面差距 + +### 3.1 与计划页面对比 + +| 计划页面 | 计划优先级 | 当前状态 | 说明 | +|----------|-----------|---------|------| +| 启动页/欢迎页 | P1 | ✅ 已实现 | SplashPage + WelcomePage | +| 登录页 | P0 | ⚠️ 过度实现 | UI 包含计划不做的手机号/邮箱/微信登录 | +| 语言与偏好页 | P1 | ❌ 未实现 | 无页面 | +| 学习方向选择页 | P0 | ⚠️ 部分实现 | GoalSetupPage 有目标选择,但非学习方向选择 | +| 学习路径页 | P0 | ✅ 已实现 | LibraryDetailPage | +| 今日学习任务页 | P0 | ✅ 已实现 | StudyHomeView | +| 内容阅读页 | P0 | ✅ 已实现 | KnowledgeDetailPage | +| 主动回忆/笔记输入页 | P0 | ✅ 已实现 | DailyThinkingPage + RecallTestPage | +| AI 分析结果页 | P0 | ✅ 已实现 | AIFeedbackPage | +| AI 对话页 | P0 | ✅ 已实现 | AIChatPage | +| 复习计划页 | P0 | ❌ 未独立 | 仅在 StudyHomeView 任务列表中混合出现 | +| 学习进度页 | P1 | ✅ 已实现 | AnalysisHomeView | +| 设置页 | P1 | ⚠️ 部分实现 | ProfileView 有设置菜单,但功能入口为空 | +| 反馈页 | P1 | ❌ 未实现 | 无反馈收集入口 | + +### 3.2 复习计划页(P0 缺失) + +**计划描述**:系统生成复习任务,用户查看待复习内容,按推荐时间安排学习。 + +**当前**:复习任务混在 StudyHomeView 的任务列表里(如"高数 - 间隔复习 8 题"),缺少独立的复习计划视图。 + +**需实现**: +- 独立的 `ReviewPlanView` +- 按时间线展示待复习任务(今天、明天、本周) +- 复习项来源标注(哪个知识点的第几次复习) +- 复习完成状态追踪 + +### 3.3 反馈页(P1 缺失) + +**计划描述**:App 内反馈入口,让内测用户提交问题和建议。 + +**需实现**: +- 简洁的反馈表单(文本输入 + 分类选择) +- 提交到后端 `/feedback` 接口 +- 确认提交状态页 + +### 3.4 等待名单入口 + +**计划**:官网 `/waitlist` 页面收集用户,App 内也需要引导用户加入等待名单/申请内测。 + +**需考虑**:是否在 App 内嵌等待名单入口(如 Welcome 页或设置页)。 + +--- + +## 四、设计与交互差距 + +### 4.1 Tab 结构调整 + +**计划设计**:4 个 Tab — 学习 | 知识库 | AI助手 | 我的 + +**当前实现**:5 个 Tab — AI | 知识库 | 学习 | 分析 | 我的 + +**差异分析**: +- 当前把"学习"和"分析"拆成了两个独立 Tab +- 计划把"AI助手"独立为一个 Tab,当前 AI 已是独立 Tab +- "分析"在计划中属于"学习"Tab 下的子页面,不需要顶层 Tab + +**建议**(两种方案): +- **方案 A**:完全对齐计划 → 合并学习和分析为一个 Tab,保持 4 Tab +- **方案 B**:保留 5 Tab 结构 → 更新计划文档,论证"分析"独立为 Tab 的合理性(学习数据可视化、学习进度监控是独立价值) + +### 4.2 登录流程简化 + +**计划要求**:仅 Sign in with Apple,不做手机号/邮箱/微信登录。 + +**当前 UI**:包含 4 种登录方式入口。 + +**建议**:第一版简化为仅 Apple 登录按钮 + 跳过选项,移除手机号/邮箱/微信登录 UI。 + +### 4.3 深色模式 + +**现状**:所有页面用 `.preferredColorScheme(.dark)` 强制深色,未验证浅色模式。 + +**建议**:确认是否需要支持浅色模式。如果只做深色,在 DesignTokens 中声明 `colorScheme: .dark`。 + +### 4.4 无障碍 + +**现状**:未考虑 VoiceOver、Dynamic Type、高对比度等无障碍需求。 + +**至少需做**: +- 关键按钮添加 `.accessibilityLabel` +- 确保 Dynamic Type 下布局不破碎 +- 重点页面 VoiceOver 测试 + +### 4.5 动效 + +**计划要求**(`官网与技术基础.md` 第 6.3 节): +- P0:页面过渡、按钮反馈、加载状态、AI 分析中状态、学习完成反馈 +- P1:今日任务卡片动效、进度条更新、AI 结果分块出现 + +**当前**:仅有基础 SwiftUI 隐式动画(withAnimation),未实现任何计划中的动效。 + +--- + +## 五、数据层缺失 + +### 5.1 Model 定义 + +**现状**:所有数据通过 View 内局部变量或硬编码定义,无独立 Model 文件。 + +**计划中定义的核心实体**(`Demo与MVP.md`): + +``` +User +├── id, appleUserId, displayName, email +├── preferredLanguage, createdAt, lastLoginAt, status + +KnowledgeBase +├── id, title, description, language, targetUser +├── createdAt, updatedAt + +LearningPath +├── id, knowledgeBaseId, title, description +├── estimatedDays, order + +Lesson +├── id, pathId, title, content, objectives +├── keyPoints, recallQuestions, practicePrompt +├── order, estimatedMinutes + +LearningSession +├── id, userId, lessonId +├── startedAt, endedAt, userInput +├── aiAnalysis, masteryScore, weakPoints +├── nextSuggestion, reviewAt + +AIAnalysis +├── id, userId, sessionId +├── inputText, outputJson, masteryScore +├── weakPoints, suggestions +├── modelName, createdAt, costEstimate + +ReviewTask +├── id, userId, lessonId, sourceSessionId +├── reviewType, scheduledAt, completedAt, status + +Feedback +UserLearningProfile +``` + +**需实现**:在 `Features/*/Models/` 下创建对应的 Swift struct(需 Codable、Identifiable)。 + +### 5.2 API Contract + +**现状**:无 API 类型定义。 + +**建议**:参考计划中定义的 JSON 结构,先创建 Swift Model,再定义 API 请求/响应类型(Request/Response struct),实现前后端类型同构。 + +### 5.3 数据流规范 + +**现状**:View 直接持有 @State,无数据流管理。 + +**建议**: +- ViewModel 持有 @Published 状态 +- ViewModel 通过 Service 获取数据 +- Service 通过 Repository 访问数据源 +- View 通过 @StateObject / @ObservedObject 绑定 ViewModel + +--- + +## 六、工程化缺失 + +### 6.1 大文件拆分 + +**当前问题**: + +| 文件 | 行数 | 包含内容 | +|------|------|----------| +| `AIStudyAppApp.swift` | ~187 | 5 个完整页面 + 多个子组件 | +| `DailyThinkingPage.swift` | ~200+ | 5 个页面 + 共享组件 | +| `LibrarySubpages.swift` | ~150+ | 6 个页面 + 组件 | + +**建议**:每个页面一个文件,共享组件移到 `Shared/Components/`。 + +### 6.2 共享组件管理 + +**现状**:`ZXTabBar` 在 ContentView.swift,`ZXBackHeader` 在 DailyThinkingPage.swift,`ZXCardRow` 在 LibrarySubpages.swift,散落各处。 + +**建议**:集中到 `Shared/Components/`,建立组件目录如: +``` +Shared/Components/ +├── ZXTabBar.swift +├── ZXBackHeader.swift +├── ZXAIInputBar.swift +├── ZXScoreBox.swift +├── ZXIconBtn.swift +├── ZXCardRow.swift +├── ZXChip.swift +├── ZXQuickAction.swift +└── ZXStatBadge.swift +``` + +### 6.3 测试 + +**现状**:无任何测试代码。 + +**至少需要**: +- ViewModel 单元测试(当 ViewModel 创建后) +- Service 层单元测试(Mock Repository) +- 关键 UI 流程的 Snapshot 测试 + +### 6.4 CI/CD + +**现状**:无。 + +**建议**(后续): +- GitHub Actions / Xcode Cloud 自动构建 +- TestFlight 自动分发 + +### 6.5 崩溃监控与埋点 + +**现状**:无。 + +**建议**:接入 Firebase Crashlytics 或类似服务,至少在 TestFlight 阶段要有崩溃收集能力。 + +--- + +## 七、优先级建议 + +### P0 — 必须在接后端前完成 + +| 优先级 | 项目 | 理由 | +|--------|------|------| +| P0 | 创建 Model 层(所有数据实体) | 是 Service/ViewModel/API 的基础 | +| P0 | 创建 API Contract 类型定义 | 前后端对齐的前提 | +| P0 | 搭建 APIClient + APIEndpoint | 所有后端交互的唯一通道 | +| P0 | 实现 AuthService + Apple 登录 | 用户身份是学习记录的前提 | +| P0 | 简化登录页为纯 Apple 登录 | 对齐计划,减少不必要 UI | +| P0 | 实现复习计划独立页 | 计划标记 P0,当前缺失 | +| P0 | 拆分大文件 | 降低后续修改的认知负担 | +| P0 | 集中共享组件 | 避免组件散落导致重复开发 | +| P0 | 添加加载/错误/空状态处理 | 真机使用的基本体验保障 | + +### P1 — 与后端对接同步推进 + +| 优先级 | 项目 | 理由 | +|--------|------|------| +| P1 | 搭建 ViewModel 层(逐步迁移) | 架构分层,但不阻塞功能开发 | +| P1 | 搭建 Service 层 | 随 API 对接自然建立 | +| P1 | 实现本地化架构 | 越晚做返工越多 | +| P1 | 实现反馈页 | TestFlight 内测必须 | +| P1 | 实现 Keychain 存储 | Token 安全存储 | + +### P2 — App Store 前完成 + +| 优先级 | 项目 | 理由 | +|--------|------|------| +| P2 | Repository 层 | 当需要本地缓存 + 网络切换时再做 | +| P2 | 动效补充 | 体验优化,不阻塞功能 | +| P2 | 无障碍适配 | App Store 审核加分项 | +| P2 | 测试 | 用户量增长后需要 | +| P2 | Tab 结构调整决策 | 需要更多用户反馈来决策 | + +--- + +## 八、总结 + +当前 iOS 项目完成了 UI 层的全量搭建(21 页),但缺少"能让产品真正运转"的架构底座和数据能力。核心矛盾是: + +> UI 超前,架构滞后。页面能点,但无数据、无认证、无 AI、无服务。 + +接下来最合理的推进顺序: + +``` +1. 建 Model 层(夯实数据基础) + ↓ +2. 搭网络层 + Apple 登录(接通身份和通信) + ↓ +3. 后端 API 逐个对接(AI 分析和学习记录最先) + ↓ +4. 重构 ViewModel 层(顺带解决架构问题) + ↓ +5. 补充缺失页面(复习计划、反馈) + ↓ +6. 本地化 + 动效 + 测试(打磨) +``` + +一句话:**先把骨架搭对,再长肉。** + +对,**登录第一版只保留苹果登录**,这是最正确的决策。 + +你整理的文档里也已经明确写了:当前 `LoginPage` 虽然有手机号、邮箱、微信、Apple 入口,但第一版计划要求只做 Sign in with Apple,并且需要实现 Apple 授权、后端验证、Keychain 存 token 和登录状态管理。 +文档后面也建议把登录页简化为 **仅 Apple 登录按钮 + 可选跳过入口**,移除手机号、邮箱、微信登录 UI。 + +--- + +# 登录页第一版最终方案 + +## 正式入口只保留 + +```text +Sign in with Apple +``` + +## 删除这些入口 + +```text +手机号登录 +邮箱登录 +微信登录 +验证码登录 +第三方微信登录 +``` + +## “跳过”要不要保留? + +我建议: + +```text +开发环境可以保留 +正式环境不要明显展示 +``` + +也就是说: + +```swift +#if DEBUG +Button("跳过,进入演示模式") { ... } +#endif +``` + +不要把“跳过”作为正式登录入口,不然后面学习记录、知识库、AI 分析都会涉及匿名用户迁移,很麻烦。 + +--- + +# 登录流程应该这样设计 + +```text +打开 App +↓ +AppSession 检查 Keychain 里有没有 refreshToken +↓ +如果有 token:调用 /auth/refresh 或 /users/me +↓ +成功:进入主界面 +↓ +失败:进入登录页 +↓ +用户点击 Sign in with Apple +↓ +iOS 获取 identityToken / authorizationCode / userIdentifier +↓ +POST /api/auth/apple +↓ +后端返回 accessToken / refreshToken / user +↓ +iOS 把 token 存到 Keychain +↓ +AppSession 更新 currentUser +↓ +判断 onboardingCompleted +↓ +未完成:进入首次引导 / 学习目标设置 +已完成:进入主界面 +``` + +--- + +# iOS 需要实现的文件 + +登录相关最少这些: + +```text +Features/Auth/Views/LoginView.swift +Features/Auth/ViewModels/LoginViewModel.swift + +Core/Services/AuthService.swift +Core/Services/AuthServiceProtocol.swift + +Core/Storage/KeychainStore.swift +Core/Storage/TokenStore.swift + +App/AppSession.swift + +Core/Models/AuthModels.swift +Core/Models/User.swift +``` + +--- + +# 接口请求结构 + +iOS 登录成功后给后端: + +```swift +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? +} +``` + +后端返回: + +```swift +struct AuthResponse: Decodable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + let user: User +} +``` + +Token 存储: + +```text +accessToken → Keychain +refreshToken → Keychain +不要存 UserDefaults +``` + +--- + +# 给 Trae 的修改指令 + +你可以直接发这段: + +```text +现在调整知习 iOS App 的登录方案。 + +第一版登录方式只保留 Sign in with Apple。 + +请删除登录页中的: +- 手机号登录 +- 邮箱登录 +- 微信登录 +- 其他第三方登录入口 + +正式 UI 只保留: +- Sign in with Apple 按钮 +- 简短产品说明 +- 隐私政策 / 用户协议入口 + +开发环境可以保留一个 DEBUG-only 的“跳过,进入演示模式”按钮,但不能出现在 Release 版本。 + +请实现以下架构: + +1. LoginView +- 只展示 Apple 登录按钮 +- 点击后调用 LoginViewModel.loginWithApple() +- 支持 loading / error 状态 +- 登录失败时展示错误提示 + +2. LoginViewModel +- @MainActor +- ObservableObject +- @Published isLoading +- @Published errorMessage +- 调用 AuthService 处理 Apple 登录 + +3. AuthService +- 集成 ASAuthorizationController +- 获取 identityToken +- 获取 authorizationCode +- 获取 userIdentifier +- 获取 email / fullName,如果 Apple 返回 +- 调用后端 POST /api/auth/apple +- 接收 accessToken / refreshToken / user + +4. TokenStore / KeychainStore +- accessToken 存 Keychain +- refreshToken 存 Keychain +- 支持 save / load / clear + +5. AppSession +- 管理全局登录状态 +- currentUser +- isAuthenticated +- bootstrap() +- loginWithApple() +- logout() +- refreshSession() + +6. App 启动逻辑 +- 启动时先检查 Keychain 中是否存在 refreshToken +- 如果存在,调用后端 refresh 或 /users/me +- 成功后进入主界面 +- 失败则清空 token 并进入登录页 + +7. 登录成功后的跳转 +- 如果 user.onboardingCompleted == false,进入首次使用引导 / 学习目标设置 +- 如果 user.onboardingCompleted == true,进入 MainTabView + +8. 注意 +- 不要再使用 @AppStorage("hasCompletedOnboarding") 单独决定是否进入主界面 +- 登录状态必须由 AppSession + Keychain token 决定 +- 不要把 token 存到 UserDefaults +- 不要在 View 里直接写网络请求 +- 不要在 View 里直接处理 Apple 登录细节 +- 不要改变其他主页面 UI +``` + +--- + +# 登录页内容建议 + +页面可以非常简单: + +```text +知习 + +更懂你,更会学。 + +用 AI 把知识库、主动回忆和间隔复习连接起来, +从“看过”走向“真正学会”。 + +[ Sign in with Apple ] + +登录即代表你同意《用户服务协议》和《隐私政策》 +``` + +就够了。 +第一版登录越简单越好,别再做多登录方式。 diff --git a/AIStudyApp/docs/architecture.md b/AIStudyApp/docs/architecture.md new file mode 100644 index 0000000..653184c --- /dev/null +++ b/AIStudyApp/docs/architecture.md @@ -0,0 +1,139 @@ +# 知习 ZhiXi — 项目架构 + +> 更新时间:2026-05-10 + +## 一、项目结构 + +``` +AIStudyApp/ +├── AIStudyAppApp.swift # 应用入口 + 引导流程路由 +│ ├── OnboardingFlowView # Splash → Welcome → Login → Onboarding → GoalSetup +│ ├── SplashPage # 启动页 +│ ├── WelcomePage # 欢迎页 +│ ├── LoginPage # 登录页 +│ ├── OnboardingPage # 功能引导 +│ └── GoalSetupPage # 学习目标设置 +│ +├── ContentView.swift # 5-Tab 主界面 +│ ├── ZXTabBar # 自定义底部 Tab 栏 +│ ├── ZXAIInputBar # AI 输入栏 +│ ├── ZXScoreBox # 评分组件 +│ └── ZXIconBtn # 图标按钮 +│ +├── Core/ +│ └── DesignSystem/ +│ └── DesignTokens.swift # 颜色/渐变/圆角/间距/字号/排版 +│ +├── Features/ +│ ├── AI/ +│ │ ├── AIHomeView.swift # AI 首页(Tab 1) +│ │ └── DailyThinkingPage.swift # 今日思考 + AIChat + RecallTest + WeakPoints + AIFeedback +│ │ +│ ├── Library/ +│ │ ├── LibraryHomeView.swift # 知识库首页(Tab 2) +│ │ └── LibrarySubpages.swift # Create/Detail/Add/Import/KnowledgeDetail/Edit +│ │ +│ ├── Study/ +│ │ └── StudyHomeView.swift # 学习工作台(Tab 3) +│ │ +│ ├── Analysis/ +│ │ └── AnalysisHomeView.swift # 学习分析(Tab 4) +│ │ +│ └── Profile/ +│ └── ProfileView.swift # 我的(Tab 5) +│ +└── Assets.xcassets/ # 资源文件 +``` + +## 二、导航架构 + +``` +@main AIStudyAppApp +├── hasCompletedOnboarding == false +│ └── OnboardingFlowView +│ ├── step 0: SplashPage ──(2s)──→ step 1 +│ ├── step 1: WelcomePage ──"开始使用"──→ step 2 +│ │ ──"已有账号"──→ hasCompletedOnboarding = true +│ ├── step 2: LoginPage ──"登录"──→ step 3 +│ │ ──"跳过"──→ hasCompletedOnboarding = true +│ ├── step 3: OnboardingPage ──"下一步"──→ step 4 +│ └── step 4: GoalSetupPage ──"开始学习"──→ hasCompletedOnboarding = true +│ +└── hasCompletedOnboarding == true + └── ContentView (5-Tab) + ├── Tab "AI" → NavigationStack { AIHomeView } + │ ├── → DailyThinkingPage → AIFeedbackPageView + │ ├── → RecallTestPage + │ ├── → WeakPointsPage + │ └── → AIChatPage + │ + ├── Tab "知识库" → NavigationStack { LibraryHomeView } + │ ├── → LibraryDetailPage → KnowledgeDetailPage + │ ├── → CreateLibraryPage + │ ├── → AddKnowledgePage + │ ├── → ImportPage + │ └── → EditKnowledgePage + │ + ├── Tab "学习" → NavigationStack { StudyHomeView } + ├── Tab "分析" → NavigationStack { AnalysisHomeView } + └── Tab "我的" → NavigationStack { ProfileView } +``` + +## 三、数据流(当前均为静态 Mock) + +所有页面目前使用 `@State` 管理的本地假数据,尚未接入真实后端: + +| 数据 | 当前状态 | +|------|----------| +| 用户信息 | ProfileView 中硬编码 | +| 知识库列表 | LibraryHomeView 中硬编码 4 个 | +| 学习任务 | StudyHomeView 中硬编码 5 个 | +| AI 分析结果 | 各 AI 页面静态文本 | +| 学习统计 | AnalysisHomeView 中硬编码 | +| 登录状态 | @AppStorage 布尔值控制 | + +## 四、技术栈 + +| 层 | 技术 | 备注 | +|----|------|------| +| 语言 | Swift | — | +| UI 框架 | SwiftUI | iOS 17+ | +| 架构模式 | 当前未分层(View 内聚) | 计划 MVVM + Service + Repository | +| 设计系统 | 自定义 DesignTokens | 从 React 原型 1:1 提取 | +| 构建工具 | Xcode | — | +| 目标平台 | iPhone (iOS 17+) | 未做 iPad/Mac 适配 | + +## 五、与计划架构的差异 + +计划文档(`官网与技术基础.md`)中定义的 iOS 目录结构: + +``` +计划架构 当前实现 +───────────────────────────────── ───────────────── +App/AIStudyApp.swift AIStudyAppApp.swift ✅ +App/AppConfig.swift 未实现 ❌ +App/AppRouter.swift 未实现 ❌ +Core/Network/ 未实现 ❌ +Core/Auth/ 未实现 ❌ +Core/Storage/ 未实现 ❌ +Core/Localization/ 未实现 ❌ +Core/DesignSystem/ DesignTokens.swift ✅ (部分) +Features/*/Views/ Features/*/ ✅ +Features/*/ViewModels/ 未实现 ❌ (View 内聚状态) +Features/*/Models/ 未实现 ❌ (无独立 Model) +Shared/Components/ 分散在各 View 文件中 ⚠️ +Shared/Extensions/ 仅 Color hex 扩展 ✅ +Shared/Utils/ 未实现 ❌ +Shared/Constants/ 未实现 ❌ +Resources/Localizable.xcstrings 未实现 ❌ +Resources/PreviewData/ 未实现 ❌ +``` + +## 六、待重构项 + +1. **View 文件过大**:`AIStudyAppApp.swift` 包含 5 个独立页面,应拆分到各自文件 +2. **无 ViewModel 层**:所有状态和数据逻辑写在 View 中,需要抽离 +3. **无 Model 层**:数据结构(如学习任务、知识库卡片)分散在 View 中用局部变量定义 +4. **共享组件未集中**:`ZXTabBar`、`ZXBackHeader` 等组件散落在不同文件中 +5. **无网络层**:无 API Client、无 Auth Service、无 Storage 层 +6. **无本地化**:所有文案硬编码,未使用 `LocalizedStringKey` diff --git a/AIStudyApp/docs/pages.md b/AIStudyApp/docs/pages.md new file mode 100644 index 0000000..c40bffa --- /dev/null +++ b/AIStudyApp/docs/pages.md @@ -0,0 +1,235 @@ +# 知习 ZhiXi — 页面清单与功能说明 + +> 更新时间:2026-05-10 + +## 项目概述 + +**知习 (ZhiXi)** 是一个 AI-first 系统化学习 iOS App,使用 SwiftUI 构建,深色主题。当前已完成 22 个页面的 UI 层实现,覆盖从引导流程到主界面 5 个 Tab 的完整交互链路。 + +--- + +## 一、引导流程(5 页) + +所有引导页面定义在 `AIStudyAppApp.swift` 的 `OnboardingFlowView` 中,通过 `@AppStorage("hasCompletedOnboarding")` 控制显示。 + +### 1. SplashPage — 启动页 + +- **路由**:`OnboardingFlowView` step 0 +- **功能**:展示品牌 Logo、App 名称"知习"、副标题"AI-first 系统化学习",底部显示加载进度条,2 秒后自动跳转 Welcome +- **视觉**:深色渐变背景 + 紫色/橙色光晕 + 品牌渐变图标 + +### 2. WelcomePage — 欢迎页 + +- **路由**:`OnboardingFlowView` step 1 +- **功能**:展示产品三大核心功能(主动回忆、费曼解释、AI 分析);提供"开始使用"和"已有账号?立即登录"两个入口 +- **UI 组件**:`FeatureRow`(emoji + 标题 + 描述卡片) + +### 3. LoginPage — 登录页 + +- **路由**:`OnboardingFlowView` step 2 +- **功能**:支持手机号(+86)和邮箱两种登录方式切换;密码输入可切换明文/密文;底部提供微信登录和 Apple 登录入口;包含"忘记密码"链接 +- **UI 组件**:`ZXInputField`、`SocialLoginBtn` + +### 4. OnboardingPage — 功能引导页 + +- **路由**:`OnboardingFlowView` step 3 +- **功能**:4 步滑动引导(输入知识 → 主动输出 → AI 分析 → 掌握知识),底部进度指示器,支持跳过 +- **UI 组件**:步进圆点指示器 + +### 5. GoalSetupPage — 学习目标设置页 + +- **路由**:`OnboardingFlowView` step 4 +- **功能**:设置学习目标(备考考试/职业技能/通识学习/自定义)、选择学习方法(间隔回忆/费曼技巧/AI 分析)、选择每日学习时间(15 分钟 ~ 不限制) +- **UI 组件**:目标选项卡片(emoji + 标题 + 描述 + 单选圆点)、方法标签组、时间选择组 + +--- + +## 二、主界面 — 5 Tab 结构 + +`ContentView.swift` 实现底部 5 个 Tab 的导航:AI、知识库、学习、分析、我的。 + +### Tab 栏组件 + +- **ZXTabBar**:自定义底部 TabBar,包含选中态光晕动画、品牌紫色高亮 +- **ZXAIInputBar**:复用的 AI 输入栏(sparkles 图标 + 输入框 + 麦克风 + 发送按钮) +- **ZXIconBtn**:通用图标按钮(支持 branded 紫色渐变样式) +- **ZXScoreBox**:评分展示组件(分数 + 背景色 + 前景色,根据分数区间变色) + +--- + +### Tab 1:AI 助手 — AIHomeView + +- **文件**:`Features/AI/AIHomeView.swift` +- **功能**: + - 今日思考题卡片(展示 AI 生成的思考题,点击进入回答) + - 快捷操作区(生成回忆测试、分析薄弱点、费曼解释练习、今日复习计划) + - 最近 AI 互动列表(费曼复习、薄弱点分析、回忆测试记录,含评分) + - AI 提问建议区(预设问题模板,点击可发送) + - 底部 AI 输入栏 +- **子页面**: + - `DailyThinkingPage` — 今日思考详情(见下方) + - `RecallTestPage` — 回忆测试 + - `WeakPointsPage` — 薄弱点分析 + - `AIChatPage` — AI 对话 +- **UI 组件**:`ZXQuickAction`(emoji + 标签)、`ZXAIInteractionRow`(标签 + 标题 + 时间 + 评分) + +#### AI 子页面(定义在 `Features/AI/DailyThinkingPage.swift`) + +| 页面 | 功能 | +|------|------| +| **DailyThinkingPage** | 展示思考题 + 文本输入框,用户写下回答后提交给 AI 评估 | +| **AIChatPage** | AI 对话页面,围绕当前知识库进行学习问答 | +| **RecallTestPage** | 回忆测试:展示题目,用户回忆并写下理解,提交验证 | +| **WeakPointsPage** | 薄弱知识点列表,每个知识点显示掌握分数、所属知识库、优先级 | +| **AIFeedbackPage** | AI 反馈页,展示 AI 对用户回答的评分、优缺点分析、改进建议 | + +**共享 UI 组件**:`ZXBackHeader` — 带返回按钮、标题、副标题、右侧操作区的通用导航头 + +--- + +### Tab 2:知识库 — LibraryHomeView + +- **文件**:`Features/Library/LibraryHomeView.swift` +- **功能**: + - 知识库列表(机器学习、高等数学、英语词汇、产品设计等),每个卡片展示 emoji、名称、描述、知识点数量、掌握度百分比、标签、最近学习时间 + - 顶部搜索栏 + - 右上角搜索和创建按钮 + - 创建新知识库入口(虚线边框卡片) +- **UI 组件**:`ZLibraryCard`(emoji + 名称 + 描述 + 进度条 + 标签 + 统计) + +#### 知识库子页面(定义在 `Features/Library/LibrarySubpages.swift`) + +| 页面 | 功能 | +|------|------| +| **CreateLibraryPage** | 创建新知识库:填写名称、描述 | +| **LibraryDetailPage** | 知识库详情:展示该知识库下所有知识点列表,每个知识点显示标题、描述、掌握状态(已掌握/学习中/待复习) | +| **AddKnowledgePage** | 添加知识点:填写标题、内容 | +| **KnowledgeDetailPage** | 知识点详情:展示完整内容、标签、复习/费曼解释按钮 | +| **ImportPage** | 导入资料 | +| **EditKnowledgePage** | 编辑知识点 | + +**知识库卡片组件**:`ZXCardRow` — emoji + 标题 + 描述 + 状态标签 + +--- + +### Tab 3:学习工作台 — StudyHomeView + +- **文件**:`Features/Study/StudyHomeView.swift` +- **功能**: + - 日期和问候语("周四,1月16日") + - 连续学习天数徽章(🔥) + - 今日进度卡片:完成任务数/总任务数、进度百分比环形图、进度条、已学时间/剩余时间/掌握积分 + - 今日任务列表(机器学习回忆测试、高数间隔复习、英语词汇复习等),每个任务可勾选完成,显示任务类型标签和预计时长 + - 本周学习活跃柱状图(周一~周日,高亮当天) + - 总计学习时长和日均统计 +- **数据模型**:`ZXSTask`(标题、类型、颜色、时长、完成状态) +- **UI 组件**:`ZXSTaskRow`(勾选框 + 任务信息 + 类型标签 + 时长 + 播放按钮) + +--- + +### Tab 4:学习分析 — AnalysisHomeView + +- **文件**:`Features/Analysis/AnalysisHomeView.swift` +- **功能**: + - 顶部统计卡片行:综合掌握度(65%,+8%)、本周积分(1,240)、待巩固知识点数(23)、连续学习天数(14) + - 掌握度趋势折线图(近 7 天数据) + - 薄弱知识点列表(可导航至 WeakPointsPage) + - AI 学习建议卡片 + - 知识库掌握分布(各知识库掌握度进度条) +- **UI 组件**:`ZXStatBadge`(图标 + 标签 + 数值 + 变化趋势)、`ZXChartView`、`ZXWeakRow` + +--- + +### Tab 5:我的 — ProfileView + +- **文件**:`Features/Profile/ProfileView.swift` +- **功能**: + - 顶部导航栏(标题"我的" + 通知铃铛 + 设置齿轮) + - 个人信息卡片:头像(emoji)、昵称"学习者"、邮箱、连续天数/知识点数/积分统计 + - 设置菜单:学习目标设置、复习提醒、学习报告、学习方法偏好、数据同步与备份 + - 成就展示区(连续 14 天、费曼达人、知识收藏家、速学者) +- **UI 组件**:`ZXProfileStat`、`ZXProfileMenuRow`(emoji + 标题 + 描述 + 箭头)、`ZXAchievementBadge` + +--- + +## 三、设计系统 + +文件:`Core/DesignSystem/DesignTokens.swift` + +### 颜色系统 +| 类别 | Token | 值 | +|------|-------|-----| +| 主背景 | `Color.zxBg0` | `#0F0F1A` | +| 品牌紫 | `Color.zxPurple` | `#7C6EFA` | +| 品牌橙 | `Color.zxOrange` | `#F97316` | +| 文字主色 | `Color.zxF0` | `#F0F0FF` | + +### 渐变系统 (`ZXGradient`) +- `page` — 页面背景渐变 +- `brand` — 品牌紫橙渐变 +- `brandPurple` — 紫色渐变 +- `thinkingCard` — 思考卡片渐变 +- `progressCard` — 进度卡片渐变 +- `feedbackScore` — 反馈评分渐变 +- `profileCard` — 个人信息卡片渐变 +- `ctaButton` / `ctaPurple` — CTA 按钮渐变 + +### 间距系统 (`ZXSpacing`) +- `pageHPadding`: 20 +- `statusBarH`: 44 +- `tabBarH`: 83 + +### 尺寸系统 (`ZXSize`) +- 图标按钮: 36, 头像: 36/64/80 +- 按钮高度: 42/52/56, 快捷操作: 72 + +### 排版系统 (`ZXFont`) +- `titleLarge`: 22pt heavy, -0.5 tracking +- `titleMedium`: 20pt heavy, -0.4 tracking +- `sectionTitle`: 15pt bold +- `body`: 13pt semibold +- `bodySmall`: 12pt medium +- `caption`: 10pt bold + +--- + +## 四、页面状态总览 + +| # | 页面 | Tab | 文件 | 导航方式 | 状态 | +|---|------|-----|------|----------|------| +| 1 | Splash 启动页 | — | AIStudyAppApp.swift | 自动跳转 | ✅ | +| 2 | Welcome 欢迎页 | — | AIStudyAppApp.swift | 按钮跳转 | ✅ | +| 3 | Login 登录页 | — | AIStudyAppApp.swift | 按钮跳转 | ✅ | +| 4 | Onboarding 引导 | — | AIStudyAppApp.swift | 步进/跳过 | ✅ | +| 5 | GoalSetup 目标 | — | AIStudyAppApp.swift | 完成进入主界面 | ✅ | +| 6 | AIHome AI 首页 | AI | AIHomeView.swift | Tab 1 | ✅ | +| 7 | LibraryHome 知识库 | 知识库 | LibraryHomeView.swift | Tab 2 | ✅ | +| 8 | StudyHome 学习 | 学习 | StudyHomeView.swift | Tab 3 | ✅ | +| 9 | AnalysisHome 分析 | 分析 | AnalysisHomeView.swift | Tab 4 | ✅ | +| 10 | Profile 我的 | 我的 | ProfileView.swift | Tab 5 | ✅ | +| 11 | AIChat AI 对话 | AI | DailyThinkingPage.swift | NavigationLink | ✅ | +| 12 | DailyThinking 今日思考 | AI | DailyThinkingPage.swift | NavigationLink | ✅ | +| 13 | RecallTest 回忆测试 | AI | DailyThinkingPage.swift | NavigationLink | ✅ | +| 14 | WeakPoints 薄弱点 | AI | DailyThinkingPage.swift | NavigationLink | ✅ | +| 15 | AIFeedback AI 反馈 | AI | DailyThinkingPage.swift | NavigationLink | ✅ | +| 16 | CreateLibrary 创建知识库 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | +| 17 | LibraryDetail 知识库详情 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | +| 18 | AddKnowledge 添加知识点 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | +| 19 | Import 导入资料 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | +| 20 | KnowledgeDetail 知识点详情 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | +| 21 | EditKnowledge 编辑知识点 | 知识库 | LibrarySubpages.swift | NavigationLink | ✅ | + +--- + +## 五、当前未实现的功能 + +以下为产品计划中但当前 iOS 项目尚未实现的能力: + +- Sign in with Apple(UI 已做,实际认证逻辑未接入) +- 后端 API 对接(当前为纯本地 UI) +- 真实 AI 分析(当前为静态展示) +- 数据持久化(无本地存储/云端同步) +- 多语言本地化架构 +- Apple IAP 支付 +- 推送通知 +- 崩溃监控/数据埋点 diff --git a/AIStudyApp/docs/plan-ios-requirements.md b/AIStudyApp/docs/plan-ios-requirements.md new file mode 100644 index 0000000..9d036e7 --- /dev/null +++ b/AIStudyApp/docs/plan-ios-requirements.md @@ -0,0 +1,339 @@ +# 个人开发者创业 v0.1 — iOS 相关需求整理 + +> 来源:`startup-plan/个人开发者创业 v0.1/` 各文档 +> 整理时间:2026-05-10 + +本文档从 v0.1 创业计划中提取所有与 iOS 客户端直接相关的内容,作为 iOS 开发的参考基线。 + +--- + +## 一、产品定位(来源:`0-项目总纲/项目总纲.md`) + +- 产品长期方向:AI 驱动的系统化学习产品(知识库 + 笔记 + AI 学习教练 + 复习计划) +- 当前只做三件事:确定方向 → 做 14 天验证 Demo → 找第一批真实反馈 +- 平台策略:**只做 iPhone**,不做 Android/iPad/Mac/Web 学习端 +- 不做:完整平台、泛学习大而全、复杂后端、支付 + +### 三个候选方向 + +1. 公考申论 AI 学习教练 +2. AI 工具学习知识库 +3. 程序员/前端面试学习助手 + +--- + +## 二、第一版产品形态(来源:`2-Demo与MVP/Demo与MVP.md`) + +``` +iPhone App + 官网基础页面 + 最小后端 + AI API +``` + +### MVP 核心学习闭环 + +``` +注册/登录 → 选择学习方向 → 进入学习路径 → 阅读知识内容 +→ 主动回忆/写笔记/写答案 → AI 分析 → 生成学习状态 +→ 给出复习和下一步建议 → 进入下一次学习 +``` + +### 第一版页面列表(计划 14 页) + +| 优先级 | 页面 | 当前 iOS 实现 | +|--------|------|---------------| +| **P0** | 登录页 | `LoginPage` ✅ | +| **P0** | 学习方向选择页 | `GoalSetupPage` ✅ (部分) | +| **P0** | 学习路径页 | `LibraryHomeView` + `LibraryDetailPage` ✅ | +| **P0** | 今日学习任务页 | `StudyHomeView` ✅ | +| **P0** | 内容阅读页 | `KnowledgeDetailPage` ✅ | +| **P0** | 主动回忆/笔记输入页 | `DailyThinkingPage` + `RecallTestPage` ✅ | +| **P0** | AI 分析结果页 | `AIFeedbackPage` ✅ | +| **P0** | AI 对话页 | `AIChatPage` ✅ | +| **P0** | 复习计划页 | 未独立实现 ⚠️ (部分在 StudyHomeView) | +| **P1** | 学习进度页 | `AnalysisHomeView` ✅ | +| **P1** | 设置页 | `ProfileView` ✅ | +| **P1** | 反馈页 | 未实现 ❌ | +| **P1** | 启动页/欢迎页 | `SplashPage` + `WelcomePage` ✅ | +| **P1** | 语言与基础偏好页 | 未实现 ❌ | + +### 底部 Tab 设计(计划) + +``` +学习 | 知识库 | AI助手 | 我的 +``` + +当前 iOS 实现为 5 个 Tab:`AI | 知识库 | 学习 | 分析 | 我的`(多了"分析"Tab,将计划的 AI 助手拆分为独立的分析页) + +--- + +## 三、账号体系(来源:`2-Demo与MVP/Demo与MVP.md`) + +- **第一版登录方式**:Sign in with Apple +- **暂不做**:微信登录、手机号登录、邮箱密码登录、Google 登录 + +> ⚠️ 当前 iOS `LoginPage` 包含了手机号/邮箱登录 + 微信/Apple 登录 UI,与计划"A Sign in with Apple"的要求不完全一致。计划强调极简,实际 UI 做了更多登录方式入口。 + +### 用户身份模型(计划) + +``` +User +├── id +├── appleUserId +├── displayName +├── email +├── preferredLanguage +├── createdAt +├── lastLoginAt +└── status +``` + +--- + +## 四、知识库设计(来源:`2-Demo与MVP/Demo与MVP.md`) + +### 数据结构 + +``` +KnowledgeBase → LearningPath → Module → Lesson + ├── 正文内容 + ├── 学习目标 + ├── 重点概念 + ├── 主动回忆问题 + ├── 练习输入 + └── AI 分析规则 +``` + +### 第一版内容范围 + +只做一个小路径,例如"AI 工具入门 7 天路径",而不是大而全的知识库市场。 + +--- + +## 五、AI 能力需求(来源:`2-Demo与MVP/Demo与MVP.md`) + +### AI 三大核心职责 + +1. 分析用户输入 +2. 判断用户当前学习状态 +3. 给出下一步学习建议 + +### AI 分析维度 + +``` +理解程度、要点覆盖、逻辑结构、表达清晰度、错误理解、遗漏内容、下一步建议 +``` + +### AI 输出结构(计划 JSON Schema) + +```json +{ + "masteryScore": 3, + "understandingLevel": "基本理解", + "summary": "用户能说出核心意思,但要点不够完整。", + "strengths": ["能识别主要问题", "表达比较清楚"], + "weakPoints": ["遗漏关键要点", "逻辑层次不够清晰"], + "suggestions": ["补充材料中的第二个要点", "回答时先概括问题,再展开原因"], + "reviewNeeded": true, + "nextAction": "建议明天复习本节,并重新回答主动回忆问题。" +} +``` + +### 掌握度评分(0-5) + +``` +0 = 没有作答/无法判断 +1 = 基本没理解 +2 = 理解较弱 +3 = 基本理解 +4 = 理解较好 +5 = 掌握很好 +``` + +### AI 对话页定位 + +只能围绕当前知识库和学习内容,不能做泛聊天。快捷问题预设: +- 帮我解释这一节 +- 用更简单的话讲 +- 给我举个例子 +- 我哪里理解错了 +- 帮我总结重点 +- 生成一个复习问题 + +--- + +## 六、学习状态模型(来源:`2-Demo与MVP/Demo与MVP.md`) + +### 用户学习画像 + +``` +UserLearningProfile +├── userId +├── currentKnowledgeBaseId +├── currentPathId +├── currentLessonId +├── overallLevel +├── weakPoints +├── strengths +├── recentMistakes +├── reviewQueue +├── learningStreak +└── updatedAt +``` + +### 单次学习记录 + +``` +LearningSession +├── id +├── userId +├── lessonId +├── startedAt +├── endedAt +├── userInput +├── aiAnalysis +├── masteryScore +├── weakPoints +├── nextSuggestion +└── reviewAt +``` + +### 复习任务 + +``` +ReviewTask +├── id +├── userId +├── lessonId +├── sourceSessionId +├── reviewType +├── scheduledAt +├── completedAt +└── status +``` + +--- + +## 七、UI 设计原则(来源:`2-Demo与MVP/Demo与MVP.md`) + +``` +安静、清晰、克制、学习感、低干扰、Apple原生感、卡片式结构、适合长时间阅读 +``` + +- 不做花哨视觉 +- 不做复杂动画 +- 不做社交信息流 +- 不做游戏化过重设计 +- 优先保证阅读体验 +- 优先保证学习任务清晰 +- 优先保证 AI 分析结果可理解 + +--- + +## 八、技术选型(来源:`3-官网与技术基础/官网与技术基础.md`) + +| 项目 | 计划选型 | 当前实现 | +|------|----------|----------| +| UI 框架 | SwiftUI | SwiftUI ✅ | +| 架构模式 | MVVM + Service + Repository | 无分层(View 内聚)⚠️ | +| 设计规范 | Apple HIG | 深色主题 + 自定义 DesignTokens ✅ | +| 动效策略 | 轻量、有意义、服务学习体验 | 最小动效(仅基础过渡)⚠️ | +| 多语言 | 预留架构,中文默认 | 未实现 ❌ | +| 部署 | 4 核 4G 轻量云 + Nginx + Docker | 未接入 ❌ | + +### 目录结构(计划 vs 实际) + +计划定义了完整的分层目录(App/Core/Features/Shared/Resources),当前实现仅有 Features 和 DesignSystem,缺少 Network、Auth、Storage、Localization、ViewModel、Model 等层。 + +### 第一版 iOS 不做(来自计划) + +- 复杂动画系统 +- iPad 专门布局 / Mac Catalyst / Watch App / Widget +- 离线完整知识库 +- 复杂搜索 +- 文件导入 +- 推送通知 +- 支付订阅 +- 复杂自定义控件 + +--- + +## 九、数据实体汇总(来源:`2-Demo与MVP/Demo与MVP.md`) + +``` +User +KnowledgeBase +LearningPath +Lesson +LearningSession +AIAnalysis +ReviewTask +Feedback +WaitlistEntry +``` + +--- + +## 十、核心 API 接口(来源:`3-官网与技术基础/官网与技术基础.md`) + +### POST /ai/analyze-learning-input + +分析用户学习输入,返回掌握度评估。 + +### POST /ai/chat + +AI 对话接口,限于当前知识库上下文。 + +### 后端模块(P0) + +Auth → User → Knowledge → Learning → AI → Review → Feedback → Waitlist + +--- + +## 十一、成功标准(来源:`2-Demo与MVP/Demo与MVP.md`) + +### 产品可用标准 + +- 用户能登录 +- 用户能选择学习路径 +- 用户能完成一节学习 +- 用户能输入内容 +- AI 能返回分析 +- 系统能生成复习建议 +- 用户知道下一步该干什么 + +### 验证成功标准 + +- 至少 10 个用户愿意试用 +- 至少 3 个用户完整走完学习闭环 +- 至少 3 条有效反馈 +- 至少 1 个用户表示愿意继续用 +- 至少 1 个用户表示未来愿意付费 + +--- + +## 十二、暂缓事项(来源:`99-暂缓事项/暂缓事项.md`) + +以下为 v0.1 明确不做、后续解冻的事项: + +| 类别 | 暂缓内容 | 解冻条件 | +|------|----------|----------| +| 商业化 | Apple IAP、订阅、免费试用 | TestFlight 有真实用户 + 有人愿意付费 | +| 运营 | 社群、客服机器人、打卡活动 | 10+ 内测用户持续反馈 | +| 数据 | 完整埋点、留存分析、付费转化 | App Store MVP 准备上线 | +| 合规 | 公司注册、微信/支付宝、备案 | Apple 端稳定收入 | +| 多端 | iPad、Mac、Android、Web | iPhone 核心稳定 + 用户多端需求 | + +--- + +## 十三、当前 iOS 实现与计划的差距 + +| 维度 | 计划要求 | 当前状态 | 差距 | +|------|----------|----------|------| +| 登录 | Sign in with Apple | 多种登录 UI | 需简化或实现 Apple 登录 | +| 架构 | MVVM + Service | View 内聚 | 需重构分层 | +| 多语言 | 架构预留 | 未实现 | 需添加本地化 | +| 后端对接 | REST API | 无 | 需接入 | +| AI 集成 | 真实 AI 分析 | 静态 Mock | 需接入 AI API | +| 数据持久化 | 本地缓存 + Keychain | 无 | 需实现 | +| Tab 设计 | 4 个 Tab | 5 个 Tab | 多了"分析"Tab | +| 反馈页 | P1 优先级 | 未实现 | 需添加 | +| 复习计划页 | P0 优先级 | 部分实现 | 需独立设计 | diff --git a/AIStudyApp/docs/样式规范.md b/AIStudyApp/docs/样式规范.md new file mode 100644 index 0000000..9c6ca3a --- /dev/null +++ b/AIStudyApp/docs/样式规范.md @@ -0,0 +1,550 @@ +# 知习 iOS 样式规范 + +> 基于现有 DesignTokens 与实际页面中反复出现的 UI 模式,归纳形成本规范。 +> 后续创建新页面时,优先从本文档引用的 token 和组件中选择,保持一致的设计语言。 + +--- + +## 一、色彩系统 + +所有颜色定义在 `Core/DesignSystem/DesignTokens.swift`,命名前缀 `zx`(知习)。 + +### 1.1 背景 + +| Token | 色值 | 用途 | +|---|---|---| +| `zxBg0` | `#0F0F1A` | 页面基底(与 zxBg1 组成 page 渐变) | +| `zxBg1` | `#12122A` | page 渐变的第二色 | +| `zxBg2` | `#0A0A14` | 手机外壳装饰用,极少使用 | +| `zxBgSplash` | `#0D0D20` | 启动页专用 | + +### 1.2 文字 + +所有文字色基于 `#F0F0FF`(近白紫调)变化透明度: + +| Token | 透明度 | 用途 | +|---|---|---| +| `zxF0` | 100% | 标题、正文高亮 | +| `zxF007` | 70% | 次重要正文 | +| `zxF006` | 60% | — | +| `zxF05` | 50% | 次要信息 | +| `zxF0045` | 45% | — | +| `zxF04` | 40% | 辅助描述 | +| `zxF035` | 35% | 弱标签、灰色字段名 | +| `zxF03` | 30% | 占位符级别 | +| `zxF02` | 20% | 极弱(如未选中图标边框) | + +### 1.3 品牌/语义色 + +| Token | 色值 | 含义 | +|---|---|---| +| `zxPurple` | `#7C6EFA` | 主品牌,选中态、标签、进度 | +| `zxAccent` | `#A78BFA` | 次要品牌,AI 相关 | +| `zxOrange` | `#F97316` | 热度/连续天数/回忆 | +| `zxTeal` | `#2DD4BF` | 语言/词汇 | +| `zxCyan` | `#4ECDC4` | 进度条渐变 | +| `zxGreen` | `#34D399` | 已掌握、成功 | +| `zxYellow` | `#F59E0B` | 薄弱/警告/待复习 | +| `zxRed` | `#EF4444` | 错误/高优先级 | + +**彩色半透明背景**(用于标签、badge 底衬): + +```swift +zxPurpleBG(0.12) // 紫色 12% 透明 +zxOrangeBG(0.10) // 橙色 10% 透明 +zxGreenBG(0.15) // 绿色 15% 透明 +zxYellowBG(0.15) // 黄色 15% 透明 +zxTealBG(0.10) // 青色 10% 透明 +zxRedBG(0.15) // 红色 15% 透明 +``` + +### 1.4 边框/分割线 + +| Token | 透明度 | 用途 | +|---|---|---| +| `zxBorder015` | 15% | 较明显边框 | +| `zxBorder01` | 10% | 标准边框、虚线框 | +| `zxBorder008` | 8% | 通用卡片边框 | +| `zxBorder006` | 6% | 弱边框、卡片分隔 | +| `zxBorder004` | 4% | 极弱边框 | + +### 1.5 填充(半透明叠层) + +| Token | 透明度 | 用途 | +|---|---|---| +| `zxFill01` | 10% | 图表柱状 | +| `zxFill008` | 8% | 进度条底色 | +| `zxFill006` | 6% | 列表图标底衬 | +| `zxFill005` | 5% | 按钮/选中行底衬 | +| `zxFill004` | 4% | 输入框/面板底色 | +| `zxFill003` | 3% | 卡片底色 | + +--- + +## 二、渐变 + +所有渐变定义在 `ZXGradient` enum。 + +| 渐变 | 颜色 | 方向 | 用途 | +|---|---|---|---| +| `page` | `#0F0F1A → #12122A` | top→bottom | 所有主页面背景 | +| `splash` | `#0D0D20 → #0F0F1A → #130D20` | top→bottom | 启动页背景 | +| `brand` | `#7C6EFA → #F97316` | topLeading→bottomTrailing | CTA 按钮、播放按钮 | +| `brandPurple` | `#7C6EFA → #9B8BFF` | leading→trailing | 发送按钮、AI 气泡 | +| `ctaButton` | `#7C6EFA → #F97316` | topLeading→bottomTrailing | 同 brand,CTA 语义 | +| `ctaPurple` | `#7C6EFA → #9B8BFF` | topLeading→bottomTrailing | 紫色 CTA(创建/保存/提交) | +| `progressBar` | `#7C6EFA → #4ECDC4` | leading→trailing | 进度条 | +| `thinkingCard` | `#7C6EFA 8% → #F97316 4%` | topLeading→bottomTrailing | 思考卡片 | +| `progressCard` | `#7C6EFA 10% → #F97316 5%` | topLeading→bottomTrailing | 进度卡片 | +| `feedbackScore` | `#7C6EFA 12% → #34D399 6%` | topLeading→bottomTrailing | 反馈评分卡片 | +| `profileCard` | `#7C6EFA 15% → #F97316 8%` | topLeading→bottomTrailing | 个人页卡片 | + +--- + +## 三、圆角 + +```swift +ZXRadius.xs = 2 // 进度指示器小圆点 +ZXRadius.sm = 8 // 小图标 +ZXRadius.md = 10 // 标准图标按钮 +ZXRadius.lg = 12 // 标签/徽章 +ZXRadius.xl = 14 // 卡片/输入框/行 +ZXRadius.xl2 = 16 // 大卡片/对话框 +ZXRadius.xl3 = 20 // 主要面板 + +ZXRadius.button = 12 // 标准按钮 +ZXRadius.buttonLg = 18 // 大按钮(CTA) +ZXRadius.icon = 10 // 小图标 +ZXRadius.iconLg = 12 // 中图标 +ZXRadius.avatar = 13 // 头像/emoji 图标 +``` + +**使用原则**: +- 卡片统一 `14`–`20` +- 按钮统 `12`–`18` +- 输入框 `14` +- 标签 `Capsule()`(自动全圆角) + +--- + +## 四、间距 + +```swift +ZXSpacing.ss = 2 // 字间距/tracking 用 +ZXSpacing.xs = 4 +ZXSpacing.sm = 6 +ZXSpacing.md = 8 // 卡片内元素间距 +ZXSpacing.lg = 10 +ZXSpacing.xl = 12 // 行内元素间距 +ZXSpacing.xl2 = 14 +ZXSpacing.xl3 = 16 // 卡片间距 +ZXSpacing.xl4 = 20 // 页面水平内边距 +ZXSpacing.xl5 = 24 +ZXSpacing.xl6 = 28 + +// 专用 +ZXSpacing.pageHPadding = 20 // 所有页面两侧统一留白 +ZXSpacing.statusBarH = 44 // 状态栏 + Dynamic Island +ZXSpacing.tabBarH = 83 // 底部 TabBar 总高 +ZXSpacing.homeIndicatorH = 34 // Home Indicator +``` + +**页面结构公式**: +``` +header top padding = statusBarH + 16 +每个 section 间距 = 12–20 +ScrollView bottom = 120(有 TabBar)/ 80–100(子页面) +``` + +--- + +## 五、尺寸 + +```swift +// 按钮 +ZXSize.iconBtn = 36 // 图标按钮(ZXIconBtn 默认) +ZXSize.buttonH = 42 // 标准按钮高 +ZXSize.buttonLgH = 52 // 大按钮高 +ZXSize.buttonXlH = 56 // CTA 按钮高 +ZXSize.sendBtn = 30 // 发送按钮 + +// 图标 +ZXSize.iconSm = 14 +ZXSize.iconMd = 16 +ZXSize.iconLg = 18 +ZXSize.tabIcon = 22 // TabBar 图标 +ZXSize.listIcon = 40 // 列表图标 +ZXSize.libraryIcon = 44 // 知识库卡片图标 + +// 头像 +ZXSize.avatarSm = 36 +ZXSize.avatarMd = 64 +ZXSize.avatarLg = 80 + +// 其他 +ZXSize.quickActionH = 72 // 快捷操作高度 +ZXSize.inputH = 44 // 输入框高度 +ZXSize.progressH = 5 // 进度条高度 +ZXSize.scoreBox = 36 // 分数方块 +ZXSize.weakBox = 40 // 薄弱点分数方块 +ZXSize.topBar = 3 // 顶部装饰条 +ZXSize.searchIconBtn = 36 // 搜索图标按钮 +``` + +--- + +## 六、字体层级 + +```swift +// 使用方式: .font(.system(size: ZXFont.xxx.size, weight: ZXFont.xxx.weight)) + +titleLarge // 22pt, heavy, -0.5 → 页面主标题 +titleMedium // 20pt, heavy, -0.4 → 二级标题 +sectionTitle // 15pt, bold → 区域标题 +subsectionTitle // 14pt, bold → 子标题 +body // 13pt, semibold, 1.4 → 正文 +bodySmall // 12pt, medium → 辅助信息 +caption // 10pt, bold → 标签加粗 +captionSmall // 10pt, regular → 标签常规 +labelXs // 9pt, regular → 最小标签 +score // 12pt, heavy → 分数 +scoreLarge // 22pt, heavy, 1 → 大分数 +date // 12pt, medium → 日期 +description // 12pt, regular, 0.4 → 描述文字 +``` + +**实际页面中常用的内联声明**(可直接使用,也可用 ZXFont 引用): +- 页面大标题:`.font(.system(size: 22, weight: .heavy)).tracking(-0.5)` `.foregroundColor(.zxF0)` +- 区域标题:`.font(.system(size: 15, weight: .bold)).foregroundColor(.zxF0)` +- 卡片标题:`.font(.system(size: 13, weight: .semibold)).foregroundColor(.zxF0)` +- 辅助文字:`.font(.system(size: 12)).foregroundColor(.zxF04)` +- 标签文字:`.font(.system(size: 10, weight: .semibold))` +- 大数值:`.font(.system(size: 26, weight: .black))` + +--- + +## 七、共享组件目录 + +以下组件散落在各页面文件中,新页面应直接复用,禁止重复实现。 + +### 7.1 导航/布局 + +**ZXTabBar**(`ContentView.swift:58`) +5 个 tab(AI / 知识库 / 学习 / 分析 / 我的),选中态紫色高亮 + 圆形背景扩散。 +```swift +ZXTabBar(active: $selectedTab) +``` + +**ZXBackHeader**(`DailyThinkingPage.swift:52`) +子页面顶部返回栏,含标题、副标题、可选的右侧按钮。 +```swift +ZXBackHeader(title: "标题", subtitle: "副标题") { trailingView } +ZXBackHeader(title: "标题", subtitle: nil, onBack: customBackAction) { ... } +``` + +### 7.2 按钮 + +**ZXIconBtn**(`ContentView.swift:100`) +标准 36pt 圆形图标按钮,可切换品牌渐变样式。 +```swift +ZXIconBtn(icon: "bell", size: 36) { action } +ZXIconBtn(icon: "plus", size: 36, branded: true) { action } // 品牌渐变底 +``` + +**ZXOutlineBtn**(`DailyThinkingPage.swift:167`) +44pt 高描边文字按钮。 +```swift +ZXOutlineBtn(text: "深入提问") +``` + +### 7.3 列表/卡片行 + +**ZXCardRow**(`LibrarySubpages.swift:40`) +带 emoji 图标、标题、描述、状态标签的标准列表行。 +```swift +ZXCardRow(emoji: "📝", title: "标题", desc: "描述", status: "已掌握", c: .zxGreen) +``` + +**ZXImportOption**(`LibrarySubpages.swift:93`) +带大图标 + 标题描述的导入选项行。 + +**ZXProfileMenuRow**(`ProfileView.swift:58`) +Profile 页菜单行,emoji + 标题 + 描述 + 箭头。 + +**ReviewTaskRow**(`ReviewPlanView.swift:82`) +复习任务行,含完成勾选 + 复习类型标签 + 播放按钮。 + +**ZXAIInteractionRow**(`AIHomeView.swift:138`) +AI 互动记录行,含标签 + 时间 + 分数。 + +### 7.4 数据展示 + +**ZXScoreBox**(`ContentView.swift:50`) +36pt 方形分数格。 +```swift +ZXScoreBox(score: 82, bg: .zxGreenBG(0.15), fg: .zxGreen) +``` + +**ZXStatBadge**(`AnalysisHomeView.swift:74`) +统计徽章,图标 + 数值 + 标签 + 趋势。 + +**ZXWeakRow**(`AnalysisHomeView.swift:91`) +薄弱知识点行,分数 + 标题 + 库名 + 优先级。 + +**ZXAchievementBadge**(`ProfileView.swift:61`) +成就徽章,emoji + 标签。 + +**ZXProfileStat**(`ProfileView.swift:55`) +Profile 页三栏统计数字。 + +**ZXChartView**(`AnalysisHomeView.swift:117`) +折线图(掌握度趋势)。 + +### 7.5 输入 + +**ZXAIInputBar**(`ContentView.swift:26`) +AI 对话输入栏,含 sparkles 图标 + 文本框 + 麦克风 + 发送按钮。 + +### 7.6 标签 + +**ZXChip**(`LibrarySubpages.swift:74`) +Capsule 标签,彩色文字 + 半透明底色。 + +**复习类型标签**(`ReviewTaskRow.swift:129`) +4 种:间隔重复(紫) / 费曼(accent) / 回忆(橙) / 薄弱(黄)。 + +### 7.7 卡片装饰 + +**ZXQuickAction**(`AIHomeView.swift:132`) +72pt 高快捷操作按钮,emoji + 文字。 + +**ZLibraryCard**(`LibraryHomeView.swift:32`) +知识库卡片,顶部渐变色条 + emoji + 名称 + 进度 + 标签。 + +### 7.8 状态组件 + +**ZXLoadingView**(`Shared/Components/ZXLoadingView.swift`) +全屏加载指示器,紫色 ProgressView + "加载中…" 文字。 + +**ZXShimmerList**(`Shared/Components/ZXLoadingView.swift`) +骨架屏列表,传入 `count` 生成占位卡片。 + +**ZXErrorView**(`Shared/Components/ZXErrorView.swift`) +全屏错误状态,黄色三角图标 + 消息 + 可选重试按钮。 + +**ZXErrorBanner**(`Shared/Components/ZXErrorView.swift`) +内联错误横幅,黄色背景 + 消息 + 关闭按钮。 + +**ZXEmptyView**(`Shared/Components/ZXEmptyView.swift`) +空状态视图,支持图标 + 标题 + 副标题 + 可选操作按钮。 + +### 7.9 其他 + +**FeatureRow**(`Shared/Components/FeatureRow.swift`) +Welcome 页功能介绍行。 + +--- + +## 八、页面布局模式 + +### 8.1 主 Tab 页(有底部 TabBar) + +```swift +struct SomeHomeView: View { + var body: some View { + ZStack { + ZXGradient.page.ignoresSafeArea() // ① 背景 + + VStack(spacing: 0) { + // ② Header + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("副标题").font(.system(size: 12)).foregroundColor(.zxF04) + Text("主标题").font(.system(size: 22, weight: .heavy)) + .foregroundColor(.zxF0).tracking(-0.5) + } + Spacer() + // 右侧按钮/状态 + } + .padding(.horizontal, 20) + .padding(.top, ZXSpacing.statusBarH + 16) + .padding(.bottom, 12) + + // ③ 可滚动内容 + ScrollView { + VStack(spacing: 16) { + // 卡片/列表... + } + .padding(.horizontal, 20) + .padding(.bottom, 120) // 为 TabBar 留空间 + } + .scrollIndicators(.hidden) + } + } + .navigationBarHidden(true) + .preferredColorScheme(.dark) + } +} +``` + +### 8.2 子页面(有返回按钮,无 TabBar) + +```swift +struct SomeDetailPage: View { + var body: some View { + ZStack { + Color.zxBg0.ignoresSafeArea() // 纯色(非渐变) + + VStack(spacing: 0) { + ZXBackHeader(title: "标题", subtitle: "副标题") { + // 右侧按钮 + } + + ScrollView { + VStack(spacing: 16) { + // 内容 + } + .padding(.horizontal, 20) + .padding(.bottom, 80) + } + .scrollIndicators(.hidden) + } + } + .navigationBarHidden(true) + } +} +``` + +### 8.3 卡片通用写法 + +```swift +VStack(alignment: .leading, spacing: 12) { + // 卡片内容 +} +.padding(16) +.background(Color.zxFill003) // 底色 +.overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.zxBorder006, lineWidth: 1) // 边框 +) +.clipShape(RoundedRectangle(cornerRadius: 16)) // 裁剪 +``` + +渐变背景卡片把 `.background(Color.zxFill003)` 换成对应的 `ZXGradient.xxx`。 + +### 8.4 输入框通用写法 + +```swift +TextField("占位文字", text: $text) + .font(.system(size: 14)) + .tint(.zxPurple) + .padding(.horizontal, 16) + .frame(height: 52) + .background(Color.zxFill004) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.zxBorder008, lineWidth: 1) + ) +``` + +### 8.5 状态标签(Capsule 形) + +```swift +Text("标签文字") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(semanticColor) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(semanticColor.opacity(0.12)) + .clipShape(Capsule()) +``` + +--- + +## 九、设计决策与约束 + +### 9.1 全局 + +- 强制深色模式:`.preferredColorScheme(.dark)` +- 所有页面 `navigationBarHidden(true)`,使用自建导航 +- 禁止使用系统默认蓝色、默认圆角、默认间距 + +### 9.2 颜色 + +- 文字统一用 `#F0F0FF` 加透明度,不要用 `Color.white` +- 不要直接写 `Color(hex: ...)` 内联,优先用现有 token;确需新色值先加 token +- 语义色不要混用——紫色是品牌/AI,橙色是热度/连续,绿色是完成,黄色是警告 + +### 9.3 字体 + +- 不使用系统默认字体大小(如 `.title`、`.headline`),一律显式 `.system(size:weight:)` +- 标题统一 tracking 负值(`-0.5` 或 `-0.4`) +- 中文内容避免使用 `.bold` 以下的极细字体 + +### 9.4 布局 + +- 页面水平留白统一 `20pt` +- 卡片间距 `12–20pt` +- 内容区底部留白至少 `80pt`(子页面)或 `120pt`(有 TabBar) +- 所有 ScrollView 隐藏指示器:`.scrollIndicators(.hidden)` + +### 9.5 组件 + +- 优先复用第七节的共享组件,不要内联重复 UI +- 新组件如通用性足够,应抽到 `Shared/` 目录 + +--- + +## 十、文件引用索引 + +| 规范项 | 定义文件 | +|---|---| +| 颜色/渐变/圆角/间距/尺寸/字体 | `Core/DesignSystem/DesignTokens.swift` | +| ZXTabBar | `Shared/Components/ZXTabBar.swift` | +| ZXIconBtn | `Shared/Components/ZXIconBtn.swift` | +| ZXScoreBox | `Shared/Components/ZXScoreBox.swift` | +| ZXAIInputBar | `Shared/Components/ZXAIInputBar.swift` | +| ZXBackHeader | `Shared/Components/ZXBackHeader.swift` | +| ZXOutlineBtn | `Shared/Components/ZXOutlineBtn.swift` | +| ZXQuickAction | `Shared/Components/ZXQuickAction.swift` | +| ZXAIInteractionRow | `Shared/Components/ZXAIInteractionRow.swift` | +| ZXCardRow | `Shared/Components/ZXCardRow.swift` | +| ZXChip | `Shared/Components/ZXChip.swift` | +| ZXImportOption | `Shared/Components/ZXImportOption.swift` | +| ZXWeakRow | `Shared/Components/ZXWeakRow.swift` | +| ZXStatBadge | `Shared/Components/ZXStatBadge.swift` | +| ZXProfileMenuRow | `Shared/Components/ZXProfileMenuRow.swift` | +| ZXProfileStat | `Shared/Components/ZXProfileStat.swift` | +| ZXAchievementBadge | `Shared/Components/ZXAchievementBadge.swift` | +| ZXChartView | `Shared/Components/ZXChartView.swift` | +| ZXSTaskRow + ZXSTask | `Shared/Components/ZXSTaskRow.swift` | +| FeatureRow | `Shared/Components/FeatureRow.swift` | +| ReviewTaskRow | `Shared/Components/ReviewTaskRow.swift` | +| ZXLoadingView / ZXShimmerList | `Shared/Components/ZXLoadingView.swift` | +| ZXErrorView / ZXErrorBanner | `Shared/Components/ZXErrorView.swift` | +| ZXEmptyView | `Shared/Components/ZXEmptyView.swift` | +| ZXLibraryCard | `Features/Library/LibraryHomeView.swift` | +| AIHomeView | `Features/AI/AIHomeView.swift` | +| DailyThinkingPage | `Features/AI/DailyThinkingPage.swift` | +| RecallTestPage | `Features/AI/RecallTestPage.swift` | +| WeakPointsPage | `Features/AI/WeakPointsPage.swift` | +| AIFeedbackPageView | `Features/AI/AIFeedbackPage.swift` | +| AIChatPage | `Features/AI/AIChatPage.swift` | +| LibraryHomeView | `Features/Library/LibraryHomeView.swift` | +| CreateLibraryPage | `Features/Library/CreateLibraryPage.swift` | +| LibraryDetailPage | `Features/Library/LibraryDetailPage.swift` | +| AddKnowledgePage | `Features/Library/AddKnowledgePage.swift` | +| KnowledgeDetailPage | `Features/Library/KnowledgeDetailPage.swift` | +| ImportPage | `Features/Library/ImportPage.swift` | +| EditKnowledgePage | `Features/Library/EditKnowledgePage.swift` | +| AnalysisHomeView | `Features/Analysis/AnalysisHomeView.swift` | +| ProfileView | `Features/Profile/ProfileView.swift` | +| ReviewPlanView | `Features/Review/ReviewPlanView.swift` | +| StudyHomeView | `Features/Study/StudyHomeView.swift` | +| SplashPage | `Features/Onboarding/SplashPage.swift` | +| WelcomePage | `Features/Onboarding/WelcomePage.swift` | +| OnboardingPage | `Features/Onboarding/OnboardingPage.swift` | +| GoalSetupPage | `Features/Onboarding/GoalSetupPage.swift` | +| AppRootView / OnboardingFlowView | `AIStudyAppApp.swift` | +| LoginView | `Features/Auth/Views/LoginView.swift` | diff --git a/AIStudyApp/docs/缺失项与待补全方向.md b/AIStudyApp/docs/缺失项与待补全方向.md new file mode 100644 index 0000000..8d56c01 --- /dev/null +++ b/AIStudyApp/docs/缺失项与待补全方向.md @@ -0,0 +1,1042 @@ +# 缺失项与待补全方向 + +> 基于 v0.1 创业计划文档与当前 iOS 代码对比分析 +> 整理时间:2026-05-10 + +本文档系统性列出知习 iOS App 当前在架构、页面、功能、设计等方面的缺失项,并给出优先级建议。 + +--- + +## 一、架构层缺失 + +### 1.1 MVVM 分层 + +**现状**:全部代码写在 SwiftUI View 中,无任何 ViewModel/ObservableObject/@Published。grep 搜索 ViewModel、ObservableObject、@Published 均为零结果。 + +**缺失**: +- 无 ViewModel 层,业务逻辑、状态管理、数据转换全部堆在 View 里 +- 无 Model 层,数据结构通过 View 内的局部 struct 或硬编码数据隐式定义 +- 代码不可测试,无法单独验证业务逻辑 + +**计划要求**(`官网与技术基础.md` 第 5.3 节): +``` +AIStudyApp/ +├── Features/ +│ ├── Onboarding/ +│ │ ├── Views/ ← 当前有,但无 ViewModel/Model 子目录 +│ │ ├── ViewModels/ ← 缺失 +│ │ └── Models/ ← 缺失 +``` + +### 1.2 Service 层 + +**现状**:无任何 Service 类,AI 分析、学习记录、用户管理等概念没有对应的服务抽象。 + +**缺失**: +| Service | 职责 | 设计文档 | +|---------|------|----------| +| AuthService | Apple 登录、Token 管理、会话维护 | `docs/AI对话.md`(详细设计) | +| LearningService | 学习记录 CRUD、进度追踪 | — | +| AIService | AI 分析请求代理、结果解析 | — | +| ReviewService | 复习任务生成、调度 | — | +| KnowledgeService | 知识库/路径/课程查询 | — | +| FeedbackService | 用户反馈提交 | — | + +**Auth 模块已有详细文件清单**(来自 `docs/AI对话.md`): +``` +Features/Auth/Views/LoginView.swift +Features/Auth/ViewModels/LoginViewModel.swift + +Core/Services/AuthService.swift +Core/Services/AuthServiceProtocol.swift + +Core/Storage/KeychainStore.swift +Core/Storage/TokenStore.swift + +App/AppSession.swift + +Core/Models/AuthModels.swift +Core/Models/User.swift +``` + +### 1.3 Repository 层 + +**现状**:零数据持久化,所有"数据"均为 View 中硬编码的 mock。 + +**缺失**: +- 无数据访问抽象(未来可能切换 CoreData → API,需要 Repository 隔离) +- 无本地缓存层 +- 无网络数据源层 + +### 1.4 网络层 + +**现状**:无任何网络请求代码,无 APIClient,无 URLSession 调用。 + +**缺失**: +- APIClient(封装 URLSession,注入 baseURL、header、token) +- APIEndpoint(枚举化 API 路径,统一请求构建) +- APIError(统一错误模型和处理) +- 请求/响应拦截器(日志、token 刷新) +- Mock 层(本地开发和 UI 预览用) + +### 1.5 依赖注入 + +**现状**:无任何 DI 模式,Service 和 ViewModel 尚未创建,暂时不存在注入问题。但需要在架构搭建时建立模式。 + +**建议**:初期使用构造函数注入 + `@EnvironmentObject`,避免引入第三方 DI 框架。 + +--- + +## 二、核心能力缺失 + +### 2.1 Sign in with Apple(已有详细设计文档) + +**现状**:`LoginPage` 有 UI(手机号/邮箱/微信/Apple 入口),但 `AIStudyAppApp` 仅用 `@AppStorage("hasCompletedOnboarding")` 控制是否进入主界面,无实际认证。 + +**计划要求**:第一版登录方式仅为 Sign in with Apple(`Demo与MVP.md` 第 5.2 节)。 + +**详细设计方案**见 `docs/AI对话.md`,核心结论: + +**登录页只保留一个入口**: +``` +Sign in with Apple +``` + +**删除的入口**:手机号登录、邮箱登录、微信登录、验证码登录 + +**"跳过"按钮处理**: +```swift +#if DEBUG +Button("跳过,进入演示模式") { ... } +#endif +``` +正式环境不展示跳过入口,避免后续匿名用户迁移问题。 + +**登录流程设计**: +``` +App 启动 + ↓ +AppSession 检查 Keychain 是否有 refreshToken + ↓ +有 token → 调用 /auth/refresh 或 /users/me + ↓ +成功 → 进入主界面 +失败 → 清空 token,进入登录页 + ↓ +无 token → 进入登录页 + ↓ +用户点击 Sign in with Apple + ↓ +获取 identityToken / authorizationCode / userIdentifier + ↓ +POST /api/auth/apple + ↓ +后端返回 accessToken / refreshToken / user + ↓ +token 存入 Keychain(不要 UserDefaults) + ↓ +判断 user.onboardingCompleted + ↓ +未完成 → 引导/目标设置 +已完成 → 主界面 +``` + +**登录相关新增文件**: +``` +Features/Auth/Views/LoginView.swift # 仅 Apple 登录按钮 +Features/Auth/ViewModels/LoginViewModel.swift # @MainActor, @Published isLoading/errorMessage + +Core/Services/AuthService.swift +Core/Services/AuthServiceProtocol.swift + +Core/Storage/KeychainStore.swift +Core/Storage/TokenStore.swift + +App/AppSession.swift # 全局登录状态管理 + +Core/Models/AuthModels.swift # AppleLoginRequest, AuthResponse +Core/Models/User.swift +``` + +**API Contract(Auth)**: +```swift +// 请求 +struct AppleLoginRequest: Encodable { + let identityToken: String + let authorizationCode: String? + let userIdentifier: String + let fullName: AppleFullName? + let email: String? +} + +// 响应 +struct AuthResponse: Decodable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + let user: User +} +``` + +**关键约束**: +- 不再用 `@AppStorage("hasCompletedOnboarding")` 单独决定是否进入主界面 +- 登录状态必须由 `AppSession` + Keychain token 决定 +- Token 不存 UserDefaults +- View 里不写网络请求,不直接处理 Apple 登录细节 + +### 2.2 后端 API 对接 + +**现状**:所有页面为静态 UI,无任何网络请求。 + +**计划定义的 P0 API**: +- `POST /ai/analyze-learning-input` — AI 分析用户学习输入 +- `POST /ai/chat` — AI 对话 +- 用户/知识库/学习记录/反馈 CRUD + +### 2.3 真实 AI 集成 + +**现状**:AI 相关页面全为静态文本。 + +**需对接**: +- 后端 AI Provider 抽象层(MiniMax/DeepSeek/OpenAI 等) +- 结构化 JSON 输出解析 +- AI 分析结果展示(掌握度评分、优缺点、建议) +- AI 对话流式响应 + +### 2.4 本地数据持久化 + +**现状**:零持久化实现。 + +**需实现**: +- UserDefaults / @AppStorage(简单偏好) +- Keychain(Token、敏感信息) +- 后续可考虑 CoreData 或 SwiftData(学习记录离线缓存) + +### 2.5 多语言本地化 + +**现状**:所有文案硬编码在 View 中,无 Localizable.xcstrings 文件。 + +**计划要求**(`Demo与MVP.md` 第 6 节): +- 默认简体中文 +- 预留英文 +- App UI 文案使用本地化资源 + +**需实现**: +- 创建 `Localizable.xcstrings` +- 将所有硬编码文案迁移为 `LocalizedStringKey` +- 支持语言切换 + +### 2.6 错误/加载/空状态处理 + +**现状**:无任何错误处理、加载态、空状态 UI。 + +**至少需要**: +- 网络请求 loading 指示器 +- 网络错误提示和重试按钮 +- AI 分析中的等待状态 +- 列表空状态(如知识库为空时的引导) +- 登录失败错误提示 + +--- + +## 三、页面层面差距 + +### 3.1 与计划页面对比 + +| 计划页面 | 计划优先级 | 当前状态 | 说明 | +|----------|-----------|---------|------| +| 启动页/欢迎页 | P1 | ✅ 已实现 | SplashPage + WelcomePage | +| 登录页 | P0 | ✅ 已实现 | 新 LoginView 替换旧 LoginPage,仅 Apple 登录 | +| 语言与偏好页 | P1 | ❌ 未实现 | 无页面 | +| 学习方向选择页 | P0 | ⚠️ 部分实现 | GoalSetupPage 有目标选择,但非学习方向选择 | +| 学习路径页 | P0 | ✅ 已实现 | LibraryDetailPage | +| 今日学习任务页 | P0 | ✅ 已实现 | StudyHomeView | +| 内容阅读页 | P0 | ✅ 已实现 | KnowledgeDetailPage | +| 主动回忆/笔记输入页 | P0 | ✅ 已实现 | DailyThinkingPage + RecallTestPage | +| AI 分析结果页 | P0 | ✅ 已实现 | AIFeedbackPage | +| AI 对话页 | P0 | ✅ 已实现 | AIChatPage | +| 复习计划页 | P0 | ✅ 已实现 | ReviewPlanView,含今天/明天/本周分组 + 复习类型标签 | +| 学习进度页 | P1 | ✅ 已实现 | AnalysisHomeView | +| 设置页 | P1 | ⚠️ 部分实现 | ProfileView 有设置菜单,但功能入口为空 | +| 反馈页 | P1 | ✅ 已实现 | FeedbackView + FeedbackViewModel,分类选择 + 提交确认 | + +### 3.2 复习计划页(P0 ✅) + +**计划描述**:系统生成复习任务,用户查看待复习内容,按推荐时间安排学习。 + +**当前**:已实现 `Features/Review/ReviewPlanView.swift`,含今天/明天/本周三组、复习类型标签(间隔重复/费曼/回忆/薄弱)、完成勾选、播放按钮。数据层使用 `ReviewTask` Model + mock 数据。 + +**待对接**:接入 ReviewService 后端数据。 + +### 3.3 反馈页(P1 ✅) + +**计划描述**:App 内反馈入口,让内测用户提交问题和建议。 + +**当前**:已实现 `Features/Feedback/FeedbackView.swift` + `FeedbackViewModel.swift`,含 4 类反馈分类(Bug/功能建议/内容问题/其他)的图标选择器、文本描述输入、提交通知。入口位于 ProfileView 菜单末项。 + +**待对接**:接入后端 `/feedback` API(FeedbackService)。 + +### 3.4 等待名单入口 + +**计划**:官网 `/waitlist` 页面收集用户,App 内也需要引导用户加入等待名单/申请内测。 + +**需考虑**:是否在 App 内嵌等待名单入口(如 Welcome 页或设置页)。 + +--- + +## 四、设计与交互差距 + +### 4.1 Tab 结构调整 + +**计划设计**:4 个 Tab — 学习 | 知识库 | AI助手 | 我的 + +**当前实现**:5 个 Tab — AI | 知识库 | 学习 | 分析 | 我的 + +**差异分析**: +- 当前把"学习"和"分析"拆成了两个独立 Tab +- 计划把"AI助手"独立为一个 Tab,当前 AI 已是独立 Tab +- "分析"在计划中属于"学习"Tab 下的子页面,不需要顶层 Tab + +**建议**(两种方案): +- **方案 A**:完全对齐计划 → 合并学习和分析为一个 Tab,保持 4 Tab +- **方案 B**:保留 5 Tab 结构 → 更新计划文档,论证"分析"独立为 Tab 的合理性(学习数据可视化、学习进度监控是独立价值) + +### 4.2 登录流程简化(已有详细设计) + +**计划要求**:仅 Sign in with Apple,不做手机号/邮箱/微信登录。 + +**当前 UI**:包含 4 种登录方式入口。 + +**最终方案**(详见 `docs/AI对话.md`): +- 登录页只保留 Sign in with Apple 一个按钮 +- 删除手机号、邮箱、微信、验证码登录 +- 跳过按钮仅限 `#if DEBUG`,Release 不展示 +- 登录页文案极简:品牌 + 一句话价值主张 + Apple 登录按钮 + 协议入口 + +### 4.3 浅色/深色模式双主题(P1 新增)✅ 已完成 + +**实现方式**: + +| 改造项 | 状态 | +|--------|------| +| `Color(light:dark:)` 自适应 helper | ✅ `DesignTokens.swift` 新增基于 `UITraitCollection.userInterfaceStyle` 的动态颜色 | +| 28 个颜色 token 双主题化 | ✅ 背景(4) + 文字(9) + 边框(5) + 填充(6) + 品牌色/彩色半透不变 | +| 渐变自适应 | ✅ `page`/`splash` 渐变改用自适应 Color token | +| 硬编码色值替换 | ✅ 13 处内联 `Color(hex:)` 替换为自适应 token | +| ColorSchemeManager | ✅ `Core/Appearance/ColorSchemeManager.swift`,@AppStorage 持久化,支持系统/浅色/深色 | +| 移除强制深色 | ✅ 移除 4 处 `.preferredColorScheme(.dark)` | +| 设置页切换入口 | ✅ ProfileView 外观行改为可点击,confirmationDialog 三选一 | + +**文件变更**: +- 新增:`Core/Appearance/ColorSchemeManager.swift` +- 修改:`DesignTokens.swift`(颜色全量自适应 + 渐变) +- 修改:`AIStudyAppApp.swift`(根视图用 manager 控制 scheme) +- 修改:`ContentView.swift`、`LoginView.swift`(移除强制暗黑) +- 修改:`ProfileView.swift`(外观切换入口) +- 修改:`SplashPage.swift`、`ZXTabBar.swift`、`ZXAIInteractionRow.swift`、`ZXChartView.swift`、`AIFeedbackPage.swift`、`WelcomePage.swift`、`OnboardingPage.swift`、`GoalSetupPage.swift`、`ZXSTaskRow.swift`、`ZXIconBtn.swift`(内联色值→token) + +### 4.4 语言系统(P1 新增) + +**现状**:所有文案硬编码中文在 SwiftUI View 中,无 Localizable.strings。 + +**要求**:先只支持中文,但搭建好本地化基础设施,后续加语言时只需加翻译文件。 + +**需实现**: + +| 步骤 | 说明 | +|------|------| +| 创建 `Localizable.strings` (Base) | 中文作为 Base 语言,不设 `zh-Hans` | +| 封装 `ZXLocalized` 辅助 | `String(localized:)` + `Text("key")` 的 SwiftUI 原生方式 | +| 迁移硬编码文案 | 逐文件将文案替换为 LocalizedStringKey | +| 设置页语言入口 | 预留语言切换 UI,当前仅显示"中文" | + +### 4.5 无障碍 + +**现状**:未考虑 VoiceOver、Dynamic Type、高对比度等无障碍需求。 + +**至少需做**: +- 关键按钮添加 `.accessibilityLabel` +- 确保 Dynamic Type 下布局不破碎 +- 重点页面 VoiceOver 测试 + +### 4.6 样式规范 + +已于 `docs/样式规范.md` 中梳理完整的样式规范文档,涵盖: +- 色彩系统(背景/文字/品牌语义色/边框/填充) +- 渐变体系(页面、品牌、卡片、进度条、CTA 等 11 组渐变) +- 圆角、间距、尺寸 token +- 字体层级(12 级定义) +- 共享组件目录(20+ 组件,含导航、按钮、卡片行、数据展示、输入、标签等) +- 页面布局模式(主 Tab 页、子页面、卡片、输入框、状态标签的标准写法) +- 设计决策与约束 + +**后续新页面必须遵循 `docs/样式规范.md`**,复用已有组件和 token,禁止随意使用内联颜色/间距/字体。 + +### 4.7 动效 + +**计划要求**(`官网与技术基础.md` 第 6.3 节): +- P0:页面过渡、按钮反馈、加载状态、AI 分析中状态、学习完成反馈 +- P1:今日任务卡片动效、进度条更新、AI 结果分块出现 + +**当前**:仅有基础 SwiftUI 隐式动画(withAnimation),未实现任何计划中的动效。 + +--- + +## 五、数据层缺失 + +### 5.1 Model 定义 + +**现状**:所有数据通过 View 内局部变量或硬编码定义,无独立 Model 文件。 + +**计划中定义的核心实体**(`Demo与MVP.md`): + +``` +User +├── id, appleUserId, displayName, email +├── preferredLanguage, createdAt, lastLoginAt, status + +KnowledgeBase +├── id, title, description, language, targetUser +├── createdAt, updatedAt + +LearningPath +├── id, knowledgeBaseId, title, description +├── estimatedDays, order + +Lesson +├── id, pathId, title, content, objectives +├── keyPoints, recallQuestions, practicePrompt +├── order, estimatedMinutes + +LearningSession +├── id, userId, lessonId +├── startedAt, endedAt, userInput +├── aiAnalysis, masteryScore, weakPoints +├── nextSuggestion, reviewAt + +AIAnalysis +├── id, userId, sessionId +├── inputText, outputJson, masteryScore +├── weakPoints, suggestions +├── modelName, createdAt, costEstimate + +ReviewTask +├── id, userId, lessonId, sourceSessionId +├── reviewType, scheduledAt, completedAt, status + +Feedback +UserLearningProfile +``` + +**需实现**:在 `Features/*/Models/` 下创建对应的 Swift struct(需 Codable、Identifiable)。 + +### 5.2 API Contract + +**现状**:无 API 类型定义。 + +**建议**:参考计划中定义的 JSON 结构,先创建 Swift Model,再定义 API 请求/响应类型(Request/Response struct),实现前后端类型同构。 + +### 5.3 数据流规范 + +**现状**:View 直接持有 @State,无数据流管理。 + +**建议**: +- ViewModel 持有 @Published 状态 +- ViewModel 通过 Service 获取数据 +- Service 通过 Repository 访问数据源 +- View 通过 @StateObject / @ObservedObject 绑定 ViewModel + +--- + +## 六、工程化缺失 + +### 6.1 大文件拆分 ✅ + +**已完成**: + +| 原文件 | 拆分结果 | +|--------|----------| +| `AIStudyAppApp.swift` (~190行) | 保留 App/Root/OnboardingFlowView,页面移入 `Features/Onboarding/` | +| `DailyThinkingPage.swift` (~200行) | 拆为 5 个文件(DailyThinking / RecallTest / WeakPoints / AIFeedback / AIChat) | +| `LibrarySubpages.swift` (~112行) | 已删除,拆为 6 个独立页面文件 | + +### 6.2 共享组件管理 ✅ + +**已完成**:20 个共享组件集中到 `Shared/Components/`,原文件中的定义已移除。 + +``` +Shared/Components/ +├── ZXTabBar.swift ├── ZXBackHeader.swift +├── ZXIconBtn.swift ├── ZXScoreBox.swift +├── ZXAIInputBar.swift ├── ZXOutlineBtn.swift +├── ZXQuickAction.swift ├── ZXAIInteractionRow.swift +├── ZXCardRow.swift ├── ZXChip.swift +├── ZXImportOption.swift ├── ZXWeakRow.swift +├── ZXStatBadge.swift ├── ZXProfileStat.swift +├── ZXProfileMenuRow.swift ├── ZXAchievementBadge.swift +├── ZXChartView.swift ├── ZXSTaskRow.swift +├── FeatureRow.swift ├── ReviewTaskRow.swift +├── ZXLoadingView.swift ├── ZXErrorView.swift +└── ZXEmptyView.swift +``` + +### 6.3 测试 + +**现状**:无任何测试代码。 + +**至少需要**: +- ViewModel 单元测试(当 ViewModel 创建后) +- Service 层单元测试(Mock Repository) +- 关键 UI 流程的 Snapshot 测试 + +### 6.4 CI/CD + +**现状**:无。 + +**建议**(后续): +- GitHub Actions / Xcode Cloud 自动构建 +- TestFlight 自动分发 + +### 6.5 崩溃监控与埋点 + +**现状**:无。 + +**建议**:接入 Firebase Crashlytics 或类似服务,至少在 TestFlight 阶段要有崩溃收集能力。 + +--- + +## 七、优先级建议 + +### P0 — 必须在接后端前完成 + +| 步骤 | 项目 | 理由 | 设计文档 | +|------|------|------|----------| +| ① | 创建 Auth Model(AuthModels + User) | 所有后续步骤的数据基础 | 第十章 步骤 1 ✅ | +| ② | 实现 Keychain 存储层 | Token 安全存储是登录的前提 | 第十章 步骤 2 ✅ | +| ③ | 搭建网络层最小实现(APIClient) | 所有后端交互的唯一通道 | 第十章 步骤 3 ✅ | +| ④ | 实现 AuthService(Apple 登录 + 后端调用) | 用户身份是学习记录的前提 | `docs/AI对话.md`、第十章 步骤 4 ✅ | +| ⑤ | 实现 AppSession(全局登录状态) | 统一的登录态管理 | 第十章 步骤 5 ✅ | +| ⑥ | 实现 LoginView + LoginViewModel | 替换当前过度实现的登录页 | `docs/AI对话.md`、第十章 步骤 6 ✅ | +| ⑦ | 改造 App 入口启动逻辑 | Token 分流替换 @AppStorage | 第十章 步骤 7 ✅ | +| — | 创建 Model 层(其余数据实体) | 是 Service/ViewModel/API 的基础 | 本文档 5.1 节 ✅ | +| — | 实现复习计划独立页 | 计划标记 P0 | ✅ | +| — | 拆分大文件 + 集中共享组件 | 降低后续修改的认知负担 | 本文档 6.1/6.2 节 ✅ | +| — | 添加加载/错误/空状态处理 | 真机使用的基本体验保障 | `Shared/Components/` (ZXLoadingView, ZXErrorView, ZXEmptyView) ✅ | + +### P1 — 与后端对接同步推进 + +| 优先级 | 项目 | 理由 | +|--------|------|------| +| P1 | 浅色/深色模式双主题 | 覆盖所有页面和 DesignTokens,工作量较大 ✅ | +| P1 | 语言系统搭建(中文 Base) | 先建基础设施,后续加语言不返工 ✅ | +| P1 | 搭建 ViewModel 层(逐步迁移) | 架构分层,但不阻塞功能开发 ✅ | +| P1 | 搭建 Service 层 | 随 API 对接自然建立 ✅ | +| P1 | 实现反馈页 | TestFlight 内测必须 ✅ | +| P1 | 实现设置页完整功能 | 外观切换、语言入口、复习提醒等 ✅ | + +### P2 — App Store 前完成 + +| 优先级 | 项目 | 理由 | +|--------|------|------| +| P2 | Repository 层 | 当需要本地缓存 + 网络切换时再做 ✅ | +| P2 | 动效补充 | 体验优化,不阻塞功能 ✅ | +| P2 | 无障碍适配 | App Store 审核加分项 ✅ | +| P2 | 测试 | 用户量增长后需要 ✅ | +| P2 | Tab 结构调整决策 | 需要更多用户反馈来决策 ✅ | + +### Tab 结构分析 + +**当前 5-Tab 结构**: + +| Tab | 页面 | 核心功能 | +|-----|------|----------| +| AI | AIHomeView | AI 对话入口 + 每日思考 + 薄弱点 | +| 知识库 | LibraryHomeView | 知识库浏览 + 导入 + 搜索 | +| 学习 | StudyHomeView | 今日任务 + 进度 + 每周活跃 | +| 分析 | AnalysisHomeView | 学习时长 + 掌握度 + 雷达图 | +| 我的 | ProfileView | 个人信息 + 设置 + 成就 | + +**问题诊断**: +1. **AI 与学习边界模糊** — AI 对话产生学习记录,但学习任务在独立 Tab,用户需要在两个 Tab 间切换 +2. **分析 Tab 内容单薄** — 纯展示仪表盘,无交互深度,与"学习"Tab 的进度卡片有重叠 +3. **知识库入口过重** — 知识库本质是学习的前置步骤,独立 Tab 使其脱离学习流程 + +**可选方案**: + +| 方案 | 结构 | 优点 | 缺点 | +|------|------|------|------| +| A: 保持现状 | 5 Tab 不变 | 无改动成本 | 上述问题持续 | +| B: 合并 AI+学习 | 4 Tab(学习/AI、知识库、分析、我的) | AI 与学习一体化 | 需重设计学习首页 | +| C: 合并分析入学习 | 4 Tab(AI、知识库、学习+分析、我的) | 分析数据有上下文 | 学习页信息密度增加 | +| D: 精简 3 Tab | 3 Tab(学习、知识库、我的) | 最简洁,AI 内嵌学习 | 分析页降级为次级入口 | + +**建议**:MVP 阶段保持方案 A,收集用户反馈后优先尝试方案 C(分析并入学习)。触发条件:分析 Tab 的周活跃用户 < 20%。 + +--- + +## 八、总结 + +当前 iOS 项目完成了 UI 层的全量搭建(21 页),但缺少"能让产品真正运转"的架构底座和数据能力。核心矛盾是: + +> UI 超前,架构滞后。页面能点,但无数据、无认证、无 AI、无服务。 + +### 当前进度(2026-05-10) + +**P0 — 全部完成 ✅**: +1. ✅ Apple 登录 + Auth 体系(9 个文件) +2. ✅ Model 层(10 个数据实体) +3. ✅ 网络层最小实现(APIClient + Endpoint + Error) +4. ✅ App 入口重构(AppSession 驱动路由,替代 @AppStorage) +5. ✅ 复习计划独立页(ReviewPlanView) +6. ✅ 大文件拆分(3 个大文件拆为 15+ 个独立文件) +7. ✅ 共享组件集中管理(`Shared/Components/` 下 20+ 个组件) +8. ✅ 加载/错误/空状态处理(ZXLoadingView / ZXErrorView / ZXEmptyView) + +**P1 — 部分完成**: +- ✅ 浅色/深色模式双主题(DesignTokens 自适应 + ColorSchemeManager + 移除强制暗黑) +- ✅ 语言系统搭建(中文 Base,Localizable.strings + ZXStrings + LanguageManager) +- ✅ 搭建 ViewModel 层(ReviewPlanViewModel + AIChatViewModel + StudyHomeViewModel) +- ✅ 搭建 Service 层(5 个 Service 协议 + 实现 + 20 个 APIEndpoint) +- ✅ 实现反馈页(TestFlight 内测必须) +- ✅ 设置页外观切换(ColorSchemeManager + ProfileView confirmationDialog) +- ✅ 设置页完整功能(5 个子页面 + 外观/语言切换) + +**接下来推荐顺序**: + +``` +1. 语言系统搭建(基础设施优先,避免后续返工) + ├── 创建 Localizable.strings (Base = 中文) + ├── 迁移硬编码文案到 LocalizedStringKey + └── 设置页预留语言入口(当前仅显示中文) + ↓ +2. 浅色/深色模式双主题(体验升级,需全量回归) + ├── DesignTokens 定义 light/dark 双套色值 + ├── 替换所有内联 Color(hex:) 为 token 引用 + ├── 设置页新增外观切换(跟随系统 / 浅色 / 深色) + └── 全页面浅色模式验证 + ↓ +3. 反馈页 + 设置页补全(独立页面,不依赖其他改造) + ↓ +4. ViewModel 层迁移(逐步,不阻塞功能) + ↓ +5. Service 层搭建(随 API 对接自然建立) +``` + +--- + +## 九、AI对话.md 登录方案摘要 + +> `docs/AI对话.md` 是登录模块的详细实现规范。以下为关键决策的结构化提取,便于对照实施。 + +### 9.1 登录入口决策 + +**第一版只保留**: +``` +Sign in with Apple +``` + +**删除这些入口**: +- 手机号登录 / 邮箱登录 / 微信登录 / 验证码登录 + +**"跳过"按钮**: +- `#if DEBUG` 保留,Release 不展示 +- 理由:避免匿名用户后续迁移(学习记录、AI 分析绑定用户身份) + +### 9.2 登录页内容 + +``` +知习 + +更懂你,更会学。 + +用 AI 把知识库、主动回忆和间隔复习连接起来, +从"看过"走向"真正学会"。 + +[ Sign in with Apple ] + +登录即代表你同意《用户服务协议》和《隐私政策》 +``` + +### 9.3 完整登录流程 + +``` +App 启动 + ↓ +AppSession 检查 Keychain 是否有 refreshToken + ↓ +有 token → 调用 /auth/refresh 或 /users/me + ├─ 成功 → 进入主界面 + └─ 失败 → 清空 token,进入登录页 + ↓ +无 token → 进入登录页 + ↓ +用户点击 Sign in with Apple + ↓ +ASAuthorizationController 获取: + · identityToken + · authorizationCode + · userIdentifier + · email / fullName(Apple 可能不返回) + ↓ +POST /api/auth/apple + ↓ +后端返回 { accessToken, refreshToken, expiresIn, user } + ↓ +accessToken / refreshToken 存入 Keychain + ↓ +判断 user.onboardingCompleted + ├─ false → 引导页 / 学习目标设置 + └─ true → 主界面(ContentView) +``` + +### 9.4 需要新增的 9 个文件 + +| 层 | 文件 | 职责 | +|----|------|------| +| Model | `Core/Models/AuthModels.swift` | AppleLoginRequest、AuthResponse 等 Codable struct | +| Model | `Core/Models/User.swift` | 用户实体 | +| Storage | `Core/Storage/KeychainStore.swift` | 通用 Keychain 读写封装 | +| Storage | `Core/Storage/TokenStore.swift` | Token 专用存取(save/load/clear) | +| Service | `Core/Services/AuthServiceProtocol.swift` | AuthService 协议定义 | +| Service | `Core/Services/AuthService.swift` | ASAuthorizationController 集成 + 后端调用 | +| App | `App/AppSession.swift` | @MainActor 全局登录状态 | +| View | `Features/Auth/Views/LoginView.swift` | 纯 Apple 登录按钮 UI | +| ViewModel | `Features/Auth/ViewModels/LoginViewModel.swift` | @Published isLoading/errorMessage | + +### 9.5 API Contract + +```swift +// 请求 → POST /api/auth/apple +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? +} + +// 响应 +struct AuthResponse: Decodable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + let user: User +} +``` + +### 9.6 关键约束 + +| 约束 | 说明 | +|------|------| +| Token 存 Keychain | 不存 UserDefaults | +| 不用 @AppStorage 控制登录 | 登录状态由 AppSession + Keychain token 决定 | +| View 不写网络请求 | 网络调用在 Service 层 | +| View 不处理 Apple 登录细节 | ASAuthorizationController 逻辑在 AuthService | +| 不改变现有主页面 UI | 只替换入口路由逻辑 | + +--- + +## 十、登录模块可行实施计划 + +以下是按依赖关系排列的 7 个实施步骤。每步独立可验证,后一步依赖前一步完成。 + +### 整体依赖图 + +``` +步骤1: Model ─────────────────────────────────────────┐ + ↓ │ +步骤2: KeychainStore / TokenStore ────────────────────┤ + ↓ │ +步骤4: APIClient / APIEndpoint ─┐ │ + ↓ │ │ +步骤3: AuthService ←────────────┘ │ + ↓ │ +步骤5: AppSession ─────────────────────────────────────┤ + ↓ │ +步骤6: LoginView + LoginViewModel ────────────────────┘ + ↓ +步骤7: 改造 App 入口启动逻辑(AIStudyAppApp.swift) +``` + +--- + +### 步骤 1:创建 Auth Model ✅ 已完成(2026-05-10) + +**产出文件**: +- `Core/Models/AuthModels.swift` +- `Core/Models/User.swift` + +**内容**: +```swift +// AuthModels.swift +struct AppleLoginRequest: Encodable { ... } +struct AppleFullName: Encodable { ... } +struct AuthResponse: Decodable { ... } + +// User.swift +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: String +} +``` + +**依赖**:无 + +**验证**:Xcode 编译通过 + +--- + +### 步骤 2:实现 Keychain 存储层 ✅ 已完成(2026-05-10) + +**产出文件**: +- `Core/Storage/KeychainStore.swift` +- `Core/Storage/TokenStore.swift` + +**KeychainStore 职责**:通用 Keychain 读写,封装 `SecItemAdd`/`SecItemCopyMatching`/`SecItemDelete`,支持 save/load/delete 操作。 + +**TokenStore 职责**: +```swift +protocol TokenStoreProtocol { + func saveAccessToken(_ token: String) throws + func getAccessToken() throws -> String? + func saveRefreshToken(_ token: String) throws + func getRefreshToken() throws -> String? + func clearAll() throws +} +``` + +**依赖**:步骤 1(Model 定义,具体来说不需要 Model 依赖,TokenStore 操作的是原始 String) + +**验证**:可写简单单元测试验证存取清除 + +--- + +### 步骤 3:搭建网络层最小实现 ✅ 已完成(2026-05-10) + +> 注意:这一步和 AuthService 互相依赖——AuthService 需要 APIClient 发请求,但可以先建网络层骨架。做的时候步骤 3 和 4 可以部分并行:先建 APIClient 基础,再写 AuthService 时补充 Auth 相关 endpoint。 + +**产出文件**: +- `Core/Network/APIClient.swift` +- `Core/Network/APIEndpoint.swift` +- `Core/Network/APIError.swift` + +**最小接口**: +```swift +// APIClient +class APIClient { + init(baseURL: URL, tokenStore: TokenStoreProtocol?) + func request(_ endpoint: APIEndpoint) async throws -> T + func requestVoid(_ endpoint: APIEndpoint) async throws +} + +// APIEndpoint +enum APIEndpoint { + case appleLogin(AppleLoginRequest) + case refreshToken(String) + case me + // 后续扩展其他 endpoint +} + +// APIError +enum APIError: Error { + case network(Error) + case httpError(Int) + case decoding(Error) + case unauthorized +} +``` + +**依赖**:步骤 1(Model)、步骤 2(TokenStore) + +**验证**:Xcode 编译通过,可以先 mock 一个请求验证 pipeline 跑通 + +--- + +### 步骤 4:实现 AuthService ✅ 已完成(2026-05-10) + +**产出文件**: +- `Core/Services/AuthServiceProtocol.swift` +- `Core/Services/AuthService.swift` + +**AuthServiceProtocol**: +```swift +protocol AuthServiceProtocol { + func loginWithApple() async throws -> AuthResponse + func refreshSession() async throws -> AuthResponse + func logout() async throws + func fetchCurrentUser() async throws -> User +} +``` + +**AuthService 实现要点**: +1. 集成 `ASAuthorizationController`(需 `import AuthenticationServices`) +2. 获取 identityToken、authorizationCode、userIdentifier +3. 调用 `APIClient.request(.appleLogin(request))` +4. 将返回的 token 写入 TokenStore +5. refreshSession:用 refreshToken 换新 token + +**依赖**:步骤 1(AuthModels)、步骤 2(TokenStore)、步骤 3(APIClient) + +**验证**:Xcode 编译通过,可在模拟器点击 Apple 登录(后端未就绪时用 mock) + +--- + +### 步骤 5:实现 AppSession ✅ 已完成(2026-05-10) + +**产出文件**: +- `App/AppSession.swift` + +**关键代码骨架**: +```swift +@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 + + func bootstrap() async { + // 1. 检查 Keychain 是否有 refreshToken + // 2. 有 → 调用 refreshSession() + // 3. 成功 → isAuthenticated=true, currentUser=user + // 4. 失败 → 清空 token, isAuthenticated=false + // 5. 无 → isAuthenticated=false + // 6. isLoading = false + } + + func loginWithApple() async { ... } + func logout() { ... } +} +``` + +**依赖**:步骤 4(AuthService)、步骤 2(TokenStore) + +**验证**:模拟器启动时可根据 Keychain 状态正确分流 + +--- + +### 步骤 6:实现 LoginView + LoginViewModel ✅ 已完成(2026-05-10) + +**产出文件**: +- `Features/Auth/Views/LoginView.swift` +- `Features/Auth/ViewModels/LoginViewModel.swift` + +**LoginView**: +- 品牌标题"知习" + 副标题 +- Sign in with Apple 按钮(ASAuthorizationAppleIDButton) +- Loading 状态(ProgressView) +- Error 提示 +- `#if DEBUG` 跳过按钮 +- 协议入口链接 + +**LoginViewModel**: +```swift +@MainActor +final class LoginViewModel: ObservableObject { + @Published var isLoading = false + @Published var errorMessage: String? + + func loginWithApple() async { + isLoading = true + errorMessage = nil + do { + try await appSession.loginWithApple() + } catch { + errorMessage = "登录失败:\(error.localizedDescription)" + isLoading = false + } + } +} +``` + +**依赖**:步骤 5(AppSession) + +**验证**:模拟器显示登录页,点击 Apple 登录按钮触发流程,loading 状态可展示 + +--- + +### 步骤 7:改造 App 入口启动逻辑 ✅ 已完成(2026-05-10) + +**修改文件**:`AIStudyAppApp.swift` + +**改动要点**: +1. 注入 `AppSession` 为 `@StateObject` +2. 启动时调用 `appSession.bootstrap()` +3. 用 `appSession.isLoading / isAuthenticated / currentUser?.onboardingCompleted` 替换原来的 `@AppStorage("hasCompletedOnboarding")` +4. 路由逻辑: + ``` + isLoading → Splash(启动加载中) + !isAuthenticated → LoginView + onboardingCompleted == false → OnboardingFlowView + onboardingCompleted == true → ContentView + ``` + +**依赖**:步骤 1-6 全部完成 + +**验证**:完整启动流程测试—— +- 首次启动 → 登录页 → Apple 登录(mock) → 引导页 → 目标设置 → 主界面 +- 二次启动(有有效 token)→ 直接进主界面 +- Token 过期 → 登录页 + +--- + +### 实施俯视图 + +``` +步骤 1 ──→ 步骤 2 ──→ 步骤 3 ──→ 步骤 4 ──→ 步骤 5 ──→ 步骤 6 ──→ 步骤 7 +Model Keychain 网络层 AuthSvc AppSession LoginUI 入口改造 +(0依赖) (无依赖) (依赖1,2) (依赖1-3) (依赖2,4) (依赖5) (依赖1-6) +``` + +每一步均可独立编译验证。后端 API 未就绪时,步骤 4-7 可以用 mock 数据先行开发,后端就绪后仅替换 APIClient 的真实 endpoint。 + +### 文件目录结构(完成后) + +``` +AIStudyApp/ +├── App/ +│ └── AppSession.swift ← 新增 +├── Core/ +│ ├── Models/ +│ │ ├── AuthModels.swift ← 新增 +│ │ └── User.swift ← 新增 +│ ├── Network/ +│ │ ├── APIClient.swift ← 新增 +│ │ ├── APIEndpoint.swift ← 新增 +│ │ └── APIError.swift ← 新增 +│ ├── Services/ +│ │ ├── AuthServiceProtocol.swift ← 新增 +│ │ └── AuthService.swift ← 新增 +│ ├── Storage/ +│ │ ├── KeychainStore.swift ← 新增 +│ │ └── TokenStore.swift ← 新增 +│ └── DesignSystem/ +│ └── DesignTokens.swift (已有,不变) +├── Features/ +│ ├── Auth/ +│ │ ├── Views/ +│ │ │ └── LoginView.swift ← 新增 +│ │ └── ViewModels/ +│ │ └── LoginViewModel.swift ← 新增 +│ ├── AI/ (已有) +│ ├── Library/ (已有) +│ ├── Study/ (已有) +│ ├── Analysis/ (已有) +│ └── Profile/ (已有) +└── AIStudyAppApp.swift ← 修改 +```