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

@@ -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)