Files
psu-schedule/ios/PSUSchedule/Views/LessonViews.swift
Zakhar Timoshenko 15851b26cd 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.
2026-10-19 20:49:13 +03:00

510 lines
23 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
/// A lesson opened in the details sheet; `date` is nil for Word rows without dates.
struct LessonRef: Identifiable, Hashable {
var lesson: Lesson
var date: CivilDate?
var id: String { (date?.iso ?? "-") + "|" + lesson.start + "|" + lesson.key }
}
/// A lesson as a soft card in its subject's colour, like School Planner's day list.
struct LessonCard: View {
@Environment(AppModel.self) private var model
@Environment(\.display) private var show
var lesson: Lesson
var time: String?
var date: CivilDate?
var status: String?
var running = false
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 {
Text(status)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.foregroundStyle(running ? c.soft : c.onSoft)
.background(running ? c.onSoft : c.onSoft.opacity(0.12), in: .rect(cornerRadius: 8, style: .continuous))
}
}
if let time {
Text(time + (show.pairs ? lesson.pair.map { " · \($0) пара" } ?? "" : ""))
.font(.body.monospacedDigit())
}
FlowLayout(spacing: 14) {
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty {
Meta(symbol: "mappin.and.ellipse", text: lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : ""))
}
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
}
let mark = date.flatMap { model.marks.forLesson(lesson.subject, $0, lesson.start) }
let tags = show.tags ? tagsOf(lesson) : []
if !tags.isEmpty || lesson.uncertain || mark != nil {
FlowLayout(spacing: 6) {
if let mark {
Tag(text: mark.present ? "был" : "пропуск", symbol: mark.present ? "checkmark" : "xmark", foreground: c.onSoft, background: c.onSoft.opacity(0.12))
}
ForEach(tags, id: \.self) { Tag(text: $0, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
}
.padding(.top, 4)
}
if show.notes && !lesson.condition.isEmpty {
Meta(symbol: "repeat", text: "Пометка в Word: \(lesson.condition)")
}
if let date { NotesHint(notes: model.notes.forLesson(lesson.subject, date, lesson.start)) }
}
.foregroundStyle(c.onSoft)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.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))
}
}
}
func tagsOf(_ lesson: Lesson) -> [String] {
var tags: [String] = []
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
}
/// A one-line hint on a lesson card: the first open task or note and how many more.
struct NotesHint: View {
var notes: [Note]
var body: some View {
let open = notes.open
if let first = open.first {
Label {
Text(first.text + (open.count > 1 ? " +\(open.count - 1)" : "")).lineLimit(1)
} icon: {
Image(systemName: first.task ? "checklist" : "note.text")
}
.font(.subheadline.weight(.semibold))
.padding(.top, 2)
}
}
}
/// Later lessons in a card: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В".
struct CompactLesson: View {
@Environment(\.display) private var show
var lesson: Lesson
var slot: Slot
var body: some View {
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(lesson.session == nil ? Color.secondary : Color.sessionAccent)
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
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 && lesson.session == nil { parts.append(lesson.type.lowercased()) }
return parts.joined(separator: " · ")
}
}
// MARK: - Details
/// Everything about one lesson: time, teacher, room, attendance, tasks and the Word record.
struct LessonDetails: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var ref: LessonRef
@State private var editor: EditorRequest?
var body: some View {
let lesson = ref.lesson
let bell = model.timetable?.bells.first { $0.start == lesson.start }
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
TypeTag(type: lesson.type)
HStack(alignment: .firstTextBaseline, spacing: 12) {
SubjectDot(subject: lesson.subject, size: 14)
Text(lesson.subject).font(.title2.bold()).accessibilityAddTraits(.isHeader)
}
VStack(alignment: .leading, spacing: 14) {
DetailRow(
symbol: "clock",
label: bell.map { "\($0.pair) пара" } ?? "Время",
value: lesson.start + (lesson.end.map { " — \($0)" } ?? ""),
extra: bell.flatMap { $0.halves.count > 1 ? $0.halves.map { $0.joined(separator: "–") }.joined(separator: ", ") : nil }
)
DetailRow(symbol: "person", label: "Преподаватель", value: lesson.teachers.isEmpty ? "Не указан" : lesson.teachers.joined(separator: ", "))
DetailRow(
symbol: "mappin.and.ellipse",
label: "Аудитория",
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
)
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
}
.card()
if lesson.uncertain {
Banner(text: "Пометку «\(lesson.condition)» не удалось сопоставить с таблицей недель. Сверьте с Word.", offline: false)
}
if let date = ref.date, model.groupKey != nil {
if model.started(date, lesson.start) { AttendancePicker(lesson: lesson, date: date) }
LessonNotes(lesson: lesson, date: date, editor: $editor)
}
VStack(alignment: .leading, spacing: 8) {
Text("Исходная запись").font(.headline)
Text(lesson.raw)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.tertiarySystemFill), in: .rect(cornerRadius: 18, style: .continuous))
}
if model.groupKey != nil {
VStack(alignment: .leading, spacing: 8) {
Button(role: .destructive) {
model.setHidden(lesson.hideKey, true)
dismiss()
} label: {
Label("Скрыть такие занятия", systemImage: "eye.slash")
}
.buttonStyle(.glass)
Text("«\(lesson.subject)»\(lesson.type.isEmpty ? "" : " · " + lesson.type.lowercased()) пропадёт из расписания, обзора, виджетов и напоминаний во все недели. Вернуть можно в настройках.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.padding(20)
}
.background(Color(.systemGroupedBackground))
.navigationTitle(ref.date.map { $0.weekdayName + ", " + $0.longLabel } ?? "Занятие")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово", systemImage: "checkmark") { dismiss() }
}
}
}
.sheet(item: $editor) { NoteEditor(request: $0) }
}
}
private struct TypeTag: View {
var type: String
var body: some View {
let color: Color = switch type {
case "Лекция": .brand
case "Практика": .purple
case "Экзамен", "Зачёт", "Защита курсовой работы", "Консультация": .sessionAccent
default: .gray
}
Text(type.isEmpty ? "Занятие" : type)
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 10).padding(.vertical, 4)
.foregroundStyle(color)
.background(color.opacity(0.14), in: .capsule)
}
}
private struct DetailRow: View {
var symbol: String
var label: String
var value: String
var extra: String?
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
Text(value).font(.body.weight(.medium))
if let extra { Text(extra).font(.subheadline).foregroundStyle(.secondary) }
}
}
.accessibilityElement(children: .combine)
}
}
/// "Был" / "Пропустил"; choosing the marked answer again takes the mark back.
private struct AttendancePicker: View {
@Environment(AppModel.self) private var model
var lesson: Lesson
var date: CivilDate
var body: some View {
let mark = model.marks.forLesson(lesson.subject, date, lesson.start)
VStack(alignment: .leading, spacing: 8) {
Text("Посещаемость").font(.headline)
GlassEffectContainer(spacing: 8) {
HStack(spacing: 8) {
choice("Был", "checkmark", value: true, current: mark?.present)
choice("Пропустил", "xmark", value: false, current: mark?.present)
}
}
if mark != nil {
Text("Нажмите на отметку ещё раз, чтобы снять её.").font(.footnote).foregroundStyle(.secondary)
}
}
}
private func choice(_ label: String, _ symbol: String, value: Bool, current: Bool?) -> some View {
let on = current == value
return Button {
model.mark(lesson, on: date, present: on ? nil : value)
} label: {
Label(label, systemImage: on ? symbol : "circle").frame(maxWidth: .infinity)
}
.buttonStyle(.glass)
.tint(on ? (value ? .green : .red) : nil)
.accessibilityAddTraits(on ? .isSelected : [])
}
}
// MARK: - Tasks and notes
/// What the editor needs to file a new note: the lesson it opened from and the next one.
struct NoteTarget: Hashable {
var subject: String
var date: CivilDate
var start: String
var next: NextLesson?
}
struct NextLesson: Hashable {
var date: CivilDate
var start: String
}
/// Opens the editor for a new note (target) or an existing one (editing).
struct EditorRequest: Identifiable {
var target: NoteTarget?
var editing: Note?
var id: String { editing?.id ?? "new-" + (target.map { $0.date.iso + $0.start + $0.subject } ?? "") }
}
/// The lesson sheet's section: this lesson's tasks and notes and a button to add one.
private struct LessonNotes: View {
@Environment(AppModel.self) private var model
var lesson: Lesson
var date: CivilDate
@Binding var editor: EditorRequest?
var body: some View {
let notes = model.notes.forLesson(lesson.subject, date, lesson.start)
VStack(alignment: .leading, spacing: 6) {
Text("Задания и заметки").font(.headline)
if notes.isEmpty {
Text("Запишите домашнее задание или напоминание — оно появится в карточке пары и в обзоре.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
ForEach(notes) { note in
NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) }
}
Button {
let next = model.nextOccurrence(of: lesson.subject, after: date, start: lesson.start)
editor = EditorRequest(target: NoteTarget(
subject: lesson.subject, date: date, start: lesson.start,
next: next.map { NextLesson(date: $0.0, start: $0.1.start) }
))
} label: {
Label("Добавить", systemImage: "plus")
}
.buttonStyle(.glass)
.padding(.top, 6)
}
}
}
/// A task with its checkbox or a note with its icon; a tap opens the editor.
struct NoteRow: View {
@Environment(AppModel.self) private var model
var note: Note
var showSubject = true
var showDue = true
var onEdit: () -> Void
var body: some View {
let overdue = note.overdue(today: model.today)
HStack(alignment: .center, spacing: 12) {
if note.task {
Button {
withAnimation(.snappy) { model.toggle(note) }
} label: {
Image(systemName: note.done ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(note.done ? Color.brand : Color.secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.accessibilityLabel(note.done ? "Выполнено" : "Не выполнено")
} else {
Image(systemName: "note.text").font(.title3).foregroundStyle(.secondary).frame(width: 28)
}
Button(action: onEdit) {
VStack(alignment: .leading, spacing: 2) {
Text(note.text)
.strikethrough(note.task && note.done)
.foregroundStyle(note.task && note.done ? .secondary : .primary)
.lineLimit(3)
HStack(spacing: 6) {
if showSubject && !note.general { SubjectDot(subject: note.subject, size: 8) }
Text(subtitle(overdue: overdue))
.font(.subheadline)
.foregroundStyle(overdue ? Color.red : Color.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
.padding(.vertical, 4)
}
private func subtitle(overdue: Bool) -> String {
var parts: [String] = []
if showSubject { parts.append(note.general ? "Общий план" : note.subject) }
if overdue { parts.append("просрочено") }
else if showDue { parts.append(note.dueLabel(today: model.today)) }
else if !note.start.isEmpty { parts.append("к " + note.start) }
return parts.joined(separator: " · ")
}
}
/**
* Adds or edits a task or a note. A new one goes to this lesson or, for homework set in
* class, to the next lesson of the subject; once the lesson is over the next one is the default.
*/
struct NoteEditor: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var request: EditorRequest
@State private var text = ""
@State private var task = true
@State private var toNext = false
@State private var day = Date()
@FocusState private var focused: Bool
private var general: Bool { request.editing?.general ?? (request.target?.subject.trimmed.isEmpty ?? true) }
var body: some View {
NavigationStack {
Form {
Section {
Picker("Вид", selection: $task) {
Text("Задание").tag(true)
Text("Заметка").tag(false)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
Section {
TextField(task ? "Например, сдать лабораторную 3" : "Например, принести конспект", text: $text, axis: .vertical)
.lineLimit(3...8)
.focused($focused)
.textInputAutocapitalization(.sentences)
}
if general {
Section("День") {
DatePicker("День", selection: $day, displayedComponents: .date)
.environment(\.timeZone, minsk)
}
}
if request.editing == nil, let target = request.target, let next = target.next {
Section {
Picker("К какому занятию", selection: $toNext) {
Text("Эта пара").tag(false)
Text("Следующая").tag(true)
}
.pickerStyle(.segmented)
} header: {
Text("К какому занятию")
} footer: {
let (d, start) = toNext ? (next.date, next.start) : (target.date, target.start)
Text(d.shortWeekday + ", " + d.longLabel + ", " + start)
}
}
if let editing = request.editing {
Section {
Button("Удалить", role: .destructive) {
model.delete(editing)
dismiss()
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена", systemImage: "xmark") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Сохранить", systemImage: "checkmark") { save() }
.disabled(text.trimmed.isEmpty)
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
if let e = request.editing {
text = e.text
task = e.task
day = (CivilDate(e.date) ?? model.today).date
} else if let t = request.target {
day = t.date.date
// Once the lesson has begun, homework most likely belongs to the next one.
toNext = t.next != nil && model.started(t.date, t.start) && !t.start.isEmpty
}
focused = true
}
}
private var title: String {
if request.editing != nil { return task ? "Задание" : "Заметка" }
if general { return task ? "Новый общий план" : "Новая заметка на день" }
return task ? "Новое задание" : "Новая заметка"
}
private func save() {
guard let group = request.editing?.group ?? model.groupKey else { return }
var note: Note
if let e = request.editing {
note = e
} else if let t = request.target {
let (d, start) = toNext && t.next != nil ? (t.next!.date, t.next!.start) : (t.date, t.start)
note = Note(group: group, subject: t.subject, date: d.iso, start: start, text: "")
} else { return }
if general { note.date = CivilDate(day).iso }
note.text = text.trimmed
note.task = task
model.save(note)
dismiss()
}
}