Compare commits

...

3 commits

Author SHA1 Message Date
Blake Oliver
25bb6d1b54
Add Announce Background Messages setting and descriptive footers
- New "Announce Background Messages" toggle in Accessibility section (off by default) — VoiceOver announces messages from non-active buffers when enabled
- Rename "Other Buffers" to "Background Buffer Sounds" in Sounds section
- Add footer descriptions to Save Debug Buffer, Background Buffer Sounds, Status Haptics, and Announce Background Messages
2026-04-05 20:12:16 -06:00
Blake Oliver
0e36f8b5ff
Add .claude and .pi with swift data pro skill and xcode mcp allows 2026-04-05 18:52:28 -06:00
Blake Oliver
8a39e5939f
Add swiftData message persistence and reactive side effects
Persistence:
- SavedMessage SwiftData model with index on [buffer, timestamp]
- @Query in ChatBufferView/DMListView/DMConversationView — SwiftData is the single source of truth, no in-memory dictionary
- Settings: Save Messages toggle, Save Debug Buffer toggle, Delete After picker (1d/1w/1m/1y/never), Delete All with confirmation dialog
- Expired and debug messages purged on launch based on settings
- Say messages go to debug buffer only (not all buffers)

Reactive side effects:
- Sound, haptic, and VoiceOver announcement all fire from ChatBufferView's onChange(of: messages.count)
- Sound plays for active buffer or background buffers if enabled
- Haptic and announcement fire on active buffer only, current session only (I'll add a toggle to announce other buffers next)
- Status haptic for Say messages stays imperative in model (global event, not per-buffer)
- Removed trigger counters and lastAnnouncement from ConnectionManager

DM conversation fix:
- Track DM recipient via lastDMRecipientId; tag outgoing echoes with partner ID in characterId
- Conversation view filters on characterId == partnerId (shows only that conversation)

Other:
- modelContext set in ContentView.onAppear (available for all screens)
- Instant scroll (no animation), respects Reduce Motion
2026-04-05 18:46:38 -06:00
14 changed files with 324 additions and 85 deletions

View file

@ -0,0 +1 @@
../../.agents/skills/swiftdata-pro

1
.pi/skills/swiftdata-pro Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/swiftdata-pro

View file

@ -1,7 +1,9 @@
import SwiftData
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
@Environment(\.modelContext) private var modelContext
var body: some View { var body: some View {
Group { Group {
@ -17,5 +19,43 @@ struct ContentView: View {
} }
} }
.animation(.default, value: manager.screen) .animation(.default, value: manager.screen)
.onAppear {
manager.modelContext = modelContext
purgeExpiredMessages()
}
}
private func purgeExpiredMessages() {
// Purge all if save is off
if !UserDefaults.standard.bool(forKey: "save_messages", default: true) {
try? modelContext.delete(model: SavedMessage.self)
try? modelContext.save()
return
}
// Purge expired messages
let deleteAfterRaw = UserDefaults.standard.integer(forKey: "delete_after")
let days = deleteAfterRaw == 0 ? 0 : (DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth).rawValue
if days > 0 {
let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: .now) ?? .now
let expired = FetchDescriptor<SavedMessage>(
predicate: #Predicate { $0.timestamp < cutoff }
)
if let old = try? modelContext.fetch(expired) {
for message in old { modelContext.delete(message) }
}
}
// Purge debug messages if not saving them
if !UserDefaults.standard.bool(forKey: "save_debug_buffer") {
let debugDesc = FetchDescriptor<SavedMessage>(
predicate: #Predicate { $0.buffer == "debug" }
)
if let debugMsgs = try? modelContext.fetch(debugDesc) {
for message in debugMsgs { modelContext.delete(message) }
}
}
try? modelContext.save()
} }
} }

View file

@ -1,3 +1,4 @@
import SwiftData
import SwiftUI import SwiftUI
@main @main
@ -13,5 +14,6 @@ struct KDChatApp: App {
ContentView() ContentView()
.environment(connectionManager) .environment(connectionManager)
} }
.modelContainer(for: SavedMessage.self)
} }
} }

View file

