feat: Enhance session handling in timetable and UI

- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI.
- Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately.
- Modified CalendarView and PlansView to include session indicators in the calendar and plans.
- Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence.
- Added new SessionMark component for consistent session representation across Android and iOS.
- Updated server logic to handle session entries from external documents, ensuring accurate timetable representation.
- Improved date parsing to accommodate session-related dates in various formats.
- Added tests to verify session handling and date parsing functionality.
This commit is contained in:
2026-10-19 20:49:13 +03:00
parent db9a3f8243
commit 15851b26cd
21 changed files with 421 additions and 42 deletions

View File

@@ -164,6 +164,7 @@ private struct MonthGrid: View {
let isToday = d == model.today
let lessons = model.timetable.map { !model.slots($0, on: d).isEmpty } ?? false
let plans = model.notes.contains { $0.date == d.iso }
let session = model.timetable.map { model.slots($0, on: d).contains { $0.lessons.contains { $0.session != nil } } } ?? false
let color: Color = isSelected ? .onBrand : isToday ? .brand : inMonth ? .primary : Color(.tertiaryLabel)
return Button { onSelect(d) } label: {
VStack(spacing: 3) {
@@ -173,8 +174,10 @@ private struct MonthGrid: View {
.frame(width: 34, height: 34)
.background { if isSelected { Circle().fill(Color.brand) } else if isToday { Circle().strokeBorder(Color.brand, lineWidth: 1.5) } }
HStack(spacing: 3) {
Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4)
if plans { Circle().fill(Color.orange).frame(width: 4, height: 4) }
// Lessons in grey, an exam or credit in amber, plans in purple.
if session { RoundedRectangle(cornerRadius: 1.5).fill(Color.sessionAccent).frame(width: 6, height: 6) }
else { Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4) }
if plans { Circle().fill(Color.purple).frame(width: 4, height: 4) }
}
.frame(height: 4)
}
@@ -183,7 +186,7 @@ private struct MonthGrid: View {
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : ""))
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : "") + (session ? ", экзамен или зачёт" : ""))
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}

View File

