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:
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
90
ios/PSUSchedule/Views/SessionMark.swift
Normal file
90
ios/PSUSchedule/Views/SessionMark.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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: ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user