Add lock-screen/inline widgets & timeline updates

Introduce Lock Screen and Inline variants for Timetable, Grades and Averages widgets and register them in HomeWidgetsBundle. Add new views and widget configurations (accessoryInline, accessoryCircular, accessoryRectangular) under LockScreen/, plus grade badge and average display UI. Extend Localization with many new keys in Helpers/Localization.swift and add lock-screen descriptions in en/de/hu Localizable.strings. Update TimetableProvider timeline logic to generate more frequent (per-minute) update entries for lock-screen families, deduplicate timeline dates and ensure proper midnight transition. Adjust TimetableViews display logic to prefer next lesson when current is absent and fix displayed label selection. Apply project.pbxproj updates to file-exception/group entries and some build phase metadata.
This commit is contained in:
Horváth Gergely
2026-01-29 13:36:11 +01:00
parent 0781685015
commit 503a51ca23
12 changed files with 1005 additions and 27 deletions

View File

@@ -103,6 +103,96 @@ struct WidgetLocalization {
"hu": "Terem",
"en": "Room",
"de": "Raum"
],
"until": [
"hu": "eddig:",
"en": "until",
"de": "bis"
],
"no_more_lessons_today": [
"hu": "Ma már nincs több óra",
"en": "No more lessons today",
"de": "Keine Stunden mehr heute"
],
"tomorrow": [
"hu": "Holnap",
"en": "Tomorrow",
"de": "Morgen"
],
"next": [
"hu": "Következő",
"en": "Next",
"de": "Nächste"
],
"minutes_short": [
"hu": "perc",
"en": "min",
"de": "Min"
],
"lesson_short": [
"hu": "óra",
"en": "lesson",
"de": "Std"
],
"in_minutes": [
"hu": "%d perc múlva",
"en": "in %d min",
"de": "in %d Min"
],
"today_new_grades": [
"hu": "Ma: %d új jegy",
"en": "Today: %d new",
"de": "Heute: %d neue"
],
"latest": [
"hu": "Legutóbbi",
"en": "Latest",
"de": "Letzte"
],
"today_grades": [
"hu": "Mai jegyek",
"en": "Today's grades",
"de": "Heutige Noten"
],
"pieces": [
"hu": "%d db",
"en": "%d pcs",
"de": "%d Stk"
],
"latest_grade": [
"hu": "Legutóbbi jegy",
"en": "Latest grade",
"de": "Letzte Note"
],
"average_short": [
"hu": "Átlag",
"en": "Avg",
"de": "Durchschn."
],
"overall_average_title": [
"hu": "Összesített átlag",
"en": "Overall average",
"de": "Gesamtdurchschnitt"
],
"subjects_count": [
"hu": "%d tárgy",
"en": "%d subjects",
"de": "%d Fächer"
],
"subject_averages_title": [
"hu": "Tantárgy átlagok",
"en": "Subject averages",
"de": "Fachdurchschnitte"
],
"subject_short": [
"hu": "tárgy",
"en": "subj",
"de": "Fächer"
],
"minutes_abbrev": [
"hu": "p",
"en": "min",
"de": "Min"
]
]
}

View File

@@ -4,8 +4,19 @@ import SwiftUI
@main
struct HomeWidgetsBundle: WidgetBundle {
var body: some Widget {
// Home Screen Widgets
TimetableWidget()
GradesWidget()
AveragesWidget()
// Lock Screen Widgets (circular & rectangular)
TimetableLockScreenWidget()
GradesLockScreenWidget()
AveragesLockScreenWidget()
// Inline Widgets (above the clock)
TimetableInlineWidget()
GradesInlineWidget()
AveragesInlineWidget()
}
}

View File

