Add Apple Watch app (TickTick-style) + midnight period notifications
WenzaWatch (new watchOS target, embed disabled until the watchOS platform is installed — see project.yml note): - Today list of actionable tasks (dated today/overdue + recurring occurrences), tap-to-complete with haptics, and add-via-dictation. - Reads/writes the same tasks through the shared iCloud KV store that CloudSyncManager mirrors on the phone; watch entitlement uses the iOS app's kvstore identifier so they share one store. New tasks include all required TaskItem keys so the phone decodes them. - NOTE: not compiled here (watchOS SDK absent on this machine); iOS build verified green with the embed commented out. Period notifications: day/week/month/year now all fire at midnight (00:00), adding the daily "One more day passed." (Pretty Progress style). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
28
WenzaWatch/Info.plist
Normal file
28
WenzaWatch/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Wenza</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
8
WenzaWatch/WenzaWatch.entitlements
Normal file
8
WenzaWatch/WenzaWatch.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
221
WenzaWatch/WenzaWatchApp.swift
Normal file
221
WenzaWatch/WenzaWatchApp.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
// MARK: - Shared iCloud task store
|
||||
//
|
||||
// The Watch is a separate device and can't read the iPhone's App Group, so it
|
||||
// reads/writes the SAME tasks via the iCloud key-value store (NSUbiquitousKeyValueStore)
|
||||
// that CloudSyncManager already mirrors on the phone. Writes here propagate back to
|
||||
// the phone, which applies external KV changes into its App Group.
|
||||
|
||||
private func tasksKey() -> String {
|
||||
let uid = NSUbiquitousKeyValueStore.default.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
return "kisani.tasks.v2.\(uid)"
|
||||
}
|
||||
|
||||
private let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
|
||||
struct WatchTask: Identifiable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var dueDate: Date?
|
||||
var isComplete: Bool
|
||||
var isRecurring: Bool
|
||||
var recurrenceLabel: String?
|
||||
var completedOccurrences: [String]
|
||||
}
|
||||
|
||||
final class WatchStore: ObservableObject {
|
||||
@Published var tasks: [WatchTask] = []
|
||||
private let kv = NSUbiquitousKeyValueStore.default
|
||||
private let cal = Calendar.current
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(externalChange),
|
||||
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv)
|
||||
kv.synchronize()
|
||||
load()
|
||||
}
|
||||
|
||||
@objc private func externalChange() {
|
||||
kv.synchronize()
|
||||
DispatchQueue.main.async { [weak self] in self?.load() }
|
||||
}
|
||||
|
||||
func load() {
|
||||
guard let data = kv.data(forKey: tasksKey()),
|
||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||
tasks = []; return
|
||||
}
|
||||
tasks = arr.compactMap { d in
|
||||
guard let idStr = d["id"] as? String, let id = UUID(uuidString: idStr),
|
||||
let title = d["title"] as? String else { return nil }
|
||||
let due = (d["dueDate"] as? Double).map { Date(timeIntervalSinceReferenceDate: $0) }
|
||||
return WatchTask(id: id, title: title, dueDate: due,
|
||||
isComplete: d["isComplete"] as? Bool ?? false,
|
||||
isRecurring: d["isRecurring"] as? Bool ?? false,
|
||||
recurrenceLabel: d["recurrenceLabel"] as? String,
|
||||
completedOccurrences: d["completedOccurrences"] as? [String] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
// Today's actionable tasks: dated today/overdue, plus recurring occurrences due today.
|
||||
var todayTasks: [WatchTask] {
|
||||
let end = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: Date()))!
|
||||
return tasks.filter { t in
|
||||
if t.isRecurring { return occursToday(t) && !completedToday(t) }
|
||||
guard !t.isComplete, let due = t.dueDate else { return false }
|
||||
return due < end
|
||||
}
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
|
||||
private func completedToday(_ t: WatchTask) -> Bool {
|
||||
t.completedOccurrences.contains(occFmt.string(from: Date()))
|
||||
}
|
||||
|
||||
private func occursToday(_ t: WatchTask) -> Bool {
|
||||
guard let due = t.dueDate else { return false }
|
||||
let now = Date()
|
||||
if due > cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: now))! { return false }
|
||||
switch t.recurrenceLabel {
|
||||
case "Daily": return true
|
||||
case "Every Weekday": let wd = cal.component(.weekday, from: now); return wd >= 2 && wd <= 6
|
||||
case "Weekly": return cal.component(.weekday, from: due) == cal.component(.weekday, from: now)
|
||||
case "Monthly": return cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
case "Yearly": return cal.component(.month, from: due) == cal.component(.month, from: now)
|
||||
&& cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(_ task: WatchTask) {
|
||||
mutate { arr in
|
||||
for i in arr.indices where (arr[i]["id"] as? String) == task.id.uuidString {
|
||||
if task.isRecurring {
|
||||
var occ = arr[i]["completedOccurrences"] as? [String] ?? []
|
||||
let k = occFmt.string(from: Date())
|
||||
if let idx = occ.firstIndex(of: k) { occ.remove(at: idx) } else { occ.append(k) }
|
||||
arr[i]["completedOccurrences"] = occ
|
||||
} else {
|
||||
let done = (arr[i]["isComplete"] as? Bool) ?? false
|
||||
arr[i]["isComplete"] = !done
|
||||
if !done { arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate }
|
||||
else { arr[i].removeValue(forKey: "completedAt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ title: String) {
|
||||
let t = title.trimmingCharacters(in: .whitespaces)
|
||||
guard !t.isEmpty else { return }
|
||||
mutate { arr in
|
||||
// All non-optional TaskItem keys must be present (synthesized Decodable
|
||||
// throws on missing keys); raw values mirror the iOS model.
|
||||
arr.append([
|
||||
"id": UUID().uuidString,
|
||||
"title": t,
|
||||
"dueDate": self.cal.startOfDay(for: Date()).timeIntervalSinceReferenceDate,
|
||||
"hasTime": false,
|
||||
"isComplete": false,
|
||||
"quadrant": "Urgent + Important",
|
||||
"category": "Reminder",
|
||||
"taskColor": "orange",
|
||||
"isRecurring": false,
|
||||
"isPinned": false,
|
||||
"isAllDay": false,
|
||||
"priority": "None",
|
||||
"constantReminder": false,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func mutate(_ change: (inout [[String: Any]]) -> Void) {
|
||||
let key = tasksKey()
|
||||
var arr: [[String: Any]] = []
|
||||
if let data = kv.data(forKey: key),
|
||||
let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { arr = parsed }
|
||||
change(&arr)
|
||||
if let out = try? JSONSerialization.data(withJSONObject: arr) {
|
||||
kv.set(out, forKey: key)
|
||||
kv.synchronize()
|
||||
}
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
@main
|
||||
struct WenzaWatchApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup { TaskListView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskListView: View {
|
||||
@StateObject private var store = WatchStore()
|
||||
@State private var showAdd = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Button { showAdd = true } label: {
|
||||
Label("Add Task", systemImage: "plus.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if store.todayTasks.isEmpty {
|
||||
Text("All caught up")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
} else {
|
||||
ForEach(store.todayTasks) { task in
|
||||
Button { withAnimation { WKInterfaceDevice.current().play(.success); store.toggle(task) } } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(task.title).lineLimit(2)
|
||||
if let due = task.dueDate, !task.isRecurring {
|
||||
Text(timeFmt.string(from: due))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Today")
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddTaskView { store.add($0); showAdd = false }
|
||||
}
|
||||
.onAppear { store.reload() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddTaskView: View {
|
||||
let onAdd: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
TextField("New task", text: $text) // Watch dictation / Scribble
|
||||
Button("Add") { onAdd(text) }
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user