- ReadingAPI: 阅读进度同步 - MaterialPathResolver: 材料路径解析 - MarkedReadStore: 已读标记存储 - MaterialReaderView/DetailView/ReadingPositionAdapter: 阅读器增强 - AnalysisHomeView/ActivityViewModel: 分析首页 - StudyHomeView/StudyHomeViewModel: 学习首页 - LibrarySubpages: 图书馆子页面 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
210 lines
7.8 KiB
Swift
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) 天前"
|
|
}
|
|
}
|