Replace full-form AddTaskSheet with a compact NL input sheet. Type "meeting today at 1500" — the words "today" and "at 1500" highlight inline in blue, the toolbar shows "Today, 15:00", and the mic button becomes a submit arrow when the title is non-empty. Parser handles: today/tomorrow, weekday names (mon–sun), times as at 3pm / at 15:00 / at 1500 / 3:30pm. Clean title strips the date tokens before saving. Sheet uses height(500) detent so it floats above the keyboard.
1038 lines
47 KiB
Swift
1038 lines
47 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(500), .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) {
|
|
Text(program.emoji).font(.system(size: 16))
|
|
.frame(width: 30, height: 30)
|
|
.background(AppColors.blueSoft)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
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))
|
|
}
|
|
}
|