@ -56,7 +56,7 @@ struct ChatMessage: Identifiable, Sendable, Decodable {
_ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound _ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound
senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7] senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7]
isSystem = false isSystem = false
timestamp = Date() timestamp = .now
} }
/// Create a system message (from Say packets) /// Create a system message (from Say packets)
@ -69,6 +69,6 @@ struct ChatMessage: Identifiable, Sendable, Decodable {
self.isInteractable = false self.isInteractable = false
self.senderIsRecipient = false self.senderIsRecipient = false
self.isSystem = true self.isSystem = true
self.timestamp = Date() self.timestamp = .now
} }
} }

View file

@ -0,0 +1,38 @@
import Foundation
import SwiftData
@Model class SavedMessage {
#Index<SavedMessage>([\.buffer, \.timestamp])
var buffer: String
var senderId: String?
var senderName: String
var content: String
var timestamp: Date
var isSystem: Bool = false
var senderIsRecipient: Bool = false
var characterId: String?
var sessionId: UUID
init(
buffer: String,
senderId: String?,
senderName: String,
content: String,
timestamp: Date = .now,
isSystem: Bool = false,
senderIsRecipient: Bool = false,
characterId: String? = nil,
sessionId: UUID
) {
self.buffer = buffer
self.senderId = senderId
self.senderName = senderName
self.content = content
self.timestamp = timestamp
self.isSystem = isSystem
self.senderIsRecipient = senderIsRecipient
self.characterId = characterId
self.sessionId = sessionId
}
}

View file

