diff --git a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj index ffeb624..9c2a886 100644 --- a/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj +++ b/AIStudyApp/AIStudyApp.xcodeproj/project.pbxproj @@ -129,8 +129,6 @@ 05F6CD242FA886350043A7BC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; - ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -186,6 +184,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -193,8 +192,6 @@ 05F6CD252FA886350043A7BC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; - ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -243,14 +240,13 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; }; name = Release; }; 05F6CD272FA886350043A7BC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements; @@ -291,6 +287,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services"; + SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; @@ -301,8 +298,6 @@ 05F6CD282FA886350043A7BC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements; @@ -343,6 +338,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services"; + SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; diff --git a/AIStudyApp/AIStudyApp/Core/Services/NetworkMonitor.swift b/AIStudyApp/AIStudyApp/Core/Services/NetworkMonitor.swift new file mode 100644 index 0000000..98b49d4 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/NetworkMonitor.swift @@ -0,0 +1,44 @@ +import Network +import Foundation + +/// Monitors network connectivity and triggers upload on recovery. +@MainActor +final class NetworkMonitor { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private(set) var isConnected = true + + private init() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + let wasDisconnected = !(self?.isConnected ?? true) + self?.isConnected = path.status == .satisfied + + // Network recovered → flush pending events + if wasDisconnected && path.status == .satisfied { + print("[Network] Connection restored — flushing upload queue") + let pipeline = ReadingEventUploadPipeline.shared + pipeline.exportAndEnqueue(contexts: [:]) + await pipeline.flush() + } + } + } + monitor.start(queue: .global()) + } + + deinit { + monitor.cancel() + } + + /// Pause reading session on logout. + func onLogout() { + ReadingRuntimeSessionManager.shared.pause() + print("[Network] User logged out — session paused") + } + + /// Clean up stale sessions from previous user, start fresh for current user. + func onLogin() { + print("[Network] User logged in — starting fresh session") + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift index 446d3a8..2448a5c 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/ActivityViewModel.swift @@ -9,6 +9,8 @@ class ActivityViewModel: ObservableObject { @Published var streak: ActivityStreak? @Published var trends: [ActivityTrend] = [] @Published var recommendations: [ActivityRecommendation] = [] + @Published var continueReading: ContinueLearningResponse? + @Published var recentRecords: [LearningRecordsResponse.RecordItem] = [] @Published var isLoading = false @Published var errorMessage: String? @@ -22,8 +24,10 @@ class ActivityViewModel: ObservableObject { async let st = try? ActivityService.shared.streak() async let t = try? ActivityService.shared.trend() async let r = try? ActivityService.shared.recommendations() + async let cr = try? ReadingAPIService.shared.getContinueLearning() + async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") - let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = await (s, f, h, st, t, r) + let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) summary = summaryResult focusItems = focusResult ?? [] @@ -31,6 +35,8 @@ class ActivityViewModel: ObservableObject { streak = streakResult trends = trendResult ?? [] recommendations = recResult ?? [] + continueReading = continueResult + recentRecords = recordsResult?.items ?? [] if summary == nil { errorMessage = "加载分析数据失败,请下拉刷新重试" @@ -48,8 +54,10 @@ class ActivityViewModel: ObservableObject { async let st = try? ActivityService.shared.streak() async let t = try? ActivityService.shared.trend() async let r = try? ActivityService.shared.recommendations() + async let cr = try? ReadingAPIService.shared.getContinueLearning() + async let recs = try? ReadingAPIService.shared.getLearningRecords(limit: 10, type: "reading") - let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult) = await (s, f, h, st, t, r) + let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, recordsResult) = await (s, f, h, st, t, r, cr, recs) summary = summaryResult focusItems = focusResult ?? [] @@ -57,5 +65,6 @@ class ActivityViewModel: ObservableObject { streak = streakResult trends = trendResult ?? [] recommendations = recResult ?? [] + continueReading = continueResult } } diff --git a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift index 30f6e76..5275f19 100644 --- a/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/Analysis/AnalysisHomeView.swift @@ -69,6 +69,34 @@ struct AnalysisHomeView: View { } }.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) } + // Continue Reading + if let cr = viewModel.continueReading, cr.type != nil, cr.type != "none", + let materialId = cr.materialId { + NavigationLink(value: Route.materialReader( + materialId: materialId, + filePath: "", + materialType: .unknown, + knowledgeBaseId: nil, + title: cr.title ?? "继续阅读" + )) { + HStack(spacing: 12) { + Image(systemName: "book.pages").font(.system(size: 18)).foregroundColor(Color.zxPurple) + VStack(alignment: .leading, spacing: 2) { + Text("继续阅读").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF04) + Text(cr.title ?? "未命名").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0) + if let seconds = cr.totalActiveSeconds, seconds > 0 { + Text(formatDuration(seconds)).font(.system(size: 11)).foregroundColor(Color.zxF05) + } + } + Spacer() + Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF05) + } + .padding(14) + .background(Color.zxFill003) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + // AI 综合分析 if let summary = viewModel.summary { VStack(alignment: .leading, spacing: 12) { @@ -187,3 +215,9 @@ struct ZXWeekBarChart: View { .onAppear { show = true } } } + +private func formatDuration(_ seconds: Int) -> String { + if seconds < 60 { return "\(seconds)s" } + if seconds < 3600 { return "\(seconds / 60)m" } + return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m" +} diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index f4e0a06..8d15c3b 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -145,6 +145,7 @@ struct LibraryDetailPage: View { @State private var sortOption = 0 @State private var showFolderManager = false @State private var sources: [KnowledgeSource] = [] + @State private var sourceReadingStatus: [String: String] = [:] @State private var isLoadingSources = false private var allSelected: Bool { @@ -270,6 +271,15 @@ struct LibraryDetailPage: View { Text("\(len) 字") .font(.system(size: 13)).foregroundColor(Color.zxF04) } + // Reading status badge + if let status = sourceReadingStatus[src.id], status != "not_started" { + Text(status == "read" ? "已读" : "阅读中") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(status == "read" ? Color.green : Color.zxPurple) + .padding(.horizontal, 6).padding(.vertical, 2) + .background((status == "read" ? Color.green : Color.zxPurple).opacity(0.1)) + .clipShape(Capsule()) + } } Spacer() Button { @@ -622,6 +632,14 @@ struct LibraryDetailPage: View { private func loadSources() async { isLoadingSources = true do { sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId) } catch {} + // Fetch reading status for sources + for src in sources.prefix(20) { + if let progress = try? await ReadingAPIService.shared.getReadingProgress( + materialId: src.id, targetType: "knowledge_source" + ) { + sourceReadingStatus[src.id] = progress.status + } + } isLoadingSources = false } diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift index 58f0910..15a319a 100644 --- a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift @@ -102,6 +102,8 @@ struct MaterialReaderView: View { @State private var restoreBlockId: String? @State private var actualContentHeight: CGFloat = 1 @State private var isMarkedRead = false + @State private var readingStatus: String? + @State private var readingProgressSeconds: Int = 0 private let title: String private let knowledgeBaseId: String? @@ -110,6 +112,7 @@ struct MaterialReaderView: View { private let sessionManager = ReadingRuntimeSessionManager.shared private let collector = ReadingEventCollector.shared private let positionStore = ReadingPositionStore.shared + private let readingAPI = ReadingAPIService.shared init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", showQuickLook: Binding = .constant(false), showNoteSheet: Binding = .constant(false)) { self.title = title @@ -226,6 +229,24 @@ struct MaterialReaderView: View { ScrollViewReader { scrollProxy in ScrollView { VStack(alignment: .leading, spacing: 16) { + // Reading progress indicator + if let status = readingStatus, status != "not_started" { + HStack(spacing: 8) { + Image(systemName: status == "read" ? "book.closed.fill" : "book.pages") + .font(.system(size: 11)) + Text(status == "read" ? "已读完" : "阅读中") + .font(.system(size: 11, weight: .medium)) + if readingProgressSeconds > 0 { + Text("· \(formatDuration(readingProgressSeconds))") + .font(.system(size: 11)) + } + } + .foregroundColor(status == "read" ? Color.green : Color.zxF03) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(Color.zxFill004) + .clipShape(Capsule()) + } + if let s = vm.stats { HStack(spacing: 16) { Label("\(s.lineCount) 行", systemImage: "text.alignleft") @@ -269,6 +290,7 @@ struct MaterialReaderView: View { // MARK: - Reading Session Lifecycle private func openReadingSession() { + let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file" let context = ReadingMaterialContext( readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile, materialId: vm.materialId, @@ -279,11 +301,24 @@ struct MaterialReaderView: View { // V2 primary if let _ = try? sessionManager.openMaterial(context) { print("[READER] V2 session opened — materialId=\(vm.materialId)") - return + } else { + print("[READER] V2 unavailable, falling back to V1") + collector.open(materialId: vm.materialId) + } + + // Query reading progress from API + Task { + do { + let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType) + await MainActor.run { + readingStatus = progress.status + readingProgressSeconds = progress.totalActiveSeconds + if progress.isMarkedRead { isMarkedRead = true } + } + } catch { + print("[READER] Progress query failed: \(error)") + } } - // V1 fallback - print("[READER] V2 unavailable, falling back to V1") - collector.open(materialId: vm.materialId) } private func closeReadingSession() { @@ -322,8 +357,21 @@ struct MaterialReaderView: View { /// Restore saved reading position on re-entry. private func restorePosition() { - guard let saved = positionStore.load(materialId: vm.materialId) else { return } - switch saved { + let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file" + + Task { + // 1. Try API for status (but use local cache for position data) + let _ = try? await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType) + + // 2. Restore position from local cache + if let saved = positionStore.load(materialId: vm.materialId) { + await MainActor.run { applyPosition(saved) } + } + } + } + + private func applyPosition(_ pos: ReadingPosition) { + switch pos { case .markdown(let blockId, _): restoreBlockId = blockId case .text(let lineNumber, _): @@ -332,7 +380,7 @@ struct MaterialReaderView: View { restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx]) } case .pdf, .image, .epub, .unknown: - break // PDF/image restoration needs platform-specific handling + break } } @@ -590,6 +638,12 @@ private struct ScrollOffsetKey: PreferenceKey { // MARK: - Helpers +private func formatDuration(_ seconds: Int) -> String { + if seconds < 60 { return "\(seconds)s" } + if seconds < 3600 { return "\(seconds / 60)m" } + return "\(seconds / 3600)h\(String(format: "%02d", (seconds % 3600) / 60))m" +} + private func formatFileSize(_ bytes: UInt64) -> String { if bytes < 1024 { return "\(bytes) B" } if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }