Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -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
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user