From a69b54ff193463d78ac4e9cedc48dd53ad1c877c Mon Sep 17 00:00:00 2001 From: kutesir Date: Mon, 6 Jul 2026 23:54:25 +0300 Subject: [PATCH] Workout streak banner: swipeable week / year / vs-last-week pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single lifetime-tally banner (which used a meaningless streak%7 tick bar) with a 3-page paged TabView: 1. This week — count + 7 per-day ticks for the current week 2. This year — count + 12 month ticks, with all-time total underneath 3. vs last week — this week's count and the delta (▲ more / ▼ fewer / same) Adds WorkoutViewModel breakdowns: workoutsThisWeek/LastWeek/ThisYear, currentWeekDays (7 flags), currentYearMonths (12 flags). (Not build-verified — sim runtime removed to reclaim disk; uses only APIs already present in the original banner.) Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Models/ExerciseModels.swift | 52 +++++++++++++ KisaniCal/Views/WorkoutView.swift | 106 +++++++++++++++++++++----- 2 files changed, 140 insertions(+), 18 deletions(-) diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index d1a171d..0031ad8 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -262,6 +262,58 @@ class WorkoutViewModel: ObservableObject { Set(workoutDates).count } + // MARK: - Streak-banner breakdowns (this week / this year / comparison) + + /// Workout days in the week `offset` weeks from now (0 = current, -1 = last). + private func workoutCount(inWeekOffset offset: Int) -> Int { + let cal = Calendar.current + guard let ref = cal.date(byAdding: .weekOfYear, value: offset, to: Date()), + let wk = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 } + let set = Set(workoutDates) + return (0..<7).reduce(0) { acc, i in + guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return acc } + return acc + (set.contains(iso.string(from: d)) ? 1 : 0) + } + } + + var workoutsThisWeek: Int { workoutCount(inWeekOffset: 0) } + var workoutsLastWeek: Int { workoutCount(inWeekOffset: -1) } + + var workoutsThisYear: Int { + let cal = Calendar.current + let year = cal.component(.year, from: Date()) + return Set(workoutDates).filter { + guard let d = iso.date(from: $0) else { return false } + return cal.component(.year, from: d) == year + }.count + } + + /// 7 flags for the current week (calendar week order) — true if worked out that day. + var currentWeekDays: [Bool] { + let cal = Calendar.current + guard let wk = cal.dateInterval(of: .weekOfYear, for: Date()) else { + return Array(repeating: false, count: 7) + } + let set = Set(workoutDates) + return (0..<7).map { i in + guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return false } + return set.contains(iso.string(from: d)) + } + } + + /// 12 flags for the current year — true if ≥1 workout that month. + var currentYearMonths: [Bool] { + let cal = Calendar.current + let year = cal.component(.year, from: Date()) + var months = Array(repeating: false, count: 12) + for s in workoutDates { + guard let d = iso.date(from: s), cal.component(.year, from: d) == year else { continue } + let m = cal.component(.month, from: d) - 1 + if (0..<12).contains(m) { months[m] = true } + } + return months + } + private let iso: DateFormatter = { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f }() diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 6b36c87..a0b8f12 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -146,7 +146,7 @@ struct WorkoutView: View { .moveDisabled(true) // ── Streak ── - StreakBannerView(streak: vm.streakDays) + StreakBannerView(vm: vm) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14)) @@ -908,28 +908,28 @@ struct RestDayCard: View { } // MARK: - Streak Banner +// Swipeable: this week → this year → this-week-vs-last-week. struct StreakBannerView: View { @Environment(\.colorScheme) private var cs - let streak: Int + @ObservedObject var vm: WorkoutViewModel + @State private var page = 0 var body: some View { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 2) { - Text("\(streak)") - .font(AppFonts.mono(28, weight: .bold)) - .foregroundColor(AppColors.text(cs)) - Text("day streak") - .font(AppFonts.sans(11)) - .foregroundColor(AppColors.text3(cs)) + VStack(spacing: 8) { + TabView(selection: $page) { + weekPage.tag(0) + yearPage.tag(1) + comparePage.tag(2) } - Spacer() - // 7-day tick bar — current week progress - HStack(spacing: 3) { - ForEach(0..<7, id: \.self) { i in - let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7) - RoundedRectangle(cornerRadius: 1.5) - .fill(filled ? AppColors.accent : AppColors.borderHi(cs)) - .frame(width: 18, height: 3) + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 50) + + // Page dots + HStack(spacing: 5) { + ForEach(0..<3, id: \.self) { i in + Circle() + .fill(i == page ? AppColors.accent : AppColors.borderHi(cs)) + .frame(width: 5, height: 5) } } } @@ -937,6 +937,76 @@ struct StreakBannerView: View { .padding(.vertical, 13) .cardStyle() } + + // Page 1 — this week (per-day ticks) + private var weekPage: some View { + HStack(spacing: 0) { + statBlock(value: "\(vm.workoutsThisWeek)", label: "this week") + Spacer() + HStack(spacing: 3) { + ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in + RoundedRectangle(cornerRadius: 1.5) + .fill(done ? AppColors.accent : AppColors.borderHi(cs)) + .frame(width: 18, height: 3) + } + } + } + } + + // Page 2 — this year (month ticks) + all-time total + private var yearPage: some View { + HStack(spacing: 0) { + statBlock(value: "\(vm.workoutsThisYear)", label: "this year") + Spacer() + VStack(alignment: .trailing, spacing: 5) { + HStack(spacing: 2) { + ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in + RoundedRectangle(cornerRadius: 1) + .fill(active ? AppColors.accent : AppColors.borderHi(cs)) + .frame(width: 9, height: 3) + } + } + Text("\(vm.streakDays) all-time") + .font(AppFonts.mono(9)) + .foregroundColor(AppColors.text3(cs)) + } + } + } + + // Page 3 — this week vs last week + private var comparePage: some View { + let delta = vm.workoutsThisWeek - vm.workoutsLastWeek + let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs)) + return HStack(spacing: 0) { + statBlock(value: "\(vm.workoutsThisWeek)", label: "this week") + Spacer() + VStack(alignment: .trailing, spacing: 3) { + HStack(spacing: 4) { + Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal")) + .font(.system(size: 10, weight: .bold)) + Text(delta == 0 + ? "same as last week" + : "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week") + .font(AppFonts.mono(10, weight: .semibold)) + } + .foregroundColor(deltaColor) + Text("last week: \(vm.workoutsLastWeek)") + .font(AppFonts.mono(9)) + .foregroundColor(AppColors.text3(cs)) + } + } + } + + private func statBlock(value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(AppFonts.mono(28, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text(label) + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + } + } } // MARK: - Exercise Card