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:
@@ -131,6 +131,7 @@ struct ContentView: View {
|
||||
|
||||
SettingsView()
|
||||
.environmentObject(workoutVM)
|
||||
.environmentObject(taskVM)
|
||||
.tag(4)
|
||||
.tabBarInset()
|
||||
}
|
||||
@@ -168,7 +169,7 @@ struct ContentView: View {
|
||||
case 1: CalendarView().environmentObject(taskVM).environmentObject(workoutVM)
|
||||
case 2: MatrixView().environmentObject(taskVM)
|
||||
case 3: WorkoutView().environmentObject(workoutVM)
|
||||
case 4: SettingsView().environmentObject(workoutVM)
|
||||
case 4: SettingsView().environmentObject(workoutVM).environmentObject(taskVM)
|
||||
default: TodayView().environmentObject(taskVM).environmentObject(workoutVM)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user