|
@@ -9,9 +9,15 @@ struct SessionListView: View {
|
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
@Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
|
|
@Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
|
|
|
|
|
|
|
|
|
|
+ var onStartRecording: () -> Void = {}
|
|
|
|
|
+
|
|
|
@State private var searchText = ""
|
|
@State private var searchText = ""
|
|
|
@State private var showSyncAuthPrompt = false
|
|
@State private var showSyncAuthPrompt = false
|
|
|
@State private var showAuthModal = false
|
|
@State private var showAuthModal = false
|
|
|
|
|
+ @State private var deletionError: String?
|
|
|
|
|
+ @State private var sessionPendingDeletion: CelestiaSession?
|
|
|
|
|
+ @State private var selectedSession: CelestiaSession?
|
|
|
|
|
+ @State private var showSessionDetail = false
|
|
|
@ObservedObject private var authManager: AuthManager = .shared
|
|
@ObservedObject private var authManager: AuthManager = .shared
|
|
|
@ObservedObject private var syncManager: SyncManager = .shared
|
|
@ObservedObject private var syncManager: SyncManager = .shared
|
|
|
|
|
|
|
@@ -26,35 +32,33 @@ struct SessionListView: View {
|
|
|
sessionList
|
|
sessionList
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- .navigationTitle("会话档案")
|
|
|
|
|
|
|
+ .navigationTitle("历史记录")
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
.toolbar {
|
|
.toolbar {
|
|
|
- ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
- Button {
|
|
|
|
|
- guard let userID = authManager.currentUser?.id else {
|
|
|
|
|
- showSyncAuthPrompt = true
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- Task {
|
|
|
|
|
- _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
|
|
|
|
|
|
|
+ if !sessions.isEmpty {
|
|
|
|
|
+ ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
+ Button {
|
|
|
|
|
+ guard let userID = authManager.currentUser?.id else {
|
|
|
|
|
+ showSyncAuthPrompt = true
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ Task {
|
|
|
|
|
+ _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
|
|
|
|
|
+ }
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ HStack(spacing: 4) {
|
|
|
|
|
+ Image(systemName: "icloud.and.arrow.up")
|
|
|
|
|
+ .font(.system(size: 13))
|
|
|
|
|
+ Text(syncManager.isSyncing ? "同步中" : "同步")
|
|
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(Color.primary)
|
|
|
}
|
|
}
|
|
|
- } label: {
|
|
|
|
|
- HStack(spacing: 4) {
|
|
|
|
|
- Image(systemName: "icloud.and.arrow.up")
|
|
|
|
|
- .font(.system(size: 13))
|
|
|
|
|
- Text(syncManager.isSyncing ? "同步中" : "同步")
|
|
|
|
|
- .font(.system(size: 13, weight: .medium))
|
|
|
|
|
- }
|
|
|
|
|
- .foregroundStyle(Color.primary)
|
|
|
|
|
|
|
+ .disabled(syncManager.isSyncing)
|
|
|
}
|
|
}
|
|
|
- .disabled(syncManager.isSyncing)
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- .searchable(
|
|
|
|
|
- text: $searchText,
|
|
|
|
|
- placement: .navigationBarDrawer(displayMode: .always),
|
|
|
|
|
- prompt: "搜索会话或笔记..."
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
|
|
|
.sheet(isPresented: $showSyncAuthPrompt) {
|
|
.sheet(isPresented: $showSyncAuthPrompt) {
|
|
|
SyncAuthPromptModal(onLoginClick: {
|
|
SyncAuthPromptModal(onLoginClick: {
|
|
|
showAuthModal = true
|
|
showAuthModal = true
|
|
@@ -63,6 +67,35 @@ struct SessionListView: View {
|
|
|
.sheet(isPresented: $showAuthModal) {
|
|
.sheet(isPresented: $showAuthModal) {
|
|
|
AuthModalView()
|
|
AuthModalView()
|
|
|
}
|
|
}
|
|
|
|
|
+ .navigationDestination(isPresented: $showSessionDetail) {
|
|
|
|
|
+ if let selectedSession {
|
|
|
|
|
+ SessionDetailView(session: selectedSession)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .alert("删除失败", isPresented: Binding(
|
|
|
|
|
+ get: { deletionError != nil },
|
|
|
|
|
+ set: { if !$0 { deletionError = nil } }
|
|
|
|
|
+ )) {
|
|
|
|
|
+ Button("好", role: .cancel) {}
|
|
|
|
|
+ } message: {
|
|
|
|
|
+ Text(deletionError ?? "请稍后重试。")
|
|
|
|
|
+ }
|
|
|
|
|
+ .alert(
|
|
|
|
|
+ "确认删除?",
|
|
|
|
|
+ isPresented: Binding(
|
|
|
|
|
+ get: { sessionPendingDeletion != nil },
|
|
|
|
|
+ set: { if !$0 { sessionPendingDeletion = nil } }
|
|
|
|
|
+ ),
|
|
|
|
|
+ presenting: sessionPendingDeletion
|
|
|
|
|
+ ) { session in
|
|
|
|
|
+ Button("取消", role: .cancel) {}
|
|
|
|
|
+ Button("删除", role: .destructive) {
|
|
|
|
|
+ deleteSession(session)
|
|
|
|
|
+ sessionPendingDeletion = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ } message: { session in
|
|
|
|
|
+ Text("删除「\(session.title)」后,相关录音和图片也会被删除,且无法恢复。")
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -82,72 +115,70 @@ struct SessionListView: View {
|
|
|
// MARK: - Session List
|
|
// MARK: - Session List
|
|
|
|
|
|
|
|
private var sessionList: some View {
|
|
private var sessionList: some View {
|
|
|
- ScrollView {
|
|
|
|
|
- LazyVStack(spacing: 12) {
|
|
|
|
|
- // Stats header
|
|
|
|
|
- statsHeader
|
|
|
|
|
- .padding(.horizontal, 16)
|
|
|
|
|
- .padding(.top, 12)
|
|
|
|
|
-
|
|
|
|
|
- ForEach(filteredSessions) { session in
|
|
|
|
|
- NavigationLink(destination: SessionDetailView(session: session)) {
|
|
|
|
|
- SessionRowCard(session: session)
|
|
|
|
|
|
|
+ List {
|
|
|
|
|
+ ForEach(filteredSessions) { session in
|
|
|
|
|
+ Button {
|
|
|
|
|
+ selectedSession = session
|
|
|
|
|
+ showSessionDetail = true
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ SessionRowCard(session: session)
|
|
|
|
|
+ .contentShape(Rectangle())
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
|
|
|
|
+ .listRowBackground(Color.clear)
|
|
|
|
|
+ .listRowSeparator(.hidden)
|
|
|
|
|
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
|
|
|
+ Button {
|
|
|
|
|
+ sessionPendingDeletion = session
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ Label("删除", systemImage: "trash")
|
|
|
}
|
|
}
|
|
|
- .buttonStyle(.plain)
|
|
|
|
|
- .padding(.horizontal, 16)
|
|
|
|
|
|
|
+ .tint(.red)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- .padding(.bottom, 24)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+ .listStyle(.plain)
|
|
|
|
|
+ .scrollContentBackground(.hidden)
|
|
|
|
|
+ .contentMargins(.bottom, 18, for: .scrollContent)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // MARK: - Stats Header
|
|
|
|
|
|
|
+ private func deleteSession(_ session: CelestiaSession) {
|
|
|
|
|
+ let storedPaths =
|
|
|
|
|
+ [session.localAudioPath].compactMap { $0 } +
|
|
|
|
|
+ session.audioChunks.map(\.localFilePath) +
|
|
|
|
|
+ session.events.compactMap(\.localFilePath)
|
|
|
|
|
+ let fileURLs = Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
|
|
|
|
|
|
|
|
- private var statsHeader: some View {
|
|
|
|
|
- HStack(spacing: 20) {
|
|
|
|
|
- statBadge(
|
|
|
|
|
- value: "\(filteredSessions.count)",
|
|
|
|
|
- label: "总会话",
|
|
|
|
|
- icon: "waveform"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ modelContext.delete(session)
|
|
|
|
|
|
|
|
- statBadge(
|
|
|
|
|
- value: "\(filteredSessions.reduce(0) { $0 + $1.photoCount })",
|
|
|
|
|
- label: "已获图片",
|
|
|
|
|
- icon: "camera"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- statBadge(
|
|
|
|
|
- value: "\(filteredSessions.filter { $0.isSynced }.count)",
|
|
|
|
|
- label: "已同步",
|
|
|
|
|
- icon: "icloud"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ do {
|
|
|
|
|
+ try modelContext.save()
|
|
|
|
|
+ for fileURL in fileURLs {
|
|
|
|
|
+ try? FileManager.default.removeItem(at: fileURL)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ modelContext.rollback()
|
|
|
|
|
+ deletionError = error.localizedDescription
|
|
|
}
|
|
}
|
|
|
- .padding(.vertical, 14)
|
|
|
|
|
- .background(Color.cardBackground.opacity(0.3))
|
|
|
|
|
- .businessBorder(cornerRadius: 10)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func statBadge(value: String, label: String, icon: String) -> some View {
|
|
|
|
|
- VStack(spacing: 4) {
|
|
|
|
|
- Image(systemName: icon)
|
|
|
|
|
- .font(.system(size: 13, weight: .light))
|
|
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
|
|
-
|
|
|
|
|
- Text(value)
|
|
|
|
|
- .font(.system(size: 16, weight: .medium, design: .monospaced))
|
|
|
|
|
- .foregroundStyle(Color.primary)
|
|
|
|
|
|
|
+ // MARK: - Empty State
|
|
|
|
|
|
|
|
- Text(label)
|
|
|
|
|
- .font(.system(size: 10, weight: .regular))
|
|
|
|
|
- .foregroundStyle(Color.secondary.opacity(0.8))
|
|
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ private var emptyState: some View {
|
|
|
|
|
+ if sessions.isEmpty {
|
|
|
|
|
+ Button(action: onStartRecording) {
|
|
|
|
|
+ emptyStateContent
|
|
|
|
|
+ .contentShape(Rectangle())
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ emptyStateContent
|
|
|
}
|
|
}
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // MARK: - Empty State
|
|
|
|
|
-
|
|
|
|
|
- private var emptyState: some View {
|
|
|
|
|
|
|
+ private var emptyStateContent: some View {
|
|
|
VStack(spacing: 16) {
|
|
VStack(spacing: 16) {
|
|
|
Image(systemName: "square.dashed")
|
|
Image(systemName: "square.dashed")
|
|
|
.font(.system(size: 36))
|
|
.font(.system(size: 36))
|
|
@@ -162,15 +193,34 @@ struct SessionListView: View {
|
|
|
.font(.system(size: 12))
|
|
.font(.system(size: 12))
|
|
|
.foregroundStyle(Color.secondary)
|
|
.foregroundStyle(Color.secondary)
|
|
|
} else {
|
|
} else {
|
|
|
- Text("启动全局录音来保存你的第一条记录")
|
|
|
|
|
|
|
+ Text("先试试去开始一次现场记录")
|
|
|
.font(.system(size: 12))
|
|
.font(.system(size: 12))
|
|
|
.foregroundStyle(Color.secondary.opacity(0.7))
|
|
.foregroundStyle(Color.secondary.opacity(0.7))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
.padding(.bottom, 60)
|
|
.padding(.bottom, 60)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private struct SessionSearchModifier: ViewModifier {
|
|
|
|
|
+ let isEnabled: Bool
|
|
|
|
|
+ @Binding var searchText: String
|
|
|
|
|
+
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ if isEnabled {
|
|
|
|
|
+ content.searchable(
|
|
|
|
|
+ text: $searchText,
|
|
|
|
|
+ placement: .navigationBarDrawer(displayMode: .always),
|
|
|
|
|
+ prompt: "搜索会话或笔记..."
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ content
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - Session Row Card
|
|
// MARK: - Session Row Card
|
|
|
|
|
|
|
|
private struct SessionRowCard: View {
|
|
private struct SessionRowCard: View {
|
|
@@ -212,10 +262,6 @@ private struct SessionRowCard: View {
|
|
|
}
|
|
}
|
|
|
.padding(.trailing, 4)
|
|
.padding(.trailing, 4)
|
|
|
|
|
|
|
|
- // Chevron
|
|
|
|
|
- Image(systemName: "chevron.right")
|
|
|
|
|
- .font(.system(size: 10, weight: .light))
|
|
|
|
|
- .foregroundStyle(Color.secondary.opacity(0.5))
|
|
|
|
|
}
|
|
}
|
|
|
.padding(14)
|
|
.padding(14)
|
|
|
.background(Color.cardBackground.opacity(0.2))
|
|
.background(Color.cardBackground.opacity(0.2))
|