Files
psu-schedule/ios/PSUSchedule/Views/Components.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

282 lines
10 KiB
Swift
Raw 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 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)
}
}