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.
This commit is contained in:
175
ios/Shared/Dates.swift
Normal file
175
ios/Shared/Dates.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// The university's time zone: dates and "now" never follow the phone's zone.
|
||||
let minsk = TimeZone(identifier: "Europe/Minsk")!
|
||||
|
||||
/// A Gregorian calendar in Minsk time, weeks from Monday.
|
||||
let minskCalendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = minsk
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale(identifier: "ru_RU")
|
||||
return c
|
||||
}()
|
||||
|
||||
/// A calendar date without a time, like java.time.LocalDate: "2026-10-18".
|
||||
struct CivilDate: Hashable, Comparable, Codable, Identifiable, CustomStringConvertible {
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
|
||||
init(year: Int, month: Int, day: Int) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
}
|
||||
|
||||
init?(_ iso: String) {
|
||||
let parts = iso.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return nil }
|
||||
self.init(year: parts[0], month: parts[1], day: parts[2])
|
||||
}
|
||||
|
||||
init(_ date: Date) {
|
||||
let c = minskCalendar.dateComponents([.year, .month, .day], from: date)
|
||||
self.init(year: c.year!, month: c.month!, day: c.day!)
|
||||
}
|
||||
|
||||
static func today(_ now: Date = Date()) -> CivilDate { CivilDate(now) }
|
||||
|
||||
/// Noon in Minsk, so adding days never trips over a daylight saving change.
|
||||
var date: Date {
|
||||
minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))!
|
||||
}
|
||||
|
||||
var iso: String { String(format: "%04d-%02d-%02d", year, month, day) }
|
||||
var description: String { iso }
|
||||
var id: String { iso }
|
||||
|
||||
func adding(days: Int) -> CivilDate {
|
||||
CivilDate(minskCalendar.date(byAdding: .day, value: days, to: date)!)
|
||||
}
|
||||
|
||||
func adding(months: Int) -> CivilDate {
|
||||
CivilDate(minskCalendar.date(byAdding: .month, value: months, to: date)!)
|
||||
}
|
||||
|
||||
/// 1 = Monday … 7 = Sunday, as in java.time.
|
||||
var weekday: Int {
|
||||
let w = minskCalendar.component(.weekday, from: date) // 1 = Sunday
|
||||
return w == 1 ? 7 : w - 1
|
||||
}
|
||||
|
||||
var monday: CivilDate { adding(days: 1 - weekday) }
|
||||
var firstOfMonth: CivilDate { CivilDate(year: year, month: month, day: 1) }
|
||||
|
||||
func days(to other: CivilDate) -> Int {
|
||||
minskCalendar.dateComponents([.day], from: date, to: other.date).day ?? 0
|
||||
}
|
||||
|
||||
func at(_ time: ClockTime) -> Date {
|
||||
minskCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: time.hour, minute: time.minute))!
|
||||
}
|
||||
|
||||
static func < (a: CivilDate, b: CivilDate) -> Bool {
|
||||
(a.year, a.month, a.day) < (b.year, b.month, b.day)
|
||||
}
|
||||
|
||||
// Codable as the ISO string, the way the server and the Android cache write dates.
|
||||
init(from decoder: Decoder) throws {
|
||||
let s = try decoder.singleValueContainer().decode(String.self)
|
||||
guard let d = CivilDate(s) else {
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Не дата: \(s)"))
|
||||
}
|
||||
self = d
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.singleValueContainer()
|
||||
try c.encode(iso)
|
||||
}
|
||||
}
|
||||
|
||||
/// A time of day in minutes since midnight: "08:30".
|
||||
struct ClockTime: Hashable, Comparable, CustomStringConvertible {
|
||||
var minutes: Int
|
||||
|
||||
init(minutes: Int) { self.minutes = minutes }
|
||||
|
||||
init(hour: Int, minute: Int) { minutes = hour * 60 + minute }
|
||||
|
||||
init?(_ hhmm: String) {
|
||||
let parts = hhmm.split(separator: ":").compactMap { Int($0) }
|
||||
guard parts.count >= 2 else { return nil }
|
||||
minutes = parts[0] * 60 + parts[1]
|
||||
}
|
||||
|
||||
static func now(_ date: Date = Date()) -> ClockTime {
|
||||
let c = minskCalendar.dateComponents([.hour, .minute], from: date)
|
||||
return ClockTime(hour: c.hour!, minute: c.minute!)
|
||||
}
|
||||
|
||||
var hour: Int { minutes / 60 }
|
||||
var minute: Int { minutes % 60 }
|
||||
var description: String { String(format: "%02d:%02d", hour, minute) }
|
||||
|
||||
func adding(minutes m: Int) -> ClockTime { ClockTime(minutes: minutes + m) }
|
||||
|
||||
static func < (a: ClockTime, b: ClockTime) -> Bool { a.minutes < b.minutes }
|
||||
}
|
||||
|
||||
// MARK: - Russian labels
|
||||
|
||||
private let monthsGenitive = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||
private let monthsNominative = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"]
|
||||
private let monthsShort = ["Янв.", "Февр.", "Март", "Апр.", "Май", "Июнь", "Июль", "Авг.", "Сент.", "Окт.", "Нояб.", "Дек."]
|
||||
private let weekdaysFull = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
|
||||
private let weekdaysShort = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
extension CivilDate {
|
||||
/// "18 октября"
|
||||
var longLabel: String { "\(day) \(monthsGenitive[month - 1])" }
|
||||
/// "Пн"
|
||||
var shortWeekday: String { weekdaysShort[weekday - 1] }
|
||||
/// "Понедельник"
|
||||
var weekdayName: String { weekdaysFull[weekday - 1] }
|
||||
/// "Октябрь 2026"
|
||||
var monthTitle: String { "\(monthsNominative[month - 1]) \(year)" }
|
||||
/// "Окт."
|
||||
var shortMonth: String { monthsShort[month - 1] }
|
||||
/// "18.10"
|
||||
var numeric: String { String(format: "%02d.%02d", day, month) }
|
||||
}
|
||||
|
||||
/// "Понедельник" for 1…7.
|
||||
func dayName(_ day: Int) -> String { weekdaysFull[max(0, min(6, day - 1))] }
|
||||
|
||||
/// "12–18 октября" or "28 сентября – 4 октября".
|
||||
func weekSpan(_ start: CivilDate, _ end: CivilDate) -> String {
|
||||
start.month == end.month ? "\(start.day)–\(end.longLabel)" : "\(start.longLabel) – \(end.longLabel)"
|
||||
}
|
||||
|
||||
/// Russian plural: 1 пара, 2 пары, 5 пар.
|
||||
func plural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String {
|
||||
let tail = n % 100
|
||||
if (11...14).contains(tail) { return many }
|
||||
switch n % 10 {
|
||||
case 1: return one
|
||||
case 2...4: return few
|
||||
default: return many
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 пара", "3 пары".
|
||||
func pairs(_ n: Int) -> String { "\(n) \(plural(n, "пара", "пары", "пар"))" }
|
||||
|
||||
/// "45 мин", "1 ч 20 мин".
|
||||
func minutesLabel(_ m: Int) -> String {
|
||||
if m < 60 { return "\(m) мин" }
|
||||
return "\(m / 60) ч" + (m % 60 > 0 ? " \(m % 60) мин" : "")
|
||||
}
|
||||
|
||||
/// "Окт. · Неделя 7": the title of a shown week.
|
||||
func weekTitle(_ date: CivilDate, _ n: Int?) -> String {
|
||||
n.map { "\(date.shortMonth) · Неделя \($0)" } ?? date.shortMonth
|
||||
}
|
||||
432
ios/Shared/Models.swift
Normal file
432
ios/Shared/Models.swift
Normal file
@@ -0,0 +1,432 @@
|
||||
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) ?? ""
|
||||
}
|
||||
}
|
||||
102
ios/Shared/Schedule.swift
Normal file
102
ios/Shared/Schedule.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// One time slot of a day: parallel lessons of subgroups share it.
|
||||
struct Slot: Hashable, Identifiable {
|
||||
var date: CivilDate
|
||||
var start: ClockTime
|
||||
var end: ClockTime
|
||||
var lessons: [Lesson]
|
||||
var id: String { date.iso + "|" + start.description }
|
||||
/// "13:40 → 15:05"
|
||||
var timeLabel: String { "\(start) → \(end)" }
|
||||
}
|
||||
|
||||
func subjectKey(_ subject: String) -> String { subject.trimmed.lowercased() }
|
||||
|
||||
/// End of a lesson: its own end, else the bell table's, else 80 minutes after the start.
|
||||
func lessonEnd(_ lesson: Lesson, _ t: Timetable) -> ClockTime {
|
||||
if let end = lesson.end.flatMap(ClockTime.init) { return end }
|
||||
if let bell = t.bells.first(where: { $0.start == lesson.start }), let end = ClockTime(bell.end) { return end }
|
||||
return (ClockTime(lesson.start) ?? ClockTime(minutes: 0)).adding(minutes: 80)
|
||||
}
|
||||
|
||||
extension Timetable {
|
||||
func day(_ date: CivilDate) -> Day? { days.first { $0.date == date.iso } }
|
||||
|
||||
func week(of date: CivilDate) -> Week? { day(date.monday)?.week ?? day(date)?.week }
|
||||
|
||||
var dates: [CivilDate] { days.compactMap { CivilDate($0.date) } }
|
||||
|
||||
/// Lessons of a date after the student's subgroup filter and the lessons they hid.
|
||||
func lessons(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Lesson] {
|
||||
(day(date)?.lessons ?? [])
|
||||
.filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
|
||||
.filter { !hidden.contains($0.hideKey) }
|
||||
}
|
||||
|
||||
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
|
||||
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
|
||||
var order: [String] = []
|
||||
var groups: [String: [Lesson]] = [:]
|
||||
for l in lessons {
|
||||
if groups[l.start] == nil { order.append(l.start) }
|
||||
groups[l.start, default: []].append(l)
|
||||
}
|
||||
return order.compactMap { start in
|
||||
guard let begin = ClockTime(start), let items = groups[start] else { return nil }
|
||||
return Slot(date: date, start: begin, end: lessonEnd(items[0], self), lessons: items)
|
||||
}
|
||||
.sorted { $0.start < $1.start }
|
||||
}
|
||||
|
||||
/// Palette slot of every subject: alphabetical, so a subject keeps its colour across refreshes.
|
||||
var subjectSlots: [String: Int] {
|
||||
let subjects = Set((days.flatMap(\.lessons) + weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).sorted()
|
||||
return Dictionary(uniqueKeysWithValues: subjects.enumerated().map { ($1, $0) })
|
||||
}
|
||||
}
|
||||
|
||||
private let shortWords: Set<String> = ["и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от"]
|
||||
|
||||
/// "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are.
|
||||
func abbreviate(_ subject: String) -> String {
|
||||
let name = (subject.split(separator: ":").first.map(String.init) ?? subject).trimmed
|
||||
if name.count <= 10 { return name }
|
||||
let letters = name.split(whereSeparator: { " -(),".contains($0) })
|
||||
.filter { !$0.isEmpty && !shortWords.contains($0.lowercased()) && ($0.first?.isLetter ?? false) }
|
||||
.map { String($0.first!).uppercased() }
|
||||
.joined()
|
||||
return letters.count >= 2 ? String(letters.prefix(4)) : String(name.prefix(8))
|
||||
}
|
||||
|
||||
/// Faculty names as the student knows them.
|
||||
private let faculties = [
|
||||
"Финансово-экономический факультет": "ФЭФ",
|
||||
"Факультет информационных технологий": "ФИТ",
|
||||
"Факультет компьютерных наук и электроники": "ФКНЭ",
|
||||
"Гуманитарный факультет": "ГФ",
|
||||
"Инженерно-строительный факультет": "ИСФ",
|
||||
"Механико-технологический факультет": "МТФ",
|
||||
"Юридический факультет": "ЮФ",
|
||||
]
|
||||
|
||||
func facultyShort(_ name: String) -> String? { name.isEmpty ? nil : (faculties[name] ?? name) }
|
||||
|
||||
/// What a widget or reminder shows: today's slots, or the next study day's once today is over.
|
||||
struct Upcoming {
|
||||
var date: CivilDate
|
||||
var slots: [Slot]
|
||||
}
|
||||
|
||||
func upcoming(_ t: Timetable, settings: UserSettings, now: Date = Date()) -> Upcoming? {
|
||||
let today = CivilDate.today(now)
|
||||
let clock = ClockTime.now(now)
|
||||
let todays = t.slots(on: today, subgroup: settings.subgroup, hidden: settings.hidden).filter { $0.end > clock }
|
||||
if !todays.isEmpty { return Upcoming(date: today, slots: todays) }
|
||||
for step in 1...7 {
|
||||
let d = today.adding(days: step)
|
||||
let slots = t.slots(on: d, subgroup: settings.subgroup, hidden: settings.hidden)
|
||||
if !slots.isEmpty { return Upcoming(date: d, slots: slots) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
109
ios/Shared/Storage.swift
Normal file
109
ios/Shared/Storage.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
enum ThemeMode: String, Codable, CaseIterable {
|
||||
case system, light, dark
|
||||
}
|
||||
|
||||
enum PlansLayout: String, Codable, CaseIterable {
|
||||
case agenda, list, board
|
||||
}
|
||||
|
||||
/** How the Plans screen lays out tasks, as in School Planner's plan settings. */
|
||||
struct PlansPrefs: Codable, Hashable {
|
||||
var layout: PlansLayout = .agenda
|
||||
/** The day's lessons above its plans; off by default, so plans come first. */
|
||||
var lessons: Bool = false
|
||||
/** Ticked-off tasks stay in the list. */
|
||||
var done: Bool = true
|
||||
}
|
||||
|
||||
/** What lesson cards, the week grid and the overview show; everything is on by default. */
|
||||
struct Display: Codable, Hashable {
|
||||
var colors = true
|
||||
var teachers = true
|
||||
var rooms = true
|
||||
var pairs = true
|
||||
var tags = true
|
||||
var notes = true
|
||||
}
|
||||
|
||||
/** Everything the student chose; stays on the device, shared with the widgets. */
|
||||
struct UserSettings: Codable, Hashable {
|
||||
var current: SavedGroup?
|
||||
/** The chosen subgroup per group ("docId|group"). */
|
||||
var subgroups: [String: String] = [:]
|
||||
var theme: ThemeMode = .system
|
||||
var reminders = false
|
||||
var reminderMinutes = 10
|
||||
var recent: [SavedGroup] = []
|
||||
var display = Display()
|
||||
/** Hidden lessons per group, as Lesson.hideKey values. */
|
||||
var hiddenByGroup: [String: [String]] = [:]
|
||||
var plans = PlansPrefs()
|
||||
|
||||
var subgroup: String? { current.flatMap { subgroups[$0.key] } }
|
||||
var hidden: Set<String> { Set(current.flatMap { hiddenByGroup[$0.key] } ?? []) }
|
||||
}
|
||||
|
||||
// Every field may be missing in an older save.
|
||||
extension UserSettings {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
current = try c.decodeIfPresent(SavedGroup.self, forKey: .current)
|
||||
subgroups = try c.decodeIfPresent([String: String].self, forKey: .subgroups) ?? [:]
|
||||
theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system
|
||||
reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false
|
||||
reminderMinutes = try c.decodeIfPresent(Int.self, forKey: .reminderMinutes) ?? 10
|
||||
recent = try c.decodeIfPresent([SavedGroup].self, forKey: .recent) ?? []
|
||||
display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display()
|
||||
hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:]
|
||||
plans = try c.decodeIfPresent(PlansPrefs.self, forKey: .plans) ?? PlansPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files and settings in the app group container, so the widgets read the same timetable,
|
||||
* notes and choices as the app. Everything stays on the device.
|
||||
*/
|
||||
enum Storage {
|
||||
static let group = "group.org.xtimms.psu.schedule"
|
||||
|
||||
static var directory: URL {
|
||||
if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) { return url }
|
||||
// Without the app group (an unsigned build) the app still works on its own.
|
||||
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
static var defaults: UserDefaults { UserDefaults(suiteName: group) ?? .standard }
|
||||
|
||||
static let encoder = JSONEncoder()
|
||||
static let decoder = JSONDecoder()
|
||||
|
||||
static func read<T: Decodable>(_ type: T.Type, _ name: String) -> T? {
|
||||
guard let data = try? Data(contentsOf: directory.appendingPathComponent(name)) else { return nil }
|
||||
return try? decoder.decode(type, from: data)
|
||||
}
|
||||
|
||||
static func write<T: Encodable>(_ value: T, _ name: String) {
|
||||
guard let data = try? encoder.encode(value) else { return }
|
||||
try? data.write(to: directory.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
|
||||
static func remove(_ name: String) {
|
||||
try? FileManager.default.removeItem(at: directory.appendingPathComponent(name))
|
||||
}
|
||||
|
||||
static func loadSettings() -> UserSettings {
|
||||
guard let data = defaults.data(forKey: "settings"),
|
||||
let s = try? decoder.decode(UserSettings.self, from: data) else { return UserSettings() }
|
||||
return s
|
||||
}
|
||||
|
||||
static func saveSettings(_ s: UserSettings) {
|
||||
if let data = try? encoder.encode(s) { defaults.set(data, forKey: "settings") }
|
||||
}
|
||||
|
||||
static func loadTimetable() -> Timetable? { read(Timetable.self, "timetable.json") }
|
||||
}
|
||||
Reference in New Issue
Block a user