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 */ = {
|
||||
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";
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,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) }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user