Add date and time handling, models, schedule management, storage, and widget support
- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
This commit is contained in:
229
ios/PSUSchedule/Views/SubjectsView.swift
Normal file
229
ios/PSUSchedule/Views/SubjectsView.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
/// Everything the loaded weeks say about one subject.
|
||||
private struct SubjectSummary: Identifiable {
|
||||
var name: String
|
||||
var types: [String]
|
||||
var teachers: [String]
|
||||
var rooms: [String]
|
||||
var upcoming: [(Slot, Lesson)]
|
||||
var weekDone: Int
|
||||
var weekTotal: Int
|
||||
var id: String { subjectKey(name) }
|
||||
}
|
||||
|
||||
private func unique(_ items: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return items.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
/// Subjects soonest first, with the week's progress, teachers and rooms.
|
||||
struct SubjectsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@State private var open: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let t = model.timetable, t.semester != nil {
|
||||
let subjects = summaries(t)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
WeekLoad(timetable: t, subjects: subjects.count)
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("Все предметы").font(.title2.bold())
|
||||
Spacer()
|
||||
Text("по ближайшему занятию").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) {
|
||||
ForEach(subjects) { s in
|
||||
Button { open = s.id } label: { SubjectCard(summary: s) }.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: 1200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.sheet(item: Binding(get: { open.flatMap { id in subjects.first { $0.id == id } } }, set: { open = $0?.id })) { s in
|
||||
SubjectDetails(summary: s).presentationDetents([.medium, .large])
|
||||
}
|
||||
} else if model.timetable != nil {
|
||||
EmptyState(title: "Предметы недоступны", text: "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", symbol: "calendar.badge.exclamationmark")
|
||||
} else {
|
||||
EmptyState(title: "Предметов пока нет", text: "Выберите группу — предметы появятся из её расписания.", symbol: "books.vertical", action: ("Выбрать группу", { router.groups = true }))
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Предметы")
|
||||
}
|
||||
|
||||
private func summaries(_ t: Timetable) -> [SubjectSummary] {
|
||||
let date = model.today
|
||||
let clock = model.clock
|
||||
let monday = date.monday
|
||||
let entries = t.dates.flatMap { model.slots(t, on: $0) }.flatMap { slot in slot.lessons.map { (slot, $0) } }
|
||||
let grouped = Dictionary(grouping: entries) { subjectKey($0.1.subject) }
|
||||
return grouped.values.map { items in
|
||||
let lessons = items.map(\.1)
|
||||
let week = items.filter { $0.0.date.monday == monday }
|
||||
return SubjectSummary(
|
||||
name: lessons[0].subject,
|
||||
types: unique(lessons.map { $0.type.isEmpty ? "Занятие" : $0.type }),
|
||||
teachers: unique(lessons.flatMap(\.teachers)),
|
||||
rooms: unique(lessons.flatMap(\.rooms)),
|
||||
upcoming: items.filter { $0.0.date > date || ($0.0.date == date && $0.0.end > clock) },
|
||||
weekDone: week.filter { $0.0.date < date || ($0.0.date == date && $0.0.end <= clock) }.count,
|
||||
weekTotal: week.count
|
||||
)
|
||||
}
|
||||
// Soonest first; subjects with nothing ahead go to the end.
|
||||
.sorted { a, b in
|
||||
switch (a.upcoming.first?.0, b.upcoming.first?.0) {
|
||||
case let (x?, y?): (x.date, x.start, a.name) < (y.date, y.start, b.name)
|
||||
case (.some, nil): true
|
||||
case (nil, .some): false
|
||||
default: a.name < b.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs per weekday of the current week as a bar chart, today's bar in the strong tone.
|
||||
private struct WeekLoad: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var subjects: Int
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let monday = date.monday
|
||||
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0).count) }
|
||||
.filter { $0.0.weekday < 7 || $0.1 > 0 }
|
||||
let total = days.reduce(0) { $0 + $1.1 }
|
||||
let most = days.map(\.1).max() ?? 0
|
||||
let busiest = days.filter { $0.1 == most && most > 0 }.map { $0.0.weekdayName.lowercased() }
|
||||
SectionCard(symbol: "chart.bar", title: "Эта неделя") {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(pairs(total)) · \(subjects) \(plural(subjects, "предмет", "предмета", "предметов"))").font(.title3.bold())
|
||||
if !busiest.isEmpty {
|
||||
Text("Больше всего — \(busiest.joined(separator: " и ")), \(pairs(most))").font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Chart(days, id: \.0) { day in
|
||||
BarMark(x: .value("День", day.0.shortWeekday), y: .value("Пары", day.1), width: .ratio(0.55))
|
||||
.foregroundStyle(day.0 == date ? Color.brand : Color.brand.opacity(day.0 < date ? 0.3 : 0.55))
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
.annotation(position: .top) {
|
||||
Text(day.1 > 0 ? "\(day.1)" : "—").font(.caption.weight(.semibold)).foregroundStyle(day.0 == date ? Color.brand : .secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.chartYScale(domain: 0...max(1, most + 1))
|
||||
.frame(height: 120)
|
||||
.accessibilityLabel(days.map { "\($0.0.weekdayName): \($0.1)" }.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SubjectCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var summary: SubjectSummary
|
||||
|
||||
var body: some View {
|
||||
WithSubjectColor(subject: summary.name) { c in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(abbreviate(summary.name))
|
||||
.font(.footnote.weight(.bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.padding(.horizontal, 4)
|
||||
.foregroundStyle(c.onStrong)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(c.strong, in: .rect(cornerRadius: 12, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.name).font(.headline).lineLimit(3)
|
||||
Text(summary.types.joined(separator: ", ")).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let first = summary.upcoming.first {
|
||||
let (slot, lesson) = first
|
||||
Meta(symbol: "clock", text: nextLabel(slot) + (lesson.rooms.isEmpty ? "" : " · ауд. " + lesson.rooms.joined(separator: ", ")))
|
||||
} else {
|
||||
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
|
||||
}
|
||||
if !summary.teachers.isEmpty { Meta(symbol: "person", text: summary.teachers.joined(separator: ", ")) }
|
||||
if summary.weekTotal > 0 {
|
||||
ProgressView(value: Double(summary.weekDone), total: Double(summary.weekTotal)) {
|
||||
Text("На этой неделе: \(summary.weekDone) из \(summary.weekTotal)").font(.subheadline)
|
||||
}
|
||||
.tint(c.strong)
|
||||
}
|
||||
let stats = model.marks.stats(for: summary.name)
|
||||
if stats.total > 0 {
|
||||
Meta(symbol: "person.badge.clock", text: "Посещено \(stats.present) из \(stats.total)")
|
||||
}
|
||||
let tasks = model.notes.filter { subjectKey($0.subject) == summary.id && $0.task && !$0.done }.count
|
||||
if tasks > 0 { Meta(symbol: "checklist", text: "\(tasks) \(plural(tasks, "задание", "задания", "заданий"))") }
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.card(padding: 18)
|
||||
}
|
||||
}
|
||||
|
||||
private func nextLabel(_ slot: Slot) -> String {
|
||||
let today = model.today
|
||||
let day = slot.date == today ? "сегодня" : slot.date == today.adding(days: 1) ? "завтра" : slot.date.shortWeekday.lowercased() + ", " + slot.date.longLabel
|
||||
return "Далее \(day), \(slot.start)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A subject in full: teachers, rooms, upcoming lessons, attendance and tasks.
|
||||
private struct SubjectDetails: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var summary: SubjectSummary
|
||||
@State private var details: LessonRef?
|
||||
@State private var editor: EditorRequest?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
LabeledContent("Виды занятий", value: summary.types.joined(separator: ", "))
|
||||
if !summary.teachers.isEmpty { LabeledContent("Преподаватели", value: summary.teachers.joined(separator: ", ")) }
|
||||
if !summary.rooms.isEmpty { LabeledContent("Аудитории", value: summary.rooms.joined(separator: ", ")) }
|
||||
let stats = model.marks.stats(for: summary.name)
|
||||
if stats.total > 0 { LabeledContent("Посещаемость", value: "\(stats.present) из \(stats.total)") }
|
||||
}
|
||||
let notes = model.notes.filter { subjectKey($0.subject) == summary.id }
|
||||
if !notes.isEmpty {
|
||||
Section("Задания и заметки") {
|
||||
ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } }
|
||||
}
|
||||
}
|
||||
Section("Ближайшие занятия") {
|
||||
if summary.upcoming.isEmpty { Text("Занятий в этом семестре больше нет").foregroundStyle(.secondary) }
|
||||
ForEach(Array(summary.upcoming.prefix(12).enumerated()), id: \.offset) { _, item in
|
||||
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
|
||||
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(summary.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
|
||||
}
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user