Add profile stats sheet with task and workout analytics

Tapping the profile card opens a full stats sheet showing:
Tasks — completed this week, overdue count, done rate, 7-day bar
chart, open count by Eisenhower quadrant.
Workouts — streak, sets today, today % progress, weekly schedule
grid (dumbbell = scheduled, checkmark = done), active program summary.
This commit is contained in:
kutesir
2026-05-29 12:06:34 +03:00
parent 27890825d4
commit 875f562801
2 changed files with 278 additions and 29 deletions

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct SettingsView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@@ -21,6 +22,7 @@ struct SettingsView: View {
@AppStorage("iCloudCalSync") private var iCloudCalSync = true
// Sheet presentation
@State private var showProfile = false
@State private var showTabBar = false
@State private var showAppearance = false
@State private var showDateTime = false
@@ -52,37 +54,44 @@ struct SettingsView: View {
.padding(.top, 10).padding(.bottom, 4)
// Profile
HStack(spacing: 12) {
ZStack {
Circle().fill(AppColors.accentSoft)
Text("RK")
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(AppColors.accent)
}
.frame(width: 44, height: 44)
.overlay(Circle().stroke(AppColors.accent.opacity(0.18), lineWidth: 1.5))
VStack(alignment: .leading, spacing: 5) {
Text("Robin Kutesa")
.font(AppFonts.sans(14, weight: .semibold))
.foregroundColor(AppColors.text(cs))
HStack(spacing: 5) {
AppBadge(
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d streak 🔥" : "No streak yet",
bg: AppColors.accentSoft, fg: AppColors.accent
)
AppBadge(
text: weightUnit == 0 ? "kg" : "lbs",
bg: AppColors.surface3(cs), fg: AppColors.text2(cs)
)
Button { showProfile = true } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(AppColors.accentSoft)
Text("RK")
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(AppColors.accent)
}
.frame(width: 44, height: 44)
.overlay(Circle().stroke(AppColors.accent.opacity(0.18), lineWidth: 1.5))
VStack(alignment: .leading, spacing: 5) {
Text("Robin Kutesa")
.font(AppFonts.sans(14, weight: .semibold))
.foregroundColor(AppColors.text(cs))
HStack(spacing: 5) {
AppBadge(
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d streak 🔥" : "No streak yet",
bg: AppColors.accentSoft, fg: AppColors.accent
)
AppBadge(
text: "\(taskVM.tasks.filter { $0.isComplete }.count) done",
bg: AppColors.greenSoft, fg: AppColors.green
)
AppBadge(
text: weightUnit == 0 ? "kg" : "lbs",
bg: AppColors.surface3(cs), fg: AppColors.text2(cs)
)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 10))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 10))
.foregroundColor(AppColors.text3(cs))
.padding(14).cardStyle()
}
.padding(14).cardStyle()
.buttonStyle(.plain)
.padding(.horizontal, 14).padding(.top, 10).padding(.bottom, 14)
// CUSTOMIZE
@@ -119,6 +128,7 @@ struct SettingsView: View {
}
}
}
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showAppearance) { AppearanceSheet(mode: $appearanceMode).presentationDetents([.height(290)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showDateTime) { DateTimeSheet(format: $dateFormat, mondayFirst: $firstDayMonday).presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
@@ -741,6 +751,244 @@ private struct FollowSheet: View {
}
}
// MARK: - Profile Stats Sheet
private struct ProfileStatsSheet: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var cs
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
// Task metrics
private var totalTasks: Int { taskVM.tasks.count }
private var doneTasks: Int { taskVM.tasks.filter { $0.isComplete }.count }
private var overdueCount: Int { taskVM.overdueTasks.count }
private var completionRate: Int {
guard totalTasks > 0 else { return 0 }
return Int(Double(doneTasks) / Double(totalTasks) * 100)
}
private var completedThisWeek: Int {
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
}
private var urgentOpen: Int {
taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count
}
// 7-day task activity
private var last7: [(Date, Int)] {
let cal = Calendar.current
return (0..<7).reversed().map { ago -> (Date, Int) in
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
let next = cal.date(byAdding: .day, value: 1, to: day)!
let n = taskVM.tasks.filter { t in
guard t.isComplete, let at = t.completedAt else { return false }
return at >= day && at < next
}.count
return (day, n)
}
}
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
// Workout metrics
private var scheduledDays: Int { workoutVM.schedule.count }
private let weekRow: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
private var todayWD: Int { Calendar.current.component(.weekday, from: Date()) }
var body: some View {
VStack(spacing: 0) {
HStack {
Spacer()
Button("Done") { dismiss() }
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 4)
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
// Avatar
VStack(spacing: 8) {
ZStack {
Circle().fill(AppColors.accentSoft)
Text("RK").font(AppFonts.sans(24, weight: .bold)).foregroundColor(AppColors.accent)
}
.frame(width: 68, height: 68)
.overlay(Circle().stroke(AppColors.accent.opacity(0.25), lineWidth: 2))
Text("Robin Kutesa")
.font(AppFonts.sans(18, weight: .bold)).foregroundColor(AppColors.text(cs))
Text("kisaniCAL.")
.font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity).padding(.vertical, 14)
// Tasks card
VStack(alignment: .leading, spacing: 12) {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 8) {
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
}
// 7-day bar chart
VStack(alignment: .leading, spacing: 6) {
Text("COMPLETED LAST 7 DAYS")
.font(AppFonts.mono(7.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(alignment: .bottom, spacing: 5) {
ForEach(last7, id: \.0) { day, count in
VStack(spacing: 3) {
if count > 0 {
Text("\(count)")
.font(AppFonts.mono(7.5, weight: .bold))
.foregroundColor(AppColors.green)
}
RoundedRectangle(cornerRadius: 3)
.fill(count > 0 ? AppColors.green : AppColors.borderHi(cs))
.frame(height: count > 0 ? max(8, CGFloat(count) / CGFloat(maxDay) * 44) : 8)
Text(dayLetter(day))
.font(AppFonts.mono(7.5, weight: .bold))
.foregroundColor(Calendar.current.isDateInToday(day) ? AppColors.accent : AppColors.text3(cs))
}
.frame(maxWidth: .infinity)
}
}
.frame(height: 62)
}
// Quadrant breakdown
HStack(spacing: 8) {
QuadStat(label: "Urgent", count: taskVM.tasks.filter { $0.quadrant == .urgent && !$0.isComplete }.count, color: AppColors.accent)
QuadStat(label: "Schedule", count: taskVM.tasks.filter { $0.quadrant == .schedule && !$0.isComplete }.count, color: AppColors.yellow)
QuadStat(label: "Delegate", count: taskVM.tasks.filter { $0.quadrant == .delegate_ && !$0.isComplete }.count, color: AppColors.blue)
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { $0.quadrant == .eliminate && !$0.isComplete }.count, color: AppColors.green)
}
}
.padding(14).background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
// Workouts card
VStack(alignment: .leading, spacing: 12) {
Text("WORKOUTS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 8) {
PStatCell(value: "\(workoutVM.streakDays)", label: "day streak", color: workoutVM.streakDays > 0 ? AppColors.accent : AppColors.text3(cs), suffix: workoutVM.streakDays > 0 ? "🔥" : "")
PStatCell(value: "\(workoutVM.doneSets)/\(workoutVM.totalSets)", label: "sets today", color: AppColors.blue)
PStatCell(value: "\(Int(workoutVM.progress * 100))%", label: "today", color: workoutVM.progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
// Weekly schedule grid
VStack(alignment: .leading, spacing: 6) {
Text("THIS WEEK'S SCHEDULE")
.font(AppFonts.mono(7.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 5) {
ForEach(weekRow, id: \.0) { wd, letter in
let scheduled = workoutVM.schedule[wd] != nil
let isToday = wd == todayWD
let done = isToday && workoutVM.progress >= 1
VStack(spacing: 4) {
ZStack {
Circle()
.fill(scheduled
? (done ? AppColors.greenSoft : (isToday ? AppColors.accentSoft : AppColors.surface2(cs)))
: AppColors.surface2(cs))
.frame(width: 30, height: 30)
if scheduled {
if done {
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundColor(AppColors.green)
} else {
Image(systemName: "dumbbell.fill")
.font(.system(size: 8))
.foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs))
}
}
}
.overlay(Circle().stroke(isToday ? AppColors.accent : Color.clear, lineWidth: 1.5))
Text(letter)
.font(AppFonts.mono(7.5, weight: .bold))
.foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs))
}
.frame(maxWidth: .infinity)
}
}
}
// Programs summary
HStack {
Image(systemName: "list.bullet.clipboard")
.font(.system(size: 13))
.foregroundColor(AppColors.blue)
.frame(width: 28, height: 28)
.background(AppColors.blueSoft)
.clipShape(RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 1) {
Text(workoutVM.workoutTitle)
.font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text("\(workoutVM.activeSections.count) sections · \(workoutVM.totalSets) sets total")
.font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Text("ACTIVE").font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(AppColors.accentSoft).cornerRadius(5)
}
.padding(10)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
}
.padding(14).background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
.padding(.horizontal, 14).padding(.bottom, 30)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private func dayLetter(_ date: Date) -> String {
let f = DateFormatter(); f.dateFormat = "E"
return String(f.string(from: date).prefix(1))
}
}
private struct PStatCell: View {
@Environment(\.colorScheme) private var cs
let value: String; let label: String; let color: Color; var suffix: String = ""
var body: some View {
VStack(spacing: 4) {
HStack(spacing: 3) {
Text(value).font(AppFonts.mono(17, weight: .bold)).foregroundColor(color)
if !suffix.isEmpty { Text(suffix).font(.system(size: 13)) }
}
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity).padding(.vertical, 12)
.background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
}
}
private struct QuadStat: View {
@Environment(\.colorScheme) private var cs
let label: String; let count: Int; let color: Color
var body: some View {
VStack(spacing: 3) {
Text("\(count)").font(AppFonts.mono(14, weight: .bold)).foregroundColor(count > 0 ? color : AppColors.text3(cs))
Text(label).font(AppFonts.mono(7, weight: .bold)).foregroundColor(AppColors.text3(cs)).lineLimit(1).minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity).padding(.vertical, 8)
.background(count > 0 ? color.opacity(0.08) : AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(count > 0 ? color.opacity(0.25) : AppColors.border(cs), lineWidth: 1))
}
}
// MARK: - About Sheet
private struct AboutSheet: View {
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs