- 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.
176 lines
6.5 KiB
Swift
176 lines
6.5 KiB
Swift
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
|
||
}
|