@@ -305,6 +305,7 @@ private struct FeaturedLesson: View {
HStack(alignment: .top, spacing: 14) {
SubjectDot(subject: lesson.subject, size: 14).padding(.top, 6)
VStack(alignment: .leading, spacing: 6) {
if let session = lesson.session { SessionBadge(kind: session) }
Text(lesson.subject).font(.title3.bold()).lineLimit(3).foregroundStyle(.primary)
HStack(spacing: 10) {
Text(slot.timeLabel).font(.body.monospacedDigit()).foregroundStyle(.secondary)

View File

@@ -20,6 +20,8 @@ struct LessonCard: View {
var body: some View {
WithSubjectColor(subject: lesson.subject) { c in
VStack(alignment: .leading, spacing: 6) {
// Exams, credits and consultations announce themselves before the subject.
if let session = lesson.session { SessionBadge(kind: session).padding(.bottom, 2) }
HStack(alignment: .top, spacing: 8) {
Text(lesson.subject).font(.headline).frame(maxWidth: .infinity, alignment: .leading)
if let status {
@@ -66,6 +68,7 @@ struct LessonCard: View {
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
.overlay {
if running { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(c.onSoft, lineWidth: 2) }
else if lesson.session != nil { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2) }
}
.contentShape(.rect(cornerRadius: 22))
}
@@ -74,7 +77,7 @@ struct LessonCard: View {
func tagsOf(_ lesson: Lesson) -> [String] {
var tags: [String] = []
if !lesson.type.isEmpty { tags.append(lesson.type) }
if !lesson.type.isEmpty && lesson.session == nil { tags.append(lesson.type) }
if lesson.variant != "all" { tags.append(lesson.variant == "upper" ? "белые недели" : "зелёные недели") }
if lesson.groups.count > 1 { tags.append("общее занятие") }
return tags
@@ -104,11 +107,11 @@ struct CompactLesson: View {
var slot: Slot
var body: some View {
HStack(spacing: 14) {
SubjectDot(subject: lesson.subject, size: 10)
HStack(spacing: lesson.session == nil ? 14 : 10) {
if let session = lesson.session { SessionDot(kind: session, size: 22) } else { SubjectDot(subject: lesson.subject, size: 10) }
VStack(alignment: .leading, spacing: 2) {
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2).foregroundStyle(.primary)
Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(lesson.session == nil ? Color.secondary : Color.sessionAccent)
}
Spacer(minLength: 0)
}
@@ -117,9 +120,11 @@ struct CompactLesson: View {
private var details: String {
var parts = [slot.timeLabel]
if let session = lesson.session { parts.insert(session.label, at: 0) }
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
if lesson.distance { parts.append("дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) }
if show.tags && !lesson.type.isEmpty { parts.append(lesson.type.lowercased()) }
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
return parts.joined(separator: " · ")
}
}
@@ -213,7 +218,7 @@ private struct TypeTag: View {
let color: Color = switch type {
case "Лекция": .brand
case "Практика": .purple
case "Экзамен", "Зачёт": .red
case "Экзамен", "Зачёт", "Защита курсовой работы", "Консультация": .sessionAccent
default: .gray
}
Text(type.isEmpty ? "Занятие" : type)

View File

@@ -86,7 +86,13 @@ struct PlansView: View {
if !overdue.isEmpty { out.append(PlanDay(date: nil, slots: [], notes: overdue)) }
var d = today
while d <= last {
let slots = prefs.lessons ? model.timetable.map { model.slots($0, on: d) } ?? [] : []
// Exams, credits and consultations are deadlines too: they stay even with "Пары дня" off.
let all = model.timetable.map { model.slots($0, on: d) } ?? []
let slots = prefs.lessons ? all : all.compactMap { slot -> Slot? in
var s = slot
s.lessons = slot.lessons.filter { $0.session != nil }
return s.lessons.isEmpty ? nil : s
}
let list = (byDay[d.iso] ?? []).sorted { (($0.task && $0.done) ? 1 : 0, $0.start) < (($1.task && $1.done) ? 1 : 0, $1.start) }
out.append(PlanDay(date: d, slots: slots, notes: list))
d = d.adding(days: 1)
@@ -206,7 +212,7 @@ private struct PlanStrip: View {
.foregroundStyle(isSelected ? Color.onBrand : d < model.today ? .secondary : .primary)
.frame(width: 32, height: 32)
.background { if isSelected { Circle().fill(Color.brand) } }
Circle().fill(has ? Color.orange : .clear).frame(width: 5, height: 5)
Circle().fill(has ? Color.purple : .clear).frame(width: 5, height: 5)
}
.frame(maxWidth: .infinity)
.contentShape(.rect)

View File

@@ -0,0 +1,90 @@
import SwiftUI
import UIKit
/// The session's events: exams, credits, consultations and coursework defences. They wear an
/// amber of their own — red would read as an error — and each kind its own symbol, so they
/// stand out from ordinary lessons in the schedule, the calendar and the plans.
enum SessionKind {
case exam, credit, defence, consultation
init?(_ type: String) {
switch type {
case "Экзамен": self = .exam
case "Зачёт": self = .credit
case "Защита курсовой работы": self = .defence
case "Консультация": self = .consultation
default: return nil
}
}
var label: String {
switch self {
case .exam: "Экзамен"
case .credit: "Зачёт"
case .defence: "Защита курсовой"
case .consultation: "Консультация"
}
}
var symbol: String {
switch self {
case .exam: "pencil.and.list.clipboard"
case .credit: "checkmark.seal.fill"
case .defence: "graduationcap.fill"
case .consultation: "bubble.left.and.bubble.right.fill"
}
}
/// Exams, credits and defences are filled; consultations are tonal.
var strong: Bool { self != .consultation }
var fill: Color { strong ? .sessionStrong : .sessionSoft }
var content: Color { strong ? .onSessionStrong : .onSessionSoft }
}
extension Lesson {
var session: SessionKind? { SessionKind(type) }
}
private func dynamic(_ light: UInt32, _ dark: UInt32) -> Color {
func ui(_ hex: UInt32) -> UIColor {
UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
return Color(UIColor { $0.userInterfaceStyle == .dark ? ui(dark) : ui(light) })
}
extension Color {
/// The session amber (an M3 tonal palette from #8A5100): text, marks and outlines.
static let sessionAccent = dynamic(0x8A5100, 0xFFB95C)
static let sessionStrong = dynamic(0x8A5100, 0xFFB95C)
static let onSessionStrong = dynamic(0xFFFFFF, 0x482900)
static let sessionSoft = dynamic(0xFFDDB8, 0x693C00)
static let onSessionSoft = dynamic(0x2C1600, 0xFFDDB8)
}
/// "Зачёт" with its symbol, as a capsule above the subject.
struct SessionBadge: View {
var kind: SessionKind
var body: some View {
Label(kind.label, systemImage: kind.symbol)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.foregroundStyle(kind.content)
.background(kind.fill, in: .capsule)
}
}
/// A round marker with the kind's symbol, in place of a subject's dot.
struct SessionDot: View {
var kind: SessionKind
var size: CGFloat = 20
var body: some View {
Image(systemName: kind.symbol)
.font(.system(size: size * 0.5, weight: .bold))
.foregroundStyle(kind.content)
.frame(width: size, height: size)
.background(kind.fill, in: .circle)
.accessibilityHidden(true)
}
}

View File

@@ -129,8 +129,17 @@ private struct LessonBlock: View {
.padding(4)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(c.strong, in: .rect(cornerRadius: 8, style: .continuous))
// Exams and credits: an amber outline and the kind's symbol in the corner.
.overlay(alignment: .bottomTrailing) {
if let session = lesson.session {
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2)
SessionDot(kind: session, size: 16).padding(3)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", ")))
.accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", ")))
}
}