feat: home/lock widgets, Health-aware streaks, workout editor, and entitlement fixes

Widgets (new KisaniCalWidgets extension)
- Event Countdown widget: configurable via AppIntent, pick from your events
  or set a custom name/date; dot-grid progress toward the event.
- Day Progress and Tasks-Done-Today widgets; lock screen widgets.
- Shared dot grid sizes circles to fill the widget evenly at any count.
- Tasks/calendar prefs now persist to the App Group so widgets read live data.

Health & streaks
- Persist HealthKit authorization and re-establish on launch (fixes the
  Today dashboard and streak sync disappearing after relaunch).
- Add a Health permission toggle to onboarding; unify Settings/Profile on the
  manager's state.
- Day streak counts from a logged Health workout OR marking all exercises
  complete; nudge to mark exercises complete even when Health logged the workout.

Workout editor
- Drag to reorder exercises and move them across sections (drag-and-drop);
  swipe to delete.
- Add-exercise sheet: global search and keep-adding-multiple with a running count.

Fixes
- Restore app entitlements link (Sign in with Apple, HealthKit, App Group, iCloud).
- Add HealthKit usage strings; widget Info.plist NSExtension + nested bundle id.
- Calendar "Connect" routes to Settings when access was denied.
- Bake DEVELOPMENT_TEAM and shared versions into project.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-03 12:27:45 +03:00
parent e5bd739499
commit 8b094691f5
23 changed files with 1272 additions and 115 deletions

View File

@@ -0,0 +1,153 @@
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Configuration Intent
//
// Each widget instance stores its own event. The user sets a name, the day the
// countdown starts ("Counting from" defaults to the day they add the widget,
// which acts as the creation anchor) and the event date. Because these values are
// persisted per-instance by WidgetKit, the widget is fully self-contained and does
// not depend on App Group data sharing.
struct EventConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Event Countdown"
static var description = IntentDescription("Count down to any upcoming event with a dot grid.")
// Pick one of your existing events (tasks with a due date). When set, it
// overrides the manual name/date fields below.
@Parameter(title: "Track event")
var event: EventEntity?
@Parameter(title: "Or event name", default: "My Event")
var eventName: String
@Parameter(title: "Event date", default: Date.now)
var targetDate: Date
@Parameter(title: "Counting from", default: Date.now)
var startDate: Date
}
// MARK: - Event picker (reads your tasks from the App Group)
struct EventEntity: AppEntity {
let id: String // task UUID string
let title: String
let dueDate: Date?
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
static var defaultQuery = EventQuery()
var displayRepresentation: DisplayRepresentation {
if let d = dueDate {
let f = DateFormatter(); f.dateStyle = .medium
return DisplayRepresentation(title: "\(title)", subtitle: "\(f.string(from: d))")
}
return DisplayRepresentation(title: "\(title)")
}
}
struct EventQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [EventEntity] {
allEvents().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [EventEntity] {
allEvents()
}
private func allEvents() -> [EventEntity] {
loadWidgetTasks()
.filter { !$0.isComplete && $0.dueDate != nil }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate) }
}
}
// MARK: - Entry
struct EventEntry: TimelineEntry {
let date: Date
let eventName: String
let start: Date
let target: Date
/// Fraction of the countdown window that has elapsed (01).
var progress: Double {
let span = target.timeIntervalSince(start)
guard span > 0 else { return target <= date ? 1 : 0 }
return min(1, max(0, date.timeIntervalSince(start) / span))
}
/// Whole days from start to target (the number of dots in the grid).
var totalDays: Int {
let cal = Calendar.current
let d = cal.dateComponents([.day],
from: cal.startOfDay(for: start),
to: cal.startOfDay(for: target)).day ?? 0
return max(1, d)
}
/// Days remaining until the event (never negative).
var daysLeft: Int {
let cal = Calendar.current
let d = cal.dateComponents([.day],
from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
return max(0, d)
}
}
// MARK: - Provider
struct EventProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> EventEntry {
EventEntry(date: .now,
eventName: "Mum's Birthday",
start: Date().addingTimeInterval(-86400 * 320),
target: Date().addingTimeInterval(86400 * 60))
}
func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry {
entry(for: configuration)
}
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
let entry = entry(for: configuration)
// Recompute at the next midnight so the grid advances one dot per day.
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
return Timeline(entries: [entry], policy: .after(nextMidnight))
}
private func entry(for config: EventConfigIntent) -> EventEntry {
let name: String
let target: Date
if let ev = config.event, let due = ev.dueDate {
// A picked event supplies its own name + date.
name = ev.title
target = due
} else {
name = config.eventName.isEmpty ? "My Event" : config.eventName
target = config.targetDate
}
// Never let the start sit after the target.
let start = min(config.startDate, target)
return EventEntry(date: .now, eventName: name, start: start, target: target)
}
}
// MARK: - Widget
struct EventCountdownWidget: Widget {
let kind = "KisaniEventCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
EventCountdownView(entry: entry)
}
.configurationDisplayName("Event Countdown")
.description("Track the days until any upcoming event with a dot grid.")
.supportedFamilies([.systemMedium, .systemSmall])
}
}