Files
psu-schedule/ios/PSUSchedule/Views/Components.swift
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00

331 lines
12 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)
)
}
}
/// "Занятия в Полоцке" 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)
}
}