Improve iOS widgets: UI, timeline and JSON fixes

Multiple widget improvements and bug fixes:

- Models: WidgetGrade now exposes subjectNameWithWeight and teacherName for display.
- AveragesProvider: added isFiltered flag, improved subject filtering logic and propagated flag to entries.
- Averages views: show filtered count badge, use configurable max visible items, and respect isFiltered.
- Grades views: display subject name with weight, show teacher in grade rows (renamed flag to showTeacher) and preserve topic display.
- TimetableProvider: build unique transition times for timeline updates, sort entries, and determine current lesson using the provided date instead of Date().
- Timetable views: compute visibleLessons window around the current lesson based on entry.date and use isLessonActive checks against entry.date.
- iOS widget JSON helper: fix subject.category serialization structure, use grade.creationDate for recordDate, and use grade.teacher for teacherName.

These changes fix incorrect JSON exports, improve timeline accuracy, and enhance widget UX when subjects are filtered or when showing teacher/weight info.
This commit is contained in:
Horváth Gergely
2026-01-28 23:37:27 +01:00
parent 4ff6f2fdb0
commit 2a4836c42f
7 changed files with 145 additions and 46 deletions

View File

@@ -37,4 +37,15 @@ struct WidgetGrade: Codable, Identifiable {
default: return .gray
}
}
var subjectNameWithWeight: String {
if let weight = weightPercentage, weight != 100 {
return "\(subject.name) (\(weight)%)"
}
return subject.name
}
var teacherName: String? {
subject.teacherName
}
}

View File

@@ -7,6 +7,7 @@ struct AveragesEntry: TimelineEntry {
let overallAverage: Double?
let subjectAverages: [SubjectAverage]
let locale: String
let isFiltered: Bool
}
struct AveragesProvider: AppIntentTimelineProvider {
@@ -19,7 +20,8 @@ struct AveragesProvider: AppIntentTimelineProvider {
configuration: AveragesWidgetIntent(),
overallAverage: nil,
subjectAverages: [],
locale: "hu"
locale: "hu",
isFiltered: false
)
}
@@ -39,9 +41,10 @@ struct AveragesProvider: AppIntentTimelineProvider {
let data = WidgetData.load()
var subjectAverages = data?.averages.subjects ?? []
let isFiltered = configuration.selectedSubjects?.isEmpty == false
if let selectedSubjects = configuration.selectedSubjects, !selectedSubjects.isEmpty {
let selectedIds = Set(selectedSubjects.map { $0.id })
if isFiltered {
let selectedIds = Set(configuration.selectedSubjects!.map { $0.id })
subjectAverages = subjectAverages.filter { selectedIds.contains($0.uid) }
}
@@ -50,7 +53,8 @@ struct AveragesProvider: AppIntentTimelineProvider {
configuration: configuration,
overallAverage: data?.averages.overall,
subjectAverages: subjectAverages,
locale: data?.locale ?? "hu"
locale: data?.locale ?? "hu",
isFiltered: isFiltered
)
}
}

View File

@@ -73,17 +73,25 @@ struct TimetableProvider: AppIntentTimelineProvider {
entries.append(createEntry(for: configuration, date: now))
var transitionTimes: Set<Date> = []
for lesson in todayLessons {
if lesson.start > now {
entries.append(createEntry(for: configuration, date: lesson.start))
transitionTimes.insert(lesson.start)
}
if lesson.end > now {
entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1)))
transitionTimes.insert(lesson.end.addingTimeInterval(1))
}
}
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86400))
entries.append(createEntry(for: configuration, date: midnight))
transitionTimes.insert(midnight)
for time in transitionTimes {
entries.append(createEntry(for: configuration, date: time))
}
entries.sort { $0.date < $1.date }
return Timeline(entries: entries, policy: .atEnd)
}
@@ -166,8 +174,7 @@ struct TimetableProvider: AppIntentTimelineProvider {
}
let currentLesson = lessons.first { lesson in
let now = Date()
return now >= lesson.start && now <= lesson.end
return date >= lesson.start && date <= lesson.end
}
let nextLesson = lessons.first { $0.start > date }

View File