@ -1,9 +1,10 @@
import Foundation import Foundation
import MessagePack import MessagePack
import SwiftData
import SwiftUI import SwiftUI
import os import os
private extension UserDefaults { extension UserDefaults {
func bool(forKey key: String, default defaultValue: Bool) -> Bool { func bool(forKey key: String, default defaultValue: Bool) -> Bool {
object(forKey: key) == nil ? defaultValue : bool(forKey: key) object(forKey: key) == nil ? defaultValue : bool(forKey: key)
} }
@ -43,17 +44,14 @@ final class ConnectionManager {
// Chat // Chat
var buffers: [ChatBuffer] = [] var buffers: [ChatBuffer] = []
var messages: [String: [ChatMessage]] = [:]
var currentCharacterId: String? var currentCharacterId: String?
var localSenderId: String? var localSenderId: String?
var sessionId = UUID()
// Feedback triggers (views observe these for sensoryFeedback / announcements) var modelContext: ModelContext?
var messageFeedbackTrigger: Int = 0 private var lastDMRecipientId: String?
var systemFeedbackTrigger: Int = 0
// Tracks which buffer the user is viewing (set by ChatView) // Tracks which buffer the user is viewing (set by ChatView)
var activeBuffer: String = "global" var activeBuffer: String = "global"
var lastAnnouncement: String = ""
// Auth // Auth
private(set) var isLoggedIn: Bool = false private(set) var isLoggedIn: Bool = false
@ -105,6 +103,7 @@ final class ConnectionManager {
private func startClient(host: String, port: UInt16) { private func startClient(host: String, port: UInt16) {
tearDownClient() tearDownClient()
sessionId = UUID()
let client = LNLClient() let client = LNLClient()
self.client = client self.client = client
@ -138,6 +137,7 @@ final class ConnectionManager {
} }
func sendDirectMessage(recipientId: String, message: String) { func sendDirectMessage(recipientId: String, message: String) {
lastDMRecipientId = recipientId
let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message) let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message)
Task { await client?.send(data: packet) } Task { await client?.send(data: packet) }
} }
@ -213,9 +213,9 @@ final class ConnectionManager {
private func resetState() { private func resetState() {
buffers.removeAll() buffers.removeAll()
messages.removeAll()
remoteForms.removeAll() remoteForms.removeAll()
currentCharacterId = nil currentCharacterId = nil
localSenderId = nil
errorMessage = nil errorMessage = nil
} }
@ -343,58 +343,64 @@ final class ConnectionManager {
log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)") log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)")
if !buffers.contains(where: { $0.name == buffer.name }) { if !buffers.contains(where: { $0.name == buffer.name }) {
buffers.append(buffer) buffers.append(buffer)
messages[buffer.name] = []
} }
} }
private func handleAddMessage(_ message: ChatMessage) { private func handleAddMessage(_ message: ChatMessage) {
messages[message.buffer, default: []].append(message)
// Learn our own entity ID from echoed messages // Learn our own entity ID from echoed messages
if message.senderIsRecipient, let id = message.senderId { if message.senderIsRecipient, let id = message.senderId {
if localSenderId == nil { localSenderId = id } if localSenderId == nil { localSenderId = id }
if currentCharacterId == nil { currentCharacterId = id } if currentCharacterId == nil { currentCharacterId = id }
} }
messageFeedbackTrigger += 1 // For DMs, characterId stores the conversation partner's ID
lastAnnouncement = "\(message.senderName): \(message.content)" let dmPartnerId: String? = if message.buffer == "direct-messages" {
playMessageSound(buffer: message.buffer) message.senderIsRecipient ? lastDMRecipientId : message.senderId
} else {
nil
}
let saved = SavedMessage(
buffer: message.buffer,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
senderIsRecipient: message.senderIsRecipient,
characterId: dmPartnerId,
sessionId: sessionId
)
modelContext?.insert(saved)
try? modelContext?.save()
} }
private func handleSay(_ say: ServerSay) { private func handleSay(_ say: ServerSay) {
let sysMessage = ChatMessage(systemMessage: say.message) let saved = SavedMessage(
buffer: "debug",
senderId: nil,
senderName: "System",
content: say.message,
isSystem: true,
sessionId: sessionId
)
modelContext?.insert(saved)
try? modelContext?.save()
for buffer in buffers { announce(say.message)
messages[buffer.name, default: []].append(sysMessage) playStatusHaptic()
}
systemFeedbackTrigger += 1
lastAnnouncement = say.message
} }
private func playMessageSound(buffer: String) { private func announce(_ text: String) {
guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return } AccessibilityNotification.Announcement(text).post()
let isActiveBuffer = buffer == activeBuffer
if !isActiveBuffer {
guard UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true) else { return }
}
switch buffer {
case "global":
guard UserDefaults.standard.bool(forKey: "sounds_global", default: true) else { return }
SoundPlayer.play("global")
case "map":
guard UserDefaults.standard.bool(forKey: "sounds_map", default: true) else { return }
SoundPlayer.play("map")
case "direct-messages":
guard UserDefaults.standard.bool(forKey: "sounds_dm", default: true) else { return }
SoundPlayer.play("direct-message")
default:
break
}
} }
private func playStatusHaptic() {
guard UserDefaults.standard.bool(forKey: "haptics_enabled", default: true),
UserDefaults.standard.bool(forKey: "haptics_status", default: true) else { return }
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
// MARK: - Game State Handlers // MARK: - Game State Handlers

View file

@ -1,14 +1,25 @@
import SwiftData
import SwiftUI import SwiftUI
struct ChatBufferView: View { struct ChatBufferView: View {
let bufferName: String let bufferName: String
let canSend: Bool let canSend: Bool
var onDM: ((ChatMessage) -> Void)? var onDM: ((SavedMessage) -> Void)?
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
@Environment(\.modelContext) private var modelContext
@Query private var messages: [SavedMessage]
@State private var messageText = "" @State private var messageText = ""
private var messages: [ChatMessage] { init(bufferName: String, canSend: Bool, onDM: ((SavedMessage) -> Void)? = nil) {
manager.messages[bufferName] ?? [] self.bufferName = bufferName
self.canSend = canSend
self.onDM = onDM
let buf = bufferName
_messages = Query(
filter: #Predicate<SavedMessage> { $0.buffer == buf },
sort: \.timestamp
)
} }
var body: some View { var body: some View {
@ -21,15 +32,34 @@ struct ChatBufferView: View {
message: message, message: message,
onDM: onDM onDM: onDM
) )
.id(message.id) .id(message.persistentModelID)
} }
} }
.padding() .padding()
} }
.onChange(of: messages.count) { .onChange(of: messages.count) {
if let lastId = messages.last?.id { guard let last = messages.last, last.sessionId == manager.sessionId else { return }
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.persistentModelID, anchor: .bottom)
proxy.scrollTo(lastId, anchor: .bottom)
let isActive = bufferName == manager.activeBuffer
let playBackground = UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true)
// Sound plays for active buffer, or background if enabled
if isActive || playBackground {
playBufferSound()
}
// Announce + haptic
let announceOther = UserDefaults.standard.bool(forKey: "announce_other_buffers")
if isActive || announceOther {
if !last.isSystem {
AccessibilityNotification.Announcement(
"\(last.senderName): \(last.content)"
).post()
}
if UserDefaults.standard.bool(forKey: "haptics_enabled", default: true),
UserDefaults.standard.bool(forKey: "haptics_messages", default: true) {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
} }
} }
} }
@ -75,4 +105,20 @@ struct ChatBufferView: View {
messageText = "" messageText = ""
} }
private func playBufferSound() {
guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return }
switch bufferName {
case "global":
guard UserDefaults.standard.bool(forKey: "sounds_global", default: true) else { return }
SoundPlayer.play("global")
case "map":
guard UserDefaults.standard.bool(forKey: "sounds_map", default: true) else { return }
SoundPlayer.play("map")
case "direct-messages":
guard UserDefaults.standard.bool(forKey: "sounds_dm", default: true) else { return }
SoundPlayer.play("direct-message")
default:
break
}
}
} }

