ios-projects/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift
wangdl 82bcbb9efb feat: iOS 学习功能增强 - 阅读API、材料阅读器、分析、学习首页
- ReadingAPI: 阅读进度同步
- MaterialPathResolver: 材料路径解析
- MarkedReadStore: 已读标记存储
- MaterialReaderView/DetailView/ReadingPositionAdapter: 阅读器增强
- AnalysisHomeView/ActivityViewModel: 分析首页
- StudyHomeView/StudyHomeViewModel: 学习首页
- LibrarySubpages: 图书馆子页面

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 11:22:26 +08:00

210 lines
7.8 KiB
Swift

import Combine
import Foundation
// MARK: - Action states
enum MainAction: Equatable {
case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPosition?)
case todaysReview(count: Int, estimatedMinutes: Int)
case selfTest(quizId: String, count: Int)
case startLearning(knowledgeBaseId: String, kbCount: Int)
case empty
}
enum HomeLoadingState: Equatable {
case idle
case loading
case loaded
case error(String)
}
// MARK: - ViewModel
@MainActor
final class StudyHomeViewModel: ObservableObject {
@Published var loadingState: HomeLoadingState = .idle
@Published var mainAction: MainAction?
@Published var todayReviewCount = 0
@Published var todayReviewEstimatedMinutes = 0
@Published var availableQuizCount = 0
@Published var weeklyMinutes = 0
@Published var weeklyCardsReviewed = 0
@Published var weeklyActiveDays = 0
@Published var streakDays = 0
@Published var banner: String?
private var firstQuizId: String?
private var firstKbId: String?
func loadAll() async {
loadingState = .loading
banner = nil
async let sessions = try? LearningSessionService.shared.list()
async let dueCards = try? ReviewService.shared.dueCards()
async let quizzes = try? QuizService.shared.list()
async let knowledgeBases = try? KnowledgeBaseService.shared.list()
async let summary = try? ActivityService.shared.summary()
async let streak = try? ActivityService.shared.streak()
let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak)
let sessionsResult = sRes ?? []
let dueCardsResult = dRes ?? []
let quizzesResult = qRes ?? []
let kbResult = kRes ?? []
// Store first IDs for navigation
firstQuizId = quizzesResult.first?.id
firstKbId = kbResult.first?.id
// Evaluate main action
mainAction = await evaluatePriority(
sessions: sessionsResult,
dueCards: dueCardsResult,
quizzes: quizzesResult,
knowledgeBases: kbResult
)
// Stats
todayReviewCount = dueCardsResult.count
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
availableQuizCount = quizzesResult.count
weeklyMinutes = sumRes?.totalMinutes ?? 0
weeklyCardsReviewed = sumRes?.totalCardsReviewed ?? 0
weeklyActiveDays = sumRes?.activeDays ?? 0
streakDays = stRes?.currentStreak ?? 0
// Banner
banner = computeBanner(
sessions: sessionsResult,
quizzes: quizzesResult,
dueCards: dueCardsResult
)
loadingState = .loaded
}
func refresh() async {
async let sessions = try? LearningSessionService.shared.list()
async let dueCards = try? ReviewService.shared.dueCards()
async let quizzes = try? QuizService.shared.list()
async let knowledgeBases = try? KnowledgeBaseService.shared.list()
async let summary = try? ActivityService.shared.summary()
async let streak = try? ActivityService.shared.streak()
let (sRes, dRes, qRes, kRes, sumRes, stRes) = await (sessions, dueCards, quizzes, knowledgeBases, summary, streak)
let sessionsResult = sRes ?? []
let dueCardsResult = dRes ?? []
let quizzesResult = qRes ?? []
let kbResult = kRes ?? []
firstQuizId = quizzesResult.first?.id
firstKbId = kbResult.first?.id
mainAction = await evaluatePriority(
sessions: sessionsResult,
dueCards: dueCardsResult,
quizzes: quizzesResult,
knowledgeBases: kbResult
)
todayReviewCount = dueCardsResult.count
todayReviewEstimatedMinutes = max(1, dueCardsResult.count)
availableQuizCount = quizzesResult.count
weeklyMinutes = sumRes?.totalMinutes ?? 0
weeklyCardsReviewed = sumRes?.totalCardsReviewed ?? 0
weeklyActiveDays = sumRes?.activeDays ?? 0
streakDays = stRes?.currentStreak ?? 0
}
// MARK: - Priority Logic
private func evaluatePriority(
sessions: [LearningSession],
dueCards: [ReviewCard],
quizzes: [Quiz],
knowledgeBases: [KnowledgeBase]
) async -> MainAction {
// Priority 1: API continue-learning (most recent material with progress)
if let cl = try? await ReadingAPIService.shared.getContinueLearning(),
let materialId = cl.materialId, !materialId.isEmpty,
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习"
let elapsed = formatElapsedSince(cl.lastReadAt)
let pos = cl.lastPosition.flatMap { ReadingPositionAdapter.fromValue($0) }
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: pos)
}
// Priority 2: Unfinished session (fallback)
if let unfinished = sessions
.filter({ $0.status != nil && $0.status != "completed" })
.sorted(by: { ($0.startedAt ?? "") > ($1.startedAt ?? "") })
.first {
let kbTitle = knowledgeBases.first(where: { $0.id == unfinished.knowledgeBaseId })?.title ?? "学习"
let elapsed = formatElapsedSince(unfinished.startedAt)
let mt: MaterialType = unfinished.readingTargetType == "knowledge_source" ? .unknown : .unknown
return .continueSession(materialId: unfinished.materialId ?? "", filePath: "", materialType: mt, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil)
}
// Priority 2: Today's review
if !dueCards.isEmpty {
return .todaysReview(count: dueCards.count, estimatedMinutes: max(1, dueCards.count))
}
// Priority 3: Self-test
if let quizId = firstQuizId, !quizzes.isEmpty {
return .selfTest(quizId: quizId, count: quizzes.count)
}
// Priority 4: Start learning
if let kbId = firstKbId, !knowledgeBases.isEmpty {
return .startLearning(knowledgeBaseId: kbId, kbCount: knowledgeBases.count)
}
// Priority 5: Empty
return .empty
}
// MARK: - Banner
private func computeBanner(
sessions: [LearningSession],
quizzes: [Quiz],
dueCards: [ReviewCard]
) -> String? {
// No banner needed if nothing is happening
if dueCards.isEmpty && quizzes.isEmpty && sessions.allSatisfy({ $0.status == "completed" }) {
return nil
}
return nil
}
// MARK: - Helpers
private func formatElapsedSince(_ iso: String?) -> String {
guard let iso else { return "最近" }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: iso) else {
// Try without fractional seconds
formatter.formatOptions = [.withInternetDateTime]
guard let date2 = formatter.date(from: iso) else { return "最近" }
return relativeTime(from: date2)
}
return relativeTime(from: date)
}
private func relativeTime(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
let minutes = Int(interval / 60)
if minutes < 1 { return "刚刚" }
if minutes < 60 { return "\(minutes) 分钟前" }
let hours = minutes / 60
if hours < 24 { return "\(hours) 小时前" }
let days = hours / 24
return "\(days) 天前"
}
}