- 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>
331 lines
12 KiB
Swift
331 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|