import SwiftUI import WidgetKit /// What a widget shows at one moment: the group and its lessons ahead. struct LessonsEntry: TimelineEntry { var date: Date var group: String? var day: CivilDate? var slots: [Slot] var message: String? } /** * Reads the timetable the app saved in the shared container, so widgets work offline. * A new entry at every lesson's start and end keeps "Идёт" and "Далее" right without the app. */ struct LessonsProvider: TimelineProvider { func placeholder(in context: Context) -> LessonsEntry { LessonsEntry(date: Date(), group: "23-БА", day: CivilDate.today(), slots: [], message: nil) } func getSnapshot(in context: Context, completion: @escaping (LessonsEntry) -> Void) { completion(entry(at: Date())) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let now = Date() var moments = [now] if let t = Storage.loadTimetable() { let settings = Storage.loadSettings() let today = CivilDate.today(now) for d in [today, today.adding(days: 1)] { for slot in t.slots(on: d, subgroup: settings.subgroup, hidden: settings.hidden) { moments.append(d.at(slot.start)) moments.append(d.at(slot.end)) } } } // The next midnight in Minsk turns "Завтра" into "Сегодня". moments.append(CivilDate.today(now).adding(days: 1).at(ClockTime(minutes: 0))) let entries = Set(moments.filter { $0 >= now }).sorted().prefix(40).map { entry(at: $0) } completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(6 * 3600)))) } private func entry(at date: Date) -> LessonsEntry { let settings = Storage.loadSettings() guard let t = Storage.loadTimetable(), settings.current != nil else { return LessonsEntry(date: date, group: nil, day: nil, slots: [], message: "Откройте приложение и выберите группу") } guard let next = upcoming(t, settings: settings, now: date) else { return LessonsEntry(date: date, group: t.group, day: nil, slots: [], message: "Занятий на неделе нет") } return LessonsEntry(date: date, group: t.group, day: next.date, slots: next.slots, message: nil) } } private func dayLabel(_ day: CivilDate, at date: Date) -> String { let today = CivilDate.today(date) if day == today { return "Сегодня, \(day.longLabel)" } if day == today.adding(days: 1) { return "Завтра, \(day.weekdayName.lowercased())" } return "\(day.weekdayName), \(day.longLabel)" } private func status(_ slot: Slot, day: CivilDate, at date: Date) -> String? { guard day == CivilDate.today(date) else { return nil } let clock = ClockTime.now(date) if clock >= slot.start && clock < slot.end { return "Идёт · ещё \(minutesLabel(slot.end.minutes - clock.minutes))" } return nil } private func place(_ lesson: Lesson) -> String? { if lesson.distance { return "дист." } return withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place) } /// "Сегодня": the day's lessons ahead, with the current one marked. struct TodayWidgetView: View { @Environment(\.widgetFamily) private var family var entry: LessonsEntry var body: some View { VStack(alignment: .leading, spacing: 6) { HStack { Text(entry.day.map { dayLabel($0, at: entry.date) } ?? "Расписание").font(.caption.weight(.semibold)).foregroundStyle(.tint) Spacer() if let group = entry.group { Text(group).font(.caption).foregroundStyle(.secondary) } } if let message = entry.message { Spacer() Text(message).font(.subheadline).foregroundStyle(.secondary) Spacer() } else { let limit = family == .systemLarge ? 7 : family == .systemMedium ? 3 : 2 ForEach(entry.slots.prefix(limit)) { slot in let lesson = slot.lessons[0] HStack(alignment: .top, spacing: 8) { Capsule().fill(.tint).frame(width: 3) VStack(alignment: .leading, spacing: 1) { Text(slot.lessons.map(\.subject).joined(separator: " / ")) .font(.subheadline.weight(.semibold)) .lineLimit(family == .systemSmall ? 2 : 1) Text(([status(slot, day: entry.day!, at: entry.date) ?? slot.timeLabel] + [place(lesson)].compactMap { $0 }).joined(separator: " · ")) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) } } } if entry.slots.count > limit { Text("Ещё \(pairs(entry.slots.count - limit))").font(.caption).foregroundStyle(.secondary) } Spacer(minLength: 0) } } .containerBackground(.fill.tertiary, for: .widget) .widgetURL(URL(string: "psuschedule://day/\(entry.day?.iso ?? "")")) } } /// "Следующая пара": one lesson, large, also on the Lock Screen. struct NextLessonView: View { @Environment(\.widgetFamily) private var family var entry: LessonsEntry var body: some View { let slot = entry.slots.first let lesson = slot?.lessons.first Group { switch family { case .accessoryInline: if let slot, let lesson { Text("\(slot.start) \(lesson.subject)") } else { Text("Пар нет") } case .accessoryRectangular: VStack(alignment: .leading, spacing: 1) { if let slot, let lesson, let day = entry.day { Text(status(slot, day: day, at: entry.date) ?? (day == CivilDate.today(entry.date) ? slot.timeLabel : day.shortWeekday + " " + slot.timeLabel)) .font(.caption.weight(.semibold)) Text(lesson.subject).font(.headline).lineLimit(1) if let p = place(lesson) { Text(p).font(.caption) } } else { Text(entry.message ?? "Пар нет").font(.caption) } } .frame(maxWidth: .infinity, alignment: .leading) default: VStack(alignment: .leading, spacing: 4) { if let slot, let lesson, let day = entry.day { Text(status(slot, day: day, at: entry.date) ?? (day == CivilDate.today(entry.date) ? "Далее · " + slot.start.description : dayLabel(day, at: entry.date))) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) Text(lesson.subject).font(.headline).lineLimit(3) Spacer(minLength: 0) Text(slot.timeLabel).font(.subheadline.monospacedDigit()) if let p = place(lesson) { Text(p).font(.caption).foregroundStyle(.secondary) } } else { Text(entry.message ?? "Пар нет").font(.subheadline).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } .containerBackground(.fill.tertiary, for: .widget) } } struct TodayWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "today", provider: LessonsProvider()) { TodayWidgetView(entry: $0) } .configurationDisplayName("Сегодня") .description("Пары на сегодня или ближайший учебный день.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } struct NextLessonWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "next", provider: LessonsProvider()) { NextLessonView(entry: $0) } .configurationDisplayName("Следующая пара") .description("Текущая или следующая пара с аудиторией.") .supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline]) } } @main struct ScheduleWidgets: WidgetBundle { var body: some Widget { TodayWidget() NextLessonWidget() } }