View file

@ -1,3 +1,4 @@
import SwiftData
import SwiftUI import SwiftUI
struct ChatView: View { struct ChatView: View {
@ -5,9 +6,6 @@ struct ChatView: View {
@State private var selectedBuffer: String = "global" @State private var selectedBuffer: String = "global"
@State private var showSettings = false @State private var showSettings = false
@State private var selectedDMPartner: String? @State private var selectedDMPartner: String?
@AppStorage("haptics_enabled") private var hapticsEnabled = true
@AppStorage("haptics_messages") private var messageHaptics = true
@AppStorage("haptics_status") private var statusHaptics = true
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -50,11 +48,6 @@ struct ChatView: View {
let name = partnerName(for: partnerId) let name = partnerName(for: partnerId)
DMConversationView(partnerId: partnerId, partnerName: name) DMConversationView(partnerId: partnerId, partnerName: name)
} }
.sensoryFeedback(.impact(flexibility: .soft), trigger: hapticsEnabled && messageHaptics ? manager.messageFeedbackTrigger : 0)
.sensoryFeedback(.warning, trigger: hapticsEnabled && statusHaptics ? manager.systemFeedbackTrigger : 0)
.onChange(of: manager.lastAnnouncement) {
AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
}
.onChange(of: selectedBuffer) { .onChange(of: selectedBuffer) {
manager.activeBuffer = selectedBuffer manager.activeBuffer = selectedBuffer
} }
@ -86,12 +79,17 @@ struct ChatView: View {
} }
} }
@Environment(\.modelContext) private var modelContext
private func partnerName(for partnerId: String) -> String { private func partnerName(for partnerId: String) -> String {
let dmMessages = manager.messages["direct-messages"] ?? [] let descriptor = FetchDescriptor<SavedMessage>(
return dmMessages.first { $0.senderId == partnerId }?.senderName ?? partnerId predicate: #Predicate { $0.buffer == "direct-messages" && $0.senderId == partnerId }
)
let results = (try? modelContext.fetch(descriptor)) ?? []
return results.first?.senderName ?? partnerId
} }
private func navigateToDM(_ message: ChatMessage) { private func navigateToDM(_ message: SavedMessage) {
guard let senderId = message.senderId else { return } guard let senderId = message.senderId else { return }
selectedBuffer = "direct-messages" selectedBuffer = "direct-messages"
selectedDMPartner = senderId selectedDMPartner = senderId

View file

@ -1,15 +1,24 @@
import SwiftData
import SwiftUI import SwiftUI
struct DMConversationView: View { struct DMConversationView: View {
let partnerId: String let partnerId: String
let partnerName: String let partnerName: String
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
@Query private var messages: [SavedMessage]
@State private var messageText = "" @State private var messageText = ""
private var messages: [ChatMessage] { init(partnerId: String, partnerName: String) {
(manager.messages["direct-messages"] ?? []).filter { message in self.partnerId = partnerId
message.senderId == partnerId || (message.senderIsRecipient && !message.isSystem) self.partnerName = partnerName
}
let pid = partnerId
_messages = Query(
filter: #Predicate<SavedMessage> {
$0.buffer == "direct-messages" && $0.characterId == pid
},
sort: \.timestamp
)
} }
var body: some View { var body: some View {
@ -19,16 +28,14 @@ struct DMConversationView: View {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
ForEach(messages) { message in ForEach(messages) { message in
MessageRow(message: message) MessageRow(message: message)
.id(message.id) .id(message.persistentModelID)
} }
} }
.padding() .padding()
} }
.onChange(of: messages.count) { .onChange(of: messages.count) {
if let lastId = messages.last?.id { if let last = messages.last {
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.persistentModelID, anchor: .bottom)
proxy.scrollTo(lastId, anchor: .bottom)
}
} }
} }
} }

