Initial commit
This commit is contained in:
189
oAI/Views/Screens/ConversationListView.swift
Normal file
189
oAI/Views/Screens/ConversationListView.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ConversationListView.swift
|
||||
// oAI
|
||||
//
|
||||
// Saved conversations list
|
||||
//
|
||||
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationListView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var conversations: [Conversation] = []
|
||||
var onLoad: ((Conversation) -> Void)?
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
if searchText.isEmpty {
|
||||
return conversations
|
||||
}
|
||||
return conversations.filter {
|
||||
$0.name.lowercased().contains(searchText.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Conversations")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search conversations...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if filteredConversations.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredConversations) { conversation in
|
||||
ConversationRow(conversation: conversation)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onLoad?(conversation)
|
||||
dismiss()
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
exportConversation(conversation)
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onAppear {
|
||||
loadConversations()
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
do {
|
||||
conversations = try DatabaseService.shared.listConversations()
|
||||
} catch {
|
||||
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
|
||||
conversations = []
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
do {
|
||||
let _ = try DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||
withAnimation {
|
||||
conversations.removeAll { $0.id == conversation.id }
|
||||
}
|
||||
} catch {
|
||||
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func exportConversation(_ conversation: Conversation) {
|
||||
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
|
||||
!loadedMessages.isEmpty else {
|
||||
return
|
||||
}
|
||||
let content = loadedMessages.map { msg in
|
||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
||||
return "\(header)\n\n\(msg.content)"
|
||||
}.joined(separator: "\n\n---\n\n")
|
||||
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
|
||||
let fileURL = downloads.appendingPathComponent(filename)
|
||||
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(conversation.name)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label("\(conversation.messageCount)", systemImage: "message")
|
||||
Text("\u{2022}")
|
||||
Text(conversation.updatedAt, style: .relative)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConversationListView()
|
||||
}
|
||||
Reference in New Issue
Block a user