@@ -47,20 +47,46 @@ struct AveragesSmallView: View {
struct AveragesMediumView: View {
let entry: AveragesEntry
let localization: WidgetLocalization
private let maxVisible = 4
var style: WidgetStyleType {
(entry.configuration.style ?? .appTheme) == .liquidGlass ? .liquidGlass : .appTheme
}
var totalCount: Int {
entry.subjectAverages.count
}
var showingCount: Int {
min(totalCount, maxVisible)
}
var body: some View {
ZStack {
WidgetBackground(style: style, colors: nil)
VStack(alignment: .leading, spacing: 8) {
Text(localization.string("subject_averages"))
.font(.caption)
.fontWeight(.medium)
.widgetTextStyle(style, colors: nil, isPrimary: false)
HStack {
Text(localization.string("subject_averages"))
.font(.caption)
.fontWeight(.medium)
.widgetTextStyle(style, colors: nil, isPrimary: false)
Spacer()
if entry.isFiltered && totalCount > maxVisible {
Text("\(showingCount)/\(totalCount)")
.font(.caption2)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Color.secondary.opacity(0.2))
)
.widgetTextStyle(style, colors: nil, isPrimary: false)
}
}
if entry.subjectAverages.isEmpty {
Spacer()
@@ -69,7 +95,7 @@ struct AveragesMediumView: View {
.widgetTextStyle(style, colors: nil, isPrimary: false)
Spacer()
} else {
ForEach(entry.subjectAverages.prefix(4)) { subject in
ForEach(entry.subjectAverages.prefix(maxVisible)) { subject in
AverageRow(subject: subject, style: style)
}
Spacer()
@@ -83,11 +109,20 @@ struct AveragesMediumView: View {
struct AveragesLargeView: View {
let entry: AveragesEntry
let localization: WidgetLocalization
private let maxVisible = 9
var style: WidgetStyleType {
(entry.configuration.style ?? .appTheme) == .liquidGlass ? .liquidGlass : .appTheme
}
var totalCount: Int {
entry.subjectAverages.count
}
var showingCount: Int {
min(totalCount, maxVisible)
}
var body: some View {
ZStack {
WidgetBackground(style: style, colors: nil)
@@ -101,6 +136,19 @@ struct AveragesLargeView: View {
Spacer()
if entry.isFiltered && totalCount > maxVisible {
Text("\(showingCount)/\(totalCount)")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Color.secondary.opacity(0.2))
)
.widgetTextStyle(style, colors: nil, isPrimary: false)
}
if let overall = entry.overallAverage {
Text(String(format: "%.2f", overall))
.font(.headline)
@@ -116,7 +164,7 @@ struct AveragesLargeView: View {
.widgetTextStyle(style, colors: nil, isPrimary: false)
Spacer()
} else {
ForEach(entry.subjectAverages.prefix(8)) { subject in
ForEach(entry.subjectAverages.prefix(maxVisible)) { subject in
AverageRow(subject: subject, style: style, showGradeCount: true)
}
Spacer()

View File

@@ -29,7 +29,7 @@ struct GradesSmallView: View {
Spacer()
}
Text(grade.subject.name)
Text(grade.subjectNameWithWeight)
.font(.subheadline)
.fontWeight(.medium)
.widgetTextStyle(style, colors: nil)
@@ -78,7 +78,7 @@ struct GradesMediumView: View {
Spacer()
} else {
ForEach(entry.grades.prefix(3)) { grade in
GradeRow(grade: grade, style: style, showType: true)
GradeRow(grade: grade, style: style, showTeacher: true)
}
}
}
@@ -114,7 +114,7 @@ struct GradesLargeView: View {
Spacer()
} else {
ForEach(entry.grades.prefix(6)) { grade in
GradeRow(grade: grade, style: style, showType: true, showTopic: true)
GradeRow(grade: grade, style: style, showTeacher: true, showTopic: true)
}
}
}
@@ -127,7 +127,7 @@ struct GradesLargeView: View {
struct GradeRow: View {
let grade: WidgetGrade
let style: WidgetStyleType
var showType: Bool = false
var showTeacher: Bool = false
var showTopic: Bool = false
var body: some View {
@@ -139,23 +139,24 @@ struct GradeRow: View {
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(grade.subject.name)
Text(grade.subjectNameWithWeight)
.font(.subheadline)
.fontWeight(.medium)
.widgetTextStyle(style, colors: nil)
.lineLimit(1)
HStack(spacing: 4) {
if showType {
Text(grade.type.name)
if showTeacher, let teacher = grade.teacherName {
Text(teacher)
.font(.caption)
.widgetTextStyle(style, colors: nil, isPrimary: false)
.lineLimit(1)
Text("")
.font(.caption)
.widgetTextStyle(style, colors: nil, isPrimary: false)
}
Text("")
.font(.caption)
.widgetTextStyle(style, colors: nil, isPrimary: false)
Text(grade.dateString)
.font(.caption)
.widgetTextStyle(style, colors: nil, isPrimary: false)

View File

@@ -79,9 +79,36 @@ struct TimetableMediumView: View {
(entry.configuration.style ?? .appTheme) == .liquidGlass ? .liquidGlass : .appTheme
}
var remainingLessons: [WidgetLesson] {
let now = Date()
return entry.lessons.filter { $0.end > now }
func isLessonActive(_ lesson: WidgetLesson) -> Bool {
let checkDate = entry.date
return checkDate >= lesson.start && checkDate <= lesson.end
}
var currentLessonIndex: Int {
let checkDate = entry.date
if let index = entry.lessons.firstIndex(where: { checkDate >= $0.start && checkDate <= $0.end }) {
return index
}
if let index = entry.lessons.firstIndex(where: { $0.start > checkDate }) {
return index
}
return max(0, entry.lessons.count - 1)
}
var visibleLessons: [WidgetLesson] {
let totalLessons = entry.lessons.count
let maxVisible = 4
if totalLessons <= maxVisible {
return Array(entry.lessons)
}
var startIndex = max(0, currentLessonIndex - 1)
startIndex = min(startIndex, totalLessons - maxVisible)
let endIndex = min(startIndex + maxVisible, totalLessons)
return Array(entry.lessons[startIndex..<endIndex])
}
var body: some View {
@@ -94,8 +121,8 @@ struct TimetableMediumView: View {
.fontWeight(.medium)
.widgetTextStyle(style, colors: nil, isPrimary: false)
ForEach(remainingLessons.prefix(4)) { lesson in
LessonRow(lesson: lesson, isActive: lesson.isCurrentlyActive, style: style)
ForEach(visibleLessons) { lesson in
LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style)
}
}
.padding()
@@ -112,9 +139,9 @@ struct TimetableLargeView: View {
(entry.configuration.style ?? .appTheme) == .liquidGlass ? .liquidGlass : .appTheme
}
var remainingLessons: [WidgetLesson] {
let now = Date()
return entry.lessons.filter { $0.end > now }
func isLessonActive(_ lesson: WidgetLesson) -> Bool {
let checkDate = entry.date
return checkDate >= lesson.start && checkDate <= lesson.end
}
var body: some View {
@@ -127,8 +154,8 @@ struct TimetableLargeView: View {
.fontWeight(.semibold)
.widgetTextStyle(style, colors: nil)
ForEach(remainingLessons.prefix(7)) { lesson in
LessonRow(lesson: lesson, isActive: lesson.isCurrentlyActive, style: style, showRoom: true)
ForEach(entry.lessons.prefix(7)) { lesson in
LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style, showRoom: true)
}
}
.padding()

View File

@@ -108,10 +108,10 @@ class IOSWidgetHelper {
'subject': subject != null ? {
'uid': subject.uid,
'name': subject.name,
'category': subject.category != null ? {
'uid': subject.category!.uid,
'name': subject.category!.name,
'description': subject.category!.description,
'category': subject.category, {
'uid': subject.category.uid,
'name': subject.category.name,
'description': subject.category.description,
} : null,
'sortIndex': subject.sortIndex,
'teacherName': subject.teacherName,
@@ -132,17 +132,18 @@ class IOSWidgetHelper {
static Map<String, dynamic> _gradeToJson(Grade grade) {
return {
'uid': grade.uid,
'recordDate': _formatDateTimeWithOffset(grade.recordDate),
'recordDate': _formatDateTimeWithOffset(grade.creationDate),
'subject': {
'uid': grade.subject.uid,
'name': grade.subject.name,
'category': grade.subject.category != null ? {
'uid': grade.subject.category!.uid,
'name': grade.subject.category!.name,
'description': grade.subject.category!.description,
'category': grade.subject.category, {
'uid': grade.subject.category.uid,
'name': grade.subject.category.name,
'description': grade.subject.category.description,
} : null,
'sortIndex': grade.subject.sortIndex,
'teacherName': grade.subject.teacherName,
// Use the grade's teacher field, not subject.teacherName (which is usually null for grades)
'teacherName': grade.teacher,
},
'topic': grade.topic,
'type': {