From f76b5fbcca582bde67d40b870bcf69c116e6e8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Thu, 29 Jan 2026 23:48:59 +0100 Subject: [PATCH] Add iOS Control widgets & timetable UI updates Introduce iOS 18 Control widgets and improve timetable/widget UI and behavior. - Add Home, Grades and Timetable Control widgets (AppIntents) that write a "controlNavigation" value into the app group (group.app.firka.firkaa) so the main app can open the requested page. - Register control widgets in HomeWidgetsBundle for iOS 18+. - Extend WidgetLocalization with short/abbrev strings (tomorrow_short, hours_abbrev, in_hours) and related localization keys. - Enhance Lock Screen and Timetable widgets to handle next-day lessons, show hours when >60 minutes, and use localized hour/minute strings. - Update TimetableProvider timeline generation to avoid producing per-minute entries far before the next lesson; if the next lesson is >60 minutes away, add only a single entry ~60 minutes before it. - Adjust various widget views (Averages, Grades, Timetable) for more compact layout: spacing, font weights/sizes, compact modes, and added average color logic. - Update project.pbxproj to add a file-system-synchronized exception for the HomeWidgetsExtension files in the Runner target. - AppDelegate: read and remove "controlNavigation" from the shared app group and return it via the Flutter method channel as a pending deep link; fall back to existing pendingWidgetDeepLink. - Flutter: map a received 'home' deep link to HomePage.home in the home screen navigation switch. These changes add Control Center / Lock Screen launch shortcuts and refine widget presentation and timeline performance. --- .../Controls/AppControls.swift | 89 +++++++++++++++++++ .../Helpers/Localization.swift | 15 ++++ .../HomeWidgetsBundle.swift | 7 ++ .../LockScreen/InlineWidgets.swift | 16 ++-- .../TimetableLockScreenWidget.swift | 67 +++++++++++--- .../Providers/TimetableProvider.swift | 17 +++- .../Views/AveragesViews.swift | 39 +++++--- .../Views/GradesViews.swift | 23 ++--- .../Views/TimetableViews.swift | 12 ++- firka/ios/Runner.xcodeproj/project.pbxproj | 8 ++ firka/ios/Runner/AppDelegate.swift | 5 +- .../ui/phone/screens/home/home_screen.dart | 3 + 12 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 firka/ios/HomeWidgetsExtension/Controls/AppControls.swift diff --git a/firka/ios/HomeWidgetsExtension/Controls/AppControls.swift b/firka/ios/HomeWidgetsExtension/Controls/AppControls.swift new file mode 100644 index 00000000..12530720 --- /dev/null +++ b/firka/ios/HomeWidgetsExtension/Controls/AppControls.swift @@ -0,0 +1,89 @@ +import WidgetKit +import SwiftUI +import AppIntents + +private let appGroup = "group.app.firka.firkaa" + +// MARK: - Home Control + +@available(iOS 18.0, *) +struct HomeControl: ControlWidget { + static let kind = "app.firka.firkaa.control.home" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: Self.kind) { + ControlWidgetButton(action: OpenHomeIntent()) { + Label("Főoldal", systemImage: "house.fill") + } + } + .displayName("Firka - Főoldal") + .description("Firka app főoldal megnyitása") + } +} + +@available(iOS 18.0, *) +struct OpenHomeIntent: AppIntent { + static var title: LocalizedStringResource = "Firka Főoldal" + static var openAppWhenRun: Bool = true + + func perform() async throws -> some IntentResult { + UserDefaults(suiteName: appGroup)?.set("home", forKey: "controlNavigation") + return .result() + } +} + +// MARK: - Grades Control + +@available(iOS 18.0, *) +struct GradesControl: ControlWidget { + static let kind = "app.firka.firkaa.control.grades" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: Self.kind) { + ControlWidgetButton(action: OpenGradesIntent()) { + Label("Jegyek", systemImage: "star.fill") + } + } + .displayName("Firka - Jegyek") + .description("Firka app jegyek megnyitása") + } +} + +@available(iOS 18.0, *) +struct OpenGradesIntent: AppIntent { + static var title: LocalizedStringResource = "Firka Jegyek" + static var openAppWhenRun: Bool = true + + func perform() async throws -> some IntentResult { + UserDefaults(suiteName: appGroup)?.set("grades", forKey: "controlNavigation") + return .result() + } +} + +// MARK: - Timetable Control + +@available(iOS 18.0, *) +struct TimetableControl: ControlWidget { + static let kind = "app.firka.firkaa.control.timetable" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: Self.kind) { + ControlWidgetButton(action: OpenTimetableIntent()) { + Label("Órarend", systemImage: "calendar") + } + } + .displayName("Firka - Órarend") + .description("Firka app órarend megnyitása") + } +} + +@available(iOS 18.0, *) +struct OpenTimetableIntent: AppIntent { + static var title: LocalizedStringResource = "Firka Órarend" + static var openAppWhenRun: Bool = true + + func perform() async throws -> some IntentResult { + UserDefaults(suiteName: appGroup)?.set("timetable", forKey: "controlNavigation") + return .result() + } +} diff --git a/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift b/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift index 5561f086..e8a041de 100644 --- a/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift +++ b/firka/ios/HomeWidgetsExtension/Helpers/Localization.swift @@ -119,6 +119,11 @@ struct WidgetLocalization { "en": "Tomorrow", "de": "Morgen" ], + "tomorrow_short": [ + "hu": "holnap", + "en": "tmrw", + "de": "morgen" + ], "next": [ "hu": "Következő", "en": "Next", @@ -193,6 +198,16 @@ struct WidgetLocalization { "hu": "p", "en": "min", "de": "Min" + ], + "hours_abbrev": [ + "hu": "ó", + "en": "h", + "de": "Std" + ], + "in_hours": [ + "hu": "%d óra múlva", + "en": "in %d h", + "de": "in %d Std" ] ] } diff --git a/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift b/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift index 0bf2cb8b..d7535634 100644 --- a/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift +++ b/firka/ios/HomeWidgetsExtension/HomeWidgetsBundle.swift @@ -18,5 +18,12 @@ struct HomeWidgetsBundle: WidgetBundle { TimetableInlineWidget() GradesInlineWidget() AveragesInlineWidget() + + // Control Widgets (iOS 18+ Control Center & Lock Screen buttons) + if #available(iOS 18.0, *) { + HomeControl() + GradesControl() + TimetableControl() + } } } diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift b/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift index d65ed661..2f2e0f00 100644 --- a/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift +++ b/firka/ios/HomeWidgetsExtension/LockScreen/InlineWidgets.swift @@ -34,19 +34,23 @@ 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.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")) + } } else if let next = entry.nextLesson { let until = minutesRemaining(until: next.start) if until <= 0 { Text("→ \(next.subject.name)") + } else if until > 60 { + let hours = until / 60 + Text("→ \(next.subject.name) · \(hours) \(localization.string("hours_abbrev"))") } 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")) } diff --git a/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift b/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift index f0fa8083..63c561b9 100644 --- a/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift +++ b/firka/ios/HomeWidgetsExtension/LockScreen/TimetableLockScreenWidget.swift @@ -59,19 +59,23 @@ 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.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")) + } } else if let next = entry.nextLesson { let until = minutesRemaining(until: next.start) if until <= 0 { Text("\(localization.string("next")): \(next.subject.name)") + } else if until > 60 { + let hours = until / 60 + Text("\(next.subject.name) \(localization.string("in_hours", hours))") } 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")) } @@ -105,16 +109,45 @@ struct TimetableCircularView: View { .font(.system(.title2, design: .rounded, weight: .bold)) } .gaugeStyle(.accessoryCircularCapacity) + } else if entry.isNextDay { + let lessonCount = entry.lessons.count + if lessonCount > 0 { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(lessonCount)") + .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) + } + } } 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) + if until > 60 { + let hours = until / 60 + Text("\(hours)") + .font(.system(.title2, design: .rounded, weight: .bold)) + Text(localization.string("hours_abbrev")) + .font(.system(.caption2)) + .foregroundStyle(.secondary) + } else { + Text("\(until)") + .font(.system(.title2, design: .rounded, weight: .bold)) + Text(localization.string("minutes_abbrev")) + .font(.system(.caption2)) + .foregroundStyle(.secondary) + } } } } else if let lesson = entry.lessons.first, let lessonNum = lesson.lessonNumber { @@ -194,9 +227,15 @@ struct TimetableRectangularView: View { .foregroundStyle(.secondary) Spacer() if until > 0 && !entry.isNextDay { - Text(localization.string("in_minutes", until)) - .font(.caption) - .foregroundStyle(.secondary) + if until > 60 { + Text(localization.string("in_hours", until / 60)) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(localization.string("in_minutes", until)) + .font(.caption) + .foregroundStyle(.secondary) + } } } Text("\(next.lessonNumber ?? 0). \(next.subject.name)") diff --git a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift index 4fa7eb4a..e18918db 100644 --- a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift +++ b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift @@ -94,10 +94,19 @@ struct TimetableProvider: AppIntentTimelineProvider { } 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) + let minutesUntilNext = next.start.timeIntervalSince(now) / 60 + + if minutesUntilNext <= 60 { + var time = currentLesson?.end.addingTimeInterval(60) ?? now.addingTimeInterval(60) + while time < next.start && minuteEntries.count < 120 { + minuteEntries.append(time) + time = time.addingTimeInterval(60) + } + } else { + let sixtyMinutesBefore = next.start.addingTimeInterval(-60 * 60) + if sixtyMinutesBefore > now { + minuteEntries.append(sixtyMinutesBefore) + } } minuteEntries.append(next.start) } diff --git a/firka/ios/HomeWidgetsExtension/Views/AveragesViews.swift b/firka/ios/HomeWidgetsExtension/Views/AveragesViews.swift index 3ac01f2b..40ef7631 100644 --- a/firka/ios/HomeWidgetsExtension/Views/AveragesViews.swift +++ b/firka/ios/HomeWidgetsExtension/Views/AveragesViews.swift @@ -65,11 +65,11 @@ struct AveragesMediumView: View { ZStack { WidgetBackground(style: style, colors: nil) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { HStack { Text(localization.string("subject_averages")) .font(.caption) - .fontWeight(.medium) + .fontWeight(.semibold) .widgetTextStyle(style, colors: nil, isPrimary: false) Spacer() @@ -86,6 +86,13 @@ struct AveragesMediumView: View { ) .widgetTextStyle(style, colors: nil, isPrimary: false) } + + if let overall = entry.overallAverage { + Text(String(format: "%.2f", overall)) + .font(.subheadline) + .fontWeight(.bold) + .foregroundStyle(averageColor(for: overall)) + } } if entry.subjectAverages.isEmpty { @@ -95,15 +102,26 @@ struct AveragesMediumView: View { .widgetTextStyle(style, colors: nil, isPrimary: false) Spacer() } else { + Spacer(minLength: 0) ForEach(entry.subjectAverages.prefix(maxVisible)) { subject in - AverageRow(subject: subject, style: style) + AverageRow(subject: subject, style: style, compact: true) } - Spacer() + Spacer(minLength: 0) } } .padding() } } + + func averageColor(for 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 + } + } } struct AveragesLargeView: View { @@ -127,11 +145,11 @@ struct AveragesLargeView: View { ZStack { WidgetBackground(style: style, colors: nil) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { HStack { Text(localization.string("subject_averages")) .font(.headline) - .fontWeight(.semibold) + .fontWeight(.bold) .widgetTextStyle(style, colors: nil) Spacer() @@ -189,11 +207,12 @@ struct AverageRow: View { let subject: SubjectAverage let style: WidgetStyleType var showGradeCount: Bool = false + var compact: Bool = false var body: some View { HStack(spacing: 12) { Text(subject.name) - .font(.subheadline) + .font(compact ? .footnote : .subheadline) .widgetTextStyle(style, colors: nil) .lineLimit(1) @@ -201,15 +220,15 @@ struct AverageRow: View { if showGradeCount { Text("(\(subject.gradeCount))") - .font(.caption) + .font(.caption2) .widgetTextStyle(style, colors: nil, isPrimary: false) } Text(subject.formattedAverage) - .font(.subheadline) + .font(compact ? .footnote : .subheadline) .fontWeight(.bold) .foregroundStyle(subject.averageColor) } - .padding(.vertical, 4) + .padding(.vertical, compact ? 2 : 4) } } diff --git a/firka/ios/HomeWidgetsExtension/Views/GradesViews.swift b/firka/ios/HomeWidgetsExtension/Views/GradesViews.swift index ab69ce5c..27b7f78e 100644 --- a/firka/ios/HomeWidgetsExtension/Views/GradesViews.swift +++ b/firka/ios/HomeWidgetsExtension/Views/GradesViews.swift @@ -64,10 +64,10 @@ struct GradesMediumView: View { ZStack { WidgetBackground(style: style, colors: nil) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { Text(localization.string("recent_grades")) .font(.caption) - .fontWeight(.medium) + .fontWeight(.semibold) .widgetTextStyle(style, colors: nil, isPrimary: false) if entry.grades.isEmpty { @@ -77,9 +77,11 @@ struct GradesMediumView: View { .widgetTextStyle(style, colors: nil, isPrimary: false) Spacer() } else { + Spacer(minLength: 0) ForEach(entry.grades.prefix(3)) { grade in - GradeRow(grade: grade, style: style, showTeacher: true) + GradeRow(grade: grade, style: style, showTeacher: true, compact: true) } + Spacer(minLength: 0) } } .padding() @@ -129,18 +131,19 @@ struct GradeRow: View { let style: WidgetStyleType var showTeacher: Bool = false var showTopic: Bool = false + var compact: Bool = false var body: some View { HStack(spacing: 12) { Text(grade.displayValue) - .font(.title2) + .font(compact ? .title3 : .title2) .fontWeight(.bold) .foregroundStyle(grade.gradeColor) .frame(width: 32) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: compact ? 1 : 2) { Text(grade.subjectNameWithWeight) - .font(.subheadline) + .font(compact ? .footnote : .subheadline) .fontWeight(.medium) .widgetTextStyle(style, colors: nil) .lineLimit(1) @@ -148,23 +151,23 @@ struct GradeRow: View { HStack(spacing: 4) { if showTeacher, let teacher = grade.teacherName { Text(teacher) - .font(.caption) + .font(.caption2) .widgetTextStyle(style, colors: nil, isPrimary: false) .lineLimit(1) Text("•") - .font(.caption) + .font(.caption2) .widgetTextStyle(style, colors: nil, isPrimary: false) } Text(grade.dateString) - .font(.caption) + .font(.caption2) .widgetTextStyle(style, colors: nil, isPrimary: false) } } Spacer() } - .padding(.vertical, 4) + .padding(.vertical, compact ? 2 : 4) } } diff --git a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift index 9882b5ad..3b2f9d44 100644 --- a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift +++ b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift @@ -48,13 +48,14 @@ struct TimetableSmallView: View { if let lesson = displayLesson { Text(lesson.displayName) - .font(.headline) + .font(.subheadline) .fontWeight(.semibold) .strikethrough(lesson.isCancelled, color: .red) .foregroundColor(lesson.isCancelled ? .red : lesson.isSubstitution ? .orange : (style == .liquidGlass ? liquidGlassPrimary : .primary)) .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) Text(lesson.timeString) .font(.subheadline) @@ -131,12 +132,14 @@ struct TimetableMediumView: View { VStack(alignment: .leading, spacing: 6) { Text(entry.isNextDay ? localization.string("tomorrow_timetable") : localization.string("today_timetable")) .font(.caption) - .fontWeight(.medium) + .fontWeight(.semibold) .widgetTextStyle(style, colors: nil, isPrimary: false) + Spacer(minLength: 0) ForEach(visibleLessons) { lesson in - LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style) + LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style, compact: true) } + Spacer(minLength: 0) } .padding() } @@ -182,6 +185,7 @@ struct LessonRow: View { let isActive: Bool let style: WidgetStyleType var showRoom: Bool = false + var compact: Bool = false @Environment(\.colorScheme) var colorScheme var lessonTextColor: Color? { @@ -253,7 +257,7 @@ struct LessonRow: View { .foregroundColor(lessonTextColor?.opacity(0.8) ?? (style == .liquidGlass ? liquidGlassSecondary : .secondary)) } } - .padding(.vertical, 4) + .padding(.vertical, compact ? 2 : 4) .padding(.horizontal, 8) .currentLessonGlow(isActive: isActive && !lesson.isCancelled) } diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index ad0517a4..94840220 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -112,6 +112,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Controls/AppControls.swift, + ); + target = 97C146ED1CF9000F007C117D /* Runner */; + }; 4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -152,6 +159,7 @@ 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + 4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */, 4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */, ); explicitFileTypes = { diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift index 1d75cef2..b4e5d182 100644 --- a/firka/ios/Runner/AppDelegate.swift +++ b/firka/ios/Runner/AppDelegate.swift @@ -29,7 +29,10 @@ import BackgroundTasks widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger) widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in if call.method == "getPendingDeepLink" { - if let link = self?.pendingWidgetDeepLink { + if let controlNav = UserDefaults(suiteName: "group.app.firka.firkaa")?.string(forKey: "controlNavigation") { + UserDefaults(suiteName: "group.app.firka.firkaa")?.removeObject(forKey: "controlNavigation") + result(controlNav) + } else if let link = self?.pendingWidgetDeepLink { self?.pendingWidgetDeepLink = nil result(link) } else { diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index f4a69220..ce335a44 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -149,6 +149,9 @@ class _HomeScreenState extends FirkaState { HomePage targetPage; switch (link) { + case 'home': + targetPage = HomePage.home; + break; case 'timetable': targetPage = HomePage.timetable; break;