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:
@@ -15,6 +15,8 @@ struct ExercisePickerSheet: View {
|
||||
@State private var setsText = "3"
|
||||
@State private var repsText = "10"
|
||||
@State private var showCustom = false
|
||||
@State private var addedCount = 0
|
||||
@State private var flashedId: UUID? = nil
|
||||
@FocusState private var customFocused: Bool
|
||||
|
||||
private func addExercise(name: String) {
|
||||
@@ -25,27 +27,48 @@ struct ExercisePickerSheet: View {
|
||||
} else {
|
||||
vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps)
|
||||
}
|
||||
addedCount += 1
|
||||
}
|
||||
|
||||
// Briefly mark a template row as "added" so the user gets feedback while keeping the sheet open.
|
||||
private func flash(_ id: UUID) {
|
||||
withAnimation(KisaniSpring.micro) { flashedId = id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
|
||||
if flashedId == id { withAnimation(KisaniSpring.micro) { flashedId = nil } }
|
||||
}
|
||||
}
|
||||
|
||||
private var filtered: [ExerciseTemplate] {
|
||||
let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle }
|
||||
guard !searchText.isEmpty else { return byMuscle }
|
||||
return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
// When searching, look across the whole library (ignore the muscle tab).
|
||||
guard searchText.isEmpty else {
|
||||
return exerciseLibrary.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
return exerciseLibrary.filter { $0.muscle == selectedMuscle }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Text("Add Exercise")
|
||||
.font(AppFonts.sans(17, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if addedCount > 0 {
|
||||
Text("\(addedCount) added")
|
||||
.font(AppFonts.mono(9.5, weight: .bold))
|
||||
.foregroundColor(AppColors.green)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(AppColors.greenSoft)
|
||||
.clipShape(Capsule())
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
Button(addedCount > 0 ? "Done" : "Cancel") { dismiss() }
|
||||
.font(AppFonts.sans(13, weight: addedCount > 0 ? .bold : .regular))
|
||||
.foregroundColor(addedCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.animation(KisaniSpring.snappy, value: addedCount)
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
@@ -114,7 +137,8 @@ struct ExercisePickerSheet: View {
|
||||
let n = customName.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
addExercise(name: n)
|
||||
dismiss()
|
||||
customName = ""
|
||||
customFocused = true
|
||||
} label: {
|
||||
Text("Add Custom Exercise")
|
||||
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white)
|
||||
@@ -134,9 +158,9 @@ struct ExercisePickerSheet: View {
|
||||
|
||||
// Library exercises
|
||||
ForEach(filtered) { template in
|
||||
ExerciseTemplateRow(template: template) {
|
||||
ExerciseTemplateRow(template: template, added: flashedId == template.id) {
|
||||
addExercise(name: template.name)
|
||||
dismiss()
|
||||
flash(template.id)
|
||||
}
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 60)
|
||||
}
|
||||
@@ -188,6 +212,7 @@ struct ExercisePickerSheet: View {
|
||||
private struct ExerciseTemplateRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let template: ExerciseTemplate
|
||||
var added: Bool = false
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -206,9 +231,10 @@ private struct ExerciseTemplateRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "plus.circle.fill")
|
||||
Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(AppColors.accent.opacity(0.7))
|
||||
.foregroundColor(added ? AppColors.green : AppColors.accent.opacity(0.7))
|
||||
.scaleEffect(added ? 1.15 : 1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -22,9 +22,13 @@ final class CalendarStore: ObservableObject {
|
||||
|
||||
init() {
|
||||
authStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
hiddenCalendarIDs = Set(UserDefaults.standard.stringArray(forKey: "kisani.cal.hiddenIDs") ?? [])
|
||||
localCalendarsEnabled = UserDefaults.standard.object(forKey: "kisani.cal.localEnabled") as? Bool ?? true
|
||||
doNotDisturb = UserDefaults.standard.bool(forKey: "kisani.cal.dnd")
|
||||
// Migrate legacy prefs from UserDefaults.standard into the App Group (one-time).
|
||||
Self.migrateCalPrefIfNeeded(hiddenIDsKey)
|
||||
Self.migrateCalPrefIfNeeded(calEnabledKey)
|
||||
Self.migrateCalPrefIfNeeded(dndKey)
|
||||
hiddenCalendarIDs = Set(UserDefaults.kisani.stringArray(forKey: hiddenIDsKey) ?? [])
|
||||
localCalendarsEnabled = UserDefaults.kisani.object(forKey: calEnabledKey) as? Bool ?? true
|
||||
doNotDisturb = UserDefaults.kisani.bool(forKey: dndKey)
|
||||
changeToken = NotificationCenter.default.addObserver(
|
||||
forName: .EKEventStoreChanged,
|
||||
object: ekStore,
|
||||
@@ -45,7 +49,10 @@ final class CalendarStore: ObservableObject {
|
||||
let current = EKEventStore.authorizationStatus(for: .event)
|
||||
guard current != authStatus else { return }
|
||||
authStatus = current
|
||||
if isAuthorized { fetchMonth(containing: fetchedMonth ?? Date()) }
|
||||
if isAuthorized {
|
||||
fetchMonth(containing: fetchedMonth ?? Date())
|
||||
loadLocalCalendars()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -108,6 +115,10 @@ final class CalendarStore: ObservableObject {
|
||||
return authStatus == .authorized
|
||||
}
|
||||
|
||||
/// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings.
|
||||
var canRequest: Bool { authStatus == .notDetermined }
|
||||
var isDenied: Bool { authStatus == .denied || authStatus == .restricted }
|
||||
|
||||
// MARK: - Local Calendar Management
|
||||
|
||||
func loadLocalCalendars() {
|
||||
@@ -137,12 +148,23 @@ final class CalendarStore: ObservableObject {
|
||||
|
||||
func setDoNotDisturb(_ on: Bool) {
|
||||
doNotDisturb = on
|
||||
UserDefaults.standard.set(on, forKey: dndKey)
|
||||
UserDefaults.kisani.set(on, forKey: dndKey)
|
||||
CloudSyncManager.shared.push(value: on, forKey: dndKey)
|
||||
}
|
||||
|
||||
private func saveCalPrefs() {
|
||||
UserDefaults.standard.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
|
||||
UserDefaults.standard.set(localCalendarsEnabled, forKey: calEnabledKey)
|
||||
UserDefaults.kisani.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
|
||||
UserDefaults.kisani.set(localCalendarsEnabled, forKey: calEnabledKey)
|
||||
CloudSyncManager.shared.push(value: Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
|
||||
CloudSyncManager.shared.push(value: localCalendarsEnabled, forKey: calEnabledKey)
|
||||
}
|
||||
|
||||
private static func migrateCalPrefIfNeeded(_ key: String) {
|
||||
if UserDefaults.kisani.object(forKey: key) == nil,
|
||||
let legacy = UserDefaults.standard.object(forKey: key) {
|
||||
UserDefaults.kisani.set(legacy, forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func groupedCalendars() -> [CalendarGroup] {
|
||||
@@ -890,6 +912,8 @@ struct CalendarConnectSheet: View {
|
||||
@ObservedObject var calStore: CalendarStore
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -906,10 +930,19 @@ struct CalendarConnectSheet: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
Text(calStore.isDenied
|
||||
? "Access denied — enable in Settings"
|
||||
: "Tap to connect and show events")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
Button("Connect") { calStore.requestAccess() }
|
||||
Button(calStore.isDenied ? "Settings" : "Connect") {
|
||||
if calStore.canRequest {
|
||||
calStore.requestAccess()
|
||||
} else if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
.font(AppFonts.mono(9.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||
@@ -992,6 +1025,10 @@ struct CalendarConnectSheet: View {
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
.onAppear { calStore.refreshStatus() }
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active { calStore.refreshStatus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ struct OnboardingView: View {
|
||||
@State private var heightStr = ""
|
||||
@State private var selectedDays: Set<Int> = []
|
||||
@State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private let ekStore = EKEventStore()
|
||||
@@ -284,6 +285,50 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// ══════════════════════════════
|
||||
// MARK: Health
|
||||
// ══════════════════════════════
|
||||
if hk.isAvailable {
|
||||
OnboardingSectionHeader(title: "Health")
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: hk.authorized ? "heart.fill" : "heart")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(hk.authorized ? AppColors.green : AppColors.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(hk.authorized ? AppColors.greenSoft : AppColors.accentSoft)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.animation(KisaniSpring.micro, value: hk.authorized)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Apple Health")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(hk.authorized
|
||||
? "Steps, calories & workouts on your dashboard"
|
||||
: "Show your activity stats above today's tasks")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { hk.authorized },
|
||||
set: { on in
|
||||
if on { Task { _ = await hk.requestAuthorization() } }
|
||||
else { hk.disconnect() }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(AppColors.accent)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||
.cardStyle()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// ══════════════════════════════
|
||||
// MARK: Workout
|
||||
// ══════════════════════════════
|
||||
|
||||
@@ -9,7 +9,6 @@ struct SettingsView: View {
|
||||
// Persisted settings
|
||||
@AppStorage("appearanceMode") private var appearanceMode = 0
|
||||
@AppStorage("soundsOn") private var soundsOn = true
|
||||
@AppStorage("healthOn") private var healthOn = false
|
||||
@AppStorage("showMatrixTab") private var showMatrixTab = true
|
||||
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
|
||||
@AppStorage("dateFormat") private var dateFormat = 0
|
||||
@@ -116,7 +115,7 @@ struct SettingsView: View {
|
||||
SttSection(label: "Fitness") {
|
||||
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
|
||||
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
|
||||
HealthToggleRow(isOn: $healthOn, isLast: true)
|
||||
HealthToggleRow(isLast: true)
|
||||
}
|
||||
|
||||
// ── ACCOUNT ──
|
||||
@@ -176,7 +175,6 @@ struct SettingsView: View {
|
||||
// MARK: - Health Toggle Row (requests auth when turned on)
|
||||
private struct HealthToggleRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@Binding var isOn: Bool
|
||||
var isLast: Bool = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
|
||||
@@ -193,25 +191,26 @@ private struct HealthToggleRow: View {
|
||||
Text("Health Integration")
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if isOn && hk.authorized {
|
||||
if hk.authorized {
|
||||
Text("Connected")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.green)
|
||||
} else if isOn && !hk.isAvailable {
|
||||
} else if !hk.isAvailable {
|
||||
Text("Not available on this device")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(AppColors.green)
|
||||
.onChange(of: isOn) { enabled in
|
||||
if enabled {
|
||||
Task { await HealthKitManager.shared.requestAuthorization() }
|
||||
}
|
||||
Toggle("", isOn: Binding(
|
||||
get: { hk.authorized },
|
||||
set: { on in
|
||||
if on { Task { _ = await hk.requestAuthorization() } }
|
||||
else { hk.disconnect() }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(AppColors.green)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||||
@@ -1031,7 +1030,6 @@ private struct ProfileStatsSheet: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
@AppStorage("healthOn") private var healthOn = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
|
||||
@@ -1225,7 +1223,7 @@ private struct ProfileStatsSheet: View {
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
|
||||
// ── Health card ──
|
||||
if healthOn && hk.authorized {
|
||||
if hk.authorized {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Text("HEALTH")
|
||||
|
||||
@@ -1039,11 +1039,19 @@ struct ProgramDetailSheet: View {
|
||||
ForEach(prog.sections) { section in
|
||||
Section {
|
||||
if section.exercises.isEmpty {
|
||||
Text("No exercises — tap + to add")
|
||||
Text("No exercises — tap + or drag one here")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
ForEach(section.exercises) { exercise in
|
||||
HStack(spacing: 10) {
|
||||
@@ -1065,6 +1073,15 @@ struct ProgramDetailSheet: View {
|
||||
.padding(.vertical, 4)
|
||||
.listRowBackground(AppColors.surface(cs))
|
||||
.listRowSeparator(.hidden)
|
||||
.draggable(exercise.id.uuidString)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: exercise.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id)
|
||||
@@ -1093,6 +1110,14 @@ struct ProgramDetailSheet: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.vertical, 2)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
} header: {
|
||||
HStack {
|
||||
|
||||
Reference in New Issue
Block a user