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:
2026-10-18 20:26:12 +03:00
parent 6b68cec34a
commit 170a44c377
40 changed files with 58862 additions and 51029 deletions

175
ios/Shared/Dates.swift Normal file
View 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
View 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
View 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
View 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") }
}