diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 811824f..67c351a 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -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) } } diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index 685650f..99c0034 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -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