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: 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: 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) ) } } /// "Занятия в Полоцке" above a day's lessons: Word marks the days a group spends in another /// town, and every lesson of that day is there. struct DayPlaces: View { var places: [DayPlace] var body: some View { if !places.isEmpty { VStack(alignment: .leading, spacing: 10) { ForEach(places, id: \.self) { p in HStack(spacing: 14) { Image(systemName: "building.2") .font(.title3) .foregroundStyle(Color.indigo) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(p.text).font(.headline) Text(p.subgroup.map { "\($0) · пометка в Word на этот день" } ?? "Пометка в Word на этот день") .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .accessibilityElement(children: .combine) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 18, style: .continuous)) } } } /// The town under a day's date in the week grid: short, in the note's colour. struct DayPlaceMark: View { var places: [DayPlace] var body: some View { if let first = places.first { Text(first.place) .font(.caption2.weight(.semibold)) .lineLimit(1) .padding(.horizontal, 4) .padding(.vertical, 1) .foregroundStyle(Color.indigo) .background(Color.indigo.opacity(0.14), in: .rect(cornerRadius: 4, 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) } }