From d424a2a8142ce2692812b2b9d5843623f992f20e Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 01:31:55 +0300 Subject: [PATCH] Clear demo data, namespace storage by user, add profile setup - Remove all personal sampleTasks from TaskViewModel (tasks start empty) - Namespace UserDefaults keys by Apple ID for task and workout data isolation - Add ProfileSetupView for name capture after first Sign in with Apple - Add updateDisplayName() to AuthManager - Route to ProfileSetupView when user has no display name yet Co-Authored-By: Kutesir --- KisaniCal.xcodeproj/project.pbxproj | 4 + KisaniCal/KisaniCalApp.swift | 10 +- KisaniCal/Managers/AuthManager.swift | 8 ++ KisaniCal/Models/ExerciseModels.swift | 141 ++++++++++++++++++------- KisaniCal/Models/TaskItem.swift | 44 ++------ KisaniCal/Views/ProfileSetupView.swift | 85 +++++++++++++++ 6 files changed, 213 insertions(+), 79 deletions(-) create mode 100644 KisaniCal/Views/ProfileSetupView.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 4762d88..0065bae 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; }; F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */; }; F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */; }; + F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -56,6 +57,7 @@ DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -97,6 +99,7 @@ isa = PBXGroup; children = ( F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */, + F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */, 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */, ADF6CCD95A587E26E30F5712 /* CalendarView.swift */, D44530A77DF12A17E52AAF34 /* MatrixView.swift */, @@ -226,6 +229,7 @@ files = ( F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */, F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */, + F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */, A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */, 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */, diff --git a/KisaniCal/KisaniCalApp.swift b/KisaniCal/KisaniCalApp.swift index 258137d..4bd2dbe 100644 --- a/KisaniCal/KisaniCalApp.swift +++ b/KisaniCal/KisaniCalApp.swift @@ -10,8 +10,14 @@ struct KisaniCalApp: App { WindowGroup { ZStack { if auth.isAuthenticated { - ContentView() - .transition(.opacity) + let hasName = !(auth.currentUser?.displayName?.trimmingCharacters(in: .whitespaces).isEmpty ?? true) + if hasName { + ContentView() + .transition(.opacity) + } else { + ProfileSetupView() + .transition(.opacity) + } } else { AuthView() .transition(.opacity) diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift index e58a72b..ae406e0 100644 --- a/KisaniCal/Managers/AuthManager.swift +++ b/KisaniCal/Managers/AuthManager.swift @@ -59,6 +59,14 @@ final class AuthManager: NSObject, ObservableObject { } } + // MARK: - Profile + + func updateDisplayName(_ name: String) { + guard var user = currentUser, !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + user.displayName = name.trimmingCharacters(in: .whitespaces) + persist(user) + } + // MARK: - Sign Out func signOut() { diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 466b88d..a866558 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -175,22 +175,66 @@ class WorkoutViewModel: ObservableObject { @Published var schedule: [Int: UUID] @Published var activeProgramId: UUID @Published var activeSections: [WorkoutSection] - @Published var streakDays: Int = 5 + @Published var workoutDates: [String] = [] @Published var restTimerSeconds: Int = 60 + @Published var sessionStartDate: Date? = nil + + var streakDays: Int { + let cal = Calendar.current + let fmt = iso; let dateSet = Set(workoutDates) + var streak = 0 + var check = cal.startOfDay(for: Date()) + if !dateSet.contains(fmt.string(from: check)) { + check = cal.date(byAdding: .day, value: -1, to: check)! + } + while dateSet.contains(fmt.string(from: check)) { + streak += 1 + check = cal.date(byAdding: .day, value: -1, to: check)! + } + return streak + } + + private let iso: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f + }() @Published var showAddExercise: Bool = false @Published var addExerciseTargetSectionId: UUID? - private static let kPrograms = "kisani.workout.programs" - private static let kSchedule = "kisani.workout.schedule" - private static let kActivePid = "kisani.workout.activePid" + private let kPrograms: String + private let kSchedule: String + private let kActivePid: String + private let kWorkoutDates: String init() { + let uid = AuthManager.shared.currentUser?.id ?? "shared" + self.kPrograms = "kisani.workout.programs.\(uid)" + self.kSchedule = "kisani.workout.schedule.\(uid)" + self.kActivePid = "kisani.workout.activePid.\(uid)" + self.kWorkoutDates = "kisani.workout.completedDates.\(uid)" + // ── Attempt to restore persisted state ── - if let data = UserDefaults.standard.data(forKey: Self.kPrograms), + if let data = UserDefaults.standard.data(forKey: kPrograms), let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data), !saved.isEmpty { - let emojiMap = ["🔥": "🔄", "💥": "🏋️"] + // Migrate any legacy emoji → SF Symbol name + let emojiMap: [String: String] = [ + "🏋️": "figure.strengthtraining.traditional", + "💪": "figure.strengthtraining.functional", + "🔄": "figure.arms.open", + "🦵": "figure.run", + "⚡": "bolt.fill", + "⚡️": "bolt.fill", + "🏃": "figure.run", + "🤸": "figure.yoga", + "🧘": "figure.yoga", + "🚴": "figure.cycling", + "🥊": "figure.boxing", + "🎯": "figure.core.training", + "🫀": "heart.fill", + "🔥": "bolt.fill", + "💥": "figure.highintensity.intervaltraining" + ] let needsMigration = saved.contains(where: { emojiMap[$0.emoji] != nil }) let migrated: [WorkoutProgram] = needsMigration ? saved.map { p in @@ -200,7 +244,7 @@ class WorkoutViewModel: ObservableObject { : saved var sched: [Int: UUID] = [:] - if let dict = UserDefaults.standard.dictionary(forKey: Self.kSchedule) as? [String: String] { + if let dict = UserDefaults.standard.dictionary(forKey: kSchedule) as? [String: String] { for (k, v) in dict { if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid } } @@ -208,7 +252,7 @@ class WorkoutViewModel: ObservableObject { schedule = sched var pid = saved[0].id - if let str = UserDefaults.standard.string(forKey: Self.kActivePid), + if let str = UserDefaults.standard.string(forKey: kActivePid), let uid = UUID(uuidString: str), saved.contains(where: { $0.id == uid }) { pid = uid } activeProgramId = pid @@ -219,30 +263,17 @@ class WorkoutViewModel: ObservableObject { if needsMigration { save() } } else { - // ── First launch: seed with sample programs ── - let progs = WorkoutViewModel.samplePrograms() - programs = progs - - var sched: [Int: UUID] = [:] - if progs.count >= 6 { - sched[2] = progs[0].id - sched[3] = progs[1].id - sched[4] = progs[2].id - sched[5] = progs[3].id - sched[6] = progs[4].id - sched[7] = progs[5].id - } - schedule = sched - - let today = Calendar.current.component(.weekday, from: Date()) - if let pid = sched[today], progs.contains(where: { $0.id == pid }) { - activeProgramId = pid - activeSections = progs.first { $0.id == pid }!.sections - } else { - activeProgramId = progs[0].id - activeSections = progs[0].sections - } + // ── First launch: start blank so the user builds their own routine ── + let blank = WorkoutProgram( + name: "My Workout", emoji: "dumbbell.fill", colorIndex: 0, + sections: [WorkoutSection(name: "Main", exercises: [])] + ) + programs = [blank] + schedule = [:] + activeProgramId = blank.id + activeSections = blank.sections } + workoutDates = UserDefaults.standard.stringArray(forKey: kWorkoutDates) ?? [] } private func save() { @@ -250,13 +281,41 @@ class WorkoutViewModel: ObservableObject { UserDefaults.standard.set(data, forKey: Self.kPrograms) } let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString } - UserDefaults.standard.set(schedDict, forKey: Self.kSchedule) + UserDefaults.standard.set(schedDict, forKey: Self.kSchedule) UserDefaults.standard.set(activeProgramId.uuidString, forKey: Self.kActivePid) + UserDefaults.standard.set(workoutDates, forKey: Self.kWorkoutDates) + } + + func logWorkoutCompleted(on date: Date = Date()) { + let key = iso.string(from: date) + guard !workoutDates.contains(key) else { return } + workoutDates.append(key) + save() + let start = sessionStartDate ?? date.addingTimeInterval(-3600) + Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) } + sessionStartDate = nil + } + + func seedStreak(days: Int) { + let cal = Calendar.current + var newDates = Set(workoutDates) + for i in 0..) { + schedule = [:] + for day in weekdays { schedule[day] = activeProgramId } + save() } var activeProgram: WorkoutProgram? { programs.first { $0.id == activeProgramId } } var workoutTitle: String { activeProgram?.name ?? "Workout" } - var workoutEmoji: String { activeProgram?.emoji ?? "💪" } + var workoutEmoji: String { activeProgram?.emoji ?? "dumbbell.fill" } var totalSets: Int { activeSections.reduce(0) { $0 + $1.totalSets } } var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } } var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 } @@ -345,7 +404,11 @@ class WorkoutViewModel: ObservableObject { let setIdx = activeSections[si].exercises[ei].sets.firstIndex(where: { $0.id == setId }) else { return } activeSections[si].exercises[ei].sets[setIdx].isDone.toggle() + if sessionStartDate == nil, activeSections[si].exercises[ei].sets[setIdx].isDone { + sessionStartDate = Date() + } syncBack() + if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() } } func addSet(sectionId: UUID, to exerciseId: UUID) { @@ -453,7 +516,7 @@ class WorkoutViewModel: ObservableObject { let lb: Double = 0.453592 // ── Monday: Chest & Triceps ────────────────────────────────────────── - let mon = WorkoutProgram(name: "Chest & Triceps", emoji: "🏋️", colorIndex: 0, sections: [ + let mon = WorkoutProgram(name: "Chest & Triceps", emoji: "figure.strengthtraining.traditional", colorIndex: 0, sections: [ warmUp, WorkoutSection(name: "Chest", exercises: [ // Incline first — 70 / 65 / 45 / 35 lbs × 12 reps (per-side dumbbell) @@ -499,7 +562,7 @@ class WorkoutViewModel: ObservableObject { ]) // ── Tuesday: Back & Biceps ─────────────────────────────────────────── - let tue = WorkoutProgram(name: "Back & Biceps", emoji: "💪", colorIndex: 1, sections: [ + let tue = WorkoutProgram(name: "Back & Biceps", emoji: "figure.strengthtraining.functional", colorIndex: 1, sections: [ warmUp, WorkoutSection(name: "Back", exercises: [ Exercise(name: "Lat Pulldown", sfSymbol: "figure.pull.up", colorIndex: 1, sets: [ @@ -538,7 +601,7 @@ class WorkoutViewModel: ObservableObject { ]) // ── Wednesday: Shoulders, Triceps & Biceps ─────────────────────────── - let wed = WorkoutProgram(name: "Shoulders & Arms", emoji: "🔄", colorIndex: 2, sections: [ + let wed = WorkoutProgram(name: "Shoulders & Arms", emoji: "figure.arms.open", colorIndex: 2, sections: [ warmUp, WorkoutSection(name: "Shoulders", exercises: [ Exercise(name: "Reverse Fly", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)), @@ -557,7 +620,7 @@ class WorkoutViewModel: ObservableObject { ]) // ── Thursday: Legs & Chest ─────────────────────────────────────────── - let thu = WorkoutProgram(name: "Legs & Chest", emoji: "🦵", colorIndex: 3, sections: [ + let thu = WorkoutProgram(name: "Legs & Chest", emoji: "figure.run", colorIndex: 3, sections: [ warmUp, WorkoutSection(name: "Legs", exercises: [ Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 2, sets: s(3)), @@ -574,7 +637,7 @@ class WorkoutViewModel: ObservableObject { ]) // ── Friday: Full Body ──────────────────────────────────────────────── - let fri = WorkoutProgram(name: "Full Body", emoji: "⚡", colorIndex: 0, sections: [ + let fri = WorkoutProgram(name: "Full Body", emoji: "bolt.fill", colorIndex: 0, sections: [ warmUp, WorkoutSection(name: "Upper Body", exercises: [ Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)), @@ -591,7 +654,7 @@ class WorkoutViewModel: ObservableObject { ]) // ── Saturday: Full Body ────────────────────────────────────────────── - let sat = WorkoutProgram(name: "Full Body", emoji: "🏋️", colorIndex: 1, sections: [ + let sat = WorkoutProgram(name: "Full Body", emoji: "figure.highintensity.intervaltraining", colorIndex: 1, sections: [ warmUp, WorkoutSection(name: "Upper Body", exercises: [ Exercise(name: "Incline Bench Press",sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)), diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 646facf..d2932d9 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -58,20 +58,22 @@ enum TaskColor: String, Codable { class TaskViewModel: ObservableObject { @Published var tasks: [TaskItem] - private static let persistKey = "kisani.tasks.v2" + private let persistKey: String init() { - if let data = UserDefaults.standard.data(forKey: Self.persistKey), + let uid = AuthManager.shared.currentUser?.id ?? "shared" + self.persistKey = "kisani.tasks.v2.\(uid)" + if let data = UserDefaults.standard.data(forKey: persistKey), let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty { tasks = saved } else { - tasks = sampleTasks + tasks = [] } } private func save() { if let data = try? JSONEncoder().encode(tasks) { - UserDefaults.standard.set(data, forKey: Self.persistKey) + UserDefaults.standard.set(data, forKey: persistKey) } } @@ -183,37 +185,3 @@ class TaskViewModel: ObservableObject { } } -// MARK: - Sample Data -private let sampleTasks: [TaskItem] = [ - .init(title: "Azure exam preparation", - dueDate: cal.date(byAdding: .day, value: -7, to: Date()), - quadrant: .urgent, category: .custom, taskColor: .orange), - .init(title: "Leona's Birthday", - dueDate: md(6, 17), quadrant: .urgent, category: .birthday, taskColor: .orange, isRecurring: true), - .init(title: "Provoc.ug Domain Expiry", - dueDate: md(7, 3), quadrant: .urgent, category: .domain, taskColor: .blue), - .init(title: "Farouk's Birthday", - dueDate: md(11, 8), quadrant: .delegate_, category: .birthday, taskColor: .green, isRecurring: true), - .init(title: "Enoch's Birthday", - dueDate: md(2, 23, 2027), quadrant: .schedule, category: .annual, taskColor: .yellow, isRecurring: true), - .init(title: "Dad's birthday", - dueDate: md(6, 8), quadrant: .delegate_, category: .birthday, taskColor: .blue, isRecurring: true), - .init(title: "Moms bd", - dueDate: md(2, 15, 2027), quadrant: .delegate_, category: .birthday, taskColor: .blue, isRecurring: true), - .init(title: "Amooti's birthday", - dueDate: md(6, 26), quadrant: .urgent, category: .birthday, taskColor: .orange, isRecurring: true), - .init(title: "Provoc hosting", - dueDate: md(11, 25, 2027), quadrant: .delegate_, category: .domain, taskColor: .blue), - .init(title: "DV lottery preps", - dueDate: md(9, 30, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), - .init(title: "Catch up Umutoni", - dueDate: md(11, 15, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), - .init(title: "Siraj wedding preps", - dueDate: md(8, 24, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), -] - -private let cal = Calendar.current -private func md(_ month: Int, _ day: Int, _ year: Int = 2026) -> Date { - var c = DateComponents(); c.year = year; c.month = month; c.day = day - return cal.date(from: c) ?? Date() -} diff --git a/KisaniCal/Views/ProfileSetupView.swift b/KisaniCal/Views/ProfileSetupView.swift new file mode 100644 index 0000000..1572e36 --- /dev/null +++ b/KisaniCal/Views/ProfileSetupView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct ProfileSetupView: View { + @ObservedObject private var auth = AuthManager.shared + @Environment(\.colorScheme) private var cs + @State private var name = "" + @FocusState private var focused: Bool + + var body: some View { + ZStack { + AppColors.background(cs).ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 24) { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(AppColors.accentSoft) + .frame(width: 88, height: 88) + Image(systemName: "person.fill") + .font(.system(size: 36, weight: .light)) + .foregroundColor(AppColors.accent) + } + + VStack(spacing: 8) { + Text("What's your name?") + .font(AppFonts.sans(26, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text("This is how you'll appear in the app.") + .font(AppFonts.sans(15)) + .foregroundColor(AppColors.text2(cs)) + .multilineTextAlignment(.center) + } + } + + Spacer() + + VStack(spacing: 16) { + TextField("Your name", text: $name) + .font(AppFonts.sans(17)) + .foregroundColor(AppColors.text(cs)) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemGray6)) + ) + .padding(.horizontal, 28) + .focused($focused) + .submitLabel(.done) + .onSubmit { save() } + + Button(action: save) { + Text("Continue") + .font(AppFonts.sans(17, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(name.trimmingCharacters(in: .whitespaces).isEmpty + ? AppColors.accent.opacity(0.4) + : AppColors.accent) + ) + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 28) + + Button("Skip for now") { + auth.updateDisplayName("User") + } + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.bottom, 56) + } + } + .onAppear { focused = true } + } + + private func save() { + auth.updateDisplayName(name) + } +}