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.
This commit is contained in:
Horváth Gergely
2026-01-29 23:48:59 +01:00
committed by 4831c0
parent d395529282
commit f76b5fbcca
12 changed files with 252 additions and 49 deletions

View File

@@ -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()
}
}

View File

@@ -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"
]
]
}

View File

@@ -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()
}
}
}

View File

@@ -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"))
}

View File

@@ -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)")

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -149,6 +149,9 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
HomePage targetPage;
switch (link) {
case 'home':
targetPage = HomePage.home;
break;
case 'timetable':
targetPage = HomePage.timetable;
break;