diff --git a/AIStudyApp/AIStudyApp/Core/Services/MaterialPathResolver.swift b/AIStudyApp/AIStudyApp/Core/Services/MaterialPathResolver.swift new file mode 100644 index 0000000..dcafec9 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/MaterialPathResolver.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Resolve a materialId + materialType to a local file path for the reader. +struct MaterialPathResolver { + /// Try to locate the file on disk given the continue-learning API response. + /// Returns (filePath, materialType) or nil if the file cannot be found. + static func resolve(materialId: String, apiType: String?, title: String?) -> (filePath: String, materialType: MaterialType)? { + let mt = materialTypeFrom(apiType: apiType, title: title) + + // 1. knowledge_source: look up by source ID in local KnowledgeSourceService + if apiType == "knowledge_source" { + if let path = resolveKnowledgeSource(id: materialId) { + return (path, mt) + } + } + + // 2. Temporary file: check Documents directory + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let tmpPath = docs.appendingPathComponent(materialId).path + if FileManager.default.fileExists(atPath: tmpPath) { + return (tmpPath, mt) + } + + // 3. Check cache directory + let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let cachePath = cache.appendingPathComponent(materialId).path + if FileManager.default.fileExists(atPath: cachePath) { + return (cachePath, mt) + } + + return nil + } + + private static func resolveKnowledgeSource(id: String) -> String? { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + // Knowledge sources are stored as /knowledge_sources// + let dir = docs.appendingPathComponent("knowledge_sources/\(id)") + guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil), + let first = files.first else { return nil } + return first.path + } + + private static func materialTypeFrom(apiType: String?, title: String?) -> MaterialType { + switch apiType { + case "knowledge_source", "temporary_file": + // Infer from title extension + if let t = title?.lowercased() { + if t.hasSuffix(".md") { return .markdown } + if t.hasSuffix(".txt") { return .text } + if t.hasSuffix(".pdf") { return .pdf } + if t.hasSuffix(".epub") { return .epub } + if t.hasSuffix(".png") || t.hasSuffix(".jpg") || t.hasSuffix(".jpeg") || t.hasSuffix(".webp") { return .image } + } + return .unknown + default: + return .unknown + } + } +} diff --git a/AIStudyApp/AIStudyApp/Core/Services/ReadingAPI.swift b/AIStudyApp/AIStudyApp/Core/Services/ReadingAPI.swift index 231b717..c28f8c5 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/ReadingAPI.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/ReadingAPI.swift @@ -50,11 +50,29 @@ class ReadingAPIService { // MARK: - Response DTOs +/// Codable wrapper for API's lastPosition (JSON object matching the server's ReadingPosition schema). +struct ReadingPositionValue: Codable { + let type: String? + let blockId: String? + let lineNumber: UInt32? + let pageNumber: UInt32? + let chapterId: String? + let scrollProgress: Float? + let pageProgress: Float? + let overallProgress: Float? + let zoomScale: Float? + let offsetX: Float? + let offsetY: Float? + let chapterProgress: Float? +} + struct MaterialReadingProgressDTO: Codable { let status: String + let lastPosition: ReadingPositionValue? let lastProgress: Float? let totalActiveSeconds: Int let isMarkedRead: Bool + let sessionCount: Int? let firstOpenedAt: String? let lastReadAt: String? } diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift index 2448a5c..980de70 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift @@ -4,10 +4,12 @@ import Foundation @MainActor class ActivityViewModel: ObservableObject { @Published var summary: ActivitySummary? + @Published var learningSummary: LearningSummaryResponse? @Published var focusItems: [FocusItem] = [] @Published var heatmap: [String: Int] = [:] @Published var streak: ActivityStreak? @Published var trends: [ActivityTrend] = [] + @Published var readingTrend: LearningTrendResponse? @Published var recommendations: [ActivityRecommendation] = [] @Published var continueReading: ContinueLearningResponse? @Published var recentRecords: [LearningRecordsResponse.RecordItem] = [] @@ -25,11 +27,15 @@ class ActivityViewModel: ObservableObject { async let t = try? ActivityService.shared.trend() async let r = try? ActivityService.shared.recommendations() async let cr = try? ReadingAPIService.shared.getContinueLearning() + async let ls = try? ReadingAPIService.shared.getLearningSummary() + async let ltr = try? ReadingAPIService.shared.getLearningTrend() async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") - let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) + let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs) summary = summaryResult + learningSummary = lsResult + readingTrend = ltrResult focusItems = focusResult ?? [] heatmap = heatmapResult ?? [:] streak = streakResult @@ -55,11 +61,15 @@ class ActivityViewModel: ObservableObject { async let t = try? ActivityService.shared.trend() async let r = try? ActivityService.shared.recommendations() async let cr = try? ReadingAPIService.shared.getContinueLearning() + async let ls = try? ReadingAPIService.shared.getLearningSummary() + async let ltr = try? ReadingAPIService.shared.getLearningTrend() async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") - let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) + let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs) summary = summaryResult + learningSummary = lsResult + readingTrend = ltrResult focusItems = focusResult ?? [] heatmap = heatmapResult ?? [:] streak = streakResult diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift index 5275f19..21b8ad7 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift @@ -37,10 +37,37 @@ struct AnalysisHomeView: View { ZXStatBadge(icon: "exclamationmark.triangle", label: "复习卡片", value: "\(viewModel.summary?.totalCardsReviewed ?? 0)", trend: "", color: Color.zxYellow) ZXStatBadge(icon: "chart.line.uptrend.xyaxis", label: "活跃天", value: "\(viewModel.summary?.activeDays ?? 0)", trend: "", color: Color.zxGreen) } + if let ls = viewModel.learningSummary { + HStack(spacing: 12) { + ZXStatBadge(icon: "book.pages", label: "今日阅读", value: "\(ls.todaySeconds / 60) 分钟", trend: "", color: Color.zxBlue) + ZXStatBadge(icon: "calendar.badge.clock", label: "本周阅读", value: "\(ls.weekSeconds / 60) 分钟", trend: "", color: Color.zxTeal) + ZXStatBadge(icon: "doc.text", label: "已读资料", value: "\(ls.markedReadCount) 本", trend: "", color: Color.zxGreen) + ZXStatBadge(icon: "books.vertical", label: "阅读资料", value: "\(ls.materialsReadCount) 本", trend: "", color: Color.zxIndigo) + } + } VStack(alignment: .leading, spacing: 16) { HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) } ZXChartView() }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) + if let rt = viewModel.readingTrend, !rt.series.isEmpty { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("阅读时长趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) + Spacer() + Text("最近 \(rt.days) 天").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + HStack(alignment: .bottom, spacing: 4) { + ForEach(rt.series, id: \.date) { pt in + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.zxPurple.opacity(0.6)) + .frame(width: 12, height: barHeight(val: pt.value, max: rt.series.map(\.value).max() ?? 1)) + Text(pt.date.suffix(5)).font(.system(size: 9)).foregroundColor(Color.zxF04).lineLimit(1) + } + } + }.frame(height: 80) + }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) + } VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) ZXWeekBarChart() @@ -54,6 +81,30 @@ struct AnalysisHomeView: View { ZXStatBadge(icon: "calendar", label: "最后活跃", value: streak.lastActiveDate.flatMap { String($0.prefix(10)) } ?? "-", trend: "", color: Color.zxPrimary) } } + if !viewModel.recentRecords.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("学习记录").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) + Spacer() + Text("最近 \(viewModel.recentRecords.count) 条").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + ForEach(viewModel.recentRecords.prefix(5)) { rec in + HStack(spacing: 10) { + Circle().fill(rec.recordType == "reading" ? Color.zxPurple : Color.zxGreen).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(rec.title).font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1) + Text(formatDuration(rec.durationSeconds)).font(.system(size: 11)).foregroundColor(Color.zxF04) + } + Spacer() + }.padding(.vertical, 4) + } + } + .padding(16) + .background(Color.zxFill004) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + if !viewModel.recommendations.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("学习推荐").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) @@ -221,3 +272,8 @@ private func formatDuration(_ seconds: Int) -> String { if seconds < 3600 { return "\(seconds / 60)m" } return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m" } + +private func barHeight(val: Int, max: Int) -> CGFloat { + guard max > 0 else { return 8 } + return max(8, CGFloat(val) / CGFloat(max) * 60) +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index 8d15c3b..997c14a 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -637,7 +637,9 @@ struct LibraryDetailPage: View { if let progress = try? await ReadingAPIService.shared.getReadingProgress( materialId: src.id, targetType: "knowledge_source" ) { - sourceReadingStatus[src.id] = progress.status + sourceReadingStatus[src.id] = progress.isMarkedRead ? "read" : progress.status + } else if MarkedReadStore.shared.contains(src.id) { + sourceReadingStatus[src.id] = "read" } } isLoadingSources = false diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MarkedReadStore.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MarkedReadStore.swift new file mode 100644 index 0000000..341baf3 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MarkedReadStore.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Local UserDefaults-backed cache of materialIds that have been marked as read. +/// +/// Provides offline resilience: if the API is unavailable when the reader +/// re-opens, the checkmark still reflects the user's prior action. +final class MarkedReadStore { + static let shared = MarkedReadStore() + + private let defaults = UserDefaults.standard + private let key = "zx_marked_read_material_ids" + + private var ids: Set + + private init() { + let arr = defaults.stringArray(forKey: key) ?? [] + ids = Set(arr) + } + + func contains(_ materialId: String) -> Bool { + ids.contains(materialId) + } + + func mark(_ materialId: String) { + ids.insert(materialId) + defaults.set(Array(ids), forKey: key) + } + + func unmark(_ materialId: String) { + ids.remove(materialId) + defaults.set(Array(ids), forKey: key) + } +} diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift index 88af63b..67fbc3c 100644 --- a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift @@ -10,6 +10,9 @@ final class MaterialDetailViewModel: ObservableObject { @Published var quizCount: Int = 0 @Published var isLoading = true @Published var aiStatus: AIStatus = .processing + @Published var readingStatus: String? + @Published var readingSeconds: Int = 0 + @Published var isMarkedRead: Bool = false enum AIStatus: Equatable { case processing // AI 整理知识点中 @@ -18,26 +21,36 @@ final class MaterialDetailViewModel: ObservableObject { } let knowledgeBaseId: String + let materialId: String? - init(knowledgeBaseId: String) { + init(knowledgeBaseId: String, materialId: String? = nil) { self.knowledgeBaseId = knowledgeBaseId + self.materialId = materialId } func load() async { isLoading = true - do { - kb = try await KnowledgeBaseService.shared.detail(id: knowledgeBaseId) - } catch {} - do { - let items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) - itemCount = items.count - } catch {} + async let kbResult = try? KnowledgeBaseService.shared.detail(id: knowledgeBaseId) + async let itemsResult = try? KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) + async let quizzesResult = try? QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) + async let progressResult: MaterialReadingProgressDTO? = { + if let mid = materialId { + return try? await ReadingAPIService.shared.getReadingProgress(materialId: mid, targetType: "knowledge_source") + } + return nil + }() - do { - let quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) - quizCount = quizzes.count - } catch {} + let (kbR, itemsR, quizzesR, progR) = await (kbResult, itemsResult, quizzesResult, progressResult) + + kb = kbR + itemCount = itemsR?.count ?? 0 + quizCount = quizzesR?.count ?? 0 + if let p = progR { + readingStatus = p.status + readingSeconds = p.totalActiveSeconds + isMarkedRead = p.isMarkedRead + } aiStatus = itemCount > 0 ? .ready : .processing isLoading = false @@ -122,6 +135,11 @@ struct MaterialDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1)) + // Reading progress + if let status = vm.readingStatus, status != "not_started" { + readingProgressCard + } + // Read original file button NavigationLink(value: Route.materialReader( materialId: knowledgeBaseId, @@ -186,6 +204,42 @@ struct MaterialDetailView: View { } } + var readingProgressCard: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: vm.isMarkedRead ? "checkmark.circle.fill" : "book.fill") + .font(.system(size: 16)) + .foregroundColor(vm.isMarkedRead ? Color.zxGreen : Color.zxPurple) + VStack(alignment: .leading, spacing: 2) { + Text(vm.isMarkedRead ? "已读完" : statusLabel(vm.readingStatus)) + .font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0) + Text("累计阅读 \(formatSeconds(vm.readingSeconds))") + .font(.system(size: 12)).foregroundColor(Color.zxF04) + } + Spacer() + } + .padding(12) + } + .background(Color.zxSurfaceElevated) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1)) + } + + func statusLabel(_ s: String?) -> String { + switch s { + case "completed": return "阅读完成" + case "in_progress": return "阅读中" + default: return "已开始" + } + } + + func formatSeconds(_ s: Int) -> String { + if s < 60 { return "\(s) 秒" } + let m = s / 60 + if m < 60 { return "\(m) 分钟" } + return "\(m / 60) 小时 \(m % 60) 分钟" + } + // MARK: - File info row func fileInfoRow(icon: String, label: String, value: String) -> some View { diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift index 6fdade9..7335f13 100644 --- a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift @@ -104,6 +104,7 @@ struct MaterialReaderView: View { @State private var isMarkedRead = false @State private var readingStatus: String? @State private var readingProgressSeconds: Int = 0 + @State private var apiRestorePosition: ReadingPosition? @Environment(\.scenePhase) private var scenePhase @@ -349,10 +350,16 @@ struct MaterialReaderView: View { await MainActor.run { readingStatus = progress.status readingProgressSeconds = progress.totalActiveSeconds - if progress.isMarkedRead { isMarkedRead = true } + if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) } + if let lp = progress.lastPosition { + apiRestorePosition = ReadingPositionAdapter.fromValue(lp) + } } } catch { - print("[READER] Progress query failed: \(error)") + // Fallback to local cache when API unavailable + if MarkedReadStore.shared.contains(vm.materialId) { isMarkedRead = true } + apiRestorePosition = positionStore.load(materialId: vm.materialId) + print("[READER] Progress query failed, using local position: \(error)") } } } @@ -379,6 +386,7 @@ struct MaterialReaderView: View { private func markAsRead() { guard !isMarkedRead else { return } isMarkedRead = true + MarkedReadStore.shared.mark(vm.materialId) if isV2Active { sessionManager.markAsRead() } else { @@ -394,17 +402,23 @@ struct MaterialReaderView: View { } /// Restore saved reading position on re-entry. + /// Priority: API lastPosition > local positionStore > session lastPosition private func restorePosition() { - let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file" + // If API already fetched a position, use it immediately + if let apiPos = apiRestorePosition { + applyPosition(apiPos) + return + } - Task { - // 1. Try API for status (but use local cache for position data) - let _ = try? await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType) + // Fallback: local store + if let saved = positionStore.load(materialId: vm.materialId) { + applyPosition(saved) + return + } - // 2. Restore position from local cache - if let saved = positionStore.load(materialId: vm.materialId) { - await MainActor.run { applyPosition(saved) } - } + // Last resort: session/collector + if let pos = sessionManager.lastPosition ?? collector.lastPosition { + applyPosition(pos) } } diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/ReadingPositionAdapter.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/ReadingPositionAdapter.swift index 299ed58..c6ecb63 100644 --- a/AIStudyApp/AIStudyApp/Features/MaterialReader/ReadingPositionAdapter.swift +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/ReadingPositionAdapter.swift @@ -74,6 +74,26 @@ struct ReadingPositionAdapter { ) } + /// Convert an API `ReadingPositionValue` DTO into a `ReadingPosition` enum. + static func fromValue(_ v: ReadingPositionValue) -> ReadingPosition? { + switch v.type { + case "Markdown": + guard let bid = v.blockId else { return nil } + return .markdown(blockId: bid, scrollProgress: clamp(v.scrollProgress ?? 0)) + case "Text": + return .text(lineNumber: v.lineNumber ?? 1, scrollProgress: clamp(v.scrollProgress ?? 0)) + case "Pdf": + return .pdf(pageNumber: v.pageNumber ?? 1, pageProgress: clamp(v.pageProgress ?? 0), overallProgress: clamp(v.overallProgress ?? 0)) + case "Image": + return .image(zoomScale: v.zoomScale ?? 1.0, offsetX: v.offsetX ?? 0, offsetY: v.offsetY ?? 0) + case "Epub": + guard let cid = v.chapterId else { return nil } + return .epub(chapterId: cid, chapterProgress: clamp(v.chapterProgress ?? 0), overallProgress: clamp(v.overallProgress ?? 0)) + default: + return .unknown + } + } + // MARK: - Helpers private static func clamp(_ value: Float) -> Float { diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift index 01e9a80..97217fe 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeView.swift @@ -121,14 +121,14 @@ struct StudyHomeView: View { @ViewBuilder private var mainActionCard: some View { switch vm.mainAction { - case .continueSession(let kbTitle, let elapsed): + case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, _): actionCard( title: "继续上次学习", subtitle: "《\(kbTitle)》· \(elapsed)", icon: "arrow.triangle.2.circlepath", color: Color.zxPurple, cta: "继续学习", - route: .learningSession(taskTitle: "继续学习", taskType: "study", taskColorHex: "#3D7FFB") + route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle) ) case .todaysReview(let count, let minutes): actionCard( diff --git a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift index 27dde6d..af61e50 100644 --- a/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Study/StudyHomeViewModel.swift @@ -4,7 +4,7 @@ import Foundation // MARK: - Action states enum MainAction: Equatable { - case continueSession(kbTitle: String, elapsed: String) + 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) @@ -59,7 +59,7 @@ final class StudyHomeViewModel: ObservableObject { firstKbId = kbResult.first?.id // Evaluate main action - mainAction = evaluatePriority( + mainAction = await evaluatePriority( sessions: sessionsResult, dueCards: dueCardsResult, quizzes: quizzesResult, @@ -103,7 +103,7 @@ final class StudyHomeViewModel: ObservableObject { firstQuizId = quizzesResult.first?.id firstKbId = kbResult.first?.id - mainAction = evaluatePriority( + mainAction = await evaluatePriority( sessions: sessionsResult, dueCards: dueCardsResult, quizzes: quizzesResult, @@ -126,15 +126,26 @@ final class StudyHomeViewModel: ObservableObject { dueCards: [ReviewCard], quizzes: [Quiz], knowledgeBases: [KnowledgeBase] - ) -> MainAction { - // Priority 1: Unfinished session + ) 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) - return .continueSession(kbTitle: kbTitle, elapsed: elapsed) + 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