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

113
ios/PSUSchedule/API.swift Normal file
View File

@@ -0,0 +1,113 @@
import Foundation
import OSLog
/// An error with a message ready to show; `offline` when the server could not be reached.
struct APIError: LocalizedError {
var message: String
var offline = false
var errorDescription: String? { message }
}
/// The schedule server's HTTP API, as the Android client's Api.kt.
struct API: Sendable {
let base: URL
/// The server address: Info.plist's PSUAPIURL; Debug builds may set PSU_API_URL instead.
static let shared: API = {
#if DEBUG
if let env = ProcessInfo.processInfo.environment["PSU_API_URL"], let url = URL(string: env) {
return API(base: url)
}
#endif
let plist = Bundle.main.object(forInfoDictionaryKey: "PSUAPIURL") as? String
return API(base: URL(string: plist ?? "https://psu.rumblur.by/")!)
}()
private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API")
private static let offline = "Нет связи с сервером расписания."
private static let retries = 3
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again
// with If-None-Match, so an unchanged semester comes back as an empty 304.
private static let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.urlCache = URLCache(memoryCapacity: 2 << 20, diskCapacity: 10 << 20)
config.requestCachePolicy = .useProtocolCachePolicy
return URLSession(configuration: config)
}()
private func get<T: Decodable & Sendable>(_ path: String, _ query: [(String, String?)] = []) async throws -> T {
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
let items = query.compactMap { name, value in value.flatMap { $0.isEmpty ? nil : URLQueryItem(name: name, value: $0) } }
if !items.isEmpty { components.queryItems = items }
let url = components.url!
let (data, response) = try await withRetry(url)
guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true) }
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String
// The server's catch-all 404 means it runs an older version without this endpoint.
if http.statusCode == 404 && message == "Адрес не найден." {
throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.")
}
throw APIError(message: message ?? "Сервер ответил ошибкой \(http.statusCode).")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
Self.log.error("Decoding \(path) failed: \(error)")
throw APIError(message: "Не удалось прочитать ответ сервера.")
}
}
/// A dropped or timed-out connection is retried twice (after 1 and 3 seconds):
/// a flaky mobile network often recovers within seconds. Server answers are not retried.
private func withRetry(_ url: URL) async throws -> (Data, URLResponse) {
var wait: UInt64 = 1_000_000_000
for attempt in 1...Self.retries {
do {
return try await Self.session.data(from: url)
} catch let error as URLError where error.code != .cancelled {
Self.log.warning("GET \(url) failed (attempt \(attempt)): \(error.localizedDescription)")
if attempt == Self.retries { break }
try await Task.sleep(nanoseconds: wait)
wait *= 3
}
}
throw APIError(message: Self.offline, offline: true)
}
/// The whole current semester; a server without range=semester answers the from/days window.
func timetable(doc: String, group: String, from: CivilDate, days: Int) async throws -> Timetable {
try await get("api/timetable", [("doc", doc), ("group", group), ("from", from.iso), ("days", String(days)), ("range", "semester")])
}
func groups(query: String = "", faculty: String = "", course: String = "") async throws -> GroupsResponse {
try await get("api/groups", [("q", query), ("faculty", faculty), ("course", course)])
}
func searchDirectory(_ query: String) async throws -> DirectorySearch {
try await get("api/directory/search", [("q", query)])
}
func teacherDay(_ name: String, date: CivilDate) async throws -> DirectoryDay {
try await get("api/directory/day", [("teacher", name), ("date", date.iso)])
}
func roomDay(_ room: String, date: CivilDate) async throws -> DirectoryDay {
try await get("api/directory/day", [("room", room), ("date", date.iso)])
}
func calendar() async throws -> CalendarInfo { try await get("api/calendar") }
func exams(group: String, faculty: String) async throws -> ExamsResponse {
try await get("api/exams", [("group", group), ("faculty", faculty)])
}
}
extension Timetable: @unchecked Sendable {}
extension GroupsResponse: @unchecked Sendable {}
extension DirectorySearch: @unchecked Sendable {}
extension DirectoryDay: @unchecked Sendable {}
extension CalendarInfo: @unchecked Sendable {}
extension ExamsResponse: @unchecked Sendable {}