Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
2 changed files with 140 additions and 18 deletions
Showing only changes of commit cac8cba38a - Show all commits

View File

@@ -262,6 +262,58 @@ class WorkoutViewModel: ObservableObject {
Set(workoutDates).count 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 = { private let iso: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}() }()

View File

@@ -146,7 +146,7 @@ struct WorkoutView: View {
.moveDisabled(true) .moveDisabled(true)
// Streak // Streak
StreakBannerView(streak: vm.streakDays) StreakBannerView(vm: vm)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14)) .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
@@ -908,28 +908,28 @@ struct RestDayCard: View {
} }
// MARK: - Streak Banner // MARK: - Streak Banner
// Swipeable: this week this year this-week-vs-last-week.
struct StreakBannerView: View { struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let streak: Int @ObservedObject var vm: WorkoutViewModel
@State private var page = 0
var body: some View { var body: some View {
HStack(spacing: 0) { VStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) { TabView(selection: $page) {
Text("\(streak)") weekPage.tag(0)
.font(AppFonts.mono(28, weight: .bold)) yearPage.tag(1)
.foregroundColor(AppColors.text(cs)) comparePage.tag(2)
Text("day streak")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
} }
Spacer() .tabViewStyle(.page(indexDisplayMode: .never))
// 7-day tick bar current week progress .frame(height: 50)
HStack(spacing: 3) {
ForEach(0..<7, id: \.self) { i in // Page dots
let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7) HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 1.5) ForEach(0..<3, id: \.self) { i in
.fill(filled ? AppColors.accent : AppColors.borderHi(cs)) Circle()
.frame(width: 18, height: 3) .fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 5, height: 5)
} }
} }
} }
@@ -937,6 +937,76 @@ struct StreakBannerView: View {
.padding(.vertical, 13) .padding(.vertical, 13)
.cardStyle() .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 // MARK: - Exercise Card