Zwischenablage, Notizen und der Gesichtsvergleich
Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` — was dort steht, liest jedes Programm mit `defaults read` mit. Die Zwischenablage achtet `ConcealedType`, `TransientType` und `AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein. Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber `VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig. `FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort, das man ohnehin kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f289d02c37
commit
3da673662f
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"notes.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Notizen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Notes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes.empty": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Noch nichts notiert."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nothing noted yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine kurze Notiz.
|
||||
///
|
||||
/// Kein Titel als eigenes Feld: wer eine Notiz schreibt, will schreiben und
|
||||
/// nicht erst benennen. Die Überschrift ist die erste Zeile — dieselbe
|
||||
/// Verabredung, die Apples Notizen seit jeher treffen.
|
||||
public struct Note: Identifiable, Equatable, Sendable, Codable {
|
||||
public let id: UUID
|
||||
public var text: String
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), text: String = "", updatedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Regeln der Notizen — ohne Dateisystem und ohne Uhr.
|
||||
public enum NoteRules {
|
||||
|
||||
/// Wie viele Notizen höchstens.
|
||||
///
|
||||
/// Die Kachel ist klein und die Liste soll überschaubar bleiben. Wer mehr
|
||||
/// braucht, braucht ein Notizprogramm und keine Notch.
|
||||
public static let limit = 50
|
||||
|
||||
/// Wie lang eine Überschrift höchstens ist.
|
||||
public static let titleLength = 60
|
||||
|
||||
/// Die erste nichtleere Zeile, gekürzt. Leer bleibt leer — daraus macht die
|
||||
/// Anzeige „Neue Notiz", und das ist ihre Sache, nicht unsere.
|
||||
public static func title(of note: Note) -> String {
|
||||
let line = note.text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
guard let line else { return "" }
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.count > titleLength else { return trimmed }
|
||||
return String(trimmed.prefix(titleLength)) + "…"
|
||||
}
|
||||
|
||||
/// Ob eine Notiz überhaupt etwas enthält.
|
||||
///
|
||||
/// Eine Notiz aus lauter Leerzeichen ist keine — sie entsteht, wenn man auf
|
||||
/// „Neu" drückt und es sich anders überlegt, und soll dann von selbst
|
||||
/// verschwinden statt als leere Zeile stehen zu bleiben.
|
||||
public static func isEmpty(_ note: Note) -> Bool {
|
||||
note.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// Zuletzt geändert zuerst, mit fester Reihenfolge bei Gleichstand.
|
||||
public static func sorted(_ notes: [Note]) -> [Note] {
|
||||
notes.sorted {
|
||||
$0.updatedAt == $1.updatedAt
|
||||
? $0.id.uuidString > $1.id.uuidString
|
||||
: $0.updatedAt > $1.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt eine geänderte Notiz auf: leere fallen weg, über der Grenze fällt
|
||||
/// die älteste heraus.
|
||||
public static func merge(_ notes: [Note], updating note: Note,
|
||||
limit: Int = limit) -> [Note] {
|
||||
var kept = notes.filter { $0.id != note.id }
|
||||
if !isEmpty(note) { kept.append(note) }
|
||||
let sorted = sorted(kept)
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notes")
|
||||
|
||||
/// Die Notizen und ihre Ablage.
|
||||
///
|
||||
/// Eine Datei für alles: Notizen sind kurz, und fünfzig davon sind ein paar
|
||||
/// Kilobyte. Eine Datei je Notiz wäre der Ordnung wegen — nicht der Sache.
|
||||
///
|
||||
/// **Nicht in `UserDefaults`.** Was dort steht, liest jedes Programm des
|
||||
/// Benutzers mit `defaults read` mit, und eine Notiz ist oft genau das, was man
|
||||
/// sich merkt, weil man es nicht im Kopf behalten will.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class NotesStore {
|
||||
|
||||
public private(set) var notes: [Note] = []
|
||||
|
||||
@ObservationIgnored private let fileURL: URL
|
||||
|
||||
private struct Archive: Codable {
|
||||
var version = 1
|
||||
var notes: [Note]
|
||||
}
|
||||
|
||||
public init(directory: URL? = nil) {
|
||||
let folder = directory ?? Self.defaultDirectory
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
fileURL = folder.appendingPathComponent("notes.json")
|
||||
load()
|
||||
}
|
||||
|
||||
public static var defaultDirectory: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Onyx/Notes", isDirectory: true)
|
||||
}
|
||||
|
||||
/// Legt eine leere Notiz an und gibt sie zurück, damit die Anzeige den
|
||||
/// Schreibcursor hineinsetzen kann.
|
||||
///
|
||||
/// Sie steht noch **nicht** in der Liste: eine leere Notiz ist keine, und
|
||||
/// wer auf „Neu" drückt und es sich anders überlegt, soll keine Leerzeile
|
||||
/// hinterlassen. Erst der erste Buchstabe macht sie zur Notiz.
|
||||
public func makeDraft() -> Note { Note() }
|
||||
|
||||
public func update(_ note: Note) {
|
||||
var updated = note
|
||||
updated.updatedAt = Date()
|
||||
notes = NoteRules.merge(notes, updating: updated)
|
||||
save()
|
||||
}
|
||||
|
||||
public func remove(_ note: Note) {
|
||||
notes.removeAll { $0.id == note.id }
|
||||
save()
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return }
|
||||
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
|
||||
archive.version == 1 else {
|
||||
log.notice("Unbekannte Fassung der Notizdatei — sie bleibt unangetastet")
|
||||
return
|
||||
}
|
||||
notes = NoteRules.sorted(archive.notes)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
try JSONEncoder().encode(Archive(notes: notes)).write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
log.error("Notizen nicht schreibbar: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
|
||||
/// Kurze Notizen, direkt in der Kachel zu schreiben.
|
||||
public struct NotesView: View {
|
||||
private let store: NotesStore
|
||||
private let compact: Bool
|
||||
|
||||
@State private var editing: Note?
|
||||
@State private var draft = ""
|
||||
@FocusState private var isWriting: Bool
|
||||
|
||||
public init(store: NotesStore, compact: Bool = false) {
|
||||
self.store = store
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||
if let editing {
|
||||
editor(for: editing)
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("notes.name", bundle: .module)
|
||||
.font(Onyx.Font.title)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
Spacer()
|
||||
Button { begin(store.makeDraft()) } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
}
|
||||
.padding(.bottom, Onyx.Space.sm)
|
||||
|
||||
if store.notes.isEmpty {
|
||||
OnyxNotice(symbol: "note.text",
|
||||
text: String(localized: "notes.empty", bundle: .module))
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(store.notes.prefix(compact ? 4 : 20).enumerated()),
|
||||
id: \.element.id) { index, note in
|
||||
if index > 0 { OnyxHairline() }
|
||||
Button { begin(note) } label: {
|
||||
HStack {
|
||||
Text(NoteRules.title(of: note))
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: Onyx.Space.xs)
|
||||
}
|
||||
.padding(.vertical, Onyx.Space.sm)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func editor(for note: Note) -> some View {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
|
||||
HStack {
|
||||
Button { finish() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { store.remove(note); editing = nil } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: Onyx.Symbol.small))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
|
||||
// `TextEditor` ohne eigenen Hintergrund: der Standardhintergrund
|
||||
// ist hell und stünde als weißer Block in der schwarzen Kachel.
|
||||
TextEditor(text: $draft)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isWriting)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.onAppear { isWriting = true }
|
||||
}
|
||||
|
||||
private func begin(_ note: Note) {
|
||||
editing = note
|
||||
draft = note.text
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
if var note = editing {
|
||||
note.text = draft
|
||||
store.update(note)
|
||||
}
|
||||
editing = nil
|
||||
draft = ""
|
||||
}
|
||||
}
|
||||
|
||||
public struct NotesWidget: OnyxWidget {
|
||||
public let id = "notes"
|
||||
public var displayName: String { String(localized: "notes.name", bundle: .module) }
|
||||
public let symbolName = "note.text"
|
||||
|
||||
private let store: NotesStore
|
||||
|
||||
public init(store: NotesStore) { self.store = store }
|
||||
|
||||
public func makeView() -> AnyView { AnyView(NotesView(store: store)) }
|
||||
}
|
||||
Reference in New Issue
Block a user