diff --git a/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift b/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift index 2bad8db6..5561f086 100644 --- a/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift +++ b/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift @@ -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" ] ] } diff --git a/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift b/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift index bec789d9..0bf2cb8b 100644 --- a/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift +++ b/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift @@ -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() } } diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/AveragesLockScreenWidget.swift b/firka/ios/HomeWidgetsExtension/LockScreen/AveragesLockScreenWidget.swift new file mode 100644 index 00000000..2a364315 --- /dev/null +++ b/firka/ios/HomeWidgetsExtension/LockScreen/AveragesLockScreenWidget.swift @@ -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 + } + } +} diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/GradesLockScreenWidget.swift b/firka/ios/HomeWidgetsExtension/LockScreen/GradesLockScreenWidget.swift new file mode 100644 index 00000000..8081a12d --- /dev/null +++ b/firka/ios/HomeWidgetsExtension/LockScreen/GradesLockScreenWidget.swift @@ -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 + } + } +} diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift b/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift new file mode 100644 index 00000000..d65ed661 --- /dev/null +++ b/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift @@ -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")) + } + } +} diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift b/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift new file mode 100644 index 00000000..f0fa8083 --- /dev/null +++ b/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift @@ -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) + } +} diff --git a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift index a0af11ef..4fa7eb4a 100644 --- a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift +++ b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift @@ -48,6 +48,7 @@ struct TimetableProvider: AppIntentTimelineProvider { func timeline(for configuration: TimetableWidgetIntent, in context: Context) async -> Timeline { 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 = [] + 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) diff --git a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift index 13334db6..9882b5ad 100644 --- a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift +++ b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift @@ -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) diff --git a/firka/ios/HomeWidgetsExtension/de.lproj/Localizable.strings b/firka/ios/HomeWidgetsExtension/de.lproj/Localizable.strings index 6ac9ef61..87edb042 100644 --- a/firka/ios/HomeWidgetsExtension/de.lproj/Localizable.strings +++ b/firka/ios/HomeWidgetsExtension/de.lproj/Localizable.strings @@ -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)"; diff --git a/firka/ios/HomeWidgetsExtension/en.lproj/Localizable.strings b/firka/ios/HomeWidgetsExtension/en.lproj/Localizable.strings index a118371e..8e8a26aa 100644 --- a/firka/ios/HomeWidgetsExtension/en.lproj/Localizable.strings +++ b/firka/ios/HomeWidgetsExtension/en.lproj/Localizable.strings @@ -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)"; diff --git a/firka/ios/HomeWidgetsExtension/hu.lproj/Localizable.strings b/firka/ios/HomeWidgetsExtension/hu.lproj/Localizable.strings index 6ad722f6..91d3b97e 100644 --- a/firka/ios/HomeWidgetsExtension/hu.lproj/Localizable.strings +++ b/firka/ios/HomeWidgetsExtension/hu.lproj/Localizable.strings @@ -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)"; diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index 057fdd62..ad0517a4 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; - 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = ""; }; + 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 = ""; + }; + 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = HomeWidgetsExtension; + sourceTree = ""; + }; /* 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";