1
0
forked from firka/firka

Add next-school-day support to iOS widgets

Add support for showing the next school day in widgets. Changes include: new localization keys (next_school_day_timetable, no_lessons_ahead) and a formatShortDate helper; updated Hungarian hours abbreviation. Widget model updated (TimetableData) to include nextSchoolDay and nextSchoolDayDate. TimetableProvider now sets isNextSchoolDay/nextSchoolDayDate and returns an entry when a future school day is found. Views (inline, lock screen, small/medium/large) updated to display the next school day header/date and show no_lessons_ahead where appropriate. Flutter helpers (widget cache/IOS helper) extended to serialize nextSchoolDayLessons/nextSchoolDayDate and to search up to a week ahead for the next school day when tomorrow is empty.
This commit is contained in:
Horváth Gergely
2026-01-30 20:25:02 +01:00
committed by 4831c0
parent 402067d624
commit 16b7d2f70a
8 changed files with 170 additions and 14 deletions

View File

@@ -19,6 +19,16 @@ struct WidgetLocalization {
"en": "Tomorrow's timetable",
"de": "Stundenplan morgen"
],
"next_school_day_timetable": [
"hu": "Következő órarend (%@)",
"en": "Next timetable (%@)",
"de": "Nächster Stundenplan (%@)"
],
"no_lessons_ahead": [
"hu": "Nincs óra a héten",
"en": "No lessons this week",
"de": "Kein Unterricht diese Woche"
],
"current_lesson": [
"hu": "Jelenlegi óra",
"en": "Current lesson",
@@ -200,7 +210,7 @@ struct WidgetLocalization {
"de": "Min"
],
"hours_abbrev": [
"hu": "ó",
"hu": "óra",
"en": "h",
"de": "Std"
],
@@ -225,4 +235,34 @@ struct WidgetLocalization {
let template = string(key)
return template.replacingOccurrences(of: "%d", with: "\(arg)")
}
static func formatShortDate(_ isoString: String?, locale: String = "hu") -> String {
guard let isoString = isoString else { return "" }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime]
let shortFormatter = DateFormatter()
shortFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
shortFormatter.locale = Locale(identifier: "en_US_POSIX")
let date: Date?
if let d = isoFormatter.date(from: isoString) {
date = d
} else if let d = shortFormatter.date(from: isoString) {
date = d
} else {
let simple = DateFormatter()
simple.dateFormat = "yyyy-MM-dd"
simple.locale = Locale(identifier: "en_US_POSIX")
date = simple.date(from: String(isoString.prefix(10)))
}
guard let date = date else { return "" }
let displayFormatter = DateFormatter()
displayFormatter.locale = Locale(identifier: locale == "de" ? "de_DE" : locale == "en" ? "en_US" : "hu_HU")
displayFormatter.dateFormat = locale == "hu" ? "MMM d." : "MMM d"
return displayFormatter.string(from: date)
}
}

View File

@@ -34,12 +34,20 @@ struct TimetableInlineWidgetView: View {
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
Text("\(current.subject.name) · \(remaining) \(localization.string("minutes_abbrev"))")
} else if entry.isNextSchoolDay {
if let first = entry.lessons.first {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
let lessonNum = first.lessonNumber ?? 1
Text("\(dateStr): \(lessonNum). \(first.subject.name)")
} else {
Text(localization.string("no_lessons_ahead"))
}
} else if entry.isNextDay {
if let first = entry.lessons.first {
let lessonNum = first.lessonNumber ?? 1
Text("\(localization.string("tomorrow")): \(lessonNum). \(first.subject.name)")
} else {
Text(localization.string("no_lessons"))
Text(localization.string("no_lessons_ahead"))
}
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
@@ -52,7 +60,7 @@ struct TimetableInlineWidgetView: View {
Text("\(next.subject.name) · \(until) \(localization.string("minutes_abbrev"))")
}
} else {
Text(localization.string("no_lessons"))
Text(localization.string("no_lessons_ahead"))
}
}

View File

