Workout streak banner: swipeable week / year / vs-last-week pages

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 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-06 23:54:25 +03:00
committed by kutesir
parent 97cc5589cc
commit a69b54ff19
2 changed files with 140 additions and 18 deletions

View File

@@ -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
}()

View File

@@ -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