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

View File

@@ -0,0 +1,281 @@
import SwiftUI
// MARK: - Colours
extension Color {
init(hex: UInt32) {
self.init(.sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
}
/// The app's blue (#0061A4 in light, #9ECAFF in dark), from the asset catalog.
static let brand = Color("AccentColor")
/// Text and icons on the brand blue: white on the dark light-theme blue, black on the pale dark-theme one.
static let onBrand = Color(.systemBackground)
// Color.greenWeek, the university's green weeks, is generated from the asset catalog.
}
/// A subject's colours: `strong`/`onStrong` for small fills (dots, week grid),
/// `soft`/`onSoft` for lesson cards; every pair keeps 4.5:1 contrast.
struct SubjectColor {
var strong: Color
var onStrong: Color
var soft: Color
var onSoft: Color
}
// Material 700 tones (strong in light theme) and 200 tones (strong in dark theme), as on Android.
private let tone700: [UInt32] = [0x1565C0, 0x7B1FA2, 0xC62828, 0x2E7D32, 0xD84315, 0x00695C, 0x283593, 0xAD1457, 0x5D4037, 0x00838F, 0x4527A0, 0x455A64]
private let tone200: [UInt32] = [0x90CAF9, 0xCE93D8, 0xEF9A9A, 0xA5D6A7, 0xFFAB91, 0x80CBC4, 0x9FA8DA, 0xF48FB1, 0xBCAAA4, 0x80DEEA, 0xB39DDB, 0xB0BEC5]
private func mix(_ a: UInt32, _ b: UInt32, _ t: Double) -> Color {
func c(_ v: UInt32, _ s: UInt32) -> Double { Double((v >> s) & 0xFF) / 255 }
func m(_ s: UInt32) -> Double { c(a, s) + (c(b, s) - c(a, s)) * t }
return Color(.sRGB, red: m(16), green: m(8), blue: m(0))
}
/// Subject colours of the open timetable, put into the environment at the root.
struct Palette {
var slots: [String: Int] = [:]
var enabled = true
func color(_ subject: String, dark: Bool) -> SubjectColor {
guard enabled else {
return SubjectColor(strong: .brand, onStrong: dark ? .black : .white, soft: Color(.secondarySystemGroupedBackground), onSoft: .primary)
}
let key = subjectKey(subject)
// Subjects of other groups (search results) still get a stable colour.
let i = slots[key].map { $0 % tone700.count } ?? Int(UInt(bitPattern: key.hashValue) % UInt(tone700.count))
return dark
? SubjectColor(strong: Color(hex: tone200[i]), onStrong: Color(hex: 0x1D1B20), soft: mix(0x211F26, tone700[i], 0.5), onSoft: mix(tone200[i], 0xFFFFFF, 0.4))
: SubjectColor(strong: Color(hex: tone700[i]), onStrong: .white, soft: mix(tone200[i], 0xFFFFFF, 0.35), onSoft: mix(tone700[i], 0x000000, 0.35))
}
}
extension EnvironmentValues {
@Entry var palette = Palette()
@Entry var display = Display()
}
/// Reads a subject's colours inside a view body.
struct WithSubjectColor<Content: View>: View {
@Environment(\.palette) private var palette
@Environment(\.colorScheme) private var scheme
var subject: String
@ViewBuilder var content: (SubjectColor) -> Content
var body: some View { content(palette.color(subject, dark: scheme == .dark)) }
}
struct SubjectDot: View {
var subject: String
var size: CGFloat = 12
var body: some View {
WithSubjectColor(subject: subject) { c in
Circle().fill(c.strong).frame(width: size, height: size)
}
.accessibilityHidden(true)
}
}
// MARK: - Cards
extension View {
/// Content cards lie on the grouped background; Liquid Glass is kept for the controls above them.
func card(padding: CGFloat = 20) -> some View {
self.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 26, style: .continuous))
}
/// A readable column on iPad and in landscape.
func readableWidth(_ width: CGFloat = 720) -> some View {
frame(maxWidth: width).frame(maxWidth: .infinity)
}
}
/// A titled card: the overview's building block.
struct SectionCard<Content: View>: View {
var symbol: String
var title: String
var trailing: String?
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
Image(systemName: symbol)
.font(.footnote.weight(.bold))
.foregroundStyle(Color.onBrand)
.frame(width: 30, height: 30)
.background(Color.brand, in: .circle)
Text(title).font(.headline).foregroundStyle(Color.brand)
.accessibilityAddTraits(.isHeader)
Spacer()
if let trailing { Text(trailing).font(.headline).foregroundStyle(.secondary) }
}
content
}
.card()
}
}
/// An error or offline message with "Повторить" and "OK".
struct Banner: View {
var text: String
var offline: Bool
var onClose: (() -> Void)?
var onRetry: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: offline ? "icloud.slash" : "exclamationmark.triangle")
.foregroundStyle(offline ? Color.secondary : Color.red)
Text(text).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading)
if let onRetry { Button("Повторить", action: onRetry).font(.subheadline.weight(.semibold)) }
if let onClose { Button("OK", action: onClose).font(.subheadline.weight(.semibold)) }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
(offline ? Color(.tertiarySystemFill) : Color.red.opacity(0.12)),
in: .rect(cornerRadius: 18, style: .continuous)
)
}
}
/// The status banner of the app model, if it has something to say.
struct StatusBanner: View {
@Environment(AppModel.self) private var model
var body: some View {
if let message = model.status.message {
Banner(
text: message,
offline: model.status.offline,
onClose: { model.dismissMessage() },
onRetry: model.status.offline ? { Task { await model.retry() } } : nil
)
}
}
}
/// An empty state: a symbol, a title, a calm explanation and an optional action.
struct EmptyState: View {
var title: String
var text: String
var symbol: String = "calendar.badge.exclamationmark"
var action: (label: String, run: () -> Void)?
var body: some View {
ContentUnavailableView {
Label(title, systemImage: symbol)
} description: {
Text(text)
} actions: {
if let action {
Button(action.label, action: action.run).buttonStyle(.glassProminent)
}
}
}
}
// MARK: - Weeks
/// "белая неделя" / "зелёная неделя": the university's week colour, never by colour alone.
struct WeekMark: View {
var week: Week
var short = false
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(week.white ? Color.white : Color.greenWeek)
.overlay(Circle().strokeBorder(Color.secondary.opacity(0.5), lineWidth: week.white ? 1 : 0))
.frame(width: 10, height: 10)
Text(short ? (week.white ? "белая" : "зелёная") : (week.white ? "белая неделя" : "зелёная неделя"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
/// The tint a week's glass header takes: none for white weeks, a light green for green ones.
func weekTint(_ week: Week?) -> Color? {
guard let week, !week.white else { return nil }
return Color.greenWeek.opacity(0.28)
}
// MARK: - Layout
/// Chips that wrap onto the next line.
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(proposal.width ?? .infinity, subviews)
let height = rows.last.map { $0.y + $0.height } ?? 0
let width = rows.map(\.width).max() ?? 0
return CGSize(width: proposal.width ?? width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(bounds.width, subviews) {
var x = bounds.minX
for i in row.items {
let size = subviews[i].sizeThatFits(.unspecified)
subviews[i].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row { var items: [Int] = []; var y: CGFloat = 0; var width: CGFloat = 0; var height: CGFloat = 0 }
private func arrange(_ maxWidth: CGFloat, _ subviews: Subviews) -> [Row] {
var rows: [Row] = []
var row = Row()
for (i, view) in subviews.enumerated() {
let size = view.sizeThatFits(.unspecified)
if !row.items.isEmpty && row.width + spacing + size.width > maxWidth {
rows.append(row)
row = Row(y: row.y + row.height + spacing)
}
row.width += (row.items.isEmpty ? 0 : spacing) + size.width
row.height = max(row.height, size.height)
row.items.append(i)
}
if !row.items.isEmpty { rows.append(row) }
return rows
}
}
/// A small rounded tag: "Лекция", "белые недели", "общее занятие".
struct Tag: View {
var text: String
var symbol: String?
var foreground: Color = .secondary
var background: Color = Color(.tertiarySystemFill)
var body: some View {
HStack(spacing: 4) {
if let symbol { Image(systemName: symbol).font(.caption.weight(.bold)) }
Text(text.prefix(1).uppercased() + text.dropFirst()).font(.footnote.weight(.medium))
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.foregroundStyle(foreground)
.background(background, in: .rect(cornerRadius: 8, style: .continuous))
}
}
/// An icon and a line of text, for rooms, teachers and other details.
struct Meta: View {
var symbol: String
var text: String
var body: some View {
Label {
Text(text)
} icon: {
Image(systemName: symbol).imageScale(.small)
}
.font(.subheadline)
.labelStyle(.titleAndIcon)
}
}