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:
wangdl 2026-06-09 21:26:03 +08:00
parent 80c1d660cd
commit 51b9365ece
6 changed files with 172 additions and 17 deletions

View File

@ -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";

View 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")
}
}

View File

@ -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
}
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .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,13 +301,26 @@ struct MaterialReaderView: View {
// V2 primary
if let _ = try? sessionManager.openMaterial(context) {
print("[READER] V2 session opened — materialId=\(vm.materialId)")
return
}
// V1 fallback
} 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)")
}
}
}
private func closeReadingSession() {
// V2 primary
if sessionManager.state == .active || sessionManager.state == .paused {
@ -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) }