Sheet was 500pt tall with ~145pt of content, leaving ~355pt blank. Dropping to height(150) makes the sheet hug its content and sit flush above the keyboard like the reference design.
1054 lines
48 KiB
Swift
1054 lines
48 KiB
Swift
import SwiftUI
|
|
import EventKit
|
|
|
|
// MARK: - Calendar Store
|
|
@MainActor
|
|
final class CalendarStore: ObservableObject {
|
|
@Published var authStatus: EKAuthorizationStatus = .notDetermined
|
|
@Published var monthEvents: [EKEvent] = []
|
|
|
|
private let ekStore = EKEventStore()
|
|
private var changeToken: NSObjectProtocol?
|
|
private var fetchedMonth: Date?
|
|
|
|
init() {
|
|
authStatus = EKEventStore.authorizationStatus(for: .event)
|
|
changeToken = NotificationCenter.default.addObserver(
|
|
forName: .EKEventStoreChanged,
|
|
object: ekStore,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self, let m = self.fetchedMonth else { return }
|
|
self.fetchMonth(containing: m)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let t = changeToken { NotificationCenter.default.removeObserver(t) }
|
|
}
|
|
|
|
// MARK: - Permission
|
|
|
|
func requestAccess() {
|
|
if #available(iOS 17.0, *) {
|
|
ekStore.requestFullAccessToEvents { [weak self] granted, _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.authStatus = granted ? .fullAccess : .denied
|
|
if granted { self?.fetchMonth(containing: Date()) }
|
|
}
|
|
}
|
|
} else {
|
|
ekStore.requestAccess(to: .event) { [weak self] granted, _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.authStatus = granted ? .authorized : .denied
|
|
if granted { self?.fetchMonth(containing: Date()) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetching
|
|
|
|
func fetchMonth(containing date: Date) {
|
|
guard isAuthorized else { return }
|
|
let cal = Calendar.current
|
|
guard let interval = cal.dateInterval(of: .month, for: date) else { return }
|
|
// Extend a week each side to cover leading/trailing grid overflow days
|
|
let start = cal.date(byAdding: .weekOfYear, value: -1, to: interval.start) ?? interval.start
|
|
let end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end
|
|
let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: nil)
|
|
fetchedMonth = date
|
|
monthEvents = ekStore.events(matching: pred)
|
|
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
|
|
}
|
|
|
|
// Events for a specific day, derived from the cached month set
|
|
func events(for date: Date) -> [EKEvent] {
|
|
let cal = Calendar.current
|
|
let start = cal.startOfDay(for: date)
|
|
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
|
return monthEvents.filter { event in
|
|
guard let s = event.startDate, let e = event.endDate else { return false }
|
|
return s < end && e > start
|
|
}
|
|
}
|
|
|
|
var isAuthorized: Bool {
|
|
if #available(iOS 17.0, *) {
|
|
return authStatus == .authorized || authStatus == .fullAccess
|
|
}
|
|
return authStatus == .authorized
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar View Mode
|
|
enum CalendarViewMode: String, CaseIterable {
|
|
case list = "List"
|
|
case year = "Year"
|
|
case month = "Month"
|
|
case week = "Week"
|
|
case threeDay = "3 Day"
|
|
case day = "Day"
|
|
|
|
var sfSymbol: String {
|
|
switch self {
|
|
case .list: return "list.bullet.below.rectangle"
|
|
case .year: return "square.grid.4x3.fill"
|
|
case .month: return "square.grid.3x2.fill"
|
|
case .week: return "rectangle.split.3x1.fill"
|
|
case .threeDay: return "square.split.2x1.fill"
|
|
case .day: return "rectangle.portrait.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar View
|
|
struct CalendarView: View {
|
|
@Environment(\.colorScheme) var cs
|
|
@EnvironmentObject var taskVM: TaskViewModel
|
|
@EnvironmentObject var workoutVM: WorkoutViewModel
|
|
@StateObject private var calStore = CalendarStore()
|
|
@State private var selectedDate: Date = Date()
|
|
@State private var displayMonth: Date = {
|
|
let c = Calendar.current
|
|
return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date()
|
|
}()
|
|
@State private var viewMode: CalendarViewMode = .month
|
|
@State private var showAddTask = false
|
|
@State private var showCalendarConnect = false
|
|
@State private var showWorkoutSession = false
|
|
|
|
private let cal = Calendar.current
|
|
private let weekdays = ["S","M","T","W","T","F","S"]
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
AppColors.background(cs).ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
// ── Header ──
|
|
HStack {
|
|
Text(monthShortTitle)
|
|
.font(AppFonts.sans(26, weight: .bold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
HStack(spacing: 0) {
|
|
// View mode picker
|
|
Menu {
|
|
Picker(selection: $viewMode, label: EmptyView()) {
|
|
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
|
|
Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode)
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "slider.horizontal.3")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(AppColors.text(cs))
|
|
.frame(width: 46, height: 36)
|
|
}
|
|
.buttonStyle(.plain)
|
|
// Divider
|
|
Rectangle()
|
|
.fill(AppColors.border(cs))
|
|
.frame(width: 1, height: 18)
|
|
// More options
|
|
Menu {
|
|
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
|
|
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
|
|
Button { } label: { Label("Arrange Tasks", systemImage: "list.bullet.rectangle") }
|
|
Divider()
|
|
Button { showCalendarConnect = true } label: {
|
|
Label("Calendar Subscription", systemImage: calStore.isAuthorized ? "calendar.badge.checkmark" : "calendar.badge.plus")
|
|
}
|
|
Divider()
|
|
Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }.disabled(true)
|
|
Button { } label: { Label("Print", systemImage: "printer") }.disabled(true)
|
|
Button { } label: { Label("Select", systemImage: "checkmark.circle") }.disabled(true)
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(AppColors.text(cs))
|
|
.frame(width: 46, height: 36)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.background(AppColors.surface2(cs))
|
|
.clipShape(Capsule())
|
|
.overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1))
|
|
}
|
|
.padding(.horizontal, 18).padding(.top, 8)
|
|
|
|
calendarContent
|
|
}
|
|
|
|
FAB { showAddTask = true }.padding(.trailing, 20).padding(.bottom, 10)
|
|
}
|
|
.sheet(isPresented: $showAddTask) {
|
|
AddTaskSheet(prefilledDate: selectedDate)
|
|
.environmentObject(taskVM)
|
|
.presentationDetents([.height(150), .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.sheet(isPresented: $showCalendarConnect) {
|
|
CalendarConnectSheet(calStore: calStore)
|
|
.presentationDetents([.height(340)])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.sheet(isPresented: $showWorkoutSession) {
|
|
WorkoutSessionSheet()
|
|
.environmentObject(workoutVM)
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.onAppear {
|
|
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
|
|
}
|
|
.onChange(of: displayMonth) { newMonth in
|
|
if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) }
|
|
}
|
|
}
|
|
|
|
private var monthTitle: String {
|
|
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
|
|
return f.string(from: displayMonth)
|
|
}
|
|
|
|
private var monthShortTitle: String {
|
|
let f = DateFormatter(); f.dateFormat = "MMMM"
|
|
return f.string(from: displayMonth)
|
|
}
|
|
|
|
private func navigateMonth(_ offset: Int) {
|
|
withAnimation(KisaniSpring.snappy) {
|
|
displayMonth = cal.date(byAdding: .month, value: offset, to: displayMonth) ?? displayMonth
|
|
}
|
|
}
|
|
|
|
private func gridDays() -> [Date] {
|
|
guard
|
|
let interval = cal.dateInterval(of: .month, for: displayMonth),
|
|
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
|
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
|
else { return [] }
|
|
var days: [Date] = []; var cur = firstWeek.start
|
|
while cur < lastWeek.end {
|
|
days.append(cur)
|
|
cur = cal.date(byAdding: .day, value: 1, to: cur)!
|
|
}
|
|
return days
|
|
}
|
|
|
|
private func eventDots(for date: Date) -> [Color] {
|
|
var dots: [Color] = []
|
|
if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) }
|
|
if calStore.isAuthorized && dots.count < 3 {
|
|
let calDay = calStore.events(for: date)
|
|
if !calDay.isEmpty {
|
|
let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green
|
|
dots.append(calColor)
|
|
}
|
|
}
|
|
let taskDots = taskVM.tasks
|
|
.filter { !$0.isComplete }
|
|
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
|
.prefix(max(0, 3 - dots.count))
|
|
.map { $0.taskColor.color() }
|
|
dots.append(contentsOf: taskDots)
|
|
return dots
|
|
}
|
|
|
|
// MARK: - Content switcher
|
|
|
|
@ViewBuilder private var calendarContent: some View {
|
|
switch viewMode {
|
|
case .month: monthView
|
|
case .list: listView
|
|
case .year: yearView
|
|
case .week: weekView
|
|
case .threeDay: threeDayView
|
|
case .day: dayView
|
|
}
|
|
}
|
|
|
|
// MARK: - Month
|
|
|
|
private var monthView: some View {
|
|
Group {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
|
|
Text(d).font(AppFonts.mono(9, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3)
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) {
|
|
ForEach(gridDays(), id: \.self) { date in
|
|
DayCell(
|
|
date: date,
|
|
isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month),
|
|
isToday: cal.isDateInToday(date),
|
|
isSelected: cal.isDate(date, inSameDayAs: selectedDate),
|
|
dots: eventDots(for: date)
|
|
)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
if v.translation.width < -40 { navigateMonth(1) }
|
|
else if v.translation.width > 40 { navigateMonth(-1) }
|
|
})
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
DayStripView(
|
|
date: selectedDate,
|
|
tasks: taskVM.tasks.filter { !$0.isComplete },
|
|
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
|
workout: workoutVM.program(for: selectedDate),
|
|
onWorkoutTap: {
|
|
if let w = workoutVM.program(for: selectedDate),
|
|
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
|
|
showWorkoutSession = true
|
|
}
|
|
)
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - List
|
|
|
|
private var listView: some View {
|
|
VStack(spacing: 0) {
|
|
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
|
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: 0) {
|
|
let dayTasks = taskVM.tasks.filter { t in
|
|
guard !t.isComplete, let d = t.dueDate else { return false }
|
|
return cal.isDate(d, inSameDayAs: selectedDate)
|
|
}
|
|
let events = calStore.isAuthorized ? calStore.events(for: selectedDate) : []
|
|
if dayTasks.isEmpty && events.isEmpty {
|
|
cvEmptyDay
|
|
} else {
|
|
ForEach(events, id: \.eventIdentifier) { ev in
|
|
HStack(spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(Color(cgColor: ev.calendar.cgColor))
|
|
.frame(width: 3, height: 38)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(ev.title ?? "Event")
|
|
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
|
|
if let s = ev.startDate {
|
|
Text(cvTimeFmt.string(from: s)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 44)
|
|
}
|
|
ForEach(dayTasks) { task in
|
|
HStack(spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.strokeBorder(task.taskColor.color(), lineWidth: 1.5)
|
|
.frame(width: 18, height: 18)
|
|
Text(task.title).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
if let d = task.dueDate {
|
|
let h = cal.component(.hour, from: d)
|
|
let m = cal.component(.minute, from: d)
|
|
if h != 0 || m != 0 {
|
|
Text(cvTimeFmt.string(from: d)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 44)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Year
|
|
|
|
private var yearView: some View {
|
|
let year = cal.component(.year, from: displayMonth)
|
|
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
|
|
return ScrollView(showsIndicators: false) {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
|
|
ForEach(months, id: \.self) { m in
|
|
cvMiniMonth(m)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private func cvMiniMonth(_ month: Date) -> some View {
|
|
let mDays = cvMiniGridDays(month)
|
|
return VStack(spacing: 3) {
|
|
Text(cvMonthFmt.string(from: month))
|
|
.font(AppFonts.sans(10, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 1) {
|
|
ForEach(Array(mDays.enumerated()), id: \.offset) { _, date in
|
|
let isToday = cal.isDateInToday(date)
|
|
let inMon = cal.isDate(date, equalTo: month, toGranularity: .month)
|
|
ZStack {
|
|
if isToday { Circle().fill(AppColors.accent).frame(width: 14, height: 14) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(7, weight: isToday ? .bold : .regular))
|
|
.foregroundColor(isToday ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
|
|
}
|
|
.frame(height: 14)
|
|
.onTapGesture {
|
|
withAnimation(KisaniSpring.micro) {
|
|
selectedDate = date
|
|
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
|
|
viewMode = .month
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cvMiniGridDays(_ month: Date) -> [Date] {
|
|
guard let interval = cal.dateInterval(of: .month, for: month),
|
|
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
|
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
|
else { return [] }
|
|
var days: [Date] = []; var cur = firstWeek.start
|
|
while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! }
|
|
return days
|
|
}
|
|
|
|
// MARK: - Week
|
|
|
|
private var weekView: some View {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
|
|
Text(d).font(AppFonts.mono(7, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal, 6).padding(.top, 10)
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
|
|
ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
|
|
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
|
|
.foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
|
|
}
|
|
.frame(height: 22)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
if v.translation.width < -40 { navigateMonth(1) }
|
|
else if v.translation.width > 40 { navigateMonth(-1) }
|
|
})
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Rectangle().fill(AppColors.border(cs)).frame(width: 1)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: 0) {
|
|
ForEach(cvWeekDays(), id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
let dots = eventDots(for: date)
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 4) {
|
|
Text(cvDayFmt.string(from: date))
|
|
.font(AppFonts.mono(9, weight: .semibold))
|
|
.foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
|
|
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
|
|
}
|
|
if dots.isEmpty {
|
|
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
|
|
} else {
|
|
ForEach(dots.indices, id: \.self) { i in
|
|
RoundedRectangle(cornerRadius: 2).fill(dots[i].opacity(0.5))
|
|
.frame(maxWidth: .infinity).frame(height: 7)
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(isSel ? AppColors.accentSoft : Color.clear)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
Divider().background(AppColors.border(cs))
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - 3 Day
|
|
|
|
private var threeDayView: some View {
|
|
let days = cvAdjacentDays(3)
|
|
return VStack(spacing: 0) {
|
|
cvDayColumnHeader(days)
|
|
Divider().background(AppColors.border(cs))
|
|
ScrollView(showsIndicators: false) {
|
|
cvTimeGrid(days: days).padding(.bottom, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Day
|
|
|
|
private var dayView: some View {
|
|
VStack(spacing: 0) {
|
|
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
|
|
Divider().background(AppColors.border(cs))
|
|
ScrollView(showsIndicators: false) {
|
|
cvTimeGrid(days: [selectedDate]).padding(.bottom, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Time grid
|
|
|
|
private let cvStartH = 6
|
|
private let cvEndH = 22
|
|
private let cvHourH: CGFloat = 52
|
|
|
|
private func cvDayColumnHeader(_ days: [Date]) -> some View {
|
|
HStack(spacing: 0) {
|
|
Color.clear.frame(width: 36)
|
|
ForEach(days, id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
VStack(spacing: 2) {
|
|
Text(cvDayFmt.string(from: date).uppercased())
|
|
.font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs))
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(14, weight: isTod ? .bold : .medium))
|
|
.foregroundColor(isTod ? .white : AppColors.text(cs))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 10).padding(.horizontal, 8)
|
|
}
|
|
|
|
private func cvTimeGrid(days: [Date]) -> some View {
|
|
let totalH = CGFloat(cvEndH - cvStartH) * cvHourH
|
|
return ZStack(alignment: .topLeading) {
|
|
VStack(spacing: 0) {
|
|
ForEach(cvStartH...cvEndH, id: \.self) { h in
|
|
HStack(spacing: 0) {
|
|
Text(h == 12 ? "12" : h > 12 ? "\(h - 12)" : "\(h)")
|
|
.font(AppFonts.mono(8.5)).foregroundColor(AppColors.text3(cs))
|
|
.frame(width: 28, alignment: .trailing)
|
|
Rectangle().fill(AppColors.border(cs)).frame(height: 0.5).padding(.leading, 8)
|
|
}
|
|
.frame(height: cvHourH, alignment: .top)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
|
|
HStack(alignment: .top, spacing: 2) {
|
|
Color.clear.frame(width: 36)
|
|
ForEach(days, id: \.self) { date in
|
|
cvTimeColumn(date: date, totalH: totalH)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.frame(height: totalH)
|
|
}
|
|
}
|
|
|
|
private func cvTimeColumn(date: Date, totalH: CGFloat) -> some View {
|
|
ZStack(alignment: .topLeading) {
|
|
Color.clear.frame(maxWidth: .infinity).frame(height: totalH)
|
|
ForEach(cvTimeItems(date)) { item in
|
|
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
|
|
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(item.color.opacity(0.28))
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(item.color).frame(width: 3)
|
|
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
Text(item.title)
|
|
.font(AppFonts.sans(9, weight: .medium))
|
|
.foregroundColor(item.color)
|
|
.padding(.horizontal, 6).padding(.top, 4)
|
|
.lineLimit(2)
|
|
}
|
|
.frame(maxWidth: .infinity).frame(height: dur)
|
|
.padding(.trailing, 2)
|
|
.offset(y: top)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// MARK: - Shared helpers
|
|
|
|
private var cvWeekStrip: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(cvWeekDays(), id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
VStack(spacing: 3) {
|
|
Text(cvDayFmt.string(from: date))
|
|
.font(AppFonts.mono(9, weight: .semibold)).foregroundColor(AppColors.text3(cs))
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
|
|
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 28, height: 28) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(13, weight: isTod ? .bold : .regular))
|
|
.foregroundColor(isTod ? .white : AppColors.text(cs))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
let delta = v.translation.width < -40 ? 7 : v.translation.width > 40 ? -7 : 0
|
|
guard delta != 0 else { return }
|
|
withAnimation(KisaniSpring.snappy) {
|
|
selectedDate = cal.date(byAdding: .day, value: delta, to: selectedDate) ?? selectedDate
|
|
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
|
|
}
|
|
})
|
|
}
|
|
|
|
private var cvEmptyDay: some View {
|
|
VStack(spacing: 4) {
|
|
Text("Nothing scheduled").font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text2(cs))
|
|
Text("Tap + to add a task").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 48)
|
|
}
|
|
|
|
private func cvWeekDays() -> [Date] {
|
|
let wd = cal.component(.weekday, from: selectedDate)
|
|
let start = cal.date(byAdding: .day, value: -(wd - 1), to: selectedDate) ?? selectedDate
|
|
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
|
|
}
|
|
|
|
private func cvAdjacentDays(_ count: Int) -> [Date] {
|
|
let half = count / 2
|
|
return (-half...(count - half - 1)).compactMap { cal.date(byAdding: .day, value: $0, to: selectedDate) }
|
|
}
|
|
|
|
private func cvTimeItems(_ date: Date) -> [CVTimeItem] {
|
|
var items: [CVTimeItem] = []
|
|
for task in taskVM.tasks where !task.isComplete {
|
|
guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue }
|
|
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
|
guard h != 0 || m != 0 else { continue }
|
|
items.append(CVTimeItem(title: task.title, color: task.taskColor.color(), startMin: h * 60 + m, endMin: h * 60 + m + 30))
|
|
}
|
|
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
|
|
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
|
|
let sh = cal.component(.hour, from: s), sm = cal.component(.minute, from: s)
|
|
let eh = cal.component(.hour, from: e), em = cal.component(.minute, from: e)
|
|
items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor),
|
|
startMin: sh * 60 + sm, endMin: max(eh * 60 + em, sh * 60 + sm + 30)))
|
|
}
|
|
return items
|
|
}
|
|
|
|
private let cvTimeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }()
|
|
private let cvDayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE"; return f }()
|
|
private let cvMonthFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM"; return f }()
|
|
}
|
|
|
|
struct CVTimeItem: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let color: Color
|
|
let startMin: Int
|
|
let endMin: Int
|
|
|
|
init(title: String, color: Color, startMin: Int, endMin: Int) {
|
|
self.id = "\(title)-\(startMin)"
|
|
self.title = title
|
|
self.color = color
|
|
self.startMin = startMin
|
|
self.endMin = endMin
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar Connect Sheet
|
|
struct CalendarConnectSheet: View {
|
|
@ObservedObject var calStore: CalendarStore
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.dismiss) var dismiss
|
|
@Environment(\.openURL) var openURL
|
|
@State private var webCalURL = ""
|
|
@FocusState private var webCalFocused: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Calendars").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
|
|
|
|
Divider().background(AppColors.border(cs))
|
|
|
|
VStack(spacing: 0) {
|
|
// Local Calendar row
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "calendar")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(AppColors.accent)
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
Text(calStore.isAuthorized ? "Connected — events shown in day view" : "Tap to connect")
|
|
.font(AppFonts.sans(11)).foregroundColor(calStore.isAuthorized ? AppColors.green : AppColors.text3(cs))
|
|
}
|
|
Spacer()
|
|
if calStore.isAuthorized {
|
|
Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18))
|
|
} else {
|
|
Button("Connect") {
|
|
calStore.requestAccess()
|
|
}
|
|
.font(AppFonts.mono(9.5, weight: .bold))
|
|
.foregroundColor(AppColors.accent)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(AppColors.accentSoft)
|
|
.clipShape(Capsule())
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 66)
|
|
|
|
// WebCal row
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "globe")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(AppColors.blue)
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Web Calendar (WebCal)").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
Text("Subscribe to an iCal / .ics feed").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16).padding(.top, 12)
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("webcal:// or https://…", text: $webCalURL)
|
|
.font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs))
|
|
.focused($webCalFocused).padding(10)
|
|
.background(AppColors.surface2(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(webCalFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
|
.autocorrectionDisabled().textInputAutocapitalization(.never)
|
|
|
|
Button {
|
|
var urlString = webCalURL.trimmingCharacters(in: .whitespaces)
|
|
if urlString.hasPrefix("https://") { urlString = urlString.replacingOccurrences(of: "https://", with: "webcal://") }
|
|
if let url = URL(string: urlString) { openURL(url) }
|
|
} label: {
|
|
Text("Subscribe").font(AppFonts.sans(12, weight: .semibold)).foregroundColor(.white)
|
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
|
.background(!webCalURL.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
}
|
|
.buttonStyle(.plain).disabled(webCalURL.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.padding(.horizontal, 16).padding(.bottom, 12)
|
|
}
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 66)
|
|
|
|
// Google Calendar row
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "g.circle.fill")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(Color(red: 0.26, green: 0.52, blue: 0.96))
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Google Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
Text("Open in Google Calendar app").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
Spacer()
|
|
Image(systemName: "arrow.up.right").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if let url = URL(string: "googlegmail://") {
|
|
openURL(url)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20).padding(.top, 16)
|
|
|
|
Spacer()
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
}
|
|
}
|
|
|
|
// MARK: - Day Cell
|
|
struct DayCell: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
|
|
private let cal = Calendar.current
|
|
var dayNum: String { "\(cal.component(.day, from: date))" }
|
|
var weekNum: Int { cal.component(.weekOfYear, from: date) }
|
|
var isSunday: Bool { cal.component(.weekday, from: date) == 1 }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 2) {
|
|
ZStack {
|
|
if isToday { Circle().fill(AppColors.accent).frame(width: 26, height: 26) }
|
|
else if isSelected { Circle().fill(AppColors.accentSoft).frame(width: 26, height: 26) }
|
|
Text(dayNum)
|
|
.font(AppFonts.sans(12.5, weight: isToday ? .bold : (isCurrentMonth ? .medium : .regular)))
|
|
.foregroundColor(isToday ? .white : !isCurrentMonth ? AppColors.text3(cs) : AppColors.text(cs))
|
|
}
|
|
.frame(width: 26, height: 26)
|
|
.overlay(alignment: .topLeading) {
|
|
if isSunday {
|
|
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
|
|
}
|
|
}
|
|
HStack(spacing: 2) {
|
|
ForEach(dots.indices, id: \.self) { i in
|
|
Circle().fill(dots[i]).frame(width: 3.5, height: 3.5)
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
.frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Day Strip
|
|
struct DayStripView: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
let date: Date
|
|
let tasks: [TaskItem]
|
|
let calEvents: [EKEvent]
|
|
var workout: WorkoutProgram? = nil
|
|
var onWorkoutTap: (() -> Void)? = nil
|
|
|
|
private let cal = Calendar.current
|
|
private let fmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE d"; return f }()
|
|
private let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }()
|
|
|
|
var dayTasks: [TaskItem] {
|
|
tasks.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
|
}
|
|
|
|
var isEmpty: Bool { dayTasks.isEmpty && calEvents.isEmpty && workout == nil }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Text(fmt.string(from: date).uppercased())
|
|
.font(AppFonts.mono(10.5, weight: .bold))
|
|
.foregroundColor(AppColors.accent)
|
|
let total = dayTasks.count + calEvents.count + (workout != nil ? 1 : 0)
|
|
Text(isEmpty ? "Nothing scheduled" : "\(total) event\(total == 1 ? "" : "s")")
|
|
.font(AppFonts.sans(10.5))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
|
|
if isEmpty {
|
|
VStack(spacing: 2) {
|
|
Text("You have a free day").font(AppFonts.sans(12.5, weight: .medium)).foregroundColor(AppColors.text2(cs))
|
|
Text("Take it easy").font(AppFonts.sans(10.5)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
|
} else {
|
|
// Workout block
|
|
if let w = workout {
|
|
CalendarWorkoutBlock(program: w, onTap: onWorkoutTap)
|
|
}
|
|
|
|
// Calendar events
|
|
ForEach(calEvents, id: \.eventIdentifier) { event in
|
|
HStack(spacing: 8) {
|
|
Circle().fill(Color(cgColor: event.calendar.cgColor)).frame(width: 6, height: 6)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(event.title ?? "Event")
|
|
.font(AppFonts.sans(12, weight: .medium))
|
|
.foregroundColor(AppColors.text(cs))
|
|
if let start = event.startDate {
|
|
Text(timeFmt.string(from: start))
|
|
.font(AppFonts.mono(9.5))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
Spacer()
|
|
TagChip(text: "Cal", color: Color(cgColor: event.calendar.cgColor), bg: Color(cgColor: event.calendar.cgColor).opacity(0.12))
|
|
}
|
|
.padding(.vertical, 5).padding(.horizontal, 10)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
|
|
}
|
|
|
|
// Task items
|
|
ForEach(dayTasks) { task in
|
|
HStack(spacing: 8) {
|
|
Circle().fill(task.taskColor.color()).frame(width: 6, height: 6)
|
|
Text(task.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
TagChip(text: task.category.rawValue, color: task.taskColor.color(), bg: task.taskColor.soft())
|
|
}
|
|
.padding(.vertical, 5).padding(.horizontal, 10)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar Workout Block
|
|
struct CalendarWorkoutBlock: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
let program: WorkoutProgram
|
|
var onTap: (() -> Void)? = nil
|
|
@State private var isExpanded = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// ── Header row — taps open session ──
|
|
HStack(spacing: 8) {
|
|
WorkoutSectionBar(count: program.sections.count)
|
|
.frame(width: 30, height: 30)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(program.name)
|
|
.font(AppFonts.sans(12, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Text("\(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s") · \(program.sections.count) section\(program.sections.count == 1 ? "" : "s")")
|
|
.font(AppFonts.mono(9))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
Spacer()
|
|
HStack(spacing: 6) {
|
|
TagChip(text: "Workout", color: AppColors.blue, bg: AppColors.blueSoft)
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(AppColors.blue.opacity(0.45))
|
|
}
|
|
}
|
|
.padding(.horizontal, 10).padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onTap?() }
|
|
|
|
// ── Expand toggle ──
|
|
if !program.sections.isEmpty {
|
|
Button {
|
|
withAnimation(KisaniSpring.snappy) { isExpanded.toggle() }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: 7, weight: .bold))
|
|
Text(isExpanded ? "Less" : "Show \(program.sections.count) sections")
|
|
.font(AppFonts.mono(8.5, weight: .bold))
|
|
}
|
|
.foregroundColor(AppColors.blue.opacity(0.55))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// ── Section breakdown (expanded) ──
|
|
if isExpanded {
|
|
Divider().background(AppColors.blue.opacity(0.12))
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(program.sections) { section in
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Text(section.name.uppercased())
|
|
.font(AppFonts.mono(8, weight: .bold))
|
|
.foregroundColor(AppColors.blue.opacity(0.75))
|
|
.frame(width: 64, alignment: .leading)
|
|
Text(section.exercises.isEmpty
|
|
? "No exercises"
|
|
: section.exercises.map(\.name).joined(separator: " · "))
|
|
.font(AppFonts.sans(10.5))
|
|
.foregroundColor(AppColors.text2(cs))
|
|
.lineLimit(2)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 10).padding(.top, 6).padding(.bottom, 10)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.blue.opacity(0.07))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.blue.opacity(0.2), lineWidth: 1))
|
|
}
|
|
}
|
|
|
|
// MARK: - Workout Section Bar
|
|
struct WorkoutSectionBar: View {
|
|
let count: Int
|
|
var body: some View {
|
|
let n = max(1, min(6, count))
|
|
VStack(spacing: 2.5) {
|
|
ForEach(0..<n, id: \.self) { _ in
|
|
RoundedRectangle(cornerRadius: 1.5)
|
|
.fill(AppColors.blue)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.padding(7)
|
|
.background(AppColors.blueSoft)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|