@@ -0,0 +1,180 @@
import WidgetKit
import SwiftUI
// MARK: - Lock Screen Averages Widget
struct AveragesLockScreenWidget: Widget {
let kind: String = "AveragesLockScreenWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: AveragesWidgetIntent.self,
provider: AveragesProvider()
) { entry in
AveragesLockScreenView(entry: entry)
}
.configurationDisplayName(LocalizedStringResource("widget_averages_title", defaultValue: "Averages"))
.description(LocalizedStringResource("widget_averages_lockscreen_description", defaultValue: "Shows your averages on lock screen"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Lock Screen View
struct AveragesLockScreenView: View {
@Environment(\.widgetFamily) var family
let entry: AveragesEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.locale)
}
var body: some View {
Group {
switch family {
case .accessoryInline:
AveragesInlineView(entry: entry, localization: localization)
case .accessoryCircular:
AveragesCircularView(entry: entry, localization: localization)
case .accessoryRectangular:
AveragesRectangularView(entry: entry, localization: localization)
default:
Text("--")
}
}
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Inline View
struct AveragesInlineView: View {
let entry: AveragesEntry
let localization: WidgetLocalization
var body: some View {
if let overall = entry.overallAverage {
Text("\(localization.string("average_short")): \(String(format: "%.2f", overall))")
} else if let first = entry.subjectAverages.first {
Text("\(first.name): \(String(format: "%.2f", first.average))")
} else {
Text(localization.string("no_averages"))
}
}
}
// MARK: - Circular View
struct AveragesCircularView: View {
let entry: AveragesEntry
let localization: WidgetLocalization
var body: some View {
if let overall = entry.overallAverage {
Gauge(value: overall, in: 1...5) {
Text("")
} currentValueLabel: {
Text(String(format: "%.1f", overall))
.font(.system(.title2, design: .rounded, weight: .bold))
.foregroundStyle(averageColor(overall))
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(averageColor(overall))
} else if let first = entry.subjectAverages.first {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text(String(format: "%.1f", first.average))
.font(.system(.title2, design: .rounded, weight: .bold))
.foregroundStyle(averageColor(first.average))
Text(String(first.name.prefix(4)))
.font(.system(.caption2))
.foregroundStyle(.secondary)
}
}
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "chart.bar")
.font(.title2)
}
}
}
private func averageColor(_ value: Double) -> Color {
switch value {
case 4.5...: return .green
case 3.5..<4.5: return .blue
case 2.5..<3.5: return .yellow
case 1.5..<2.5: return .orange
default: return .red
}
}
}
// MARK: - Rectangular View
struct AveragesRectangularView: View {
let entry: AveragesEntry
let localization: WidgetLocalization
var body: some View {
if let overall = entry.overallAverage {
HStack(spacing: 8) {
Text(String(format: "%.2f", overall))
.font(.system(.title, design: .rounded, weight: .bold))
.foregroundStyle(averageColor(overall))
.fixedSize()
VStack(alignment: .leading, spacing: 0) {
Text(localization.string("average_short"))
.font(.caption)
.foregroundStyle(.secondary)
Text(localization.string("subjects_count", entry.subjectAverages.count))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if !entry.subjectAverages.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text(localization.string("subject_averages_title"))
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
ForEach(entry.subjectAverages.prefix(3), id: \.uid) { subject in
VStack(alignment: .leading, spacing: 0) {
Text(String(format: "%.1f", subject.average))
.font(.system(.subheadline, design: .rounded, weight: .bold))
.foregroundStyle(averageColor(subject.average))
Text(String(subject.name.prefix(5)))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading) {
Label(localization.string("no_averages"), systemImage: "chart.bar")
.font(.headline)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func averageColor(_ value: Double) -> Color {
switch value {
case 4.5...: return .green
case 3.5..<4.5: return .blue
case 2.5..<3.5: return .yellow
case 1.5..<2.5: return .orange
default: return .red
}
}
}

View File

@@ -0,0 +1,232 @@
import WidgetKit
import SwiftUI
// MARK: - Lock Screen Grades Widget
struct GradesLockScreenWidget: Widget {
let kind: String = "GradesLockScreenWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: GradesWidgetIntent.self,
provider: GradesProvider()
) { entry in
GradesLockScreenView(entry: entry)
}
.configurationDisplayName(LocalizedStringResource("widget_grades_title", defaultValue: "Recent Grades"))
.description(LocalizedStringResource("widget_grades_lockscreen_description", defaultValue: "Shows recent grades on lock screen"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Lock Screen View
struct GradesLockScreenView: View {
@Environment(\.widgetFamily) var family
let entry: GradesEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.locale)
}
var body: some View {
Group {
switch family {
case .accessoryInline:
GradesInlineView(entry: entry, localization: localization)
case .accessoryCircular:
GradesCircularView(entry: entry, localization: localization)
case .accessoryRectangular:
GradesRectangularView(entry: entry, localization: localization)
default:
Text("--")
}
}
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Inline View
struct GradesInlineView: View {
let entry: GradesEntry
let localization: WidgetLocalization
var todayGrades: [WidgetGrade] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
}
var body: some View {
if let latest = entry.grades.first {
if todayGrades.count > 0 {
Text(localization.string("today_new_grades", todayGrades.count))
} else {
Text("\(localization.string("latest")): \(latest.displayValue) \(latest.subject.name)")
}
} else {
Text(localization.string("no_grades"))
}
}
}
// MARK: - Circular View
struct GradesCircularView: View {
let entry: GradesEntry
let localization: WidgetLocalization
var todayGradesCount: Int {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }.count
}
var body: some View {
if let latest = entry.grades.first {
ZStack {
AccessoryWidgetBackground()
Text(latest.displayValue)
.font(.system(.title, design: .rounded, weight: .bold))
.foregroundStyle(gradeColor(latest.numericValue))
}
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "graduationcap")
.font(.title2)
}
}
}
private func gradeColor(_ value: Int?) -> Color {
guard let value = value else { return .primary }
switch value {
case 5: return .green
case 4: return .blue
case 3: return .yellow
case 2: return .orange
case 1: return .red
default: return .primary
}
}
}
// MARK: - Rectangular View
struct GradesRectangularView: View {
let entry: GradesEntry
let localization: WidgetLocalization
var todayGrades: [WidgetGrade] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
}
var body: some View {
if !entry.grades.isEmpty {
VStack(alignment: .leading, spacing: 2) {
if todayGrades.count > 0 {
HStack {
Text(localization.string("today_grades"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(localization.string("pieces", todayGrades.count))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 4) {
ForEach(todayGrades.prefix(5), id: \.uid) { grade in
GradeBadge(grade: grade)
}
if todayGrades.count > 5 {
Text("+\(todayGrades.count - 5)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
} else if let latest = entry.grades.first {
HStack {
Text(localization.string("latest_grade"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formatDate(latest.recordDate))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
Text(latest.displayValue)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(gradeColor(latest.numericValue))
Text(latest.subject.name)
.font(.subheadline)
.lineLimit(1)
Spacer()
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading) {
Label(localization.string("no_grades"), systemImage: "graduationcap")
.font(.headline)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d."
return formatter.string(from: date)
}
private func gradeColor(_ value: Int?) -> Color {
guard let value = value else { return .primary }
switch value {
case 5: return .green
case 4: return .blue
case 3: return .yellow
case 2: return .orange
case 1: return .red
default: return .primary
}
}
}
// MARK: - Grade Badge
struct GradeBadge: View {
let grade: WidgetGrade
var body: some View {
Text(grade.displayValue)
.font(.system(.caption, design: .rounded, weight: .bold))
.foregroundStyle(gradeColor(grade.numericValue))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(gradeColor(grade.numericValue).opacity(0.2))
)
}
private func gradeColor(_ value: Int?) -> Color {
guard let value = value else { return .primary }
switch value {
case 5: return .green
case 4: return .blue
case 3: return .yellow
case 2: return .orange
case 1: return .red
default: return .primary
}
}
}

View File

@@ -0,0 +1,142 @@
import WidgetKit
import SwiftUI
// MARK: - Timetable Inline Widget
struct TimetableInlineWidget: Widget {
let kind: String = "TimetableInlineWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: TimetableWidgetIntent.self,
provider: TimetableProvider()
) { entry in
TimetableInlineWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName(LocalizedStringResource("widget_timetable_title", defaultValue: "Timetable"))
.description(LocalizedStringResource("widget_timetable_inline_description", defaultValue: "Shows next lesson above the clock"))
.supportedFamilies([.accessoryInline])
}
}
struct TimetableInlineWidgetView: View {
let entry: TimetableEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.data?.locale ?? "hu")
}
var body: some View {
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
Text(localization.string(breakInfo.nameKey))
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
Text("\(current.subject.name) · \(remaining) \(localization.string("minutes_abbrev"))")
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
if until <= 0 {
Text("\(next.subject.name)")
} else {
Text("\(next.subject.name) · \(until) \(localization.string("minutes_abbrev"))")
}
} else if entry.isNextDay {
if let first = entry.lessons.first {
Text("\(localization.string("tomorrow")): \(first.subject.name)")
} else {
Text(localization.string("no_lessons"))
}
} else {
Text(localization.string("no_lessons"))
}
}
private func minutesRemaining(until date: Date) -> Int {
let diff = date.timeIntervalSince(entry.date)
return max(0, Int(ceil(diff / 60)))
}
}
// MARK: - Grades Inline Widget
struct GradesInlineWidget: Widget {
let kind: String = "GradesInlineWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: GradesWidgetIntent.self,
provider: GradesProvider()
) { entry in
GradesInlineWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName(LocalizedStringResource("widget_grades_title", defaultValue: "Grades"))
.description(LocalizedStringResource("widget_grades_inline_description", defaultValue: "Shows recent grades above the clock"))
.supportedFamilies([.accessoryInline])
}
}
struct GradesInlineWidgetView: View {
let entry: GradesEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.locale)
}
var todayGrades: [WidgetGrade] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return entry.grades.filter { calendar.startOfDay(for: $0.recordDate) == today }
}
var body: some View {
if todayGrades.count > 0 {
Text("📝 \(localization.string("today_new_grades", todayGrades.count))")
} else if let latest = entry.grades.first {
// No grades today - show latest
Text("\(localization.string("latest")): \(latest.displayValue) \(latest.subject.name)")
} else {
Text(localization.string("no_grades"))
}
}
}
// MARK: - Averages Inline Widget
struct AveragesInlineWidget: Widget {
let kind: String = "AveragesInlineWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: AveragesWidgetIntent.self,
provider: AveragesProvider()
) { entry in
AveragesInlineWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName(LocalizedStringResource("widget_averages_title", defaultValue: "Averages"))
.description(LocalizedStringResource("widget_averages_inline_description", defaultValue: "Shows your average above the clock"))
.supportedFamilies([.accessoryInline])
}
}
struct AveragesInlineWidgetView: View {
let entry: AveragesEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.locale)
}
var body: some View {
if let overall = entry.overallAverage {
Text("\(localization.string("average_short")): \(String(format: "%.2f", overall)) · \(entry.subjectAverages.count) \(localization.string("subject_short"))")
} else if let first = entry.subjectAverages.first {
Text("\(first.name): \(String(format: "%.2f", first.average))")
} else {
Text(localization.string("no_averages"))
}
}
}

View File

@@ -0,0 +1,246 @@
import WidgetKit
import SwiftUI
// MARK: - Lock Screen Timetable Widget
struct TimetableLockScreenWidget: Widget {
let kind: String = "TimetableLockScreenWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: TimetableWidgetIntent.self,
provider: TimetableProvider()
) { entry in
TimetableLockScreenView(entry: entry)
}
.configurationDisplayName(LocalizedStringResource("widget_timetable_title", defaultValue: "Timetable"))
.description(LocalizedStringResource("widget_timetable_lockscreen_description", defaultValue: "Shows current lesson on lock screen"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Lock Screen View
struct TimetableLockScreenView: View {
@Environment(\.widgetFamily) var family
let entry: TimetableEntry
var localization: WidgetLocalization {
WidgetLocalization(locale: entry.data?.locale ?? "hu")
}
var body: some View {
Group {
switch family {
case .accessoryInline:
TimetableInlineView(entry: entry, localization: localization)
case .accessoryCircular:
TimetableCircularView(entry: entry, localization: localization)
case .accessoryRectangular:
TimetableRectangularView(entry: entry, localization: localization)
default:
Text("--")
}
}
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Inline View (single line next to date)
struct TimetableInlineView: View {
let entry: TimetableEntry
let localization: WidgetLocalization
var body: some View {
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
Text("🏖️ \(localization.string(breakInfo.nameKey))")
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
Text("\(current.subject.name) - \(remaining) \(localization.string("minutes_short"))")
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
if until <= 0 {
Text("\(localization.string("next")): \(next.subject.name)")
} else {
Text("\(next.subject.name) \(localization.string("in_minutes", until))")
}
} else if entry.isNextDay {
if let first = entry.lessons.first {
Text("\(localization.string("tomorrow")): \(first.subject.name)")
} else {
Text(localization.string("no_lessons"))
}
} else {
Text(localization.string("no_lessons"))
}
}
private func minutesRemaining(until date: Date) -> Int {
let diff = date.timeIntervalSince(entry.date)
return max(0, Int(ceil(diff / 60)))
}
}
// MARK: - Circular View (small circle)
struct TimetableCircularView: View {
let entry: TimetableEntry
let localization: WidgetLocalization
var body: some View {
if entry.state == .onBreak {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "sun.max.fill")
.font(.title2)
}
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
Gauge(value: Double(remaining), in: 0...45) {
Text("")
} currentValueLabel: {
Text("\(remaining)")
.font(.system(.title2, design: .rounded, weight: .bold))
}
.gaugeStyle(.accessoryCircularCapacity)
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(until)")
.font(.system(.title2, design: .rounded, weight: .bold))
Text(localization.string("minutes_short"))
.font(.system(.caption2))
.foregroundStyle(.secondary)
}
}
} else if let lesson = entry.lessons.first, let lessonNum = lesson.lessonNumber {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(lessonNum).")
.font(.system(.title2, design: .rounded, weight: .bold))
Text(localization.string("lesson_short"))
.font(.system(.caption2))
.foregroundStyle(.secondary)
}
}
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "calendar.badge.checkmark")
.font(.title2)
}
}
}
private func minutesRemaining(until date: Date) -> Int {
let diff = date.timeIntervalSince(entry.date)
return max(0, Int(ceil(diff / 60)))
}
}
// MARK: - Rectangular View (medium rectangle)
struct TimetableRectangularView: View {
let entry: TimetableEntry
let localization: WidgetLocalization
var body: some View {
if entry.state == .onBreak, let breakInfo = entry.breakInfo {
VStack(alignment: .leading, spacing: 2) {
Label(localization.string(breakInfo.nameKey), systemImage: "sun.max.fill")
.font(.headline)
Text(localization.string("until") + " " + formatDate(breakInfo.endDate))
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if let current = entry.currentLesson {
let remaining = minutesRemaining(until: current.end)
let lessonNum = current.lessonNumber ?? 0
VStack(alignment: .leading, spacing: 2) {
HStack {
Text("\(lessonNum). \(current.subject.name)")
.font(.headline)
.lineLimit(1)
Spacer()
Text("\(remaining)'")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack {
if let room = current.roomName {
Label(room, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(formatTimeRange(current.start, current.end))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if let next = entry.nextLesson {
let until = minutesRemaining(until: next.start)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.isNextDay ? localization.string("tomorrow") : localization.string("next"))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if until > 0 && !entry.isNextDay {
Text(localization.string("in_minutes", until))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("\(next.lessonNumber ?? 0). \(next.subject.name)")
.font(.headline)
.lineLimit(1)
HStack {
if let room = next.roomName {
Label(room, systemImage: "mappin")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Text(formatTimeRange(next.start, next.end))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading) {
Label(localization.string("no_lessons"), systemImage: "checkmark.circle")
.font(.headline)
Text(localization.string("no_more_lessons_today"))
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func minutesRemaining(until date: Date) -> Int {
let diff = date.timeIntervalSince(entry.date)
return max(0, Int(ceil(diff / 60)))
}
private func formatTimeRange(_ start: Date, _ end: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d."
return formatter.string(from: date)
}
}

View File

@@ -48,6 +48,7 @@ struct TimetableProvider: AppIntentTimelineProvider {
func timeline(for configuration: TimetableWidgetIntent, in context: Context) async -> Timeline<TimetableEntry> {
var entries: [TimetableEntry] = []
let now = Date()
let calendar = Calendar.current
let data = WidgetData.load()
@@ -66,31 +67,64 @@ struct TimetableProvider: AppIntentTimelineProvider {
debugInfo: WidgetData.lastError
)
entries.append(entry)
return Timeline(entries: entries, policy: .after(Calendar.current.startOfDay(for: now.addingTimeInterval(86400))))
return Timeline(entries: entries, policy: .after(calendar.startOfDay(for: now.addingTimeInterval(86400))))
}
let todayLessons = data?.timetable.today ?? []
entries.append(createEntry(for: configuration, date: now))
var transitionTimes: Set<Date> = []
let currentLesson = todayLessons.first { now >= $0.start && now <= $0.end }
let nextLesson = todayLessons.first { $0.start > now }
let isLockScreenWidget = context.family == .accessoryInline ||
context.family == .accessoryCircular ||
context.family == .accessoryRectangular
if isLockScreenWidget {
var minuteEntries: [Date] = []
if let current = currentLesson {
var time = now.addingTimeInterval(60)
while time <= current.end && minuteEntries.count < 60 {
minuteEntries.append(time)
time = time.addingTimeInterval(60)
}
minuteEntries.append(current.end.addingTimeInterval(1))
}
if let next = nextLesson {
var time = currentLesson?.end.addingTimeInterval(60) ?? now.addingTimeInterval(60)
while time < next.start && minuteEntries.count < 120 {
minuteEntries.append(time)
time = time.addingTimeInterval(60)
}
minuteEntries.append(next.start)
}
for time in minuteEntries {
if time > now {
entries.append(createEntry(for: configuration, date: time))
}
}
}
for lesson in todayLessons {
if lesson.start > now {
transitionTimes.insert(lesson.start)
entries.append(createEntry(for: configuration, date: lesson.start))
}
if lesson.end > now {
transitionTimes.insert(lesson.end.addingTimeInterval(1))
entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1)))
}
}
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86400))
transitionTimes.insert(midnight)
let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400))
entries.append(createEntry(for: configuration, date: midnight))
for time in transitionTimes {
entries.append(createEntry(for: configuration, date: time))
let uniqueDates = Set(entries.map { $0.date })
entries = uniqueDates.map { date in
entries.first { $0.date == date }!
}
entries.sort { $0.date < $1.date }
return Timeline(entries: entries, policy: .atEnd)

View File

@@ -11,7 +11,20 @@ struct TimetableSmallView: View {
}
var displayLesson: WidgetLesson? {
(entry.configuration.displayMode ?? .current) == .current ? entry.currentLesson : entry.nextLesson
let mode = entry.configuration.displayMode ?? .current
if mode == .current {
return entry.currentLesson ?? entry.nextLesson
} else {
return entry.nextLesson
}
}
var isShowingNextLesson: Bool {
let mode = entry.configuration.displayMode ?? .current
if mode == .current {
return entry.currentLesson == nil && entry.nextLesson != nil
}
return true
}
var liquidGlassPrimary: Color {
@@ -27,9 +40,9 @@ struct TimetableSmallView: View {
WidgetBackground(style: style, colors: nil)
VStack(alignment: .leading, spacing: 4) {
Text((entry.configuration.displayMode ?? .current) == .current ?
localization.string("current_lesson") :
localization.string("next_lesson"))
Text(isShowingNextLesson ?
localization.string("next_lesson") :
localization.string("current_lesson"))
.font(.caption)
.widgetTextStyle(style, colors: nil, isPrimary: false)

View File

@@ -8,6 +8,11 @@
"widget_grades_description" = "Zeigt deine letzten Noten";
"widget_averages_description" = "Zeigt Fachdurchschnitte";
/* Lock Screen Widget Descriptions */
"widget_timetable_lockscreen_description" = "Stundenplan auf dem Sperrbildschirm";
"widget_grades_lockscreen_description" = "Noten auf dem Sperrbildschirm";
"widget_averages_lockscreen_description" = "Durchschnitte auf dem Sperrbildschirm";
/* Parameter Titles */
"param_style" = "Stil";
"param_display_mode_small" = "Anzeige (kleines Widget)";

View File

@@ -8,6 +8,11 @@
"widget_grades_description" = "Shows your recent grades";
"widget_averages_description" = "Shows subject averages";
/* Lock Screen Widget Descriptions */
"widget_timetable_lockscreen_description" = "Timetable on lock screen";
"widget_grades_lockscreen_description" = "Grades on lock screen";
"widget_averages_lockscreen_description" = "Averages on lock screen";
/* Parameter Titles */
"param_style" = "Style";
"param_display_mode_small" = "Display (small widget)";

View File

@@ -8,6 +8,11 @@
"widget_grades_description" = "A legutóbbi jegyeidet mutatja";
"widget_averages_description" = "A tantárgyi átlagokat mutatja";
/* Lock Screen Widget Descriptions */
"widget_timetable_lockscreen_description" = "Órarend a zárolt képernyőn";
"widget_grades_lockscreen_description" = "Jegyek a zárolt képernyőn";
"widget_averages_lockscreen_description" = "Átlagok a zárolt képernyőn";
/* Parameter Titles */
"param_style" = "Stílus";
"param_display_mode_small" = "Megjelenítés (kis widget)";

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -112,21 +112,21 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ActivityAttributes.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
};
4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -136,8 +136,31 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = "<group>"; };
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = "<group>"; };
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */,
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = LiveActivityWidget;
sourceTree = "<group>";
};
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = HomeWidgetsExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -449,14 +472,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -545,14 +564,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";