Files
psu-schedule/ios/Shared/Dates.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

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