View file

@ -1,21 +1,23 @@
import SwiftData
import SwiftUI import SwiftUI
struct DMListView: View { struct DMListView: View {
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
@Binding var selectedConversation: String? @Binding var selectedConversation: String?
@Query(
filter: #Predicate<SavedMessage> { $0.buffer == "direct-messages" && !$0.isSystem },
sort: \.timestamp,
order: .reverse
) private var dmMessages: [SavedMessage]
private var conversations: [(id: String, name: String, lastMessage: ChatMessage)] { private var conversations: [(id: String, name: String, lastMessage: SavedMessage)] {
let dmMessages = manager.messages["direct-messages"] ?? [] var latest: [String: (name: String, message: SavedMessage)] = [:]
var latest: [String: (name: String, message: ChatMessage)] = [:] for message in dmMessages {
for message in dmMessages where !message.isSystem {
// The conversation partner is the other person
guard let senderId = message.senderId, !message.senderIsRecipient else { continue } guard let senderId = message.senderId, !message.senderIsRecipient else { continue }
if latest[senderId].map({ message.timestamp > $0.message.timestamp }) ?? true { if latest[senderId] == nil {
latest[senderId] = (message.senderName, message) latest[senderId] = (message.senderName, message)
} }
} }
return latest return latest
.map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) } .map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) }
.sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp } .sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp }

View file

