Files
psu-schedule/ios/Widgets/Widgets.swift
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00

190 lines
8.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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()
}
}