|
@@ -40,8 +40,8 @@ struct SessionDetailView: View {
|
|
|
@State private var showSyncAuthPrompt = false
|
|
@State private var showSyncAuthPrompt = false
|
|
|
@State private var showAuthModal = false
|
|
@State private var showAuthModal = false
|
|
|
@State private var showLargeSyncConfirmation = false
|
|
@State private var showLargeSyncConfirmation = false
|
|
|
|
|
+ @State private var showSyncInfo = false
|
|
|
@State private var hasApprovedLargeTransfer = false
|
|
@State private var hasApprovedLargeTransfer = false
|
|
|
- @State private var autoSyncTask: Task<Void, Never>?
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
ZStack {
|
|
ZStack {
|
|
@@ -203,18 +203,16 @@ struct SessionDetailView: View {
|
|
|
playbackVM.totalDurationMs = Double(session.durationMs)
|
|
playbackVM.totalDurationMs = Double(session.durationMs)
|
|
|
playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
|
|
playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
|
|
|
playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
|
|
playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
|
|
|
- if session.syncState == .pending || session.syncState == .localOnly {
|
|
|
|
|
|
|
+ if session.needsCloudSync {
|
|
|
scheduleAutoSync()
|
|
scheduleAutoSync()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
.onChange(of: session.syncStateRaw) { _, newValue in
|
|
.onChange(of: session.syncStateRaw) { _, newValue in
|
|
|
guard let state = SessionSyncState(rawValue: newValue),
|
|
guard let state = SessionSyncState(rawValue: newValue),
|
|
|
- state == .pending || state == .localOnly else { return }
|
|
|
|
|
|
|
+ state == .pending || state == .localOnly,
|
|
|
|
|
+ session.needsCloudSync else { return }
|
|
|
scheduleAutoSync()
|
|
scheduleAutoSync()
|
|
|
}
|
|
}
|
|
|
- .onDisappear {
|
|
|
|
|
- autoSyncTask?.cancel()
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var detailHeader: some View {
|
|
private var detailHeader: some View {
|
|
@@ -269,8 +267,7 @@ struct SessionDetailView: View {
|
|
|
recordName = trimmedName
|
|
recordName = trimmedName
|
|
|
if session.title != trimmedName {
|
|
if session.title != trimmedName {
|
|
|
session.title = trimmedName
|
|
session.title = trimmedName
|
|
|
- session.isSynced = false
|
|
|
|
|
- session.syncState = .pending
|
|
|
|
|
|
|
+ session.markContentModified()
|
|
|
try? modelContext.save()
|
|
try? modelContext.save()
|
|
|
scheduleAutoSync()
|
|
scheduleAutoSync()
|
|
|
}
|
|
}
|
|
@@ -480,6 +477,8 @@ struct SessionDetailView: View {
|
|
|
Button {
|
|
Button {
|
|
|
if isThisSessionSyncing {
|
|
if isThisSessionSyncing {
|
|
|
syncManager.pauseSync(sessionID: session.id)
|
|
syncManager.pauseSync(sessionID: session.id)
|
|
|
|
|
+ } else if isDisplayedSynced {
|
|
|
|
|
+ showSyncInfo = true
|
|
|
} else {
|
|
} else {
|
|
|
requestSessionSync(isAutomatic: false)
|
|
requestSessionSync(isAutomatic: false)
|
|
|
}
|
|
}
|
|
@@ -527,6 +526,12 @@ struct SessionDetailView: View {
|
|
|
? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
|
|
? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
|
|
|
: syncStatusText
|
|
: syncStatusText
|
|
|
)
|
|
)
|
|
|
|
|
+ .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
|
|
|
|
|
+ syncInfoCard
|
|
|
|
|
+ .padding(16)
|
|
|
|
|
+ .frame(idealWidth: 320)
|
|
|
|
|
+ .presentationCompactAdaptation(.popover)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
Text(syncStatusText)
|
|
Text(syncStatusText)
|
|
|
.font(.system(size: 9, weight: .medium))
|
|
.font(.system(size: 9, weight: .medium))
|
|
@@ -534,23 +539,84 @@ struct SessionDetailView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private var syncInfoCard: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: "checkmark.icloud.fill")
|
|
|
|
|
+ .foregroundStyle(Color.lessCosmosGold)
|
|
|
|
|
+
|
|
|
|
|
+ Text("同步信息")
|
|
|
|
|
+ .font(.system(size: 15, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(Color.primary)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Divider()
|
|
|
|
|
+ .background(Color.lineBorder)
|
|
|
|
|
+
|
|
|
|
|
+ syncInfoRow(
|
|
|
|
|
+ label: "同步时间",
|
|
|
|
|
+ value: session.lastSyncedAt.map {
|
|
|
|
|
+ Self.syncDateTimeFormatter.string(from: $0)
|
|
|
|
|
+ } ?? "历史记录未保存"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ syncInfoRow(
|
|
|
|
|
+ label: "云端版本",
|
|
|
|
|
+ value: "v\(max(session.serverRevision, 1))",
|
|
|
|
|
+ monospaced: true
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if let cloudSessionID = session.cloudSessionId {
|
|
|
|
|
+ syncInfoRow(
|
|
|
|
|
+ label: "云端记录",
|
|
|
|
|
+ value: cloudSessionID,
|
|
|
|
|
+ monospaced: true
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(14)
|
|
|
|
|
+ .background(Color.cardBackground.opacity(0.15))
|
|
|
|
|
+ .businessBorder(cornerRadius: 10)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func syncInfoRow(
|
|
|
|
|
+ label: String,
|
|
|
|
|
+ value: String,
|
|
|
|
|
+ monospaced: Bool = false
|
|
|
|
|
+ ) -> some View {
|
|
|
|
|
+ HStack(alignment: .firstTextBaseline, spacing: 14) {
|
|
|
|
|
+ Text(label)
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
|
|
+
|
|
|
|
|
+ Spacer(minLength: 12)
|
|
|
|
|
+
|
|
|
|
|
+ Text(value)
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
|
|
|
|
|
+ .foregroundStyle(Color.primary)
|
|
|
|
|
+ .multilineTextAlignment(.trailing)
|
|
|
|
|
+ .lineLimit(2)
|
|
|
|
|
+ .textSelection(.enabled)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private var isThisSessionSyncing: Bool {
|
|
private var isThisSessionSyncing: Bool {
|
|
|
syncManager.isSyncing && syncManager.activeSessionID == session.id
|
|
syncManager.isSyncing && syncManager.activeSessionID == session.id
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var isDisplayedSyncing: Bool {
|
|
private var isDisplayedSyncing: Bool {
|
|
|
- isThisSessionSyncing || session.syncState == .syncing
|
|
|
|
|
|
|
+ isThisSessionSyncing
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var isDisplayedSynced: Bool {
|
|
private var isDisplayedSynced: Bool {
|
|
|
- !isDisplayedSyncing && session.isSynced && session.syncState == .synced
|
|
|
|
|
|
|
+ !isDisplayedSyncing && session.hasConfirmedCloudSync
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var sessionSyncProgress: Double {
|
|
private var sessionSyncProgress: Double {
|
|
|
if isThisSessionSyncing {
|
|
if isThisSessionSyncing {
|
|
|
return min(max(syncManager.syncProgress, 0), 1)
|
|
return min(max(syncManager.syncProgress, 0), 1)
|
|
|
}
|
|
}
|
|
|
- return session.syncState == .synced ? 1 : 0
|
|
|
|
|
|
|
+ return session.hasConfirmedCloudSync ? 1 : 0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var syncStatusText: String {
|
|
private var syncStatusText: String {
|
|
@@ -568,6 +634,14 @@ struct SessionDetailView: View {
|
|
|
return .degrees(cycle / 1.2 * 360)
|
|
return .degrees(cycle / 1.2 * 360)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private static let syncDateTimeFormatter: DateFormatter = {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.locale = Locale(identifier: "zh_CN")
|
|
|
|
|
+ formatter.calendar = Calendar(identifier: .gregorian)
|
|
|
|
|
+ formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
|
|
|
|
|
+ return formatter
|
|
|
|
|
+ }()
|
|
|
|
|
+
|
|
|
private var largeTransferMessage: String {
|
|
private var largeTransferMessage: String {
|
|
|
let bytes = syncManager.estimatedUploadBytes(for: session)
|
|
let bytes = syncManager.estimatedUploadBytes(for: session)
|
|
|
let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
|
let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
|
@@ -610,15 +684,10 @@ struct SessionDetailView: View {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func scheduleAutoSync() {
|
|
private func scheduleAutoSync() {
|
|
|
- guard session.endTime != nil,
|
|
|
|
|
- session.syncState != .conflict,
|
|
|
|
|
- authManager.currentUser?.id != nil else { return }
|
|
|
|
|
- autoSyncTask?.cancel()
|
|
|
|
|
- autoSyncTask = Task { @MainActor in
|
|
|
|
|
- try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
|
- guard !Task.isCancelled else { return }
|
|
|
|
|
- requestSessionSync(isAutomatic: true)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ syncManager.scheduleAutomaticSync(
|
|
|
|
|
+ for: session,
|
|
|
|
|
+ modelContext: modelContext
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func infoStat(icon: String, label: String, value: String) -> some View {
|
|
private func infoStat(icon: String, label: String, value: String) -> some View {
|
|
@@ -991,8 +1060,7 @@ struct SessionDetailView: View {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func saveTimelineChanges() throws {
|
|
private func saveTimelineChanges() throws {
|
|
|
- session.isSynced = false
|
|
|
|
|
- session.syncState = .pending
|
|
|
|
|
|
|
+ session.markContentModified()
|
|
|
try modelContext.save()
|
|
try modelContext.save()
|
|
|
scheduleAutoSync()
|
|
scheduleAutoSync()
|
|
|
}
|
|
}
|