@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
struct MessageRow: View { struct MessageRow: View {
let message: ChatMessage let message: SavedMessage
var onDM: ((ChatMessage) -> Void)? var onDM: ((SavedMessage) -> Void)?
var onProfile: ((ChatMessage) -> Void)? var onProfile: ((SavedMessage) -> Void)?
@Environment(ConnectionManager.self) private var manager @Environment(ConnectionManager.self) private var manager
private var isOwnMessage: Bool { private var isOwnMessage: Bool {

View file

@ -1,6 +1,28 @@
import SwiftData
import SwiftUI import SwiftUI
enum DeleteAfter: Int, CaseIterable {
case oneDay = 1
case oneWeek = 7
case oneMonth = 30
case oneYear = 365
case never = 0
var label: String {
switch self {
case .oneDay: "1 Day"
case .oneWeek: "1 Week"
case .oneMonth: "1 Month"
case .oneYear: "1 Year"
case .never: "Never"
}
}
}
struct SettingsView: View { struct SettingsView: View {
@AppStorage("save_messages") private var saveMessages = true
@AppStorage("save_debug_buffer") private var saveDebugBuffer = false
@AppStorage("delete_after") private var deleteAfterRaw = DeleteAfter.oneMonth.rawValue
@AppStorage("haptics_enabled") private var hapticsEnabled = true @AppStorage("haptics_enabled") private var hapticsEnabled = true
@AppStorage("haptics_messages") private var messageHaptics = true @AppStorage("haptics_messages") private var messageHaptics = true
@AppStorage("haptics_status") private var statusHaptics = true @AppStorage("haptics_status") private var statusHaptics = true
@ -9,10 +31,50 @@ struct SettingsView: View {
@AppStorage("sounds_map") private var mapSound = true @AppStorage("sounds_map") private var mapSound = true
@AppStorage("sounds_dm") private var dmSound = true @AppStorage("sounds_dm") private var dmSound = true
@AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true @AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true
@AppStorage("announce_other_buffers") private var announceOtherBuffers = false
@Environment(\.modelContext) private var modelContext
@State private var showDeleteConfirmation = false
private var deleteAfter: Binding<DeleteAfter> {
Binding(
get: { DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth },
set: { deleteAfterRaw = $0.rawValue }
)
}
var body: some View { var body: some View {
Form { Form {
Section("Sounds") { Section {
Toggle("Save Messages", isOn: $saveMessages)
Toggle("Save Debug Buffer", isOn: $saveDebugBuffer)
.disabled(!saveMessages)
Picker("Delete After", selection: deleteAfter) {
ForEach(DeleteAfter.allCases, id: \.self) { option in
Text(option.label).tag(option)
}
}
.disabled(!saveMessages)
Button("Delete All Messages", role: .destructive) {
showDeleteConfirmation = true
}
.confirmationDialog(
"Delete Messages",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete All Messages", role: .destructive) {
deleteAllMessages()
}
} message: {
Text("Are you sure you want to delete all messages from all buffers?")
}
} header: {
Text("Messages")
} footer: {
Text("Save Debug Buffer saves system and debug messages between sessions.")
}
Section {
Toggle("Sounds", isOn: $soundsEnabled) Toggle("Sounds", isOn: $soundsEnabled)
Toggle("Global Chat", isOn: $globalSound) Toggle("Global Chat", isOn: $globalSound)
.disabled(!soundsEnabled) .disabled(!soundsEnabled)
@ -20,16 +82,32 @@ struct SettingsView: View {
.disabled(!soundsEnabled) .disabled(!soundsEnabled)
Toggle("Direct Messages", isOn: $dmSound) Toggle("Direct Messages", isOn: $dmSound)
.disabled(!soundsEnabled) .disabled(!soundsEnabled)
Toggle("Other Buffers", isOn: $backgroundBufferSounds) Toggle("Background Buffer Sounds", isOn: $backgroundBufferSounds)
.disabled(!soundsEnabled) .disabled(!soundsEnabled)
} header: {
Text("Sounds")
} footer: {
Text("Background Buffer Sounds plays sounds for messages in buffers you're not currently viewing.")
} }
Section("Haptics") { Section {
Toggle("Haptics", isOn: $hapticsEnabled) Toggle("Haptics", isOn: $hapticsEnabled)
Toggle("Message Haptics", isOn: $messageHaptics) Toggle("Message Haptics", isOn: $messageHaptics)
.disabled(!hapticsEnabled) .disabled(!hapticsEnabled)
Toggle("Status Haptics", isOn: $statusHaptics) Toggle("Status Haptics", isOn: $statusHaptics)
.disabled(!hapticsEnabled) .disabled(!hapticsEnabled)
} header: {
Text("Haptics")
} footer: {
Text("Status Haptics provides feedback for system events such as connect and disconnect.")
}
Section {
Toggle("Announce Background Messages", isOn: $announceOtherBuffers)
} header: {
Text("Accessibility")
} footer: {
Text("When on, VoiceOver announces new messages from buffers you're not currently viewing.")
} }
Section { Section {
@ -43,5 +121,15 @@ struct SettingsView: View {
} }
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onChange(of: saveMessages) {
if !saveMessages {
deleteAllMessages()
}
}
}
private func deleteAllMessages() {
try? modelContext.delete(model: SavedMessage.self)
try? modelContext.save()
} }
} }

10
skills-lock.json Normal file
View file

@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"swiftdata-pro": {
"source": "twostraws/swiftdata-agent-skill",
"sourceType": "github",
"computedHash": "2f979bad98ea3a6744084c5f93e27897f02e8d0ffe15dd03042e88aaae4da14c"
}
}
}