- 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.
510 lines
23 KiB
Swift
510 lines
23 KiB
Swift
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()
|
||
}
|
||
}
|