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:
281
ios/PSUSchedule/Views/Components.swift
Normal file
281
ios/PSUSchedule/Views/Components.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user