commit
21edee59aa
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
Binary file not shown.
@ -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<String>()
|
||||
@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<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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() }
|
||||
@ -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<Void, Never>?
|
||||
|
||||
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<Person>? {
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
@ -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 } }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
|
After Width: | Height: | Size: 195 KiB |
Loading…
Reference in new issue