Files
psu-schedule/ios/Shared/Models.swift
Zakhar Timoshenko 170a44c377 Add date and time handling, models, schedule management, storage, and widget support
- 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.
2026-10-18 20:26:12 +03:00

433 lines
15 KiB
Swift

import Foundation
// Shapes of the server JSON (see server/timetable.js and server/directory.js), as in the
// Android client's Models.kt. Missing keys fall back to defaults, so an older or newer
// server never breaks decoding.
struct Timetable: Codable, Hashable {
var group: String
var document: DocumentRef
var period: String?
var semester: Semester?
var note: String?
var subgroups: [String] = []
var bells: [Bell] = []
var from: String
var days: [Day] = []
var weekly: [WeeklyDay] = []
var notes: [String] = []
}
struct DocumentRef: Codable, Hashable {
var id: String
var name: String
var faculty: String = ""
var course: String = ""
var modified: String?
}
struct Semester: Codable, Hashable {
var id: String
var label: String
var start: String
var end: String
}
struct Bell: Codable, Hashable {
var pair: Int
var start: String
var end: String
var halves: [[String]] = []
}
struct Week: Codable, Hashable {
var n: Int
var color: String
var white: Bool { color == "white" }
}
struct Day: Codable, Hashable {
var date: String
var weekday: Int
var week: Week?
var lessons: [Lesson] = []
}
struct WeeklyDay: Codable, Hashable {
var day: Int
var lessons: [Lesson] = []
}
struct Lesson: Codable, Hashable {
var key: String
var start: String
var end: String?
var pair: Int?
var subject: String
var type: String = ""
var teachers: [String] = []
var rooms: [String] = []
var roomMoved: Bool = false
var distance: Bool = false
var condition: String = ""
var variant: String = "all"
var subgroup: String?
var groups: [String] = []
var uncertain: Bool = false
var raw: String = ""
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
var hideKey: String { subject.trimmed.lowercased() + "|" + type.trimmed.lowercased() }
}
struct GroupsResponse: Codable {
var results: [GroupResult] = []
var facets: Facets = Facets()
var total: Int = 0
var year: String = ""
}
struct GroupResult: Codable, Hashable, Identifiable {
var group: String
var fileId: String
var file: String = ""
var faculty: String = ""
var course: String = ""
var form: String = ""
var period: String = ""
var id: String { fileId + "|" + group }
}
struct Facets: Codable {
var faculties: [Facet] = []
var courses: [Facet] = []
var forms: [Facet] = []
}
struct Facet: Codable, Hashable {
var name: String
var count: Int
}
struct DirectorySearch: Codable {
var teachers: [TeacherHit] = []
var rooms: [RoomHit] = []
}
struct TeacherHit: Codable, Hashable {
var name: String
var count: Int = 0
var faculties: [String] = []
}
struct RoomHit: Codable, Hashable {
var room: String
var count: Int = 0
}
struct DirectoryDay: Codable {
var date: String
var lessons: [DirectoryLesson] = []
var coverage: Coverage
}
struct DirectoryLesson: Codable, Hashable {
var start: String
var end: String?
var subject: String
var type: String = ""
var teachers: [String] = []
var rooms: [String] = []
var moved: Bool = false
var distance: Bool = false
var groups: [String] = []
var condition: String = ""
var uncertain: Bool = false
}
struct Coverage: Codable {
var documents: Int
var total: Int
var failed: [String] = []
var withoutSemester: [String] = []
var year: String = ""
}
struct CalendarInfo: Codable {
var bells: [Bell] = []
}
/** An exam, credit or consultation of the group, from any document of the catalog (/api/exams). */
struct Exam: Codable, Hashable {
var date: String
var start: String
var end: String?
var type: String
var subject: String
var teachers: [String] = []
var rooms: [String] = []
/** Consultations go into the list but not into the countdown. */
var counts: Bool { type != "Консультация" }
}
struct ExamsResponse: Codable {
var group: String = ""
var exams: [Exam] = []
}
/** A group the student opened; kept on the device for quick switching. */
struct SavedGroup: Codable, Hashable {
var docId: String
var group: String
var faculty: String = ""
var course: String = ""
/** "docId|group": notes, marks and choices of one group never show up in another. */
var key: String { docId + "|" + group }
}
/**
* A homework task or a plain note pinned to one lesson (subject, date, start time),
* or a general plan with an empty subject. Tasks can be ticked off; notes just remind.
*/
struct Note: Codable, Hashable, Identifiable {
var id: String = UUID().uuidString
var group: String
var subject: String
var date: String
var start: String
var text: String
var task: Bool = true
var done: Bool = false
var created: Double = Date().timeIntervalSince1970 * 1000
/** A plan not pinned to any lesson: just a day. */
var general: Bool { subject.trimmed.isEmpty }
}
/** Whether the student was at one lesson. */
struct Mark: Codable, Hashable {
var group: String
var subject: String
var date: String
var start: String
var present: Bool
var id: String { [group, date, start, subject.trimmed.lowercased()].joined(separator: "|") }
}
extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
// MARK: - Lenient decoding
/// Keys may be missing in older answers or caches: every optional field falls back to its default.
extension Timetable {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
group = try c.decode(String.self, forKey: .group)
document = try c.decode(DocumentRef.self, forKey: .document)
period = try c.decodeIfPresent(String.self, forKey: .period)
semester = try c.decodeIfPresent(Semester.self, forKey: .semester)
note = try c.decodeIfPresent(String.self, forKey: .note)
subgroups = try c.decodeIfPresent([String].self, forKey: .subgroups) ?? []
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
from = try c.decodeIfPresent(String.self, forKey: .from) ?? ""
days = try c.decodeIfPresent([Day].self, forKey: .days) ?? []
weekly = try c.decodeIfPresent([WeeklyDay].self, forKey: .weekly) ?? []
notes = try c.decodeIfPresent([String].self, forKey: .notes) ?? []
}
}
extension DocumentRef {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
modified = try c.decodeIfPresent(String.self, forKey: .modified)
}
}
extension Bell {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
pair = try c.decode(Int.self, forKey: .pair)
start = try c.decode(String.self, forKey: .start)
end = try c.decode(String.self, forKey: .end)
halves = try c.decodeIfPresent([[String]].self, forKey: .halves) ?? []
}
}
extension Day {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
week = try c.decodeIfPresent(Week.self, forKey: .week)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
}
}
extension WeeklyDay {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
day = try c.decode(Int.self, forKey: .day)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
}
}
extension Lesson {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
key = try c.decodeIfPresent(String.self, forKey: .key) ?? UUID().uuidString
start = try c.decode(String.self, forKey: .start)
end = try c.decodeIfPresent(String.self, forKey: .end)
pair = try c.decodeIfPresent(Int.self, forKey: .pair)
subject = try c.decode(String.self, forKey: .subject)
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
raw = try c.decodeIfPresent(String.self, forKey: .raw) ?? ""
}
}
extension GroupsResponse {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
results = try c.decodeIfPresent([GroupResult].self, forKey: .results) ?? []
facets = try c.decodeIfPresent(Facets.self, forKey: .facets) ?? Facets()
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
}
}
extension GroupResult {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
group = try c.decode(String.self, forKey: .group)
fileId = try c.decode(String.self, forKey: .fileId)
file = try c.decodeIfPresent(String.self, forKey: .file) ?? ""
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
form = try c.decodeIfPresent(String.self, forKey: .form) ?? ""
period = try c.decodeIfPresent(String.self, forKey: .period) ?? ""
}
}
extension Facets {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
faculties = try c.decodeIfPresent([Facet].self, forKey: .faculties) ?? []
courses = try c.decodeIfPresent([Facet].self, forKey: .courses) ?? []
forms = try c.decodeIfPresent([Facet].self, forKey: .forms) ?? []
}
}
extension DirectorySearch {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
teachers = try c.decodeIfPresent([TeacherHit].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([RoomHit].self, forKey: .rooms) ?? []
}
}
extension TeacherHit {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
faculties = try c.decodeIfPresent([String].self, forKey: .faculties) ?? []
}
}
extension RoomHit {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
room = try c.decode(String.self, forKey: .room)
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
}
}
extension DirectoryDay {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
lessons = try c.decodeIfPresent([DirectoryLesson].self, forKey: .lessons) ?? []
coverage = try c.decode(Coverage.self, forKey: .coverage)
}
}
extension DirectoryLesson {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
start = try c.decode(String.self, forKey: .start)
end = try c.decodeIfPresent(String.self, forKey: .end)
subject = try c.decode(String.self, forKey: .subject)
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
}
}
extension Coverage {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
documents = try c.decodeIfPresent(Int.self, forKey: .documents) ?? 0
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
failed = try c.decodeIfPresent([String].self, forKey: .failed) ?? []
withoutSemester = try c.decodeIfPresent([String].self, forKey: .withoutSemester) ?? []
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
}
}
extension CalendarInfo {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
}
}
extension Exam {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
start = try c.decodeIfPresent(String.self, forKey: .start) ?? ""
end = try c.decodeIfPresent(String.self, forKey: .end)
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
subject = try c.decode(String.self, forKey: .subject)
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
}
}
extension ExamsResponse {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
group = try c.decodeIfPresent(String.self, forKey: .group) ?? ""
exams = try c.decodeIfPresent([Exam].self, forKey: .exams) ?? []
}
}
extension SavedGroup {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
docId = try c.decode(String.self, forKey: .docId)
group = try c.decode(String.self, forKey: .group)
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
}
}