Files
KisaniCal/KisaniCal/Views/AddExerciseSheet.swift
Robin Kutesa 8b094691f5 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>
2026-06-03 12:27:45 +03:00

288 lines
14 KiB
Swift

import SwiftUI
// MARK: - Exercise Picker Sheet
struct ExercisePickerSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
let sectionId: UUID
var programId: UUID? = nil // nil = active program
@State private var selectedMuscle: ExerciseMuscle = .chest
@State private var searchText = ""
@State private var customName = ""
@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) {
let sets = Int(setsText) ?? 3
let reps = Int(repsText) ?? 10
if let pid = programId {
vm.addExercise(programId: pid, sectionId: sectionId, name: name, setsCount: sets, repsCount: reps)
} 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] {
// 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(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(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))
// Search
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13))
.foregroundColor(AppColors.text3(cs))
TextField("Search exercises…", text: $searchText)
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text(cs))
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 13))
.foregroundColor(AppColors.text3(cs))
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
// Category Tabs
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(ExerciseMuscle.allCases) { muscle in
let isSel = selectedMuscle == muscle
Button { withAnimation(KisaniSpring.micro) { selectedMuscle = muscle } } label: {
HStack(spacing: 4) {
Image(systemName: muscle.sfSymbol).font(.system(size: 10))
Text(muscle.rawValue).font(AppFonts.mono(9.5, weight: .bold))
}
.foregroundColor(isSel ? AppColors.accent : AppColors.text3(cs))
.padding(.horizontal, 11).padding(.vertical, 6)
.background(isSel ? AppColors.accentSoft : AppColors.surface2(cs))
.clipShape(Capsule())
.overlay(Capsule().stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
}
.buttonStyle(PressButtonStyle(scale: 0.94))
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 8)
// Exercise List
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
// Custom exercise entry
if showCustom {
VStack(alignment: .leading, spacing: 8) {
Text("CUSTOM EXERCISE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
TextField("Exercise name", text: $customName)
.font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
.focused($customFocused).padding(11)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(customFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
SetRepsRow(setsText: $setsText, repsText: $repsText)
Button {
let n = customName.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty else { return }
addExercise(name: n)
customName = ""
customFocused = true
} label: {
Text("Add Custom Exercise")
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(11)
.background(!customName.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain)
.disabled(customName.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(14)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.medium))
.overlay(RoundedRectangle(cornerRadius: AppRadius.medium).stroke(AppColors.accent.opacity(0.3), lineWidth: 1))
.padding(.horizontal, 16).padding(.bottom, 8)
}
// Library exercises
ForEach(filtered) { template in
ExerciseTemplateRow(template: template, added: flashedId == template.id) {
addExercise(name: template.name)
flash(template.id)
}
Divider().background(AppColors.border(cs)).padding(.leading, 60)
}
if filtered.isEmpty && !showCustom {
VStack(spacing: 6) {
Text("No exercises found")
.font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text2(cs))
Text("Try a different category or add a custom one")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity).padding(.vertical, 30)
}
}
.padding(.horizontal, 16)
}
// Footer: Default sets/reps + custom toggle
Divider().background(AppColors.border(cs))
VStack(spacing: 10) {
SetRepsRow(setsText: $setsText, repsText: $repsText)
Button {
withAnimation(KisaniSpring.snappy) {
showCustom.toggle()
if showCustom { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { customFocused = true } }
}
} label: {
HStack(spacing: 6) {
Image(systemName: showCustom ? "xmark.circle" : "plus.circle")
.font(.system(size: 13))
Text(showCustom ? "Cancel Custom" : "Add Custom Exercise")
.font(AppFonts.sans(12, weight: .semibold))
}
.foregroundColor(AppColors.accent)
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16).padding(.vertical, 12)
}
.background(AppColors.background(cs).ignoresSafeArea())
}
}
// MARK: - Exercise Template Row
private struct ExerciseTemplateRow: View {
@Environment(\.colorScheme) private var cs
let template: ExerciseTemplate
var added: Bool = false
let onAdd: () -> Void
var body: some View {
Button(action: onAdd) {
HStack(spacing: 12) {
Image(systemName: template.sfSymbol)
.font(.system(size: 15, weight: .medium))
.foregroundColor(AppColors.accent)
.frame(width: 36, height: 36)
.background(AppColors.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 9))
Text(template.name)
.font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs))
Spacer()
Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill")
.font(.system(size: 18))
.foregroundColor(added ? AppColors.green : AppColors.accent.opacity(0.7))
.scaleEffect(added ? 1.15 : 1)
}
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Sets / Reps Row (shared)
private struct SetRepsRow: View {
@Environment(\.colorScheme) private var cs
@Binding var setsText: String
@Binding var repsText: String
var body: some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Text("Sets").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
TextField("3", text: $setsText)
.font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs))
.keyboardType(.numberPad).multilineTextAlignment(.center)
.frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
}
.padding(8)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1))
HStack(spacing: 6) {
Text("Reps").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
TextField("10", text: $repsText)
.font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs))
.keyboardType(.numberPad).multilineTextAlignment(.center)
.frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
}
.padding(8)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1))
Spacer()
}
}
}