- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
190 lines
8.6 KiB
Swift
190 lines
8.6 KiB
Swift
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<LessonsEntry>) -> 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 lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", ")
|
||
}
|
||
|
||
/// "Сегодня": 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()
|
||
}
|
||
}
|