commit 21edee59aa7e978a9bbc3eb52c60edbaf1984b1b Author: samuelzielke Date: Thu Oct 22 13:15:14 2026 +0200 chore: initial commit of Mawari 1.0.0 Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd4cb17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# macOS +.DS_Store + +# Xcode / Build +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.moved-aside +build-dmg.log +*.log + +# SwiftPM +.build/ +.swiftpm/ + +# Aus project.yml generiert (xcodegen) +Mawari.xcodeproj/ +App/Info.plist + +# Schlüssel und Exporte – gehören NIE ins Repo +*.key +*.p12 +*.pem +mawari-sparkle.key + +# Echte Daten (falls mal zum Testen in den Projektordner kopiert) +daten.json +leben-und-dienst*.json +mawari-sicherung-*.json +Sicherungen/ diff --git a/App/Mawari.entitlements b/App/Mawari.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/App/Mawari.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/App/Resources/AppIcon.icns b/App/Resources/AppIcon.icns new file mode 100644 index 0000000..49f874f Binary files /dev/null and b/App/Resources/AppIcon.icns differ diff --git a/App/Sources/EntrySheet.swift b/App/Sources/EntrySheet.swift new file mode 100644 index 0000000..c0264da --- /dev/null +++ b/App/Sources/EntrySheet.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct EntrySheet: View { + @EnvironmentObject var store: Store + @EnvironmentObject var ui: UIState + @Environment(\.dismiss) private var dismiss + + @State private var entry: Entry + @State private var date: Date + @State private var showAll = false + + init(entry: Entry) { + _entry = State(initialValue: entry) + _date = State(initialValue: Day.date(entry.date) ?? Date()) + } + + private var task: DutyTask? { store.task(entry.task) } + private var showPartner: Bool { task?.withPartner == true || !entry.partner.isEmpty } + + private func people(for taskID: String?, keep: String) -> [Person] { + store.personsSorted.filter { showAll || taskID == nil || $0.may(taskID!) || $0.id == keep } + } + + private var warnings: [String] { + var w: [String] = [] + if let t = task, let p = store.person(entry.person), !p.may(t.id) { w.append("\(p.name) ist für „\(t.name)“ nicht freigegeben.") } + if let pa = store.person(entry.partner), !pa.may("partner") { w.append("\(pa.name) ist nicht als Partner freigegeben.") } + if !entry.partner.isEmpty, entry.partner == entry.person { w.append("Person und Partner sind gleich.") } + return w + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(entry.isNew ? "Neuer Eintrag" : "Eintrag bearbeiten").font(.title3.weight(.semibold)) + + Form { + DatePicker("Datum", selection: $date, displayedComponents: .date) + .datePickerStyle(.field) + Picker("Aufgabe", selection: $entry.task) { + Text("Aufgabe wählen …").tag("") + ForEach(TaskGroup.allCases) { g in + Section(g.shortTitle) { + ForEach(store.db.tasks.filter { $0.group == g && !$0.partnerColumn }) { Text($0.name).tag($0.id) } + } + } + } + Picker("Person", selection: $entry.person) { + Text("Person wählen …").tag("") + ForEach(people(for: task?.id, keep: entry.person)) { Text($0.name).tag($0.id) } + } + Toggle("Alle Personen anzeigen (auch ohne Freigabe)", isOn: $showAll) + if showPartner { + Picker("Partner", selection: $entry.partner) { + Text("– kein Partner –").tag("") + ForEach(people(for: "partner", keep: entry.partner)) { Text($0.name).tag($0.id) } + } + } + TextField("Thema / Notiz", text: $entry.note, prompt: Text("optional")) + } + .formStyle(.columns) + + ForEach(warnings, id: \.self) { w in + Label(w, systemImage: "exclamationmark.triangle").foregroundStyle(Theme.kohaku).font(.callout) + } + + HStack { + if !entry.isNew { + Button("Löschen", role: .destructive) { store.delete(entry: entry.id); dismiss() } + } + Spacer() + Button("Abbrechen", role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction) + Button(entry.isNew ? "Eintragen" : "Sichern", action: save) + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(entry.task.isEmpty || entry.person.isEmpty) + } + } + .padding(22) + .frame(width: 460) + } + + private func save() { + var e = entry + e.date = Day.string(date) + if !(task?.withPartner ?? false) && e.partner.isEmpty { e.partner = "" } + let wasNew = e.isNew + if wasNew { e.id = newID("e") } + e.note = e.note.trimmingCharacters(in: .whitespaces) + store.save(entry: e) + dismiss() + ui.show(wasNew ? "Eingetragen: \(store.task(e.task)?.name ?? "") · \(store.name(e.person))" : "Eintrag geändert.") + } +} diff --git a/App/Sources/Exporter.swift b/App/Sources/Exporter.swift new file mode 100644 index 0000000..7a60131 --- /dev/null +++ b/App/Sources/Exporter.swift @@ -0,0 +1,206 @@ +import AppKit +import UniformTypeIdentifiers + +/// Export für Numbers (.xlsx), Sicherung speichern und laden. +/// Eine echte .numbers-Datei lässt sich nicht erzeugen (Apple-eigenes Format); +/// die .xlsx öffnet Numbers direkt und wird nach dem Speichern dort geöffnet. +@MainActor +enum Exporter { + + static func exportXlsx(store: Store, ui: UIState) { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data] + panel.nameFieldStringValue = "Leben-und-Dienst-\(Day.today).xlsx" + panel.message = "Der aktuelle Stand wird als Tabelle gespeichert und in Numbers geöffnet." + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try XLSXWriter.workbook(sheets: sheets(store: store)).write(to: url, options: .atomic) + openInNumbers(url) + ui.show("Export gespeichert.") + } catch { + ui.show("Export fehlgeschlagen: \(error.localizedDescription)") + } + } + + static func saveBackup(store: Store, ui: UIState) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "mawari-sicherung-\(Day.today).json" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try store.encoded().write(to: url, options: .atomic); ui.show("Sicherung gespeichert.") } + catch { ui.show("Sicherung fehlgeschlagen: \(error.localizedDescription)") } + } + + static func importBackup(store: Store, ui: UIState) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.message = "Sicherung oder Datei aus dem HTML-Tool (leben-und-dienst.json) wählen. Der aktuelle Stand wird dabei ersetzt (eine Kopie bleibt im Datenordner)." + guard panel.runModal() == .OK, let url = panel.url else { return } + if !store.db.persons.isEmpty || !store.db.entries.isEmpty { + let alert = NSAlert() + alert.messageText = "Aktuelle Daten ersetzen?" + alert.informativeText = "Alle Personen und Einträge werden durch die Datei ersetzt. Der bisherige Stand wird vorher im Datenordner gesichert." + alert.addButton(withTitle: "Ersetzen") + alert.addButton(withTitle: "Abbrechen") + guard alert.runModal() == .alertFirstButtonReturn else { return } + } + do { + try store.importFile(url) + ui.tab = .overview + ui.show("Geladen: \(store.db.persons.count) Personen, \(store.db.entries.count) Einträge.") + } catch { + ui.show("Datei konnte nicht gelesen werden.") + } + } + + private static func openInNumbers(_ url: URL) { + if let numbers = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.iWork.Numbers") { + NSWorkspace.shared.open([url], withApplicationAt: numbers, configuration: NSWorkspace.OpenConfiguration()) + } else { + NSWorkspace.shared.open(url) + } + } + + // MARK: Inhalt + + private static func sheets(store: Store) -> [XLSXWriter.Sheet] { + typealias C = XLSXWriter.Cell + var ov: [[C]] = [ + [.text("Leben und Dienst – Übersicht", .title)], + [.text("Stand \(Day.display(Day.today)) · Datum = letzte Einteilung · nie = freigegeben, aber noch nie eingeteilt · – = nicht freigegeben")], + [], + ] + var maxCols = 2 + for group in TaskGroup.allCases { + let d = UserDefaults.standard + let sort = OverviewSort(column: d.string(forKey: "sort.\(group.rawValue).column") ?? "name", + ascending: d.object(forKey: "sort.\(group.rawValue).ascending") as? Bool ?? true) + let (tasks, rows) = overviewRows(store: store, group: group, sort: sort) + maxCols = max(maxCols, tasks.count + 2) + let band: XLSXWriter.Style = group == .diener ? .bandDiener : .bandSchulung + ov.append([.text(group.title, band)] + Array(repeating: .text(" ", band), count: tasks.count + 1)) + ov.append([.text("Name", .header), .text("Zuletzt (irgendeine)", .header)] + tasks.map { .text($0.name, .header) }) + for r in rows { + ov.append([.text(r.person.name), r.any.map { .date($0) } ?? .text("nie", .center)] + r.cells.map { + switch $0 { + case .notAllowed: return .text("–", .center) + case .never: return .text("nie", .center) + case .date(let s): return .date(s) + } + }) + } + ov.append([]) + } + let hist: [[C]] = [["Datum", "Aufgabe", "Person", "Partner", "Thema / Notiz"].map { .text($0, .header) }] + + store.db.entries.sorted { $0.date > $1.date }.map { e in + [.date(e.date), .text(store.task(e.task)?.name ?? e.task), .text(store.name(e.person)), + .text(e.partner.isEmpty ? "" : store.name(e.partner)), .text(e.note)] + } + let pers: [[C]] = [(["Name", "Bruder/Schwester", "Notiz"] + store.db.tasks.map(\.name)).map { .text($0, .header) }] + + store.personsSorted.map { p in + [.text(p.name), .text(p.gender), .text(p.note)] + store.db.tasks.map { p.may($0.id) ? .text("x", .center) : .text("") } + } + return [ + .init(name: "Übersicht", rows: ov, widths: [26, 13] + Array(repeating: 14, count: maxCols - 2)), + .init(name: "Verlauf", rows: hist, widths: [11, 26, 24, 24, 40]), + .init(name: "Personen", rows: pers, widths: [24, 14, 24] + store.db.tasks.map { _ in 9 }), + ] + } +} + +/// Minimaler .xlsx-Schreiber (ZIP ohne Kompression), ohne Fremdbibliothek. +enum XLSXWriter { + enum Style: Int { case normal = 0, date = 1, header = 2, title = 3, bandDiener = 4, bandSchulung = 5, center = 6 } + enum Cell { case text(String, Style = .normal), date(String) } + struct Sheet { let name: String; let rows: [[Cell]]; let widths: [Double] } + + static func workbook(sheets: [Sheet]) -> Data { + var files: [(String, String)] = [ + ("[Content_Types].xml", #""# + + sheets.indices.map { #""# }.joined() + ""), + ("_rels/.rels", #""#), + ("xl/workbook.xml", #""# + + sheets.enumerated().map { #""# }.joined() + ""), + ("xl/_rels/workbook.xml.rels", #""# + + sheets.indices.map { #""# }.joined() + + #""#), + ("xl/styles.xml", #""#), + ] + for (i, s) in sheets.enumerated() { files.append(("xl/worksheets/sheet\(i + 1).xml", sheetXML(s))) } + return zip(files.map { ($0.0, Data($0.1.utf8)) }) + } + + private static func sheetXML(_ s: Sheet) -> String { + let cols = s.widths.enumerated().map { #""# }.joined() + var out = #"\#(cols)"# + for (r, row) in s.rows.enumerated() { + out += #""# + for (c, cell) in row.enumerated() { + let ref = column(c) + "\(r + 1)" + switch cell { + case .text(let t, let st): + guard !t.isEmpty else { continue } + out += #"\#(esc(t))"# + case .date(let d): + out += #"\#(serial(d))"# + } + } + out += "" + } + return out + "" + } + + private static func serial(_ iso: String) -> Int { + let p = iso.split(separator: "-").compactMap { Int($0) } + guard p.count == 3 else { return 0 } + var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(identifier: "UTC")! + let d = cal.date(from: DateComponents(year: p[0], month: p[1], day: p[2]))! + let base = cal.date(from: DateComponents(year: 1899, month: 12, day: 30))! + return cal.dateComponents([.day], from: base, to: d).day ?? 0 + } + + private static func column(_ i: Int) -> String { + var n = i + 1, s = "" + while n > 0 { let m = (n - 1) % 26; s = String(UnicodeScalar(65 + m)!) + s; n = (n - 1) / 26 } + return s + } + + private static func esc(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) + } + + // MARK: ZIP (stored) + + private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in + var c = UInt32(n) + for _ in 0..<8 { c = c & 1 != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1 } + return c + } + private static func crc32(_ d: Data) -> UInt32 { + var c: UInt32 = 0xFFFFFFFF + for b in d { c = crcTable[Int((c ^ UInt32(b)) & 0xFF)] ^ (c >> 8) } + return c ^ 0xFFFFFFFF + } + + private static func zip(_ files: [(String, Data)]) -> Data { + var out = Data(), central = Data() + func u16(_ v: Int, _ d: inout Data) { withUnsafeBytes(of: UInt16(v).littleEndian) { d.append(contentsOf: $0) } } + func u32(_ v: UInt32, _ d: inout Data) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } + for (name, body) in files { + let n = Data(name.utf8), crc = crc32(body), offset = UInt32(out.count) + u32(0x04034b50, &out); u16(20, &out); u16(0x0800, &out); u16(0, &out); u16(0, &out); u16(0, &out) + u32(crc, &out); u32(UInt32(body.count), &out); u32(UInt32(body.count), &out); u16(n.count, &out); u16(0, &out) + out.append(n); out.append(body) + u32(0x02014b50, ¢ral); u16(20, ¢ral); u16(20, ¢ral); u16(0x0800, ¢ral); u16(0, ¢ral) + u16(0, ¢ral); u16(0, ¢ral); u32(crc, ¢ral); u32(UInt32(body.count), ¢ral); u32(UInt32(body.count), ¢ral) + u16(n.count, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u16(0, ¢ral); u32(0, ¢ral) + u32(offset, ¢ral); central.append(n) + } + let cdOffset = UInt32(out.count) + out.append(central) + u32(0x06054b50, &out); u16(0, &out); u16(0, &out); u16(files.count, &out); u16(files.count, &out) + u32(UInt32(central.count), &out); u32(cdOffset, &out); u16(0, &out) + return out + } +} diff --git a/App/Sources/HistoryView.swift b/App/Sources/HistoryView.swift new file mode 100644 index 0000000..eb995ea --- /dev/null +++ b/App/Sources/HistoryView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct HistoryRow: Identifiable { + let entry: Entry + let task: String + let person: String + let partner: String + var id: String { entry.id } + var date: String { entry.date } + var note: String { entry.note } +} + +struct HistoryView: View { + @EnvironmentObject var store: Store + @EnvironmentObject var ui: UIState + @State private var query = "" + @State private var taskFilter = "" + @State private var selection = Set() + @State private var sortOrder = [KeyPathComparator(\HistoryRow.date, order: .reverse)] + + private var rows: [HistoryRow] { + let q = query.lowercased() + return store.db.entries + .filter { taskFilter.isEmpty || $0.task == taskFilter } + .map { e in + HistoryRow(entry: e, task: store.task(e.task)?.name ?? e.task, person: store.name(e.person), + partner: e.partner.isEmpty ? "" : store.name(e.partner)) + } + .filter { q.isEmpty || "\($0.task) \($0.person) \($0.partner) \($0.note)".lowercased().contains(q) } + .sorted(using: sortOrder) + } + + var body: some View { + let rows = rows + VStack(spacing: 0) { + HStack(spacing: 12) { + TextField("Suchen (Name, Notiz …)", text: $query) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + Picker("Aufgabe", selection: $taskFilter) { + Text("Alle Aufgaben").tag("") + ForEach(store.db.tasks.filter { !$0.partnerColumn }) { Text($0.name).tag($0.id) } + } + .frame(width: 280) + Text("\(rows.count) Einträge").foregroundStyle(Theme.muted) + Spacer() + Button("Bearbeiten") { edit(selection.first) }.disabled(selection.count != 1) + Button("Löschen", role: .destructive) { delete(selection) }.disabled(selection.isEmpty) + } + .padding(16) + + Table(rows, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Datum", value: \.date) { Text(Day.display($0.date)).monospacedDigit() }.width(90) + TableColumn("Aufgabe", value: \.task).width(min: 160, ideal: 220) + TableColumn("Person", value: \.person).width(min: 140, ideal: 200) + TableColumn("Partner", value: \.partner).width(min: 120, ideal: 180) + TableColumn("Thema / Notiz", value: \.note) { Text($0.note).foregroundStyle(Theme.muted) } + } + .contextMenu(forSelectionType: String.self) { ids in + if ids.count == 1 { Button("Bearbeiten") { edit(ids.first) } } + Button("Löschen", role: .destructive) { delete(ids) } + } primaryAction: { ids in edit(ids.first) } + .onDeleteCommand { delete(selection) } + .overlay { + if store.db.entries.isEmpty { + Text("Noch keine Einträge. Mit ⌘N oder einem Klick in die Übersicht geht's los.") + .foregroundStyle(Theme.muted) + } + } + } + } + + private func edit(_ id: String?) { + guard let id, let e = store.db.entries.first(where: { $0.id == id }) else { return } + ui.draft = e + } + + private func delete(_ ids: Set) { + guard !ids.isEmpty else { return } + let alert = NSAlert() + alert.messageText = ids.count == 1 ? "Eintrag löschen?" : "\(ids.count) Einträge löschen?" + alert.informativeText = "Das lässt sich nicht rückgängig machen." + alert.addButton(withTitle: "Löschen").hasDestructiveAction = true + alert.addButton(withTitle: "Abbrechen") + guard alert.runModal() == .alertFirstButtonReturn else { return } + ids.forEach(store.delete(entry:)) + selection.subtract(ids) + } +} diff --git a/App/Sources/MawariApp.swift b/App/Sources/MawariApp.swift new file mode 100644 index 0000000..2ce1770 --- /dev/null +++ b/App/Sources/MawariApp.swift @@ -0,0 +1,115 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum Tab: String, CaseIterable, Identifiable { + case overview = "Übersicht", history = "Verlauf", persons = "Personen", tasks = "Aufgaben" + var id: String { rawValue } +} + +/// Zustand der Oberfläche (welcher Bereich, welcher Eintrag wird gerade bearbeitet). +@MainActor +final class UIState: ObservableObject { + @Published var tab: Tab = .overview + @Published var draft: Entry? + @Published var toast: String? + + func newEntry(task: String = "", person: String = "", partner: String = "") { + draft = Entry(id: "", date: Day.today, task: task, person: person, partner: partner) + } + func show(_ message: String) { + toast = message + Task { try? await Task.sleep(for: .seconds(3)); if toast == message { toast = nil } } + } +} + +@main +struct MawariApp: App { + @StateObject private var store = Store() + @StateObject private var ui = UIState() + @StateObject private var updates = UpdateController() + + var body: some Scene { + Window("Mawari", id: "main") { + ContentView() + .environmentObject(store) + .environmentObject(ui) + .frame(minWidth: 960, minHeight: 600) + } + .defaultSize(width: 1280, height: 820) + .commands { + CommandGroup(after: .appInfo) { + Button("Nach Updates suchen …") { updates.checkForUpdates() } + .disabled(!updates.canCheck) + } + CommandGroup(replacing: .newItem) { + Button("Neuer Eintrag") { ui.newEntry() }.keyboardShortcut("n") + } + CommandGroup(replacing: .importExport) { + Button("Export für Numbers …") { Exporter.exportXlsx(store: store, ui: ui) }.keyboardShortcut("e") + Divider() + Button("Sicherung speichern …") { Exporter.saveBackup(store: store, ui: ui) } + Button("Sicherung / HTML-Daten laden …") { Exporter.importBackup(store: store, ui: ui) } + Button("Datenordner im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([store.folder]) } + } + CommandMenu("Ansicht") { + ForEach(Array(Tab.allCases.enumerated()), id: \.element) { i, tab in + Button(tab.rawValue) { ui.tab = tab }.keyboardShortcut(KeyEquivalent(Character("\(i + 1)"))) + } + } + } + } +} + +struct ContentView: View { + @EnvironmentObject var store: Store + @EnvironmentObject var ui: UIState + + var body: some View { + Group { + switch ui.tab { + case .overview: OverviewView() + case .history: HistoryView() + case .persons: PersonsView() + case .tasks: TasksView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Bereich", selection: $ui.tab) { + ForEach(Tab.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .frame(width: 380) + } + ToolbarItemGroup(placement: .primaryAction) { + Button { Exporter.exportXlsx(store: store, ui: ui) } label: { + Label("Export für Numbers", systemImage: "tablecells") + } + .help("Aktuellen Stand als Tabelle für Numbers speichern (⌘E)") + Button { ui.newEntry() } label: { Label("Neuer Eintrag", systemImage: "plus") } + .help("Neuer Eintrag (⌘N)") + } + } + .sheet(item: $ui.draft) { draft in + EntrySheet(entry: draft) + } + .overlay(alignment: .bottom) { + if let t = ui.toast ?? store.lastError { + Text(t) + .font(.callout) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(.bottom, 18) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: ui.toast) + .tint(Theme.tokiwa) + } +} + +extension Entry { + var isNew: Bool { id.isEmpty } +} diff --git a/App/Sources/Model.swift b/App/Sources/Model.swift new file mode 100644 index 0000000..22e26c1 --- /dev/null +++ b/App/Sources/Model.swift @@ -0,0 +1,145 @@ +import Foundation + +// Datenformat ist bewusst identisch mit dem HTML-Tool (leben-und-dienst.json), +// damit dessen Dateien direkt importiert und Sicherungen dort geöffnet werden können. + +enum TaskGroup: String, Codable, CaseIterable, Identifiable { + case diener, schulung + var id: String { rawValue } + var title: String { self == .diener ? "Aufgaben für Diener (Ernannte)" : "Schulungsaufgaben" } + var shortTitle: String { self == .diener ? "Diener (Ernannte)" : "Schulung" } +} + +struct DutyTask: Codable, Identifiable, Hashable { + var id: String + var name: String + var group: TaskGroup + var withPartner: Bool = false + var isPartner: Bool? = nil + + var partnerColumn: Bool { isPartner == true } +} + +struct Person: Codable, Identifiable, Hashable { + var id: String + var name: String + var gender: String = "" + var note: String = "" + var tasks: [String] = [] + + func may(_ taskID: String) -> Bool { tasks.contains(taskID) } +} + +struct Entry: Codable, Identifiable, Hashable { + var id: String + var date: String // YYYY-MM-DD + var task: String + var person: String + var partner: String = "" + var note: String = "" +} + +struct Database: Codable { + var app = "leben-und-dienst-uebersicht" + var version = 1 + var updated = "" + var tasks: [DutyTask] + var persons: [Person] + var entries: [Entry] + + static let defaultTasks: [DutyTask] = ([ + ("vorsitz", "Vorsitz", .diener, false), ("gebet", "Gebet", .diener, false), + ("schaetze", "Schätze aus Gottes Wort", .diener, false), ("graben", "Schätze graben", .diener, false), + ("leben", "Leben als Christ", .diener, false), ("vbs-leitung", "VBS-Leitung", .diener, false), + ("vbs-leser", "VBS-Leser", .diener, false), ("bibellesung", "Bibellesung", .schulung, false), + ("gespraeche", "Gespräche beginnen", .schulung, true), ("interesse", "Interesse fördern", .schulung, true), + ("juenger", "Menschen zu Jüngern machen", .schulung, true), ("glauben", "Glaubensansichten erklären", .schulung, true), + ("sagen", "Was würdest du sagen?", .schulung, true), ("vortrag", "Vortrag", .schulung, false), + ("partner", "Partner", .schulung, false), + ] as [(String, String, TaskGroup, Bool)]).map { DutyTask(id: $0.0, name: $0.1, group: $0.2, withPartner: $0.3, isPartner: $0.0 == "partner" ? true : nil) } + + static var empty: Database { Database(tasks: defaultTasks, persons: [], entries: []) } + + // Tolerant gegenüber Dateien des HTML-Tools mit fehlenden Feldern. + init(tasks: [DutyTask], persons: [Person], entries: [Entry]) { + self.tasks = tasks; self.persons = persons; self.entries = entries + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + app = try c.decodeIfPresent(String.self, forKey: .app) ?? app + version = try c.decodeIfPresent(Int.self, forKey: .version) ?? 1 + updated = try c.decodeIfPresent(String.self, forKey: .updated) ?? "" + let t = try c.decodeIfPresent([DutyTask].self, forKey: .tasks) ?? [] + tasks = t.isEmpty ? Database.defaultTasks : t + persons = try c.decode([Person].self, forKey: .persons) + entries = try c.decode([Entry].self, forKey: .entries) + } +} + +extension Person { + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + gender = try c.decodeIfPresent(String.self, forKey: .gender) ?? "" + note = try c.decodeIfPresent(String.self, forKey: .note) ?? "" + tasks = try c.decodeIfPresent([String].self, forKey: .tasks) ?? [] + } +} + +extension Entry { + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + date = try c.decode(String.self, forKey: .date) + task = try c.decode(String.self, forKey: .task) + person = try c.decode(String.self, forKey: .person) + partner = try c.decodeIfPresent(String.self, forKey: .partner) ?? "" + note = try c.decodeIfPresent(String.self, forKey: .note) ?? "" + } +} + +extension DutyTask { + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + group = (try? c.decode(TaskGroup.self, forKey: .group)) ?? .schulung + withPartner = try c.decodeIfPresent(Bool.self, forKey: .withPartner) ?? false + isPartner = try c.decodeIfPresent(Bool.self, forKey: .isPartner) + } +} + +// MARK: - Datum + +enum Day { + nonisolated(unsafe) private static let iso: DateFormatter = { + let f = DateFormatter(); f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = .current; f.dateFormat = "yyyy-MM-dd"; return f + }() + nonisolated(unsafe) private static let short: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "de_DE"); f.dateFormat = "dd.MM.yy"; return f + }() + + static func string(_ d: Date) -> String { iso.string(from: d) } + static func date(_ s: String) -> Date? { iso.date(from: s) } + static var today: String { string(Date()) } + static func display(_ s: String) -> String { date(s).map { short.string(from: $0) } ?? s } + + static func weeksAgo(_ s: String) -> Int? { + guard let d = date(s), let t = date(today) else { return nil } + let days = Calendar.current.dateComponents([.day], from: d, to: t).day ?? 0 + return days < 0 ? -1 : days / 7 + } + static func agoText(_ s: String) -> String { + switch weeksAgo(s) { + case .none: return "" + case .some(let w) where w < 0: return "geplant" + case 0: return "diese Woche" + case 1: return "vor 1 Woche" + case .some(let w): return "vor \(w) Wochen" + } + } +} + +func newID(_ prefix: String) -> String { prefix + UUID().uuidString.prefix(8).lowercased() } diff --git a/App/Sources/OverviewView.swift b/App/Sources/OverviewView.swift new file mode 100644 index 0000000..3e1ea77 --- /dev/null +++ b/App/Sources/OverviewView.swift @@ -0,0 +1,242 @@ +import SwiftUI + +/// Sortierung einer Übersichtstabelle. `column` ist "name", "any" oder eine Aufgaben-ID. +struct OverviewSort: Equatable { + var column = "name" + var ascending = true +} + +struct OverviewRow: Identifiable { + let person: Person + let any: String? + let cells: [Cell] + var id: String { person.id } + + enum Cell { case notAllowed, never, date(String) } +} + +/// Zeilen eines Bereichs – auch vom Export genutzt, damit er dieselbe Sortierung zeigt. +@MainActor +func overviewRows(store: Store, group: TaskGroup, sort: OverviewSort) -> (tasks: [DutyTask], rows: [OverviewRow]) { + let tasks = store.db.tasks.filter { $0.group == group } + let ids = Set(tasks.map(\.id)) + let people = store.db.persons.filter { $0.tasks.contains(where: ids.contains) } + var rows = people.map { p in + OverviewRow(person: p, any: store.lastAny(person: p.id), cells: tasks.map { t in + guard p.may(t.id) else { return .notAllowed } + return store.lastDate(person: p.id, task: t).map { .date($0) } ?? .never + }) + } + let byName: (OverviewRow, OverviewRow) -> Bool = { $0.person.name.localizedCompare($1.person.name) == .orderedAscending } + // "nie" zählt als am längsten her, "nicht freigegeben" steht immer am Ende. + func key(_ r: OverviewRow) -> String? { + if sort.column == "any" { return r.any ?? "0000" } + guard let i = tasks.firstIndex(where: { $0.id == sort.column }) else { return nil } + switch r.cells[i] { + case .notAllowed: return nil + case .never: return "0000" + case .date(let d): return d + } + } + rows.sort { a, b in + if sort.column == "name" { return sort.ascending ? byName(a, b) : byName(b, a) } + switch (key(a), key(b)) { + case (nil, nil): return byName(a, b) + case (nil, _): return false + case (_, nil): return true + case let (ka?, kb?): + if ka == kb { return byName(a, b) } + return sort.ascending ? ka < kb : ka > kb + } + } + return (tasks, rows) +} + +struct OverviewView: View { + @EnvironmentObject var store: Store + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 16) { + legend(Text("Datum"), "letzte Einteilung") + legend(Text("nie").foregroundStyle(Theme.kohaku).bold(), "freigegeben, aber noch nie eingeteilt") + legend(Text("–").foregroundStyle(Theme.faint), "nicht freigegeben") + Spacer() + Text("Spaltenkopf klicken = sortieren · Zelle klicken = eintragen") + } + .font(.caption) + .foregroundStyle(Theme.muted) + + if store.db.persons.isEmpty { + EmptyStart() + } else { + ForEach(TaskGroup.allCases) { OverviewSection(group: $0) } + } + } + .padding(24) + } + } + + private func legend(_ symbol: Text, _ text: String) -> some View { + HStack(spacing: 4) { symbol; Text("= \(text)") } + } +} + +struct EmptyStart: View { + @EnvironmentObject var store: Store + @EnvironmentObject var ui: UIState + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "person.3.sequence") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(Theme.tokiwa) + Text("Noch niemand eingetragen.").font(.title2.weight(.semibold)) + Text("Leg unter „Personen“ alle an, die mitmachen, und hak an, wer was übernehmen darf.") + .foregroundStyle(Theme.muted) + HStack { + Button("Personen anlegen") { ui.tab = .persons }.buttonStyle(.borderedProminent) + Button("Daten aus dem HTML-Tool laden …") { Exporter.importBackup(store: store, ui: ui) } + } + .padding(.top, 6) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 80) + } +} + +struct OverviewSection: View { + let group: TaskGroup + @EnvironmentObject var store: Store + @EnvironmentObject var ui: UIState + @AppStorage private var sortColumn: String + @AppStorage private var sortAscending: Bool + + init(group: TaskGroup) { + self.group = group + _sortColumn = AppStorage(wrappedValue: "name", "sort.\(group.rawValue).column") + _sortAscending = AppStorage(wrappedValue: true, "sort.\(group.rawValue).ascending") + } + + private let nameWidth: CGFloat = 210 + private let anyWidth: CGFloat = 100 + private let cellWidth: CGFloat = 108 + + var body: some View { + let sort = OverviewSort(column: sortColumn, ascending: sortAscending) + let (tasks, rows) = overviewRows(store: store, group: group, sort: sort) + let color = Theme.color(for: group) + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Text(group.title).font(.headline) + Text("\(rows.count) Personen").font(.subheadline).opacity(0.8) + } + .foregroundStyle(.white) + .padding(.horizontal, 14).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color) + + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + header("Name", column: "name", width: nameWidth, alignment: .leading) + header("Zuletzt (irgendeine)", column: "any", width: anyWidth) + ForEach(tasks) { header($0.name, column: $0.id, width: cellWidth) } + } + .background(Theme.soft(for: group)) + + if rows.isEmpty { + Text("Noch niemand freigegeben – unter „Personen“ Häkchen setzen.") + .foregroundStyle(Theme.muted) + .padding(14) + } + ForEach(rows) { row in + HStack(spacing: 0) { + Text(row.person.name) + .fontWeight(.medium) + .lineLimit(1) + .padding(.horizontal, 12) + .frame(width: nameWidth, alignment: .leading) + Text(row.any.map(Day.display) ?? "nie") + .monospacedDigit() + .foregroundStyle(Theme.muted) + .frame(width: anyWidth) + ForEach(Array(zip(tasks, row.cells)), id: \.0.id) { task, cell in + OverviewCell(cell: cell, width: cellWidth) { + if task.partnerColumn { ui.newEntry(partner: row.person.id) } + else { ui.newEntry(task: task.id, person: row.person.id) } + } + .help("\(task.name) · \(row.person.name)") + } + } + .frame(height: 44) + .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) } + } + } + } + } + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.line)) + } + + private func header(_ title: String, column: String, width: CGFloat, alignment: Alignment = .center) -> some View { + Button { + if sortColumn == column { sortAscending.toggle() } else { sortColumn = column; sortAscending = true } + } label: { + HStack(spacing: 3) { + if sortColumn == column { + Image(systemName: sortAscending ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Theme.color(for: group)) + } + Text(title) + .font(.caption.weight(.semibold)) + .multilineTextAlignment(alignment == .leading ? .leading : .center) + .lineLimit(2) + } + .foregroundStyle(sortColumn == column ? Color.primary : Theme.muted) + .padding(.horizontal, alignment == .leading ? 12 : 6) + .frame(width: width, height: 46, alignment: alignment == .leading ? .bottomLeading : .bottom) + .padding(.bottom, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(column == "name" ? "Nach Name sortieren" : "Sortieren: wer am längsten nicht dran war, steht oben") + } +} + +struct OverviewCell: View { + let cell: OverviewRow.Cell + let width: CGFloat + let action: () -> Void + @State private var hover = false + + var body: some View { + switch cell { + case .notAllowed: + Text("–").foregroundStyle(Theme.faint).frame(width: width, height: 44) + case .never: + Button(action: action) { + Text("nie").fontWeight(.medium).foregroundStyle(Theme.kohaku) + .frame(width: width, height: 44) + .background(hover ? Theme.kohaku.opacity(0.22) : Theme.kohakuSoft) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).onHover { hover = $0 } + case .date(let d): + Button(action: action) { + VStack(spacing: 1) { + Text(Day.display(d)).monospacedDigit() + Text(Day.agoText(d)).font(.caption2).foregroundStyle(Theme.muted) + } + .frame(width: width, height: 44) + .background(hover ? Color.primary.opacity(0.06) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).onHover { hover = $0 } + } + } +} diff --git a/App/Sources/PersonsView.swift b/App/Sources/PersonsView.swift new file mode 100644 index 0000000..4398c8c --- /dev/null +++ b/App/Sources/PersonsView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct PersonsView: View { + @EnvironmentObject var store: Store + @State private var newName = "" + @FocusState private var newFocused: Bool + + private let nameWidth: CGFloat = 220 + private let genderWidth: CGFloat = 120 + private let noteWidth: CGFloat = 180 + private let checkWidth: CGFloat = 32 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + TextField("Name der neuen Person", text: $newName) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + .focused($newFocused) + .onSubmit(add) + Button("Hinzufügen", action: add).buttonStyle(.borderedProminent) + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + Text("Häkchen = darf diese Aufgabe übernehmen. Wer mindestens eine Diener-Aufgabe hat, erscheint oben in der Übersicht, wer eine Schulungsaufgabe hat, unten.") + .font(.caption).foregroundStyle(Theme.muted) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + + ScrollView([.horizontal, .vertical]) { + VStack(alignment: .leading, spacing: 0) { + headerRow + ForEach(store.personsSorted) { p in + if let binding = store.binding(person: p.id) { + PersonRow(person: binding, widths: (nameWidth, genderWidth, noteWidth, checkWidth)) + } + } + } + .padding([.horizontal, .bottom], 16) + } + } + } + + private var headerRow: some View { + HStack(alignment: .bottom, spacing: 0) { + Text("Name").frame(width: nameWidth, alignment: .leading).padding(.leading, 4) + Text("Bruder/Schwester").frame(width: genderWidth, alignment: .leading) + Text("Notiz").frame(width: noteWidth, alignment: .leading) + ForEach(store.db.tasks) { t in + Text(t.name) + .lineLimit(1) + .fixedSize() + .rotationEffect(.degrees(-90)) + .frame(width: checkWidth, height: 170, alignment: .center) + .background(Theme.soft(for: t.group)) + .help(t.name) + } + Spacer().frame(width: 80) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.muted) + .padding(.bottom, 6) + .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) } + } + + private func add() { + if store.addPerson(named: newName) { newName = "" } else { NSSound.beep() } + newFocused = true + } +} + +struct PersonRow: View { + @Binding var person: Person + let widths: (name: CGFloat, gender: CGFloat, note: CGFloat, check: CGFloat) + @EnvironmentObject var store: Store + @State private var name = "" + @FocusState private var focused: Bool + + var body: some View { + HStack(spacing: 0) { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .frame(width: widths.name - 12) + .padding(.trailing, 12) + .onAppear { name = person.name } + .focused($focused) + .onSubmit(commitName) + .onChange(of: focused) { _, now in if !now { commitName() } } + .onDisappear(perform: commitName) + Picker("", selection: $person.gender) { + Text("–").tag("") + Text("Bruder").tag("Bruder") + Text("Schwester").tag("Schwester") + } + .labelsHidden() + .frame(width: widths.gender - 12) + .padding(.trailing, 12) + TextField("", text: $person.note) + .textFieldStyle(.roundedBorder) + .frame(width: widths.note - 12) + .padding(.trailing, 12) + ForEach(store.db.tasks) { t in + Toggle("", isOn: Binding( + get: { person.tasks.contains(t.id) }, + set: { on in + if on { if !person.tasks.contains(t.id) { person.tasks.append(t.id) } } + else { person.tasks.removeAll { $0 == t.id } } + })) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: widths.check) + .help(t.name) + } + Button("Löschen", role: .destructive, action: delete) + .buttonStyle(.link) + .foregroundStyle(Theme.bengara) + .frame(width: 80) + } + .padding(.vertical, 5) + .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) } + } + + private func commitName() { + let n = name.trimmingCharacters(in: .whitespaces) + guard n != person.name else { return } + if n.isEmpty || store.db.persons.contains(where: { $0.id != person.id && $0.name.lowercased() == n.lowercased() }) { + NSSound.beep(); name = person.name; return + } + person.name = n + } + + private func delete() { + let count = store.entryCount(person: person.id) + let alert = NSAlert() + alert.messageText = "\(person.name) löschen?" + alert.informativeText = count > 0 + ? "\(count) Einträge im Verlauf gehören zu dieser Person und werden ebenfalls gelöscht." + : "Die Person hat noch keine Einträge im Verlauf." + alert.addButton(withTitle: "Löschen").hasDestructiveAction = true + alert.addButton(withTitle: "Abbrechen") + guard alert.runModal() == .alertFirstButtonReturn else { return } + store.deletePerson(person.id) + } +} diff --git a/App/Sources/Store.swift b/App/Sources/Store.swift new file mode 100644 index 0000000..acee4e6 --- /dev/null +++ b/App/Sources/Store.swift @@ -0,0 +1,139 @@ +import Foundation +import SwiftUI + +/// Hält alle Daten und speichert jede Änderung sofort in +/// ~/Library/Application Support/Mawari/daten.json. Einmal am Tag wird eine +/// Sicherungskopie in "Sicherungen" abgelegt (die letzten 60 bleiben). +@MainActor +final class Store: ObservableObject { + @Published var db: Database { didSet { scheduleSave() } } + @Published var lastError: String? + + let folder: URL + private var file: URL { folder.appendingPathComponent("daten.json") } + private var backups: URL { folder.appendingPathComponent("Sicherungen") } + private var saveTask: Task? + + init() { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + // MAWARI_DATA_DIR: nur zum Testen, damit echte Daten unberührt bleiben. + if let dir = ProcessInfo.processInfo.environment["MAWARI_DATA_DIR"] { + folder = URL(fileURLWithPath: dir, isDirectory: true) + } else { + folder = base.appendingPathComponent("Mawari", isDirectory: true) + } + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + if let data = try? Data(contentsOf: folder.appendingPathComponent("daten.json")), + let loaded = try? JSONDecoder().decode(Database.self, from: data) { + db = loaded + } else { + db = .empty + } + dailyBackup() + } + + // MARK: Speichern + + private func scheduleSave() { + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + self?.saveNow() + } + } + + func saveNow() { + do { + try encoded().write(to: file, options: .atomic) + lastError = nil + } catch { + lastError = "Speichern fehlgeschlagen: \(error.localizedDescription)" + } + } + + func encoded() throws -> Data { + var copy = db + copy.updated = ISO8601DateFormatter().string(from: Date()) + let enc = JSONEncoder() + enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try enc.encode(copy) + } + + private func dailyBackup() { + guard FileManager.default.fileExists(atPath: file.path) else { return } + try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) + let target = backups.appendingPathComponent("daten-\(Day.today).json") + if !FileManager.default.fileExists(atPath: target.path) { + try? FileManager.default.copyItem(at: file, to: target) + } + let all = (try? FileManager.default.contentsOfDirectory(at: backups, includingPropertiesForKeys: nil)) ?? [] + for old in all.filter({ $0.pathExtension == "json" }).sorted(by: { $0.lastPathComponent > $1.lastPathComponent }).dropFirst(60) { + try? FileManager.default.removeItem(at: old) + } + } + + // MARK: Import / Sicherung + + func importFile(_ url: URL) throws { + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + let data = try Data(contentsOf: url) + let loaded = try JSONDecoder().decode(Database.self, from: data) + saveNow() // aktuellen Stand vorher sichern + let safety = backups.appendingPathComponent("vor-import-\(Int(Date().timeIntervalSince1970)).json") + try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) + try? FileManager.default.copyItem(at: file, to: safety) + db = loaded + } + + // MARK: Abfragen + + func task(_ id: String) -> DutyTask? { db.tasks.first { $0.id == id } } + func person(_ id: String) -> Person? { db.persons.first { $0.id == id } } + func name(_ id: String) -> String { person(id)?.name ?? "(gelöscht)" } + var personsSorted: [Person] { db.persons.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } } + + func lastDate(person: String, task: DutyTask) -> String? { + var best: String? + for e in db.entries { + let hit = task.partnerColumn ? e.partner == person : (e.task == task.id && e.person == person) + if hit, e.date > (best ?? "") { best = e.date } + } + return best + } + + func lastAny(person: String) -> String? { + var best: String? + for e in db.entries where (e.person == person || e.partner == person) && e.date > (best ?? "") { best = e.date } + return best + } + + // MARK: Ändern + + func save(entry: Entry) { + if let i = db.entries.firstIndex(where: { $0.id == entry.id }) { db.entries[i] = entry } else { db.entries.append(entry) } + } + func delete(entry id: String) { db.entries.removeAll { $0.id == id } } + + func addPerson(named name: String) -> Bool { + let n = name.trimmingCharacters(in: .whitespaces) + guard !n.isEmpty, !db.persons.contains(where: { $0.name.lowercased() == n.lowercased() }) else { return false } + db.persons.append(Person(id: newID("p"), name: n)) + return true + } + func deletePerson(_ id: String) { + db.entries.removeAll { $0.person == id } + for i in db.entries.indices where db.entries[i].partner == id { db.entries[i].partner = "" } + db.persons.removeAll { $0.id == id } + } + func entryCount(person id: String) -> Int { db.entries.filter { $0.person == id || $0.partner == id }.count } + func entryCount(task: DutyTask) -> Int { task.partnerColumn ? db.entries.filter { !$0.partner.isEmpty }.count : db.entries.filter { $0.task == task.id }.count } + + func binding(person id: String) -> Binding? { + guard db.persons.contains(where: { $0.id == id }) else { return nil } + return Binding( + get: { self.db.persons.first { $0.id == id } ?? Person(id: id, name: "") }, + set: { new in if let i = self.db.persons.firstIndex(where: { $0.id == id }) { self.db.persons[i] = new } }) + } +} diff --git a/App/Sources/TasksView.swift b/App/Sources/TasksView.swift new file mode 100644 index 0000000..2d0dede --- /dev/null +++ b/App/Sources/TasksView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct TasksView: View { + @EnvironmentObject var store: Store + @State private var newName = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + TextField("Neue Aufgabe", text: $newName) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + .onSubmit(add) + Button("Hinzufügen", action: add).buttonStyle(.borderedProminent) + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + Text("Reihenfolge = Spalten in der Übersicht (ziehen zum Umsortieren). „Mit Partner“ blendet beim Eintragen das Partner-Feld ein.") + .font(.caption).foregroundStyle(Theme.muted) + } + .padding(16) + + List { + ForEach($store.db.tasks) { $task in + HStack(spacing: 16) { + TextField("Name", text: $task.name) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + Picker("", selection: $task.group) { + ForEach(TaskGroup.allCases) { Text($0.shortTitle).tag($0) } + } + .labelsHidden() + .frame(width: 170) + if task.partnerColumn { + Text("zählt die Partner").foregroundStyle(Theme.muted).frame(width: 120, alignment: .leading) + } else { + Toggle("Mit Partner", isOn: $task.withPartner).frame(width: 120, alignment: .leading) + } + Text(count(store.entryCount(task: task))).foregroundStyle(Theme.muted).frame(width: 90, alignment: .leading) + Spacer() + if !task.partnerColumn { + Button("Löschen", role: .destructive) { delete(task) } + .buttonStyle(.link).foregroundStyle(Theme.bengara) + } + } + .padding(.vertical, 3) + } + .onMove { store.db.tasks.move(fromOffsets: $0, toOffset: $1) } + } + } + } + + private func count(_ n: Int) -> String { n == 1 ? "1 Eintrag" : "\(n) Einträge" } + + private func add() { + let n = newName.trimmingCharacters(in: .whitespaces) + guard !n.isEmpty else { return } + let at = store.db.tasks.firstIndex(where: \.partnerColumn) ?? store.db.tasks.endIndex + store.db.tasks.insert(DutyTask(id: newID("t"), name: n, group: .schulung), at: at) + newName = "" + } + + private func delete(_ task: DutyTask) { + let n = store.entryCount(task: task) + let alert = NSAlert() + if n > 0 { + alert.messageText = "„\(task.name)“ hat \(n) Einträge im Verlauf." + alert.informativeText = "Aufgaben mit Einträgen lassen sich nicht löschen. Du kannst sie umbenennen." + alert.runModal(); return + } + alert.messageText = "Aufgabe „\(task.name)“ löschen?" + alert.addButton(withTitle: "Löschen").hasDestructiveAction = true + alert.addButton(withTitle: "Abbrechen") + guard alert.runModal() == .alertFirstButtonReturn else { return } + store.db.tasks.removeAll { $0.id == task.id } + for i in store.db.persons.indices { store.db.persons[i].tasks.removeAll { $0 == task.id } } + } +} diff --git a/App/Sources/Theme.swift b/App/Sources/Theme.swift new file mode 100644 index 0000000..438fd34 --- /dev/null +++ b/App/Sources/Theme.swift @@ -0,0 +1,35 @@ +import SwiftUI +import AppKit + +/// Farbwelt von Mawari: Diensttafeln aus Holz (Hinoki), immergrünes Tokiwa als +/// Markenfarbe, Kastanie (Kuri) für die Schulungsaufgaben. Hell ist Standard, +/// dunkel wird unterstützt. Siehe design/README.md. +enum Theme { + static let tokiwa = adaptive(light: 0x2F6B4F, dark: 0x6DBB93) // Marke, Diener-Bereich + static let kuri = adaptive(light: 0x7A5230, dark: 0xD0A07A) // Schulungsbereich + static let tokiwaSoft = adaptive(light: 0xE4EFE8, dark: 0x1F2D26) + static let kuriSoft = adaptive(light: 0xF2E9DF, dark: 0x2E2620) + static let kohaku = adaptive(light: 0xB45309, dark: 0xF0A35A) // "nie" + static let kohakuSoft = adaptive(light: 0xFDF1E3, dark: 0x3A2A18) + static let line = adaptive(light: 0xE4E0D8, dark: 0x30332F) + static let muted = adaptive(light: 0x7A746B, dark: 0x9A958C) + static let faint = adaptive(light: 0xC6C0B6, dark: 0x4C4F4A) + static let bengara = adaptive(light: 0xB5452B, dark: 0xE8775D) // Warnungen + + static func color(for group: TaskGroup) -> Color { group == .diener ? tokiwa : kuri } + static func soft(for group: TaskGroup) -> Color { group == .diener ? tokiwaSoft : kuriSoft } + + static func adaptive(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return NSColor(hex: isDark ? dark : light) + }) + } +} + +extension NSColor { + convenience init(hex: UInt32) { + self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + } +} diff --git a/App/Sources/UpdateController.swift b/App/Sources/UpdateController.swift new file mode 100644 index 0000000..92a38f9 --- /dev/null +++ b/App/Sources/UpdateController.swift @@ -0,0 +1,22 @@ +import Foundation +import Sparkle + +/// Selbstaktualisierung über Sparkle. Mawari sucht einmal am Tag von selbst +/// (SUEnableAutomaticChecks in project.yml) und bietet gefundene Updates an; +/// installiert wird erst nach Zustimmung. Appcast: update.zielke.studio/mawari. +@MainActor +final class UpdateController: ObservableObject { + private let controller: SPUStandardUpdaterController + @Published private(set) var canCheck = true + private var observation: NSKeyValueObservation? + + init() { + controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + observation = controller.updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in + let can = updater.canCheckForUpdates + Task { @MainActor in self?.canCheck = can } + } + } + + func checkForUpdates() { controller.updater.checkForUpdates() } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36d1c84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 1.0.0 (Build 1) – 2026-10-22 + +Erste Version. + +- Übersicht in zwei Bereichen (Diener/Ernannte, Schulung): je Person und Aufgabe das letzte Datum, „nie“ oder „–“ +- Sortieren per Spaltenkopf, Eintrag per Klick auf eine Zelle mit Person und Aufgabe vorbelegt +- Verlauf, Personen mit Freigaben, Aufgaben pflegen +- Export für Numbers (.xlsx) +- Sicherung speichern/laden, Übernahme der Daten aus dem früheren HTML-Tool +- Automatisches Speichern, tägliche Sicherungskopie (60 Tage) +- Selbstaktualisierung über Sparkle (update.zielke.studio/mawari) +- Signiert und notarisiert, Universal (Apple Silicon + Intel), macOS 14+ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..64cd59f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +# CLAUDE.md – Mawari + +Du arbeitest an **Mawari**, einer kleinen nativen macOS-App von Zielke Studio. Sie zeigt für die Leben-und-Dienst-Zusammenkunft, wer wann zuletzt welche Aufgabe hatte. Die offizielle Einteilung bleibt in NW Scheduler; Mawari ist nur das Nachschlagewerk daneben. Nutzer ist eine einzelne Person ohne Technikinteresse – alles muss ohne Erklärung funktionieren. + +## Technische Leitplanken + +- **Plattform:** macOS 14+, Universal Build (arm64 + x86_64), Swift 6, SwiftUI (AppKit nur für Panels und Alerts). +- **Projekt:** `Mawari.xcodeproj` wird aus `project.yml` erzeugt (`xcodegen`) und ist nicht im Repo. `App/Info.plist` ebenso. +- **Abhängigkeiten:** nur Sparkle 2.10.0 (gleiche Fassung wie drop.look/drop.two). Keine weiteren Pakete ohne Rückfrage. +- **Datenformat:** muss kompatibel zum früheren HTML-Tool bleiben (`docs/datenformat.md`). Neue Felder nur optional und mit Standardwert dekodieren. +- **Echte Daten nie anfassen:** Zum Testen `MAWARI_DATA_DIR=/tmp/irgendwas` setzen. +- **Bewusst nicht im Umfang:** Vorschläge, wer als Nächstes dran ist; Abwesenheiten; Live-Verknüpfung mit NW Scheduler. + +## Design + +Farben in `App/Sources/Theme.swift`, Begründung in `design/README.md`. Hell ist Standard, dunkel wird unterstützt. Texte auf Deutsch, kurz, in Du-Form. + +## Arbeitsweise + +1. Vor Code: kurzer Plan in wenigen Punkten. +2. Nach jedem lauffähigen Stand committen (Conventional Commits auf Englisch). +3. Kommentare im Code auf Deutsch (wie bisher), UI-Texte auf Deutsch. +4. Neue Version: Ablauf in `README.md` → „Neue Version veröffentlichen“, Eintrag in `CHANGELOG.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..432ade4 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Mawari + +**Wer war wann dran? Auf einen Blick.** + +Mawari (回り, japanisch: „reihum, die Runde“) ist eine macOS-App von Zielke Studio für die Planung der Leben-und-Dienst-Zusammenkunft. Die offizielle Einteilung läuft weiter über die bestehende App (NW Scheduler). Mawari ergänzt sie um die eine Frage, die dort untergeht: **Wann hatte wer zuletzt welche Aufgabe?** + +- Übersicht in zwei Bereichen: Aufgaben für Diener (Ernannte) und Schulungsaufgaben +- je Person und Aufgabe das letzte Datum, „nie“ oder „–“ (nicht freigegeben) +- Spaltenkopf klicken = sortieren (wer am längsten nicht dran war, steht oben) +- Zelle klicken = Eintrag mit Person und Aufgabe vorbelegt +- Export für Numbers (.xlsx, öffnet sich direkt in Numbers) +- bewusst **keine** Vorschläge, keine Abwesenheiten, keine Verknüpfung mit der App + +## Dokumente + +| Datei | Inhalt | +|---|---| +| [CLAUDE.md](CLAUDE.md) | Arbeitsanweisungen für Claude Code | +| [CHANGELOG.md](CHANGELOG.md) | Versionen | +| [docs/datenformat.md](docs/datenformat.md) | Aufbau von `daten.json`, Sicherungen, Kompatibilität zum HTML-Tool | +| [design/README.md](design/README.md) | Farben, Symbol, Texte | + +## Daten + +`~/Library/Application Support/Mawari/daten.json`, jede Änderung wird sofort gespeichert. Einmal täglich landet eine Kopie in `Sicherungen/` (die letzten 60 bleiben). Das Format ist identisch mit dem früheren HTML-Tool (`leben-und-dienst.json`), dessen Dateien lassen sich über *Ablage › Sicherung / HTML-Daten laden …* übernehmen. + +Zum Testen ohne echte Daten: `MAWARI_DATA_DIR=/irgendein/ordner …/Mawari.app/Contents/MacOS/Mawari`. + +## Bauen + +```bash +brew install xcodegen # einmalig +xcodegen # erzeugt Mawari.xcodeproj aus project.yml +open Mawari.xcodeproj +``` + +macOS 14+, Universal (arm64 + x86_64), Swift 6, SwiftUI. Einzige Abhängigkeit: Sparkle 2.10.0. +Symbol neu zeichnen: `swift Tools/make-icon.swift`. + +Das `.xcodeproj` und `App/Info.plist` sind generiert und nicht im Repo. Nach Änderungen an `project.yml` erneut `xcodegen` ausführen. + +Signiert wird mit **Developer ID Application** (Team `XY4C2RG3L5`), Hardened Runtime an. Ohne dieses Zertifikat `CODE_SIGN_IDENTITY`/`DEVELOPMENT_TEAM` in `project.yml` anpassen. + +## Struktur + +| Pfad | Inhalt | +|---|---| +| `App/Sources/MawariApp.swift` | Einstieg, Fenster, Menüs, Oberflächenzustand | +| `App/Sources/Model.swift` | Datenmodell (Aufgaben, Personen, Einträge), Datumshilfen | +| `App/Sources/Store.swift` | Laden, Speichern, tägliche Sicherung, Abfragen | +| `App/Sources/OverviewView.swift` | Übersicht „wer war wann dran“ mit Sortierung | +| `App/Sources/HistoryView.swift`, `EntrySheet.swift` | Verlauf und Eintrag bearbeiten | +| `App/Sources/PersonsView.swift`, `TasksView.swift` | Personen (mit Freigaben) und Aufgaben pflegen | +| `App/Sources/Exporter.swift` | .xlsx-Export (eigener Writer, ohne Abhängigkeit), Sicherung speichern/laden | +| `App/Sources/Theme.swift` | Farben | +| `App/Sources/UpdateController.swift` | Sparkle | +| `Tools/` | Symbol erzeugen, DMG bauen, Appcast, Veröffentlichen | +| `design/` | Symbolvorlage und Designnotizen | +| `project.yml` | XcodeGen-Projektdefinition | + +## Neue Version veröffentlichen + +1. In `project.yml` `MARKETING_VERSION` erhöhen und `CURRENT_PROJECT_VERSION` um 1 hochzählen. +2. `bash Tools/build-dmg.sh` – Release-Build, Signatur (Developer ID), Notarisierung von App und DMG. +3. `bash Tools/make-appcast.sh "Was ist neu – ein Satz"` – signiert das DMG mit dem Sparkle-Schlüssel, schreibt `build/appcast.xml`. +4. `bash Tools/publish.sh` – lädt DMG und Appcast nach `update.zielke.studio/mawari/`. + +Installierte Kopien suchen einmal am Tag nach Updates und bieten sie an. Von Hand: *Mawari › Nach Updates suchen …* + +**Einmalig vor dem ersten Veröffentlichen:** Ordner auf dem Server anlegen (siehe `zielke-apps/README.md`): + +```bash +ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari' +``` + +**Schlüssel sichern:** Der private Sparkle-Schlüssel liegt im Schlüsselbund dieses Macs (Dienst `https://sparkle-project.org`, Konto `mawari`). Geht er verloren, kann keine installierte Kopie mehr aktualisiert werden. Export: `generate_keys --account mawari -x mawari-sparkle.key` (liegt nach dem ersten Build unter `build/dd/SourcePackages/artifacts/sparkle/Sparkle/bin/`), Datei in die Passwörter-App, dann löschen. + +Notarisierung nutzt das Profil `patchcord-notary` (gleiches Team). Anderes Profil: `NOTARY_PROFILE=… bash Tools/build-dmg.sh`. + +## Ausblick: Anbindung an NW Scheduler + +Geprüft, noch nicht umgesetzt. Eine Live-Schnittstelle (API) bietet NW Scheduler nicht. Möglich ist ein **Import aus Dateien**: + +- NW Scheduler kann die Einteilungen der Leben-und-Dienst-Zusammenkunft als CSV ausgeben (Spalten `Date, Person, PartType, Assignment, School`, PartType z. B. `Chairman`, `OpeningPrayer`, `TreasuresTalk`, `Gems`, `BibleReading`, `Apply1–4`, `Living1–3`, `CBS`, `CBSReader`). Andere Tools lesen genau dieses Format ein. +- Außerdem gibt es Sicherungspakete, die z. B. CongPlan inklusive Zuteilungsverlauf übernimmt. + +Ein Upgrade wäre also: *Ablage › Aus NW Scheduler importieren …* liest die CSV, ordnet `PartType` den Mawari-Aufgaben zu, gleicht Namen mit den Personen ab und ergänzt nur neue Einträge. Vorher an einer echten Exportdatei prüfen, wie Namen und Partner darin stehen. + +© Zielke Studio diff --git a/Tools/build-dmg.sh b/Tools/build-dmg.sh new file mode 100644 index 0000000..400191c --- /dev/null +++ b/Tools/build-dmg.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Baut Mawari signiert und notarisiert als DMG (zum Verschicken und für Updates). +# +# Voraussetzungen (einmalig): +# - Developer-ID-Application-Zertifikat im Login-Schlüsselbund (Team XY4C2RG3L5) +# - notarytool-Profil. Gleiches Team wie Patchcord, daher dessen Profil als Standard: +# NOTARY_PROFILE=patchcord-notary +# +# Aufruf: bash Tools/build-dmg.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +TEAM_ID="XY4C2RG3L5" +NOTARY_PROFILE="${NOTARY_PROFILE:-patchcord-notary}" +VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/') +APP_NAME="Mawari" +BUILD_DIR="build" +ARCHIVE_PATH="$BUILD_DIR/Mawari.xcarchive" +DMG_STAGING="$BUILD_DIR/dmg-staging" +DMG_PATH="$BUILD_DIR/${APP_NAME}-${VERSION}.dmg" +SIGN_ID="Developer ID Application: Samuel Zielke ($TEAM_ID)" + +echo "==> Version $VERSION, Team $TEAM_ID, Notarisierung über Profil $NOTARY_PROFILE" +xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ + || { echo "Fehler: notarytool-Profil $NOTARY_PROFILE fehlt (xcrun notarytool store-credentials …)." >&2; exit 1; } + +rm -rf "$ARCHIVE_PATH" "$DMG_STAGING" "$DMG_PATH" +mkdir -p "$DMG_STAGING" + +command -v xcodegen >/dev/null && xcodegen >/dev/null + +echo "==> Archiviere (Release, Universal)" +xcodebuild -project Mawari.xcodeproj -scheme Mawari -configuration Release \ + -archivePath "$ARCHIVE_PATH" -derivedDataPath "$BUILD_DIR/dd-release" \ + CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" DEVELOPMENT_TEAM="$TEAM_ID" \ + archive -quiet + +APP_PATH="$ARCHIVE_PATH/Products/Applications/${APP_NAME}.app" +[ -d "$APP_PATH" ] || { echo "Fehler: $APP_PATH nicht gefunden." >&2; exit 1; } + +# Sparkles Hilfsprogramme signiert Xcode nur ad-hoc – die Notarisierung lehnt das ab. +# Von innen nach außen nachsignieren (siehe drop.look/Tools/build-beta-dmg.sh). +echo "==> Sparkle nachsignieren" +resign() { [ -e "$1" ] || return 0; codesign --force --timestamp --options runtime --sign "$SIGN_ID" "$1"; } +SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework" +if [ -d "$SPARKLE" ]; then + V="$SPARKLE/Versions/Current" + resign "$V/XPCServices/Downloader.xpc" + resign "$V/XPCServices/Installer.xpc" + resign "$V/Autoupdate" + resign "$V/Updater.app" + resign "$SPARKLE" +fi +echo "==> App signieren" +codesign --force --timestamp --options runtime --entitlements App/Mawari.entitlements --sign "$SIGN_ID" "$APP_PATH" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +lipo -archs "$APP_PATH/Contents/MacOS/$APP_NAME" + +echo "==> App notarisieren (dauert ein paar Minuten)" +ZIP_PATH="$BUILD_DIR/${APP_NAME}-notarize.zip" +rm -f "$ZIP_PATH" +ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" +xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$APP_PATH" +spctl --assess --type execute --verbose "$APP_PATH" + +echo "==> DMG bauen" +cp -R "$APP_PATH" "$DMG_STAGING/" +ln -s /Applications "$DMG_STAGING/Programme" +hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH" -quiet +codesign --force --timestamp --sign "$SIGN_ID" "$DMG_PATH" + +echo "==> DMG notarisieren (damit sie auch per Mail verschickt ohne Warnung aufgeht)" +xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$DMG_PATH" +spctl --assess --type open --context context:primary-signature --verbose "$DMG_PATH" + +echo "==> Fertig: $DMG_PATH" +echo "Für ein Online-Update danach: bash Tools/make-appcast.sh && bash Tools/publish.sh" diff --git a/Tools/make-appcast.sh b/Tools/make-appcast.sh new file mode 100644 index 0000000..a350ad3 --- /dev/null +++ b/Tools/make-appcast.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Signiert das fertige DMG mit Mawaris Sparkle-Schlüssel (Schlüsselbund, Konto "mawari") +# und schreibt build/appcast.xml. Danach: bash Tools/publish.sh +# +# Aufruf: bash Tools/make-appcast.sh ["Was ist neu – ein Satz"] +set -euo pipefail +cd "$(dirname "$0")/.." + +BASE_URL="https://update.zielke.studio/mawari" +VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/') +BUILD=$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*CURRENT_PROJECT_VERSION: *"?([^"]+)"?/\1/') +MIN_OS=$(grep -m1 'macOS:' project.yml | sed -E 's/.*macOS: *"?([^"]+)"?/\1/') +DMG="build/Mawari-${VERSION}.dmg" +NOTES="${1:-}" + +SIGN_UPDATE=$(find build/dd build/dd-release -path "*artifacts/sparkle/Sparkle/bin/sign_update" 2>/dev/null | head -1) +[ -x "$SIGN_UPDATE" ] || { echo "Fehler: sign_update fehlt – erst einmal bauen." >&2; exit 1; } +[ -f "$DMG" ] || { echo "Fehler: $DMG fehlt – erst Tools/build-dmg.sh." >&2; exit 1; } + +SIG_LINE=$("$SIGN_UPDATE" --account mawari "$DMG") +ED_SIG=$(echo "$SIG_LINE" | sed -E 's/.*sparkle:edSignature="([^"]+)".*/\1/') +LENGTH=$(echo "$SIG_LINE" | sed -E 's/.*length="([^"]+)".*/\1/') +[ -n "$ED_SIG" ] && [ "$ED_SIG" != "$SIG_LINE" ] || { echo "Fehler beim Signieren: $SIG_LINE" >&2; exit 1; } + +DESC="" +[ -n "$NOTES" ] && DESC="${NOTES}

]]>
" + +cat > build/appcast.xml < + + + Mawari + ${BASE_URL}/appcast.xml + Updates für Mawari + de + + ${VERSION} + ${DESC} + $(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000') + ${BUILD} + ${VERSION} + ${MIN_OS} + + + + +XML +echo "==> build/appcast.xml für Version $VERSION (Build $BUILD) geschrieben" diff --git a/Tools/make-icon.swift b/Tools/make-icon.swift new file mode 100644 index 0000000..2c0035c --- /dev/null +++ b/Tools/make-icon.swift @@ -0,0 +1,117 @@ +import Foundation +import AppKit + +// Zeichnet das App-Symbol von Mawari (回り, „reihum“): eine Diensttafel aus Hinoki-Holz +// mit drei hängenden Namensschildern. Das mittlere ist auf die rote Seite +// gedreht – „ist dran“. Grund in Tokiwa-Grün. +// +// Aufruf: swift Tools/make-icon.swift (schreibt App/Resources/AppIcon.icns +// und design/icon-1024.png) + +func rgb(_ hex: UInt32, _ a: CGFloat = 1) -> CGColor { + CGColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: a) +} + +func drawIcon(size: CGFloat) -> CGImage { + let cs = CGColorSpaceCreateDeviceRGB() + let ctx = CGContext(data: nil, width: Int(size), height: Int(size), bitsPerComponent: 8, bytesPerRow: 0, + space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let s = size / 1024 + let inset = 100 * s + let rect = CGRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2) + let radius = rect.width * 0.2237 + + // Schatten unter der Kachel + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -10 * s), blur: 24 * s, color: rgb(0x000000, 0.28)) + ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)) + ctx.setFillColor(rgb(0x2F6B4F)); ctx.fillPath() + ctx.restoreGState() + + ctx.saveGState() + ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)) + ctx.clip() + let bg = CGGradient(colorsSpace: cs, colors: [rgb(0x1E4A36), rgb(0x2F6B4F), rgb(0x3E8462)] as CFArray, locations: [0, 0.55, 1])! + ctx.drawLinearGradient(bg, start: CGPoint(x: 0, y: rect.minY), end: CGPoint(x: 0, y: rect.maxY), options: []) + + // Holztafel + let board = CGRect(x: rect.minX + 118 * s, y: rect.minY + 170 * s, width: rect.width - 236 * s, height: rect.height - 330 * s) + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -12 * s), blur: 20 * s, color: rgb(0x0B1F16, 0.45)) + ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil)) + ctx.setFillColor(rgb(0xE6CC9C)); ctx.fillPath() + ctx.restoreGState() + // Maserung, ganz zart + ctx.saveGState() + ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil)) + ctx.clip() + ctx.setStrokeColor(rgb(0xC9A86A, 0.35)); ctx.setLineWidth(5 * s) + for i in 0..<7 { + let y = board.minY + CGFloat(i) * board.height / 6.5 + 18 * s + ctx.move(to: CGPoint(x: board.minX, y: y)) + ctx.addCurve(to: CGPoint(x: board.maxX, y: y + 10 * s), + control1: CGPoint(x: board.minX + board.width * 0.35, y: y + 22 * s), + control2: CGPoint(x: board.minX + board.width * 0.65, y: y - 18 * s)) + } + ctx.strokePath() + ctx.restoreGState() + + // Leiste oben mit drei Haken + let rail = CGRect(x: board.minX + 40 * s, y: board.maxY - 96 * s, width: board.width - 80 * s, height: 22 * s) + ctx.addPath(CGPath(roundedRect: rail, cornerWidth: 11 * s, cornerHeight: 11 * s, transform: nil)) + ctx.setFillColor(rgb(0xB89260)); ctx.fillPath() + + // Namensschilder: hoch und schmal, wie in Japan üblich + let plateW = 128 * s, plateH = 330 * s, gap = 40 * s + let total = plateW * 3 + gap * 2 + let x0 = board.midX - total / 2 + let top = rail.minY - 20 * s + for i in 0..<3 { + let x = x0 + CGFloat(i) * (plateW + gap) + let plate = CGRect(x: x, y: top - plateH, width: plateW, height: plateH) + let onDuty = i == 1 + // Haken + ctx.setFillColor(rgb(0x6B5135)) + ctx.fillEllipse(in: CGRect(x: plate.midX - 13 * s, y: rail.midY - 13 * s, width: 26 * s, height: 26 * s)) + ctx.setStrokeColor(rgb(0x6B5135)); ctx.setLineWidth(6 * s) + ctx.move(to: CGPoint(x: plate.midX, y: rail.midY)); ctx.addLine(to: CGPoint(x: plate.midX, y: plate.maxY - 10 * s)); ctx.strokePath() + // Schild + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -6 * s), blur: 10 * s, color: rgb(0x3D2A14, 0.35)) + ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 18 * s, cornerHeight: 18 * s, transform: nil)) + ctx.setFillColor(onDuty ? rgb(0xB5452B) : rgb(0xFBF6EC)); ctx.fillPath() + ctx.restoreGState() + // "Name": drei kurze Zeilen wie Schrift (bewusst ohne Zeichen, die als Symbol gelesen werden könnten) + ctx.setStrokeColor(onDuty ? rgb(0xFBF6EC) : rgb(0x2B2A27, 0.85)); ctx.setLineCap(.round); ctx.setLineWidth(16 * s) + let lines: [(CGFloat, CGFloat)] = [(0.30, 0.62), (0.45, 0.44), (0.60, 0.54)] + for (y, w) in lines { + let yy = plate.maxY - plate.height * y + ctx.move(to: CGPoint(x: plate.midX - plate.width * w / 2, y: yy)) + ctx.addLine(to: CGPoint(x: plate.midX + plate.width * w / 2, y: yy)) + } + ctx.strokePath() + } + ctx.restoreGState() + return ctx.makeImage()! +} + +func png(_ img: CGImage, to url: URL) { + let rep = NSBitmapImageRep(cgImage: img) + try! rep.representation(using: .png, properties: [:])!.write(to: url) +} + +let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) +let iconset = root.appendingPathComponent("build/AppIcon.iconset") +try? FileManager.default.removeItem(at: iconset) +try! FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) +for base in [16, 32, 128, 256, 512] { + png(drawIcon(size: CGFloat(base)), to: iconset.appendingPathComponent("icon_\(base)x\(base).png")) + png(drawIcon(size: CGFloat(base * 2)), to: iconset.appendingPathComponent("icon_\(base)x\(base)@2x.png")) +} +try? FileManager.default.createDirectory(at: root.appendingPathComponent("design"), withIntermediateDirectories: true) +png(drawIcon(size: 1024), to: root.appendingPathComponent("design/icon-1024.png")) +let p = Process() +p.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +p.arguments = ["-c", "icns", iconset.path, "-o", root.appendingPathComponent("App/Resources/AppIcon.icns").path] +try! p.run(); p.waitUntilExit() +print(p.terminationStatus == 0 ? "AppIcon.icns geschrieben" : "iconutil fehlgeschlagen") diff --git a/Tools/publish.sh b/Tools/publish.sh new file mode 100644 index 0000000..eb2e82f --- /dev/null +++ b/Tools/publish.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Lädt DMG und Appcast auf den Update-Server. Ab dann bieten alle installierten +# Mawari-Kopien das Update beim nächsten Start (bzw. binnen eines Tages) an. +# +# Einmalig vorher den Ordner auf dem Server anlegen (siehe zielke-apps/README.md): +# ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari' +# +# Aufruf: bash Tools/publish.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +SERVER="${SERVER:-samuelzielke@10.0.5.223}" +DEST="/var/www/updates/mawari/" +VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/') +DMG="build/Mawari-${VERSION}.dmg" + +[ -f "$DMG" ] && [ -f build/appcast.xml ] || { echo "Fehler: erst Tools/build-dmg.sh und Tools/make-appcast.sh." >&2; exit 1; } +grep -q "Mawari-${VERSION}.dmg" build/appcast.xml || { echo "Fehler: appcast.xml passt nicht zu $VERSION." >&2; exit 1; } + +cp "$DMG" build/Mawari-latest.dmg +# DMG zuerst, Appcast zuletzt – sonst zeigt der Appcast kurz auf eine fehlende Datei. +rsync -a --chmod=F644 "$DMG" build/Mawari-latest.dmg "$SERVER:$DEST" +rsync -a --chmod=F644 build/appcast.xml "$SERVER:$DEST" +echo "==> Veröffentlicht: https://update.zielke.studio/mawari/appcast.xml (Version $VERSION)" +curl -fsS "https://update.zielke.studio/mawari/appcast.xml" | grep -o "[^<]*" || echo "Hinweis: Appcast noch nicht abrufbar (Proxy/DNS prüfen)." diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..ca26bb4 --- /dev/null +++ b/design/README.md @@ -0,0 +1,29 @@ +# Design + +Leitbild: die hölzerne Diensttafel (*tōban-hyō*), auf der reihum (*mawari*) eingetragen wird, wer dran ist. Ruhig, hell, gut lesbar – die Tabelle ist das Produkt. + +## Farben + +Definiert in `App/Sources/Theme.swift`, jede Farbe mit heller und dunkler Fassung. + +| Name | Hell | Dunkel | Verwendung | +|---|---|---|---| +| Tokiwa (常盤, immergrün) | `#2F6B4F` | `#6DBB93` | Markenfarbe, Bereich „Diener (Ernannte)“ | +| Kuri (栗, Kastanie) | `#7A5230` | `#D0A07A` | Bereich „Schulung“ | +| Kohaku (琥珀, Bernstein) | `#B45309` | `#F0A35A` | „nie“ – Person hatte die Aufgabe noch nie | +| Bengara (弁柄, Eisenrot) | `#B5452B` | `#E8775D` | Warnungen, Fehler | +| Line / Muted / Faint | – | – | Linien, Nebentext, „–“ (nicht freigegeben) | + +Zu jeder Bereichsfarbe gibt es eine helle Fläche (`tokiwaSoft`, `kuriSoft`, `kohakuSoft`) für Kopfzeilen und Hervorhebungen. + +## Symbol + +`icon-1024.png` ist die Vorlage. Das Symbol wird nicht von Hand gezeichnet, sondern erzeugt: + +```bash +swift Tools/make-icon.swift # schreibt design/icon-1024.png und App/Resources/AppIcon.icns +``` + +## Schrift und Texte + +SF Pro (System), Zahlen und Daten in Monospaced-Ziffern. Texte auf Deutsch, kurz, Du-Form. Datum in der Übersicht als `TT.MM.JJ`, dazu „vor n Wochen“. diff --git a/design/icon-1024.png b/design/icon-1024.png new file mode 100644 index 0000000..f21d192 Binary files /dev/null and b/design/icon-1024.png differ diff --git a/docs/datenformat.md b/docs/datenformat.md new file mode 100644 index 0000000..1cca02d --- /dev/null +++ b/docs/datenformat.md @@ -0,0 +1,48 @@ +# Datenformat + +Mawari speichert alles in einer JSON-Datei: + +``` +~/Library/Application Support/Mawari/ +├── daten.json aktueller Stand, wird nach jeder Änderung (300 ms verzögert) atomar geschrieben +└── Sicherungen/ + ├── daten-YYYY-MM-DD.json eine Kopie pro Tag beim Start, die letzten 60 bleiben + └── vor-import-.json Stand vor jedem Import +``` + +Das Format ist identisch mit `leben-und-dienst.json` aus dem früheren HTML-Tool. Dateien lassen sich in beide Richtungen austauschen (*Ablage › Sicherung speichern / laden*). + +## Aufbau + +```json +{ + "app": "leben-und-dienst-uebersicht", + "version": 1, + "updated": "2026-10-22T11:00:00Z", + "tasks": [ + { "id": "vorsitz", "name": "Vorsitz", "group": "diener", "withPartner": false }, + { "id": "gespraeche", "name": "Gespräche beginnen", "group": "schulung", "withPartner": true }, + { "id": "partner", "name": "Partner", "group": "schulung", "withPartner": false, "isPartner": true } + ], + "persons": [ + { "id": "p1a2b3c4d", "name": "Max Muster", "gender": "", "note": "", "tasks": ["gebet", "bibellesung"] } + ], + "entries": [ + { "id": "e5f6a7b8c", "date": "2026-10-16", "task": "gespraeche", "person": "p1a2b3c4d", "partner": "p9z8y7x6w", "note": "" } + ] +} +``` + +| Feld | Bedeutung | +|---|---| +| `tasks[].group` | `diener` (Aufgaben für Ernannte) oder `schulung` | +| `tasks[].withPartner` | Eintrag hat ein Partner-Feld | +| `tasks[].isPartner` | Sonderspalte „Partner“: zeigt, wann jemand zuletzt **als Partner** dabei war (wird aus `entries[].partner` berechnet, nicht direkt eingetragen) | +| `persons[].tasks` | Aufgaben, für die die Person freigegeben ist. Nicht freigegeben = „–“ in der Übersicht | +| `entries[].date` | `YYYY-MM-DD`; Datum in der Zukunft wird als „geplant“ angezeigt | + +## Toleranz beim Laden + +Fehlende Felder werden mit Standardwerten ergänzt. Fehlt `tasks` oder ist die Liste leer, gelten die 15 Standardaufgaben aus `Database.defaultTasks` (`App/Sources/Model.swift`). Pflicht sind nur `persons` und `entries`. + +Wird eine Person gelöscht, verschwinden ihre Einträge; wo sie Partner war, wird das Feld geleert. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..af99af1 --- /dev/null +++ b/project.yml @@ -0,0 +1,78 @@ +name: Mawari +options: + bundleIdPrefix: studio.zielke + deploymentTarget: + macOS: "14.0" + developmentLanguage: de + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + MACOSX_DEPLOYMENT_TARGET: "14.0" + ARCHS: "arm64 x86_64" + DEAD_CODE_STRIPPING: YES + configs: + Debug: + ONLY_ACTIVE_ARCH: YES + Release: + ONLY_ACTIVE_ARCH: NO + +packages: + # Selbstaktualisierung, gleiche Fassung wie drop.look und drop.two. + Sparkle: + url: https://github.com/sparkle-project/Sparkle + exactVersion: 2.10.0 + +targets: + Mawari: + type: application + platform: macOS + sources: + - path: App/Sources + # Das Symbol wird aus Tools/make-icon.swift erzeugt, nicht von Hand gezeichnet. + - path: App/Resources/AppIcon.icns + buildPhase: resources + dependencies: + - package: Sparkle + info: + # xcodegen ERZEUGT diese Datei aus den Angaben hier. + path: App/Info.plist + properties: + CFBundleName: Mawari + CFBundleDisplayName: Mawari + CFBundleIconFile: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) + LSApplicationCategoryType: public.app-category.productivity + NSHumanReadableCopyright: © Zielke Studio + # --- Selbstaktualisierung (Sparkle) --- + SUFeedURL: https://update.zielke.studio/mawari/appcast.xml + # Öffentlicher EdDSA-Schlüssel, eigenes Paar für Mawari (Konto "mawari"). + # Der private liegt im Schlüsselbund dieses Rechners – sichern! + SUPublicEDKey: MMz3ZcmIvAmxRd7hXMGFYq0E4wrwV8pMhUxnDcyc48I= + # Beim Start nach Updates suchen (Papa soll sich um nichts kümmern müssen). + SUEnableAutomaticChecks: YES + SUScheduledCheckInterval: 86400 + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.mawari + PRODUCT_NAME: Mawari + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Developer ID Application" + DEVELOPMENT_TEAM: XY4C2RG3L5 + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_ENTITLEMENTS: App/Mawari.entitlements + +schemes: + Mawari: + build: + targets: + Mawari: all + run: + config: Debug + archive: + config: Release