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