@@ -59,12 +59,20 @@ struct TimetableInlineView: View {
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
Text("\(current.subject.name) - \(remaining) \(localization.string("minutes_short"))")
} else if entry.isNextSchoolDay {
if let first = entry.lessons.first {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
let lessonNum = first.lessonNumber ?? 1
Text("\(dateStr): \(lessonNum). \(first.subject.name)")
} else {
Text(localization.string("no_lessons_ahead"))
}
} else if entry.isNextDay {
if let first = entry.lessons.first {
let lessonNum = first.lessonNumber ?? 1
Text("\(localization.string("tomorrow")): \(lessonNum). \(first.subject.name)")
} else {
Text(localization.string("no_lessons"))
Text(localization.string("no_lessons_ahead"))
}
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
@@ -77,7 +85,7 @@ struct TimetableInlineView: View {
Text("\(next.subject.name) \(localization.string("in_minutes", until))")
}
} else {
Text(localization.string("no_lessons"))
Text(localization.string("no_lessons_ahead"))
}
}
@@ -109,7 +117,7 @@ struct TimetableCircularView: View {
.font(.system(.title2, design: .rounded, weight: .bold))
}
.gaugeStyle(.accessoryCircularCapacity)
} else if entry.isNextDay {
} else if entry.isNextSchoolDay || entry.isNextDay {
let lessonCount = entry.lessons.count
if lessonCount > 0 {
ZStack {
@@ -220,13 +228,21 @@ struct TimetableRectangularView: View {
.frame(maxWidth: .infinity, alignment: .leading)
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
let isFutureDay = entry.isNextDay || entry.isNextSchoolDay
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.isNextDay ? localization.string("tomorrow") : localization.string("next"))
.font(.caption)
.foregroundStyle(.secondary)
if entry.isNextSchoolDay {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
Text(dateStr)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(entry.isNextDay ? localization.string("tomorrow") : localization.string("next"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if until > 0 && !entry.isNextDay {
if until > 0 && !isFutureDay {
if until > 60 {
Text(localization.string("in_hours", until / 60))
.font(.caption)

View File

@@ -95,7 +95,7 @@ struct WidgetData: Codable {
lastUpdated: nil,
locale: "hu",
theme: "dark",
timetable: TimetableData(today: [], tomorrow: [], currentBreak: nil),
timetable: TimetableData(today: [], tomorrow: [], nextSchoolDay: nil, nextSchoolDayDate: nil, currentBreak: nil),
grades: [],
averages: AveragesData(overall: nil, subjects: [])
)
@@ -105,6 +105,8 @@ struct WidgetData: Codable {
struct TimetableData: Codable {
let today: [WidgetLesson]
let tomorrow: [WidgetLesson]
let nextSchoolDay: [WidgetLesson]?
let nextSchoolDayDate: String?
let currentBreak: BreakInfo?
}

View File

@@ -9,6 +9,8 @@ struct TimetableEntry: TimelineEntry {
let currentLesson: WidgetLesson?
let nextLesson: WidgetLesson?
let isNextDay: Bool
let isNextSchoolDay: Bool
let nextSchoolDayDateString: String?
let breakInfo: BreakInfo?
let state: TimetableState
let debugInfo: String
@@ -35,6 +37,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: nil,
nextLesson: nil,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: .normal,
debugInfo: "placeholder"
@@ -62,6 +66,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: nil,
nextLesson: nil,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: breakInfo,
state: .onBreak,
debugInfo: WidgetData.lastError
@@ -152,6 +158,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: nil,
nextLesson: nil,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: .loginRequired,
debugInfo: WidgetData.lastError
@@ -167,6 +175,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: nil,
nextLesson: nil,
isNextDay: false,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: breakInfo,
state: .onBreak,
debugInfo: WidgetData.lastError
@@ -202,6 +212,23 @@ struct TimetableProvider: AppIntentTimelineProvider {
}
if lessons.isEmpty {
if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.isEmpty {
return TimetableEntry(
date: date,
configuration: configuration,
data: data,
lessons: nextSchoolDayLessons,
currentLesson: nil,
nextLesson: nextSchoolDayLessons.first,
isNextDay: false,
isNextSchoolDay: true,
nextSchoolDayDateString: data.timetable.nextSchoolDayDate,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError
)
}
return TimetableEntry(
date: date,
configuration: configuration,
@@ -210,6 +237,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: nil,
nextLesson: nil,
isNextDay: isNextDay,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: isNextDay ? .noMoreLessons : .unavailable,
debugInfo: WidgetData.lastError
@@ -229,6 +258,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
currentLesson: currentLesson,
nextLesson: nextLesson,
isNextDay: isNextDay,
isNextSchoolDay: false,
nextSchoolDayDateString: nil,
breakInfo: nil,
state: .normal,
debugInfo: WidgetData.lastError

View File

@@ -47,6 +47,13 @@ struct TimetableSmallView: View {
.widgetTextStyle(style, colors: nil, isPrimary: false)
if let lesson = displayLesson {
if entry.isNextSchoolDay {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
Text(dateStr)
.font(.caption2)
.widgetTextStyle(style, colors: nil, isPrimary: false)
}
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.semibold)
@@ -73,7 +80,7 @@ struct TimetableSmallView: View {
(style == .liquidGlass ? liquidGlassSecondary : .secondary))
}
} else {
Text(localization.string("no_lessons"))
Text(localization.string("no_lessons_ahead"))
.font(.subheadline)
.widgetTextStyle(style, colors: nil, isPrimary: false)
}
@@ -125,12 +132,23 @@ struct TimetableMediumView: View {
return Array(entry.lessons[startIndex..<endIndex])
}
var headerText: String {
if entry.isNextSchoolDay {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
return localization.string("next_school_day_timetable", dateStr)
} else if entry.isNextDay {
return localization.string("tomorrow_timetable")
} else {
return localization.string("today_timetable")
}
}
var body: some View {
ZStack {
WidgetBackground(style: style, colors: nil)
VStack(alignment: .leading, spacing: 6) {
Text(entry.isNextDay ? localization.string("tomorrow_timetable") : localization.string("today_timetable"))
Text(headerText)
.font(.caption)
.fontWeight(.semibold)
.widgetTextStyle(style, colors: nil, isPrimary: false)
@@ -160,12 +178,23 @@ struct TimetableLargeView: View {
return checkDate >= lesson.start && checkDate <= lesson.end
}
var headerText: String {
if entry.isNextSchoolDay {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
return localization.string("next_school_day_timetable", dateStr)
} else if entry.isNextDay {
return localization.string("tomorrow_timetable")
} else {
return localization.string("today_timetable")
}
}
var body: some View {
ZStack {
WidgetBackground(style: style, colors: nil)
VStack(alignment: .leading, spacing: 6) {
Text(entry.isNextDay ? localization.string("tomorrow_timetable") : localization.string("today_timetable"))
Text(headerText)
.font(.headline)
.fontWeight(.semibold)
.widgetTextStyle(style, colors: nil)

View File

@@ -28,6 +28,8 @@ class IOSWidgetHelper {
required String theme,
required List<Lesson> todayLessons,
required List<Lesson> tomorrowLessons,
List<Lesson> nextSchoolDayLessons = const [],
DateTime? nextSchoolDayDate,
required List<Grade> grades,
required Map<String, double> subjectAverages,
required double? overallAverage,
@@ -53,6 +55,8 @@ class IOSWidgetHelper {
'timetable': {
'today': todayLessons.map((l) => _lessonToJson(l)).toList(),
'tomorrow': tomorrowLessons.map((l) => _lessonToJson(l)).toList(),
'nextSchoolDay': nextSchoolDayLessons.map((l) => _lessonToJson(l)).toList(),
'nextSchoolDayDate': nextSchoolDayDate?.toIso8601String(),
'currentBreak': currentBreak != null ? {
'name': currentBreak.name,
'nameKey': currentBreak.nameKey,

View File

@@ -82,6 +82,8 @@ class WidgetCacheHelper {
required String theme,
required List<Lesson> todayLessons,
required List<Lesson> tomorrowLessons,
List<Lesson> nextSchoolDayLessons = const [],
DateTime? nextSchoolDayDate,
required List<Grade> grades,
required Map<String, double> subjectAverages,
required double? overallAverage,
@@ -92,6 +94,8 @@ class WidgetCacheHelper {
theme: theme,
todayLessons: todayLessons,
tomorrowLessons: tomorrowLessons,
nextSchoolDayLessons: nextSchoolDayLessons,
nextSchoolDayDate: nextSchoolDayDate,
grades: grades,
subjectAverages: subjectAverages,
overallAverage: overallAverage,
@@ -160,6 +164,26 @@ class WidgetCacheHelper {
debugPrint('iOS widget refresh: ${todayLessons.length} today lessons, ${tomorrowLessons.length} tomorrow lessons');
List<Lesson> nextSchoolDayLessons = [];
DateTime? nextSchoolDayDate;
if (tomorrowLessons.isEmpty) {
for (int i = 2; i <= 7; i++) {
final dayMidnight = todayMidnight.add(Duration(days: i));
final dayResponse = await client.getTimeTable(
dayMidnight,
dayMidnight.add(Duration(hours: 23, minutes: 59)),
forceCache: false,
);
final dayLessons = dayResponse.response ?? [];
if (dayLessons.isNotEmpty) {
nextSchoolDayLessons = dayLessons;
nextSchoolDayDate = dayMidnight;
debugPrint('iOS widget: Next school day found ${i} days ahead with ${dayLessons.length} lessons');
break;
}
}
}
final gradesResponse = await client.getGrades(forceCache: false);
final grades = gradesResponse.response ?? [];
@@ -199,6 +223,8 @@ class WidgetCacheHelper {
theme: theme,
todayLessons: todayLessons,
tomorrowLessons: tomorrowLessons,
nextSchoolDayLessons: nextSchoolDayLessons,
nextSchoolDayDate: nextSchoolDayDate,
grades: grades,
subjectAverages: subjectAverages,
overallAverage: overallAverage,