feat: M-IOS-INFO Batch D-F complete (38/38)
Batch D: MarkedAsRead UI, reading progress query, position restore, continue learning, source reading status Batch E: NetworkMonitor, summary/trend/heatmap alignment, learning records, error handling, background upload, debug/logging Batch F: Test stubs, iOS integration doc Batch G: V2 FFI/MarkedAsRead/export-ack coverage verification Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
80c1d660cd
commit
51b9365ece
@ -129,8 +129,6 @@
|
|||||||
05F6CD242FA886350043A7BC /* Debug */ = {
|
05F6CD242FA886350043A7BC /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
|
||||||
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
@ -186,6 +184,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -193,8 +192,6 @@
|
|||||||
05F6CD252FA886350043A7BC /* Release */ = {
|
05F6CD252FA886350043A7BC /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
|
||||||
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
@ -243,14 +240,13 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
05F6CD272FA886350043A7BC /* Debug */ = {
|
05F6CD272FA886350043A7BC /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
|
||||||
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
|
||||||
@ -291,6 +287,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
|
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_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||||
@ -301,8 +298,6 @@
|
|||||||
05F6CD282FA886350043A7BC /* Release */ = {
|
05F6CD282FA886350043A7BC /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/AIStudyApp/BridgingHeader.h";
|
|
||||||
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = AIStudyApp.entitlements;
|
||||||
@ -343,6 +338,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
|
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_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||||
|
|||||||
44
AIStudyApp/AIStudyApp/Core/Services/NetworkMonitor.swift
Normal file
44
AIStudyApp/AIStudyApp/Core/Services/NetworkMonitor.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ class ActivityViewModel: ObservableObject {
|
|||||||
@Published var streak: ActivityStreak?
|
@Published var streak: ActivityStreak?
|
||||||
@Published var trends: [ActivityTrend] = []
|
@Published var trends: [ActivityTrend] = []
|
||||||
@Published var recommendations: [ActivityRecommendation] = []
|
@Published var recommendations: [ActivityRecommendation] = []
|
||||||
|
@Published var continueReading: ContinueLearningResponse?
|
||||||
|
@Published var recentRecords: [LearningRecordsResponse.RecordItem] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
@ -22,8 +24,10 @@ class ActivityViewModel: ObservableObject {
|
|||||||
async let st = try? ActivityService.shared.streak()
|
async let st = try? ActivityService.shared.streak()
|
||||||
async let t = try? ActivityService.shared.trend()
|
async let t = try? ActivityService.shared.trend()
|
||||||
async let r = try? ActivityService.shared.recommendations()
|
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
|
summary = summaryResult
|
||||||
focusItems = focusResult ?? []
|
focusItems = focusResult ?? []
|
||||||
@ -31,6 +35,8 @@ class ActivityViewModel: ObservableObject {
|
|||||||
streak = streakResult
|
streak = streakResult
|
||||||
trends = trendResult ?? []
|
trends = trendResult ?? []
|
||||||
recommendations = recResult ?? []
|
recommendations = recResult ?? []
|
||||||
|
continueReading = continueResult
|
||||||
|
recentRecords = recordsResult?.items ?? []
|
||||||
|
|
||||||
if summary == nil {
|
if summary == nil {
|
||||||
errorMessage = "加载分析数据失败,请下拉刷新重试"
|
errorMessage = "加载分析数据失败,请下拉刷新重试"
|
||||||
@ -48,8 +54,10 @@ class ActivityViewModel: ObservableObject {
|
|||||||
async let st = try? ActivityService.shared.streak()
|
async let st = try? ActivityService.shared.streak()
|
||||||
async let t = try? ActivityService.shared.trend()
|
async let t = try? ActivityService.shared.trend()
|
||||||
async let r = try? ActivityService.shared.recommendations()
|
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
|
summary = summaryResult
|
||||||
focusItems = focusResult ?? []
|
focusItems = focusResult ?? []
|
||||||
@ -57,5 +65,6 @@ class ActivityViewModel: ObservableObject {
|
|||||||
streak = streakResult
|
streak = streakResult
|
||||||
trends = trendResult ?? []
|
trends = trendResult ?? []
|
||||||
recommendations = recResult ?? []
|
recommendations = recResult ?? []
|
||||||
|
continueReading = continueResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
}.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 综合分析
|
// AI 综合分析
|
||||||
if let summary = viewModel.summary {
|
if let summary = viewModel.summary {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@ -187,3 +215,9 @@ struct ZXWeekBarChart: View {
|
|||||||
.onAppear { show = true }
|
.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"
|
||||||
|
}
|
||||||
|
|||||||
@ -145,6 +145,7 @@ struct LibraryDetailPage: View {
|
|||||||
@State private var sortOption = 0
|
@State private var sortOption = 0
|
||||||
@State private var showFolderManager = false
|
@State private var showFolderManager = false
|
||||||
@State private var sources: [KnowledgeSource] = []
|
@State private var sources: [KnowledgeSource] = []
|
||||||
|
@State private var sourceReadingStatus: [String: String] = [:]
|
||||||
@State private var isLoadingSources = false
|
@State private var isLoadingSources = false
|
||||||
|
|
||||||
private var allSelected: Bool {
|
private var allSelected: Bool {
|
||||||
@ -270,6 +271,15 @@ struct LibraryDetailPage: View {
|
|||||||
Text("\(len) 字")
|
Text("\(len) 字")
|
||||||
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
.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()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
@ -622,6 +632,14 @@ struct LibraryDetailPage: View {
|
|||||||
private func loadSources() async {
|
private func loadSources() async {
|
||||||
isLoadingSources = true
|
isLoadingSources = true
|
||||||
do { sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId) } catch {}
|
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
|
isLoadingSources = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,8 @@ struct MaterialReaderView: View {
|
|||||||
@State private var restoreBlockId: String?
|
@State private var restoreBlockId: String?
|
||||||
@State private var actualContentHeight: CGFloat = 1
|
@State private var actualContentHeight: CGFloat = 1
|
||||||
@State private var isMarkedRead = false
|
@State private var isMarkedRead = false
|
||||||
|
@State private var readingStatus: String?
|
||||||
|
@State private var readingProgressSeconds: Int = 0
|
||||||
|
|
||||||
private let title: String
|
private let title: String
|
||||||
private let knowledgeBaseId: String?
|
private let knowledgeBaseId: String?
|
||||||
@ -110,6 +112,7 @@ struct MaterialReaderView: View {
|
|||||||
private let sessionManager = ReadingRuntimeSessionManager.shared
|
private let sessionManager = ReadingRuntimeSessionManager.shared
|
||||||
private let collector = ReadingEventCollector.shared
|
private let collector = ReadingEventCollector.shared
|
||||||
private let positionStore = ReadingPositionStore.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<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
|
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -226,6 +229,24 @@ struct MaterialReaderView: View {
|
|||||||
ScrollViewReader { scrollProxy in
|
ScrollViewReader { scrollProxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
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 {
|
if let s = vm.stats {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
Label("\(s.lineCount) 行", systemImage: "text.alignleft")
|
Label("\(s.lineCount) 行", systemImage: "text.alignleft")
|
||||||
@ -269,6 +290,7 @@ struct MaterialReaderView: View {
|
|||||||
// MARK: - Reading Session Lifecycle
|
// MARK: - Reading Session Lifecycle
|
||||||
|
|
||||||
private func openReadingSession() {
|
private func openReadingSession() {
|
||||||
|
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
||||||
let context = ReadingMaterialContext(
|
let context = ReadingMaterialContext(
|
||||||
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
||||||
materialId: vm.materialId,
|
materialId: vm.materialId,
|
||||||
@ -279,13 +301,26 @@ struct MaterialReaderView: View {
|
|||||||
// V2 primary
|
// V2 primary
|
||||||
if let _ = try? sessionManager.openMaterial(context) {
|
if let _ = try? sessionManager.openMaterial(context) {
|
||||||
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
// V1 fallback
|
|
||||||
print("[READER] V2 unavailable, falling back to V1")
|
print("[READER] V2 unavailable, falling back to V1")
|
||||||
collector.open(materialId: vm.materialId)
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func closeReadingSession() {
|
private func closeReadingSession() {
|
||||||
// V2 primary
|
// V2 primary
|
||||||
if sessionManager.state == .active || sessionManager.state == .paused {
|
if sessionManager.state == .active || sessionManager.state == .paused {
|
||||||
@ -322,8 +357,21 @@ struct MaterialReaderView: View {
|
|||||||
|
|
||||||
/// Restore saved reading position on re-entry.
|
/// Restore saved reading position on re-entry.
|
||||||
private func restorePosition() {
|
private func restorePosition() {
|
||||||
guard let saved = positionStore.load(materialId: vm.materialId) else { return }
|
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
||||||
switch saved {
|
|
||||||
|
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, _):
|
case .markdown(let blockId, _):
|
||||||
restoreBlockId = blockId
|
restoreBlockId = blockId
|
||||||
case .text(let lineNumber, _):
|
case .text(let lineNumber, _):
|
||||||
@ -332,7 +380,7 @@ struct MaterialReaderView: View {
|
|||||||
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
|
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
|
||||||
}
|
}
|
||||||
case .pdf, .image, .epub, .unknown:
|
case .pdf, .image, .epub, .unknown:
|
||||||
break // PDF/image restoration needs platform-specific handling
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -590,6 +638,12 @@ private struct ScrollOffsetKey: PreferenceKey {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// 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 {
|
private func formatFileSize(_ bytes: UInt64) -> String {
|
||||||
if bytes < 1024 { return "\(bytes) B" }
|
if bytes < 1024 { return "\(bytes) B" }
|
||||||
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user