From 35e1e2c6ab43f24439f0c69460b2805ed6ae0265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Thu, 5 Feb 2026 16:01:58 +0100 Subject: [PATCH] Add Apple Watch app and watch sync support Add a new Firka Watch app target with UI components, views and services: DataStore, BackgroundRefreshManager, WatchConnectivity/WatchSession integration, localization (WatchL10n), entitlements and assets. Move widget/shared models into ios/Shared and update Xcode project/schemes; add native Kreta API client and TokenManager for watch use. Implement watch-side caching, proactive token refresh, background scheduling, and pairing/pairing UI. Update Flutter side (watch_sync_helper, main, API/token helper changes) and tweak iOS .gitignore and project metadata to enable the watch integration and data sync between phone and watch. --- firka/.metadata | 10 +- firka/ios/.gitignore | 5 +- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 14 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 150965 bytes .../Assets.xcassets/Contents.json | 6 + .../Components/CountdownRing.swift | 59 ++ .../Components/FirkaCard.swift | 33 + .../Components/GradeBadge.swift | 39 + .../Components/GradeRow.swift | 46 ++ .../Components/LessonCard.swift | 146 ++++ .../Components/ProgressBar.swift | 68 ++ .../Components/SubjectRow.swift | 43 ++ .../FirkaWatch Watch App/ContentView.swift | 107 +++ .../FirkaWatch Watch App.entitlements | 10 + .../FirkaWatch Watch App/FirkaWatchApp.swift | 34 + .../Localization/WatchL10n.swift | 364 ++++++++++ .../Services/BackgroundRefreshManager.swift | 42 ++ .../Services/DataStore.swift | 390 ++++++++++ .../Services/WatchConnectivityManager.swift | 268 +++++++ .../Views/GradeSubjectView.swift | 98 +++ .../Views/GradesView.swift | 108 +++ .../FirkaWatch Watch App/Views/HomeView.swift | 466 ++++++++++++ .../Views/LessonDetailView.swift | 109 +++ .../Views/ReauthRequiredView.swift | 281 +++++++ .../Views/SettingsView.swift | 73 ++ .../Views/TimetableView.swift | 357 +++++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../FirkaComplications.swift | 369 ++++++++++ firka/ios/FirkaWatchComplications/Info.plist | 11 + ...kaWatchComplicationsExtension.entitlements | 10 + .../HomeWidgetsExtension/Models/Subject.swift | 15 - .../Providers/TimetableProvider.swift | 104 ++- .../Views/TimetableViews.swift | 46 +- firka/ios/Runner.xcodeproj/project.pbxproj | 687 +++++++++++++++++- .../xcschemes/FirkaWatch Watch App.xcscheme | 102 +++ .../xcschemes/HomeWidgetsExtension.xcscheme | 113 +++ .../xcschemes/LiveActivityWidget.xcscheme | 113 +++ firka/ios/Runner/AppDelegate.swift | 1 + firka/ios/Runner/WatchSessionManager.swift | 297 ++++++++ firka/ios/Shared/API/KretaAPIClient.swift | 295 ++++++++ firka/ios/Shared/API/KretaAPIModels.swift | 158 ++++ firka/ios/Shared/API/TokenManager.swift | 303 ++++++++ .../Helpers}/SeasonalIconHelper.swift | 4 +- .../Models/Average.swift | 0 .../Models/Grade.swift | 29 + .../Models/Lesson.swift | 19 + firka/ios/Shared/Models/Subject.swift | 29 + .../Models/WidgetColors.swift | 0 .../Models/WidgetData.swift | 13 +- .../lib/helpers/api/client/kreta_client.dart | 111 +++ firka/lib/helpers/api/token_grant.dart | 65 +- firka/lib/helpers/db/ios_widget_helper.dart | 9 + firka/lib/helpers/watch_sync_helper.dart | 273 +++++++ firka/lib/main.dart | 27 + .../ui/phone/screens/home/home_screen.dart | 34 +- firka/lib/ui/phone/widgets/login_webview.dart | 27 + 60 files changed, 6396 insertions(+), 96 deletions(-) create mode 100644 firka/ios/FirkaWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 firka/ios/FirkaWatch Watch App/Assets.xcassets/Contents.json create mode 100644 firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/GradeRow.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/LessonCard.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift create mode 100644 firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift create mode 100644 firka/ios/FirkaWatch Watch App/ContentView.swift create mode 100644 firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements create mode 100644 firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift create mode 100644 firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift create mode 100644 firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift create mode 100644 firka/ios/FirkaWatch Watch App/Services/DataStore.swift create mode 100644 firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/GradesView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/HomeView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/SettingsView.swift create mode 100644 firka/ios/FirkaWatch Watch App/Views/TimetableView.swift create mode 100644 firka/ios/FirkaWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 firka/ios/FirkaWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 firka/ios/FirkaWatchComplications/Assets.xcassets/Contents.json create mode 100644 firka/ios/FirkaWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 firka/ios/FirkaWatchComplications/FirkaComplications.swift create mode 100644 firka/ios/FirkaWatchComplications/Info.plist create mode 100644 firka/ios/FirkaWatchComplicationsExtension.entitlements delete mode 100644 firka/ios/HomeWidgetsExtension/Models/Subject.swift create mode 100644 firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/FirkaWatch Watch App.xcscheme create mode 100644 firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/HomeWidgetsExtension.xcscheme create mode 100644 firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/LiveActivityWidget.xcscheme create mode 100644 firka/ios/Runner/WatchSessionManager.swift create mode 100644 firka/ios/Shared/API/KretaAPIClient.swift create mode 100644 firka/ios/Shared/API/KretaAPIModels.swift create mode 100644 firka/ios/Shared/API/TokenManager.swift rename firka/ios/{LiveActivityWidget => Shared/Helpers}/SeasonalIconHelper.swift (95%) rename firka/ios/{HomeWidgetsExtension => Shared}/Models/Average.swift (100%) rename firka/ios/{HomeWidgetsExtension => Shared}/Models/Grade.swift (52%) rename firka/ios/{HomeWidgetsExtension => Shared}/Models/Lesson.swift (51%) create mode 100644 firka/ios/Shared/Models/Subject.swift rename firka/ios/{HomeWidgetsExtension => Shared}/Models/WidgetColors.swift (100%) rename firka/ios/{HomeWidgetsExtension => Shared}/Models/WidgetData.swift (88%) create mode 100644 firka/lib/helpers/watch_sync_helper.dart diff --git a/firka/.metadata b/firka/.metadata index d898604..b9fd747 100644 --- a/firka/.metadata +++ b/firka/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "05db9689081f091050f01aed79f04dce0c750154" + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 05db9689081f091050f01aed79f04dce0c750154 - base_revision: 05db9689081f091050f01aed79f04dce0c750154 + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 - platform: ios - create_revision: 05db9689081f091050f01aed79f04dce0c750154 - base_revision: 05db9689081f091050f01aed79f04dce0c750154 + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 # User provided section diff --git a/firka/ios/.gitignore b/firka/ios/.gitignore index 3a5c1fd..bf0b209 100644 --- a/firka/ios/.gitignore +++ b/firka/ios/.gitignore @@ -33,4 +33,7 @@ Runner/GeneratedPluginRegistrant.* !default.pbxuser !default.perspectivev3 -/.DerivedData \ No newline at end of file +/.DerivedData + +# Developer-specific configuration +.dev_config \ No newline at end of file diff --git a/firka/ios/FirkaWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..44472cb --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon-App-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..0b50e8b5872ec44e08fe97f3fa52235cdd651bda GIT binary patch literal 150965 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 Z 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
    #C^Z0t)cTo;hMsr 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 z∾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 literal 0 HcmV?d00001 diff --git a/firka/ios/FirkaWatch Watch App/Assets.xcassets/Contents.json b/firka/ios/FirkaWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift b/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift new file mode 100644 index 0000000..bd1a13c --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CountdownRing: View { + let totalMinutes: Int + let remainingMinutes: Int + let label: String + var size: CGFloat = 80 + var lineWidth: CGFloat = 8 + var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1) + + var progress: Double { + guard totalMinutes > 0 else { return 0 } + return Double(totalMinutes - remainingMinutes) / Double(totalMinutes) + } + + var displayedMinutes: Int { + remainingMinutes + displayOffset + } + + var ringColor: Color { + if remainingMinutes < 5 { return .red } + if remainingMinutes < 10 { return .yellow } + return .green + } + + var body: some View { + ZStack { + Circle() + .stroke(Color(white: 0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: progress) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut, value: progress) + + VStack(spacing: 1) { + Text("\(displayedMinutes)") + .font(size > 60 ? .title2 : .headline) + .fontWeight(.bold) + Text(label) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .frame(width: size, height: size) + } +} + +#Preview { + VStack(spacing: 20) { + CountdownRing(totalMinutes: 45, remainingMinutes: 30, label: "min") + + CountdownRing(totalMinutes: 45, remainingMinutes: 8, label: "min") + + CountdownRing(totalMinutes: 45, remainingMinutes: 3, label: "min") + } + .padding() +} diff --git a/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift b/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift new file mode 100644 index 0000000..ddd7a9f --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct FirkaCard: View { + let content: Content + var isHighlighted: Bool = false + + init(isHighlighted: Bool = false, @ViewBuilder content: () -> Content) { + self.isHighlighted = isHighlighted + self.content = content() + } + + var body: some View { + content + .padding(12) + .background(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12)) + .cornerRadius(12) + } +} + +#Preview { + VStack(spacing: 12) { + FirkaCard { + Text("Normal Card") + .foregroundColor(.primary) + } + + FirkaCard(isHighlighted: true) { + Text("Highlighted Card") + .foregroundColor(.primary) + } + } + .padding() +} diff --git a/firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift b/firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift new file mode 100644 index 0000000..223c411 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct GradeBadge: View { + let grade: Int + var size: CGFloat = 24 + + var color: Color { + switch grade { + case 5: return .green + case 4: return .blue + case 3: return .yellow + case 2: return .orange + default: return .red + } + } + + var body: some View { + ZStack { + Circle() + .fill(color) + .frame(width: size, height: size) + + Text("\(grade)") + .font(.system(size: size * 0.5, weight: .bold)) + .foregroundColor(.white) + } + } +} + +#Preview { + HStack(spacing: 12) { + GradeBadge(grade: 5) + GradeBadge(grade: 4) + GradeBadge(grade: 3) + GradeBadge(grade: 2) + GradeBadge(grade: 1) + } + .padding() +} diff --git a/firka/ios/FirkaWatch Watch App/Components/GradeRow.swift b/firka/ios/FirkaWatch Watch App/Components/GradeRow.swift new file mode 100644 index 0000000..1b83f1d --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/GradeRow.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct GradeRow: View { + let grade: WidgetGrade + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Text(grade.displayValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(width: 24, height: 24) + .background( + Circle() + .fill(grade.gradeColor) + ) + + VStack(alignment: .leading, spacing: 2) { + if let topic = grade.topic { + Text(topic) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(2) + } + + HStack(spacing: 4) { + Text(grade.type.name) + .font(.system(size: 10)) + .foregroundColor(.secondary) + + if let weight = grade.weightPercentage, weight != 100 { + Text("(\(weight)%)") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(white: 0.15)) + .cornerRadius(6) + } +} diff --git a/firka/ios/FirkaWatch Watch App/Components/LessonCard.swift b/firka/ios/FirkaWatch Watch App/Components/LessonCard.swift new file mode 100644 index 0000000..645436e --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/LessonCard.swift @@ -0,0 +1,146 @@ +mport SwiftUI + +struct LessonCard: View { + let lesson: WidgetLesson + let isActive: Bool + let colors: WidgetColors? + + var backgroundColor: Color { + if let colors = colors { + return colors.cardColor + } + return Color(white: 0.15) + } + + var textPrimaryColor: Color { + if let colors = colors { + return colors.textPrimaryColor + } + return .primary + } + + var textSecondaryColor: Color { + if let colors = colors { + return colors.textSecondaryColor + } + return .secondary + } + + var textTertiaryColor: Color { + if let colors = colors { + return colors.textTertiaryColor + } + return .secondary.opacity(0.7) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + if let number = lesson.lessonNumber { + Text("\(number)") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(isActive ? .white : textPrimaryColor) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(isActive ? Color.green : Color.clear) + ) + } + + VStack(alignment: .leading, spacing: 2) { + Text(lesson.displayName) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(lesson.isCancelled ? .red : + lesson.isSubstitution ? .orange : textPrimaryColor) + .strikethrough(lesson.isCancelled, color: .red) + .lineLimit(1) + + Text(lesson.timeString) + .font(.caption2) + .foregroundColor(lesson.isCancelled ? .red.opacity(0.8) : + lesson.isSubstitution ? .orange.opacity(0.8) : textSecondaryColor) + } + + Spacer() + } + + if let room = lesson.roomName { + HStack(spacing: 4) { + Image(systemName: "door.right.hand.closed") + .font(.caption2) + Text(room) + .font(.caption2) + } + .foregroundColor(lesson.isCancelled ? .red.opacity(0.7) : + lesson.isSubstitution ? .orange.opacity(0.7) : textSecondaryColor) + .lineLimit(1) + } + + if let teacher = lesson.teacher { + Text(teacher) + .font(.caption2) + .foregroundColor(lesson.isCancelled ? .red.opacity(0.7) : + lesson.isSubstitution ? .orange.opacity(0.7) : textTertiaryColor) + .lineLimit(1) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(backgroundColor) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + isActive ? Color.green : Color.clear, + lineWidth: isActive ? 2 : 0 + ) + ) + } +} + +#Preview { + VStack(spacing: 12) { + LessonCard( + lesson: WidgetLesson( + uid: "1", + date: "2026-02-01", + start: Date(), + end: Date().addingTimeInterval(3600), + name: "Matematika", + lessonNumber: 3, + teacher: "Nagy János", + substituteTeacher: nil, + subject: WidgetSubject(uid: "math", name: "Matematika", category: nil, sortIndex: 1, teacherName: "Nagy János"), + theme: nil, + roomName: "201", + isCancelled: false, + isSubstitution: false + ), + isActive: true, + colors: nil + ) + + LessonCard( + lesson: WidgetLesson( + uid: "2", + date: "2026-02-01", + start: Date().addingTimeInterval(7200), + end: Date().addingTimeInterval(10800), + name: "Angol", + lessonNumber: 4, + teacher: "Kovács Éva", + substituteTeacher: nil, + subject: WidgetSubject(uid: "eng", name: "Angol", category: nil, sortIndex: 2, teacherName: "Kovács Éva"), + theme: nil, + roomName: "105", + isCancelled: false, + isSubstitution: false + ), + isActive: false, + colors: nil + ) + } + .padding() +} diff --git a/firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift b/firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift new file mode 100644 index 0000000..4c3bcc0 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct AverageProgressBar: View { + let average: Double + + var progress: Double { + (average - 1) / 4 + } + + var color: Color { + switch average { + 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 + } + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(white: 0.3)) + + RoundedRectangle(cornerRadius: 2) + .fill(color) + .frame(width: geo.size.width * progress) + } + } + .frame(height: 4) + } +} + +#Preview { + VStack(spacing: 16) { + VStack(alignment: .leading) { + Text("5.0 - Excellent") + .font(.caption) + AverageProgressBar(average: 5.0) + } + + VStack(alignment: .leading) { + Text("4.2 - Good") + .font(.caption) + AverageProgressBar(average: 4.2) + } + + VStack(alignment: .leading) { + Text("3.0 - Average") + .font(.caption) + AverageProgressBar(average: 3.0) + } + + VStack(alignment: .leading) { + Text("2.0 - Below Average") + .font(.caption) + AverageProgressBar(average: 2.0) + } + + VStack(alignment: .leading) { + Text("1.2 - Poor") + .font(.caption) + AverageProgressBar(average: 1.2) + } + } + .padding() +} diff --git a/firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift b/firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift new file mode 100644 index 0000000..23407ec --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct SubjectRow: View { + let name: String + let average: Double? + let gradeCount: Int + + var averageColor: Color { + guard let avg = average else { return .gray } + switch avg { + case 4.5...: return .green + case 3.5...: return .blue + case 2.5...: return .yellow + case 1.5...: return .orange + default: return .red + } + } + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Text(name) + .font(.caption) + .foregroundColor(.primary) + + Spacer() + + if let avg = average { + Text(String(format: "%.2f", avg)) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(averageColor) + } else { + Text("\(gradeCount)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(white: 0.15)) + .cornerRadius(6) + } +} diff --git a/firka/ios/FirkaWatch Watch App/ContentView.swift b/firka/ios/FirkaWatch Watch App/ContentView.swift new file mode 100644 index 0000000..4544f92 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/ContentView.swift @@ -0,0 +1,107 @@ +import SwiftUI +import WatchConnectivity + +struct ContentView: View { + var dataStore = DataStore.shared + @State private var selectedTab = 0 + @State private var isRequestingToken = false + + var body: some View { + Group { + if dataStore.needsReauth && dataStore.hasToken { + ReauthRequiredView(onTokenReceived: { + dataStore.checkTokenState() + Task { + await dataStore.refreshAll() + } + }) + } else if !dataStore.hasToken && dataStore.data == nil { + if isRequestingToken { + ProgressView("connecting".localized) + } else { + PairingView(onRequestToken: requestToken) + } + } else { + mainContent + } + } + .task { + dataStore.checkTokenState() + dataStore.loadFromCache() + if dataStore.hasToken { + await dataStore.refreshTokenProactively() + await dataStore.refreshAll() + } else { + requestToken() + } + } + } + + private func requestToken() { + guard !isRequestingToken else { return } + guard WCSession.default.activationState == .activated else { + print("[Watch] Cannot request token: session not activated") + return + } + guard WCSession.default.isReachable else { + print("[Watch] Cannot request token: iPhone not reachable") + return + } + + print("[Watch] Requesting token from iPhone...") + isRequestingToken = true + WatchConnectivityManager.shared.requestTokenFromPhone() + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + self.isRequestingToken = false + } + } + + private var mainContent: some View { + TabView(selection: $selectedTab) { + HomeView(dataStore: dataStore) + .tag(0) + + TimetableView(dataStore: dataStore) + .tag(1) + + GradesView(dataStore: dataStore) + .tag(2) + + NavigationStack { + SettingsView() + } + .tag(3) + } + .tabViewStyle(.verticalPage) + } +} + +struct PairingView: View { + var onRequestToken: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "iphone.and.arrow.right.inward") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("pair_with_iphone".localized) + .font(.headline) + + Text("open_firka_on_iphone".localized) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if WCSession.default.isReachable { + Button("sync_button".localized) { + onRequestToken?() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + } +} diff --git a/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements new file mode 100644 index 0000000..fda7eff --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/FirkaWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.firka.firkaa + + + diff --git a/firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift b/firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift new file mode 100644 index 0000000..c8e2c59 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift @@ -0,0 +1,34 @@ +import SwiftUI +import WatchKit + +@main +struct FirkaWatchApp: App { + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +class WatchAppDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + print("[Watch] applicationDidFinishLaunching called") + WatchConnectivityManager.shared.activate() + } + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let refreshTask as WKApplicationRefreshBackgroundTask: + Task { + await BackgroundRefreshManager.shared.handleBackgroundRefresh() + refreshTask.setTaskCompletedWithSnapshot(false) + } + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } +} diff --git a/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift b/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift new file mode 100644 index 0000000..3ab2510 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift @@ -0,0 +1,364 @@ +import Foundation +import SwiftUI +import WidgetKit + +enum WatchLanguage: String, CaseIterable, Codable { + case hungarian = "hu" + case english = "en" + case german = "de" + + var displayName: String { + switch self { + case .hungarian: return "Magyar" + case .english: return "English" + case .german: return "Deutsch" + } + } + + var flag: String { + switch self { + case .hungarian: return "🇭🇺" + case .english: return "🇬🇧" + case .german: return "🇩🇪" + } + } +} + +@Observable +class WatchL10n { + static let shared = WatchL10n() + + private let languageKey = "watch_language" + private let syncWithiPhoneKey = "watch_sync_language_with_iphone" + private static let appGroupID = "group.app.firka.firkaa" + private var appGroupDefaults: UserDefaults? { + UserDefaults(suiteName: Self.appGroupID) + } + + var currentLanguage: WatchLanguage { + didSet { + UserDefaults.standard.set(currentLanguage.rawValue, forKey: languageKey) + appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey) + } + } + + var syncWithiPhone: Bool { + didSet { + UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey) + if syncWithiPhone { + requestLanguageFromiPhone() + } + } + } + + private var strings: [String: String] = [:] + + private init() { + let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu" + self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian + self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey) + appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey) + loadStrings() + } + + private func loadStrings() { + strings = Self.stringsForLanguage(currentLanguage) + } + + func setLanguage(_ language: WatchLanguage) { + currentLanguage = language + loadStrings() + WidgetCenter.shared.reloadAllTimelines() + } + + func updateFromiPhone(languageCode: String) { + guard syncWithiPhone else { return } + if let language = WatchLanguage(rawValue: languageCode) { + setLanguage(language) + } + } + + private func requestLanguageFromiPhone() { + WatchConnectivityManager.shared.requestLanguageFromPhone() + } + + func string(_ key: String) -> String { + return strings[key] ?? key + } + + func string(_ key: String, _ args: CVarArg...) -> String { + let format = strings[key] ?? key + return String(format: format, arguments: args) + } + + static func stringsForLanguage(_ language: WatchLanguage) -> [String: String] { + switch language { + case .hungarian: + return hungarianStrings + case .english: + return englishStrings + case .german: + return germanStrings + } + } + + private static let hungarianStrings: [String: String] = [ + // Home View + "current_lesson": "Jelenlegi óra", + "next": "Következő", + "break": "Szünet", + "next_lesson": "Következő: %@", + "first_lesson": "Első órád", + "today_lessons_count": "Ma %d órád van", + "no_more_lessons": "Ma nincs több órád", + "pair_with_iphone": "Párosítsd az iPhone-oddal", + "open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon", + "updated": "Frissítve: %@", + "minutes": "perc", + "time_now": "most", + "time_hours_minutes": "%d ó %d p", + "time_hours": "%d óra", + "time_minutes_only": "%d perc", + + // Timetable View + "free_day": "Szabad nap", + "lesson_number": "%d. óra", + "day_mon": "H", + "day_tue": "K", + "day_wed": "Sz", + "day_thu": "Cs", + "day_fri": "P", + + // Grades View + "grades_count": "%d jegy", + "total_average": "Teljes átlag", + "average": "Átlag:", + "no_data": "Nincs adat", + "no_grades": "Nincsenek jegyek", + + // Lesson Detail + "lesson_details": "Óra részletei", + "cancelled": "Elmarad", + "substitution": "Helyettesítés", + "teacher": "Tanár", + "room": "Terem", + "topic": "Téma", + + // Settings + "settings": "Beállítások", + "refresh_interval": "Frissítési időköz", + "15_minutes": "15 perc", + "30_minutes": "30 perc", + "1_hour": "1 óra", + "version": "Verzió", + "language": "Nyelv", + "sync_with_iphone": "iPhone nyelvével", + "clear_cache": "Cache törlése", + "logout": "Kijelentkezés", + + // Refresh + "refresh": "Frissítés", + "refreshing": "Frissítés...", + "refresh_success": "Sikeres!", + "refresh_failed": "Sikertelen", + "error_api": "Kréta API hiba", + "error_network": "Hálózati hiba", + + // Date labels + "tomorrow_first_lesson": "Holnap első órád", + "day_first_lesson": "%@ első órád", + "next_school_day": "Következő iskolai nap", + + // Navigation + "home": "Kezdőlap", + "timetable": "Órarend", + "grades": "Jegyek", + + // Reauth + "reauth_required": "Újrabelépés szükséges", + "reauth_description": "A munkamenet lejárt. Lépj be újra az iPhone appban.", + "sync_button": "Szinkronizálás", + "syncing": "Szinkronizálás...", + "sync_success": "Sikeres!", + "sync_failed": "Sikertelen", + "phone_not_reachable": "iPhone nem elérhető", + "connecting": "Kapcsolódás...", + ] + + private static let englishStrings: [String: String] = [ + // Home View + "current_lesson": "Current Lesson", + "next": "Next", + "break": "Break", + "next_lesson": "Next: %@", + "first_lesson": "First Lesson", + "today_lessons_count": "You have %d lessons today", + "no_more_lessons": "No more lessons today", + "pair_with_iphone": "Pair with iPhone", + "open_firka_on_iphone": "Open Firka app on your iPhone", + "updated": "Updated: %@", + "minutes": "min", + "time_now": "now", + "time_hours_minutes": "%dh %dm", + "time_hours": "%d hours", + "time_minutes_only": "%d min", + + // Timetable View + "free_day": "Free Day", + "lesson_number": "Lesson %d", + "day_mon": "Mon", + "day_tue": "Tue", + "day_wed": "Wed", + "day_thu": "Thu", + "day_fri": "Fri", + + // Grades View + "grades_count": "%d grades", + "total_average": "Total Average", + "average": "Average:", + "no_data": "No data", + "no_grades": "No grades", + + // Lesson Detail + "lesson_details": "Lesson Details", + "cancelled": "Cancelled", + "substitution": "Substitution", + "teacher": "Teacher", + "room": "Room", + "topic": "Topic", + + // Settings + "settings": "Settings", + "refresh_interval": "Refresh Interval", + "15_minutes": "15 minutes", + "30_minutes": "30 minutes", + "1_hour": "1 hour", + "version": "Version", + "language": "Language", + "sync_with_iphone": "Sync with iPhone", + "clear_cache": "Clear Cache", + "logout": "Log Out", + + // Refresh + "refresh": "Refresh", + "refreshing": "Refreshing...", + "refresh_success": "Success!", + "refresh_failed": "Failed", + "error_api": "Kréta API Error", + "error_network": "Network Error", + + // Date labels + "tomorrow_first_lesson": "Tomorrow's first lesson", + "day_first_lesson": "%@'s first lesson", + "next_school_day": "Next school day", + + // Navigation + "home": "Home", + "timetable": "Timetable", + "grades": "Grades", + + // Reauth + "reauth_required": "Re-login Required", + "reauth_description": "Your session has expired. Please log in again on your iPhone.", + "sync_button": "Sync", + "syncing": "Syncing...", + "sync_success": "Success!", + "sync_failed": "Failed", + "phone_not_reachable": "iPhone not reachable", + "connecting": "Connecting...", + ] + + private static let germanStrings: [String: String] = [ + // Home View + "current_lesson": "Aktuelle Stunde", + "next": "Nächste", + "break": "Pause", + "next_lesson": "Nächste: %@", + "first_lesson": "Erste Stunde", + "today_lessons_count": "Du hast heute %d Stunden", + "no_more_lessons": "Keine Stunden mehr heute", + "pair_with_iphone": "Mit iPhone koppeln", + "open_firka_on_iphone": "Öffne Firka auf deinem iPhone", + "updated": "Aktualisiert: %@", + "minutes": "Min", + "time_now": "jetzt", + "time_hours_minutes": "%d Std %d Min", + "time_hours": "%d Stunden", + "time_minutes_only": "%d Min", + + // Timetable View + "free_day": "Freier Tag", + "lesson_number": "%d. Stunde", + "day_mon": "Mo", + "day_tue": "Di", + "day_wed": "Mi", + "day_thu": "Do", + "day_fri": "Fr", + + // Grades View + "grades_count": "%d Noten", + "total_average": "Gesamtdurchschnitt", + "average": "Durchschnitt:", + "no_data": "Keine Daten", + "no_grades": "Keine Noten", + + // Lesson Detail + "lesson_details": "Stundendetails", + "cancelled": "Entfällt", + "substitution": "Vertretung", + "teacher": "Lehrer", + "room": "Raum", + "topic": "Thema", + + // Settings + "settings": "Einstellungen", + "refresh_interval": "Aktualisierungsintervall", + "15_minutes": "15 Minuten", + "30_minutes": "30 Minuten", + "1_hour": "1 Stunde", + "version": "Version", + "language": "Sprache", + "sync_with_iphone": "Mit iPhone synchronisieren", + "clear_cache": "Cache löschen", + "logout": "Abmelden", + + // Refresh + "refresh": "Aktualisieren", + "refreshing": "Wird aktualisiert...", + "refresh_success": "Erfolgreich!", + "refresh_failed": "Fehlgeschlagen", + "error_api": "Kréta API Fehler", + "error_network": "Netzwerkfehler", + + // Date labels + "tomorrow_first_lesson": "Morgen erste Stunde", + "day_first_lesson": "%@ erste Stunde", + "next_school_day": "Nächster Schultag", + + // Navigation + "home": "Startseite", + "timetable": "Stundenplan", + "grades": "Noten", + + // Reauth + "reauth_required": "Erneute Anmeldung erforderlich", + "reauth_description": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut auf dem iPhone an.", + "sync_button": "Synchronisieren", + "syncing": "Synchronisierung...", + "sync_success": "Erfolgreich!", + "sync_failed": "Fehlgeschlagen", + "phone_not_reachable": "iPhone nicht erreichbar", + "connecting": "Verbindung...", + ] +} + +extension String { + var localized: String { + WatchL10n.shared.string(self) + } + + func localized(_ args: CVarArg...) -> String { + let format = WatchL10n.shared.string(self) + return String(format: format, arguments: args) + } +} diff --git a/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift b/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift new file mode 100644 index 0000000..ceebab6 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Services/BackgroundRefreshManager.swift @@ -0,0 +1,42 @@ +import Foundation +import WatchKit +import WidgetKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + + private init() {} + + func scheduleNextRefresh() { + let calendar = Calendar.current + let now = Date() + let hour = calendar.component(.hour, from: now) + let weekday = calendar.component(.weekday, from: now) + let isWeekday = weekday >= 2 && weekday <= 6 + + let interval: TimeInterval + if isWeekday && hour >= 6 && hour <= 16 { + interval = 15 * 60 // 15 minutes during school hours + } else { + interval = 60 * 60 // 1 hour outside school hours + } + + let preferredDate = now.addingTimeInterval(interval) + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: preferredDate, + userInfo: nil + ) { error in + if let error = error { + print("[BackgroundRefresh] Schedule error: \(error)") + } + } + } + + func handleBackgroundRefresh() async { + await DataStore.shared.refreshAll() + + WidgetCenter.shared.reloadAllTimelines() + + scheduleNextRefresh() + } +} diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift new file mode 100644 index 0000000..41ee9b3 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -0,0 +1,390 @@ +import Foundation +import Observation +import WidgetKit + +// MARK: - Cache Wrapper + +struct CachedWatchData: Codable { + let widgetData: WidgetData + let lastUpdated: Date +} + +// MARK: - DataStore + +@Observable +class DataStore { + static let shared = DataStore() + + var data: WidgetData? + var lastUpdated: Date? + var isLoading: Bool = false + var error: String? + + private(set) var hasToken: Bool = false + + var needsReauth: Bool { + error == "token_expired" || error == "no_token" + } + + private let appGroupID = "group.app.firka.firkaa" + private let cacheFileName = "watch_data.json" + + private init() { + checkTokenState() + loadFromCache() + } + + + var hasValidToken: Bool { + TokenManager.shared.loadToken() != nil + } + + func checkTokenState() { + hasToken = TokenManager.shared.loadToken() != nil + print("[Watch] Token state updated: hasToken = \(hasToken)") + } + + // MARK: - Cache Loading + + func loadFromCache() { + if let widgetData = WidgetData.load() { + self.data = widgetData + self.lastUpdated = widgetData.lastUpdated + return + } + + guard let cachedData = loadWatchCache() else { + return + } + + self.data = cachedData.widgetData + self.lastUpdated = cachedData.lastUpdated + } + + private func loadWatchCache() -> CachedWatchData? { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + return nil + } + + let fileURL = containerURL.appendingPathComponent(cacheFileName) + + guard let cacheData = try? Data(contentsOf: fileURL) else { + return nil + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + return try? decoder.decode(CachedWatchData.self, from: cacheData) + } + + private func saveToCache(_ data: WidgetData) { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + return + } + + let fileURL = containerURL.appendingPathComponent(cacheFileName) + let cached = CachedWatchData(widgetData: data, lastUpdated: Date()) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + do { + let encodedData = try encoder.encode(cached) + try encodedData.write(to: fileURL) + } catch { + self.error = "Failed to save cache" + } + } + + // MARK: - Cache Management + + func clearCache() { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { return } + + let fileURL = containerURL.appendingPathComponent(cacheFileName) + try? FileManager.default.removeItem(at: fileURL) + + data = nil + lastUpdated = nil + + print("[Watch] Cache cleared") + } + + func clearAll() { + clearCache() + error = nil + isLoading = false + checkTokenState() + + print("[Watch] All data cleared") + } + + func clearError() { + error = nil + print("[Watch] Error cleared") + } + + func setReauthRequired() { + error = "token_expired" + print("[Watch] Reauth required state set") + } + + private func refreshComplications() { + WidgetCenter.shared.reloadAllTimelines() + print("[Watch] Complications refreshed") + } + + // MARK: - Proactive Token Refresh + + func refreshTokenProactively() async { + guard hasValidToken else { return } + await TokenManager.shared.refreshTokenProactively() + checkTokenState() + } + + // MARK: - Data Refresh + + func refreshAll() async { + print("[Watch] DataStore.refreshAll() called") + isLoading = true + error = nil + + defer { isLoading = false } + + await TokenManager.shared.refreshTokenProactively() + + guard hasValidToken else { + print("[Watch] No valid token, setting error = no_token") + error = "no_token" + return + } + + do { + let (startOfWeek, endOfWeek) = getCurrentWeekDateRange() + + async let timetableTask = KretaAPIClient.shared.fetchTimetable( + from: startOfWeek, + to: endOfWeek + ) + async let gradesTask = KretaAPIClient.shared.fetchGrades() + + let (lessons, grades) = try await (timetableTask, gradesTask) + + let timetableData = buildTimetableData(from: lessons) + let averagesData = buildAveragesData(from: grades) + + let widgetData = WidgetData( + lastUpdated: Date(), + locale: Locale.current.language.languageCode?.identifier ?? "hu", + theme: "dark", + timetable: timetableData, + grades: grades, + averages: averagesData + ) + + self.data = widgetData + self.lastUpdated = Date() + + saveToCache(widgetData) + + refreshComplications() + + print("[Watch] refreshAll() completed successfully") + + } catch let error as APIError { + handleAPIError(error) + } catch { + print("[Watch] refreshAll() network error: \(error)") + self.error = "network" + } + } + + /// Handles API errors and maps them to user-friendly messages + private func handleAPIError(_ error: APIError) { + print("[Watch] handleAPIError: \(error)") + switch error { + case .tokenError(let tokenError): + switch tokenError { + case .noToken: + print("[Watch] Setting error = no_token") + self.error = "no_token" + case .refreshExpired, .invalidGrant: + print("[Watch] Setting error = token_expired") + self.error = "token_expired" + case .invalidResponse, .networkError: + print("[Watch] Setting error = network (token error)") + self.error = "network" + } + case .unauthorized: + print("[Watch] Setting error = token_expired (unauthorized)") + self.error = "token_expired" + case .requestFailed(let statusCode): + if statusCode >= 500 { + print("[Watch] Setting error = api_error (server error \(statusCode))") + self.error = "api_error" + } else { + print("[Watch] Setting error = network (request failed \(statusCode))") + self.error = "network" + } + case .decodingFailed, .invalidURL: + print("[Watch] Setting error = network") + self.error = "network" + } + } + + // MARK: - Data Processing + + private func buildTimetableData(from lessons: [WidgetLesson]) -> TimetableData { + let today = Date() + let todayString = formatDateForComparison(today) + let tomorrowString = formatDateForComparison(today.addingTimeInterval(86400)) + + let todayLessons = lessons.filter { $0.date == todayString }.sorted { $0.start < $1.start } + let tomorrowLessons = lessons.filter { $0.date == tomorrowString }.sorted { $0.start < $1.start } + + var nextSchoolDayLessons: [WidgetLesson]? = nil + var nextSchoolDayDateString: String? = nil + + for daysOffset in 2...14 { + let checkDate = today.addingTimeInterval(TimeInterval(daysOffset * 86400)) + let checkDateString = formatDateForComparison(checkDate) + let checkLessons = lessons.filter { $0.date == checkDateString } + + if !checkLessons.isEmpty { + nextSchoolDayLessons = checkLessons.sorted { $0.start < $1.start } + nextSchoolDayDateString = checkDateString + break + } + } + + let currentBreak: BreakInfo? = nil + + return TimetableData( + today: todayLessons, + tomorrow: tomorrowLessons, + nextSchoolDay: nextSchoolDayLessons, + nextSchoolDayDate: nextSchoolDayDateString, + currentBreak: currentBreak, + allLessons: lessons + ) + } + + /// Builds AveragesData from grades (matching Flutter's calculation) + private func buildAveragesData(from grades: [WidgetGrade]) -> AveragesData { + guard !grades.isEmpty else { + return AveragesData(overall: nil, subjects: []) + } + + var subjectGradesMap: [String: [(value: Int, weight: Double)]] = [:] + + for grade in grades { + if let numeric = grade.numericValue { + let key = grade.subject.uid + let weight = Double(grade.weightPercentage ?? 100) / 100.0 + subjectGradesMap[key, default: []].append((value: numeric, weight: weight)) + } + } + + var subjectAverages: [SubjectAverage] = [] + + for (uid, gradeValues) in subjectGradesMap { + if let firstGrade = grades.first(where: { $0.subject.uid == uid }) { + var weightedSum = 0.0 + var totalWeight = 0.0 + + for (value, weight) in gradeValues { + weightedSum += Double(value) * weight + totalWeight += weight + } + + let average = totalWeight > 0 ? weightedSum / totalWeight : Double.nan + + if !average.isNaN { + subjectAverages.append( + SubjectAverage( + uid: uid, + name: firstGrade.subject.name, + average: average, + gradeCount: gradeValues.count + ) + ) + } + } + } + + let overall: Double? + if !subjectAverages.isEmpty { + let sumOfAverages = subjectAverages.reduce(0.0) { $0 + $1.average } + overall = sumOfAverages / Double(subjectAverages.count) + } else { + overall = nil + } + + return AveragesData(overall: overall, subjects: subjectAverages) + } + + private func getCurrentWeekDateRange() -> (start: Date, end: Date) { + let calendar = Calendar.current + let today = Date() + + let weekday = calendar.component(.weekday, from: today) + let daysToMonday = weekday == 1 ? -6 : (2 - weekday) + let monday = calendar.date(byAdding: .day, value: daysToMonday, to: today)! + + let nextSunday = calendar.date(byAdding: .day, value: 13, to: monday)! + + return (monday, nextSunday) + } + + private func formatDateForComparison(_ date: Date) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + + // MARK: - Computed Helpers + + var timeSinceUpdate: String? { + guard let lastUpdated = lastUpdated else { return nil } + + let elapsed = Date().timeIntervalSince(lastUpdated) + + if elapsed < 60 { + return nil + } + + // Minutes + let minutes = Int(elapsed / 60) + if minutes < 60 { + return minutes == 1 ? "1 perce" : "\(minutes) perce" + } + + // Hours + let hours = Int(elapsed / 3600) + if hours < 24 { + return hours == 1 ? "1 órája" : "\(hours) órája" + } + + // Days + let days = Int(elapsed / 86400) + return days == 1 ? "1 napja" : "\(days) napja" + } + + /// Returns true if data is stale (> 1 hour old or never updated) + var isStale: Bool { + guard let lastUpdated = lastUpdated else { return true } + + let elapsed = Date().timeIntervalSince(lastUpdated) + return elapsed > 3600 // 1 hour + } +} diff --git a/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift new file mode 100644 index 0000000..ff514f5 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Services/WatchConnectivityManager.swift @@ -0,0 +1,268 @@ +import Foundation +import WatchConnectivity + +class WatchConnectivityManager: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityManager() + + private override init() { + super.init() + } + + func activate() { + print("[Watch] WatchConnectivityManager.activate() called") + if WCSession.isSupported() { + print("[Watch] WCSession is supported, activating...") + WCSession.default.delegate = self + WCSession.default.activate() + } else { + print("[Watch] WCSession is NOT supported!") + } + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + print("[Watch] Session activation completed with state: \(activationState.rawValue)") + if let error = error { + print("[Watch] Activation error: \(error.localizedDescription)") + } + DispatchQueue.main.async { + if activationState == .activated { + let context = session.receivedApplicationContext + if !context.isEmpty { + self.processApplicationContext(context) + } + } + } + } + + func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + print("[Watch] didReceiveApplicationContext called") + DispatchQueue.main.async { + self.processApplicationContext(applicationContext) + } + } + + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] = [:] + ) { + print("[Watch] didReceiveUserInfo called") + DispatchQueue.main.async { + self.processUserInfo(userInfo) + } + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + print("[Watch] didReceiveMessage called: \(message)") + + guard let action = message["action"] as? String else { + replyHandler(["error": "no_action"]) + return + } + + switch action { + case "getToken": + handleGetTokenRequest(replyHandler: replyHandler) + default: + replyHandler(["error": "unknown_action"]) + } + } + + private func handleGetTokenRequest(replyHandler: @escaping ([String: Any]) -> Void) { + guard let token = TokenManager.shared.loadToken() else { + print("[Watch] No token to send to iPhone") + replyHandler(["error": "no_token"]) + return + } + + let tokenData: [String: Any] = [ + "studentId": token.studentId, + "studentIdNorm": token.studentIdNorm, + "iss": token.iss, + "idToken": token.idToken, + "accessToken": token.accessToken, + "refreshToken": token.refreshToken, + "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) + ] + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + formatter.timeZone = TimeZone.current + print("[Watch] Sending token to iPhone, expiry: \(formatter.string(from: token.expiryDate))") + replyHandler(["token": tokenData]) + } + + func requestTokenFromPhone() { + guard WCSession.default.activationState == .activated else { + print("[Watch] Cannot request token: session not activated") + return + } + + guard WCSession.default.isReachable else { + print("[Watch] Cannot request token: iPhone not reachable") + return + } + + print("[Watch] Requesting token from iPhone...") + + WCSession.default.sendMessage( + ["action": "requestToken"], + replyHandler: { response in + print("[Watch] Received response from iPhone") + DispatchQueue.main.async { + if let authDict = response["auth"] as? [String: Any] { + print("[Watch] Token received from iPhone") + self.processAuthData(authDict) + } else if let error = response["error"] as? String { + print("[Watch] Token request error: \(error)") + } + } + }, + errorHandler: { error in + print("[Watch] Token request failed: \(error.localizedDescription)") + } + ) + } + + private func processApplicationContext(_ context: [String: Any]) { + if let authDict = context["auth"] as? [String: Any] { + print("[Watch] Received auth from iPhone") + processAuthData(authDict) + } + + if let language = context["language"] as? String { + print("[Watch] Received language from iPhone: \(language)") + WatchL10n.shared.updateFromiPhone(languageCode: language) + } + } + + private func processUserInfo(_ userInfo: [String: Any]) { + if let messageId = userInfo["id"] as? String { + switch messageId { + case "token_update": + if let authDict = userInfo["auth"] as? [String: Any] { + print("[Watch] Received token_update via userInfo") + processAuthData(authDict) + } + case "language_update": + if let language = userInfo["language"] as? String { + print("[Watch] Received language_update via userInfo: \(language)") + WatchL10n.shared.updateFromiPhone(languageCode: language) + } + case "reauth_required": + print("[Watch] Received reauth_required notification from iPhone") + DataStore.shared.setReauthRequired() + default: + break + } + } + } + + func sendTokenToiPhoneInBackground() { + guard WCSession.default.activationState == .activated else { + print("[Watch] Cannot send token: session not activated") + return + } + + guard let token = TokenManager.shared.loadToken() else { + print("[Watch] No token to send to iPhone") + return + } + + let tokenData: [String: Any] = [ + "studentId": token.studentId, + "studentIdNorm": token.studentIdNorm, + "iss": token.iss, + "idToken": token.idToken, + "accessToken": token.accessToken, + "refreshToken": token.refreshToken, + "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) + ] + + do { + try WCSession.default.updateApplicationContext(["auth": tokenData]) + print("[Watch] Token sent via applicationContext") + } catch { + print("[Watch] Failed to update applicationContext: \(error)") + } + + WCSession.default.transferUserInfo([ + "id": "token_update_from_watch", + "auth": tokenData + ]) + + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + formatter.timeZone = TimeZone.current + print("[Watch] Token sent to iPhone (background), expiry: \(formatter.string(from: token.expiryDate))") + } + + func requestLanguageFromPhone() { + guard WCSession.default.activationState == .activated else { + print("[Watch] Cannot request language: session not activated") + return + } + + guard WCSession.default.isReachable else { + print("[Watch] Cannot request language: iPhone not reachable") + return + } + + print("[Watch] Requesting language from iPhone...") + + WCSession.default.sendMessage( + ["action": "requestLanguage"], + replyHandler: { response in + print("[Watch] Received language response from iPhone") + DispatchQueue.main.async { + if let language = response["language"] as? String { + print("[Watch] Language received from iPhone: \(language)") + WatchL10n.shared.updateFromiPhone(languageCode: language) + } + } + }, + errorHandler: { error in + print("[Watch] Language request failed: \(error.localizedDescription)") + } + ) + } + + private func processAuthData(_ authDict: [String: Any]) { + print("[Watch] processAuthData called") + do { + let jsonData = try JSONSerialization.data(withJSONObject: authDict) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let timestamp = try container.decode(Int64.self) + return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + } + + let token = try decoder.decode(WatchToken.self, from: jsonData) + print("[Watch] Token decoded, saving...") + + try TokenManager.shared.saveToken(token) + print("[Watch] Token saved successfully") + + DataStore.shared.checkTokenState() + + Task { + await DataStore.shared.refreshAll() + print("[Watch] Data refresh completed") + } + } catch { + print("[Watch] Failed to process auth data: \(error)") + } + } +} diff --git a/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift b/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift new file mode 100644 index 0000000..5b5332c --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct GradeSubjectView: View { + let subjectName: String + let grades: [WidgetGrade] + let average: Double + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + FirkaCard { + HStack { + Text("average".localized) + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "%.2f", average)) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(averageColor(average)) + } + } + + ForEach(groupedGrades, id: \.date) { group in + VStack(alignment: .leading, spacing: 6) { + Text(formatDate(group.date)) + .font(.caption) + .foregroundColor(.secondary) + + ForEach(group.grades) { grade in + gradeRow(grade) + } + } + } + } + .padding() + } + .navigationTitle(subjectName) + } + + private var groupedGrades: [(date: Date, grades: [WidgetGrade])] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: grades) { grade in + calendar.startOfDay(for: grade.recordDate) + } + return grouped + .map { (date: $0.key, grades: $0.value) } + .sorted { $0.date > $1.date } + } + + @ViewBuilder + private func gradeRow(_ grade: WidgetGrade) -> some View { + FirkaCard { + HStack(alignment: .top, spacing: 10) { + if let numeric = grade.numericValue { + GradeBadge(grade: numeric) + } else { + Text(grade.displayValue) + .font(.caption) + .fontWeight(.bold) + .padding(6) + .background(Color.gray) + .cornerRadius(12) + } + + VStack(alignment: .leading, spacing: 2) { + Text(grade.displayType) + .font(.subheadline) + .fontWeight(.medium) + + if let topic = grade.topic, !topic.isEmpty { + Text(topic) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + } + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy. MM. dd." + return formatter.string(from: date) + } + + private func averageColor(_ avg: Double) -> Color { + switch avg { + 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/FirkaWatch Watch App/Views/GradesView.swift b/firka/ios/FirkaWatch Watch App/Views/GradesView.swift new file mode 100644 index 0000000..0a69a8a --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/GradesView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct GradesView: View { + let dataStore: DataStore + + var body: some View { + NavigationStack { + if dataStore.data == nil { + ContentUnavailableView("no_data".localized, systemImage: "graduationcap") + } else if subjects.isEmpty { + ContentUnavailableView("no_grades".localized, systemImage: "graduationcap") + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(subjects, id: \.uid) { subject in + NavigationLink { + GradeSubjectView( + subjectName: subject.name, + grades: gradesFor(subject.uid), + average: subject.average + ) + } label: { + subjectRow(subject) + } + .buttonStyle(.plain) + } + + if let overall = dataStore.data?.averages.overall { + overallAverageCard(overall) + } + } + .padding() + } + } + } + } + + private var subjects: [SubjectAverage] { + (dataStore.data?.averages.subjects ?? []).sorted { $0.name < $1.name } + } + + private func gradesFor(_ uid: String) -> [WidgetGrade] { + dataStore.data?.grades.filter { $0.subject.uid == uid } ?? [] + } + + @ViewBuilder + private func subjectRow(_ subject: SubjectAverage) -> some View { + FirkaCard { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(subject.name) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + + Spacer() + + Text(String(format: "%.2f", subject.average)) + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(averageColor(subject.average)) + } + + HStack(spacing: 8) { + AverageProgressBar(average: subject.average) + + Text("grades_count".localized(subject.gradeCount)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + + @ViewBuilder + private func overallAverageCard(_ average: Double) -> some View { + FirkaCard { + VStack(alignment: .leading, spacing: 4) { + Text("total_average".localized) + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Text(String(format: "%.2f", average)) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(averageColor(average)) + + Spacer() + + AverageProgressBar(average: average) + .frame(width: 60) + } + } + } + .padding(.top, 8) + } + + private func averageColor(_ avg: Double) -> Color { + switch avg { + 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/FirkaWatch Watch App/Views/HomeView.swift b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift new file mode 100644 index 0000000..0befcb3 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift @@ -0,0 +1,466 @@ +import SwiftUI +internal import Combine + +struct HomeView: View { + let dataStore: DataStore + @State private var currentTime = Date() + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(spacing: 12) { + if let breakInfo = dataStore.data?.timetable.currentBreak { + breakView(breakInfo) + } else if !dataStore.hasToken && dataStore.data == nil { + noTokenView + } else if let current = currentLesson { + currentLessonView(current) + } else if let next = nextLesson { + if isBreakBetweenLessons { + breakBetweenView(next) + } else { + beforeSchoolView(next) + } + } else { + noMoreLessonsView + } + + refreshButton + + if dataStore.lastUpdated != nil { + lastUpdatedView + } + } + .padding() + } + .onReceive(timer) { _ in + currentTime = Date() + } + } + + // MARK: - Refresh Button + + @State private var refreshStatus: RefreshStatus = .idle + + enum RefreshStatus { + case idle, loading, success, failure + } + + private var refreshButton: some View { + Button(action: { + Task { + refreshStatus = .loading + await dataStore.refreshAll() + if dataStore.error == nil && dataStore.data != nil { + refreshStatus = .success + } else { + refreshStatus = .failure + } + try? await Task.sleep(nanoseconds: 2_000_000_000) + refreshStatus = .idle + } + }) { + HStack(spacing: 6) { + switch refreshStatus { + case .idle: + Image(systemName: "arrow.clockwise") + case .loading: + ProgressView() + .scaleEffect(0.8) + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .failure: + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + Text(refreshStatusText) + } + .font(.caption) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .disabled(refreshStatus == .loading) + .padding(.top, 8) + } + + private var refreshStatusText: String { + switch refreshStatus { + case .idle: return "refresh".localized + case .loading: return "refreshing".localized + case .success: return "refresh_success".localized + case .failure: + if let error = dataStore.error { + switch error { + case "api_error": return "error_api".localized + case "network": return "error_network".localized + case "token_expired", "no_token": return "reauth_required".localized + default: return "refresh_failed".localized + } + } + return "refresh_failed".localized + } + } + + // MARK: - Computed Properties + + private var now: Date { currentTime } + + private var todayLessons: [WidgetLesson] { + let todayStr = formatDateForHomeView(currentTime) + + if let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty { + return allLessons + .filter { $0.date == todayStr } + .sorted { $0.start < $1.start } + } + + return dataStore.data?.timetable.today ?? [] + } + + private var currentLesson: WidgetLesson? { + todayLessons.first { currentTime >= $0.start && currentTime <= $0.end } + } + + private var nextLesson: WidgetLesson? { + todayLessons + .filter { $0.start > currentTime } + .sorted { $0.start < $1.start } + .first + } + + private var previousLesson: WidgetLesson? { + todayLessons + .filter { $0.end < currentTime } + .sorted { $0.end > $1.end } + .first + } + + private var isBreakBetweenLessons: Bool { + guard let prev = previousLesson, let next = nextLesson else { return false } + return currentTime > prev.end && currentTime < next.start + } + + // MARK: - Current Lesson View (with CountdownRing) + + @ViewBuilder + private func currentLessonView(_ lesson: WidgetLesson) -> some View { + VStack(spacing: 10) { + Text("current_lesson".localized) + .font(.caption) + .foregroundColor(.secondary) + + let totalMinutes = Int(lesson.end.timeIntervalSince(lesson.start) / 60) + let remaining = max(0, Int(lesson.end.timeIntervalSince(now) / 60)) + + HStack(spacing: 10) { + CountdownRing( + totalMinutes: totalMinutes, + remainingMinutes: remaining, + label: "minutes".localized, + size: 56, + lineWidth: 6, + displayOffset: 1 + ) + .id("lesson-\(lesson.start.timeIntervalSince1970)") + FirkaCard(isHighlighted: true) { + VStack(alignment: .leading, spacing: 4) { + Text(lesson.displayName) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(2) + + HStack(spacing: 6) { + if let room = lesson.roomName { + Label(room, systemImage: "door.right.hand.closed") + } + Text(lesson.timeString) + } + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // Next lesson preview + if let next = nextLesson { + Text("next".localized) + .font(.caption) + .foregroundColor(.secondary) + + FirkaCard { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(next.displayName) + .font(.subheadline) + if let room = next.roomName { + Text(room) + .font(.caption2) + .foregroundColor(.secondary) + } + } + Spacer() + Text(next.start, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + + // MARK: - Break Between Lessons (with CountdownRing) + + @ViewBuilder + private func breakBetweenView(_ next: WidgetLesson) -> some View { + VStack(spacing: 10) { + Text("break".localized) + .font(.caption) + .foregroundColor(.secondary) + + let remaining = max(0, Int(next.start.timeIntervalSince(now) / 60)) + + HStack(spacing: 10) { + CountdownRing( + totalMinutes: 15, + remainingMinutes: remaining, + label: "minutes".localized, + size: 56, + lineWidth: 6, + displayOffset: 1 + ) + .id("break-\(next.start.timeIntervalSince1970)") + + FirkaCard { + VStack(alignment: .leading, spacing: 4) { + Text("next_lesson".localized(next.displayName)) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(2) + + HStack(spacing: 6) { + if let room = next.roomName { + Label(room, systemImage: "door.right.hand.closed") + } + Text(next.start, style: .time) + } + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + // MARK: - Before School View + + @ViewBuilder + private func beforeSchoolView(_ first: WidgetLesson) -> some View { + VStack(spacing: 12) { + Text("first_lesson".localized) + .font(.caption) + .foregroundColor(.secondary) + + FirkaCard { + VStack(alignment: .leading, spacing: 8) { + Text(first.displayName) + .font(.headline) + + HStack { + if let room = first.roomName { + Label(room, systemImage: "door.right.hand.closed") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text(relativeTimeString(to: first.start)) + .font(.caption) + .foregroundColor(.blue) + } + } + } + + if !todayLessons.isEmpty { + Text("today_lessons_count".localized(todayLessons.count)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // MARK: - No More Lessons View + + private var noMoreLessonsView: some View { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundColor(.green) + + Text("no_more_lessons".localized) + .font(.headline) + + if let (nextLesson, dayLabel) = nextSchoolDayFirstLesson { + Text(dayLabel) + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + + FirkaCard { + HStack { + Text(nextLesson.displayName) + .font(.subheadline) + Spacer() + Text(nextLesson.start, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + + private var nextSchoolDayFirstLesson: (lesson: WidgetLesson, label: String)? { + guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else { + if let tomorrow = dataStore.data?.timetable.tomorrow.first { + return (tomorrow, "tomorrow_first_lesson".localized) + } + return nil + } + + let calendar = Calendar.current + let now = currentTime + let todayStr = formatDateForHomeView(now) + + let futureLessons = allLessons.filter { $0.date > todayStr } + .sorted { $0.date < $1.date || ($0.date == $1.date && $0.start < $1.start) } + + guard let firstFuture = futureLessons.first else { + return nil + } + + let label = labelForDate(firstFuture.date, relativeTo: now) + + return (firstFuture, label) + } + + private func formatDateForHomeView(_ date: Date) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + + private func labelForDate(_ dateStr: String, relativeTo: Date) -> String { + let calendar = Calendar.current + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + + guard let targetDate = formatter.date(from: dateStr) else { + return "next_school_day".localized + } + + let today = calendar.startOfDay(for: relativeTo) + let target = calendar.startOfDay(for: targetDate) + + let daysDiff = calendar.dateComponents([.day], from: today, to: target).day ?? 0 + + switch daysDiff { + case 1: + return "tomorrow_first_lesson".localized + case 2...6: + let dayFormatter = DateFormatter() + let langCode = WatchL10n.shared.currentLanguage.rawValue + dayFormatter.locale = Locale(identifier: langCode) + dayFormatter.dateFormat = "EEEE" + let dayName = dayFormatter.string(from: targetDate).capitalized + return "day_first_lesson".localized(dayName) + default: + return "next_school_day".localized + } + } + + // MARK: - Break/Vacation View + + @ViewBuilder + private func breakView(_ breakInfo: BreakInfo) -> some View { + VStack(spacing: 12) { + let icon = SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil) + let color = SeasonalIconHelper.iconColor(for: breakInfo.nameKey, season: nil) + + Image(systemName: icon) + .font(.system(size: 44)) + .foregroundColor(color) + + Text(breakInfo.name) + .font(.headline) + } + } + + // MARK: - No Token View + + private var noTokenView: some View { + VStack(spacing: 12) { + Image(systemName: "iphone.and.arrow.right.inward") + .font(.system(size: 44)) + .foregroundColor(.blue) + + Text("pair_with_iphone".localized) + .font(.headline) + .multilineTextAlignment(.center) + + Text("open_firka_on_iphone".localized) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + // MARK: - Last Updated View + + private var lastUpdatedView: some View { + HStack(spacing: 4) { + if dataStore.isStale { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + } + if let text = dataStore.timeSinceUpdate { + Text("updated".localized(text)) + } + } + .font(.caption2) + .foregroundColor(.secondary) + .padding(.top, 8) + } + + // MARK: - Relative Time Helper + + private func relativeTimeString(to date: Date) -> String { + let now = currentTime + let interval = date.timeIntervalSince(now) + + guard interval > 0 else { + return "time_now".localized + } + + let totalMinutes = Int(interval / 60) + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + if hours > 0 && minutes > 0 { + return "time_hours_minutes".localized(hours, minutes) + } else if hours > 0 { + return "time_hours".localized(hours) + } else { + return "time_minutes_only".localized(minutes) + } + } +} diff --git a/firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift b/firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift new file mode 100644 index 0000000..4489d16 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct LessonDetailView: View { + let lesson: WidgetLesson + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack { + if let number = lesson.lessonNumber { + Text("lesson_number".localized(number)) + .font(.caption) + .foregroundColor(.blue) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + .cornerRadius(8) + } + + Spacer() + + Text("\(formatTime(lesson.start)) - \(formatTime(lesson.end))") + .font(.caption) + .foregroundColor(.secondary) + } + + Text(lesson.displayName) + .font(.headline) + .lineLimit(3) + + if lesson.isCancelled || lesson.isSubstitution { + HStack(spacing: 8) { + if lesson.isCancelled { + Label("cancelled".localized, systemImage: "xmark.circle.fill") + .font(.caption2) + .foregroundColor(.red) + } + if lesson.isSubstitution { + Label("substitution".localized, systemImage: "person.2.fill") + .font(.caption2) + .foregroundColor(.orange) + } + } + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + if lesson.isSubstitution, let substitute = lesson.substituteTeacher { + VStack(alignment: .leading, spacing: 4) { + Label("teacher".localized, systemImage: "person.fill") + .font(.caption) + .foregroundColor(.secondary) + + if let original = lesson.teacher { + HStack(spacing: 4) { + Text(original) + .strikethrough() + .foregroundColor(.secondary) + Text("→") + .foregroundColor(.orange) + Text(substitute) + .foregroundColor(.orange) + } + .font(.subheadline) + } else { + Text(substitute) + .font(.subheadline) + .foregroundColor(.orange) + } + } + } else if let teacher = lesson.teacher { + detailRow(icon: "person.fill", label: "teacher".localized, value: teacher) + } + + if let room = lesson.roomName { + detailRow(icon: "door.right.hand.closed", label: "room".localized, value: room) + } + + if let theme = lesson.theme, !theme.isEmpty { + detailRow(icon: "doc.text.fill", label: "topic".localized, value: theme) + } + } + } + .padding() + } + .navigationTitle("lesson_details".localized) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func detailRow(icon: String, label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(label, systemImage: icon) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.subheadline) + .lineLimit(5) + } + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } +} diff --git a/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift new file mode 100644 index 0000000..a442f14 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift @@ -0,0 +1,281 @@ +import SwiftUI +import WatchConnectivity + +struct ReauthRequiredView: View { + @State private var isSyncing = false + @State private var syncStatus: SyncStatus = .idle + var onTokenReceived: (() -> Void)? + + enum SyncStatus { + case idle + case syncing + case success + case failed + case phoneNotReachable + } + + var body: some View { + ScrollView { + VStack(spacing: 16) { + Image(systemName: statusIcon) + .font(.system(size: 44)) + .foregroundColor(statusColor) + .symbolEffect(.pulse, isActive: syncStatus == .syncing) + + Text("reauth_required".localized) + .font(.headline) + .multilineTextAlignment(.center) + + Text("reauth_description".localized) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + + if let statusMessage = statusMessage { + Text(statusMessage) + .font(.caption2) + .foregroundColor(statusMessageColor) + .multilineTextAlignment(.center) + } + + Button(action: syncWithiPhone) { + HStack { + if syncStatus == .syncing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text("sync_button".localized) + } + } + .buttonStyle(.borderedProminent) + .tint(syncStatus == .success ? .green : .blue) + .disabled(syncStatus == .syncing) + } + .padding() + } + } + + private var statusIcon: String { + switch syncStatus { + case .idle: + return "exclamationmark.arrow.circlepath" + case .syncing: + return "arrow.triangle.2.circlepath" + case .success: + return "checkmark.circle.fill" + case .failed: + return "xmark.circle.fill" + case .phoneNotReachable: + return "iphone.slash" + } + } + + private var statusColor: Color { + switch syncStatus { + case .idle: + return .orange + case .syncing: + return .blue + case .success: + return .green + case .failed: + return .red + case .phoneNotReachable: + return .gray + } + } + + private var statusMessage: String? { + switch syncStatus { + case .idle: + return nil + case .syncing: + return "syncing".localized + case .success: + return "sync_success".localized + case .failed: + return "sync_failed".localized + case .phoneNotReachable: + return "phone_not_reachable".localized + } + } + + private var statusMessageColor: Color { + switch syncStatus { + case .success: + return .green + case .failed, .phoneNotReachable: + return .red + default: + return .secondary + } + } + + private func syncWithiPhone() { + guard WCSession.default.activationState == .activated else { + syncStatus = .failed + return + } + + guard WCSession.default.isReachable else { + syncStatus = .phoneNotReachable + return + } + + syncStatus = .syncing + + WCSession.default.sendMessage( + ["action": "requestToken"], + replyHandler: { response in + DispatchQueue.main.async { + if let authDict = response["auth"] as? [String: Any] { + print("[Watch] Token received from iPhone via reauth sync") + self.processAuthData(authDict) + + if !TokenManager.shared.isTokenExpired() { + self.syncStatus = .success + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.onTokenReceived?() + } + } else { + print("[Watch] Received token is already expired - iPhone needs reauth") + self.syncStatus = .failed + } + } else if let error = response["error"] as? String { + print("[Watch] iPhone returned error: \(error)") + + if error == "needsReauth" || error == "no_token" { + self.sendWatchTokenToiPhone() + } else { + self.syncStatus = .failed + } + } else { + print("[Watch] No token in response - iPhone may need reauth") + self.syncStatus = .failed + } + } + }, + errorHandler: { error in + DispatchQueue.main.async { + print("[Watch] Reauth sync failed: \(error.localizedDescription)") + self.syncStatus = .failed + } + } + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 15) { + if self.syncStatus == .syncing { + self.syncStatus = .failed + } + } + } + + private func sendWatchTokenToiPhone() { + guard TokenManager.shared.loadToken() != nil else { + print("[Watch] No token to send to iPhone") + syncStatus = .failed + return + } + + if TokenManager.shared.isTokenExpired() { + print("[Watch] Watch token is expired - attempting to refresh...") + Task { + do { + _ = try await TokenManager.shared.refreshToken() + print("[Watch] Token refresh succeeded! Now sending to iPhone...") + await MainActor.run { + self.sendRefreshedTokenToiPhone() + } + } catch { + print("[Watch] Token refresh failed: \(error) - both devices need reauth") + await MainActor.run { + self.syncStatus = .failed + } + } + } + return + } + + sendRefreshedTokenToiPhone() + } + + private func sendRefreshedTokenToiPhone() { + guard let token = TokenManager.shared.loadToken() else { + print("[Watch] No token after refresh") + syncStatus = .failed + return + } + + print("[Watch] Sending Watch token to iPhone...") + + let tokenData: [String: Any] = [ + "studentId": token.studentId, + "studentIdNorm": token.studentIdNorm, + "iss": token.iss, + "idToken": token.idToken, + "accessToken": token.accessToken, + "refreshToken": token.refreshToken, + "expiryDate": Int64(token.expiryDate.timeIntervalSince1970 * 1000) + ] + + WCSession.default.sendMessage( + ["action": "receiveTokenFromWatch", "token": tokenData], + replyHandler: { response in + DispatchQueue.main.async { + if let success = response["success"] as? Bool, success { + print("[Watch] iPhone accepted our token!") + self.syncStatus = .success + + DataStore.shared.clearError() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.onTokenReceived?() + } + } else if let error = response["error"] as? String { + print("[Watch] iPhone rejected our token: \(error)") + self.syncStatus = .failed + } else { + self.syncStatus = .failed + } + } + }, + errorHandler: { error in + DispatchQueue.main.async { + print("[Watch] Failed to send token to iPhone: \(error)") + self.syncStatus = .failed + } + } + ) + } + + private func processAuthData(_ authDict: [String: Any]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: authDict) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let timestamp = try container.decode(Int64.self) + return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + } + + let token = try decoder.decode(WatchToken.self, from: jsonData) + try TokenManager.shared.saveToken(token) + + DataStore.shared.checkTokenState() + DataStore.shared.clearError() + + print("[Watch] Token saved via reauth sync") + } catch { + print("[Watch] Failed to process auth data: \(error)") + } + } +} + +#Preview { + ReauthRequiredView() +} diff --git a/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift b/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift new file mode 100644 index 0000000..2627a01 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/SettingsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct SettingsView: View { + @AppStorage("refreshInterval") private var refreshInterval: Int = 15 + @State private var l10n = WatchL10n.shared + + var body: some View { + List { + Section("language".localized) { + Toggle("sync_with_iphone".localized, isOn: Binding( + get: { l10n.syncWithiPhone }, + set: { l10n.syncWithiPhone = $0 } + )) + + if !l10n.syncWithiPhone { + Picker("language".localized, selection: Binding( + get: { l10n.currentLanguage }, + set: { l10n.setLanguage($0) } + )) { + ForEach(WatchLanguage.allCases, id: \.self) { lang in + HStack { + Text(lang.flag) + Text(lang.displayName) + } + .tag(lang) + } + } + } + } + + Section("refresh".localized) { + Picker("refresh_interval".localized, selection: $refreshInterval) { + Text("15_minutes".localized).tag(15) + Text("30_minutes".localized).tag(30) + Text("1_hour".localized).tag(60) + } + } + + Section { + Button("clear_cache".localized) { + clearCache() + } + + Button("logout".localized, role: .destructive) { + logout() + } + } + + Section { + HStack { + Text("version".localized) + Spacer() + Text(appVersion) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("settings".localized) + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + + private func clearCache() { + DataStore.shared.clearCache() + } + + private func logout() { + TokenManager.shared.deleteToken() + DataStore.shared.clearAll() + } +} diff --git a/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift b/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift new file mode 100644 index 0000000..1d2e573 --- /dev/null +++ b/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift @@ -0,0 +1,357 @@ +import SwiftUI + +struct TimetableView: View { + let dataStore: DataStore + + @State private var selectedDay: Int = 0 + @State private var weekOffset: Int = 0 + + private var dayLabels: [String] { + [ + "day_mon".localized, + "day_tue".localized, + "day_wed".localized, + "day_thu".localized, + "day_fri".localized + ] + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + daySelector + + Divider() + .padding(.vertical, 4) + + lessonsContent + } + .onAppear { + updateWeekAndDay() + } + } + } + + private func updateWeekAndDay() { + let calendar = Calendar.current + let now = Date() + + if shouldShowNextWeek() { + weekOffset = 1 + selectedDay = findFirstSchoolDay(weekOffset: 1) + return + } + + weekOffset = 0 + let weekday = calendar.component(.weekday, from: now) + let todayIndex = weekday - 2 + + if todayIndex < 0 || todayIndex > 4 { + selectedDay = findFirstSchoolDay(weekOffset: 0) + return + } + + if areTodayLessonsDone(dayIndex: todayIndex) { + if let nextDay = findNextSchoolDay(after: todayIndex) { + selectedDay = nextDay + } else { + selectedDay = todayIndex + } + } else { + selectedDay = todayIndex + } + } + + private func areTodayLessonsDone(dayIndex: Int) -> Bool { + let todayLessons = lessonsForDay(dayIndex) + guard !todayLessons.isEmpty else { return true } + + let now = Date() + let lastLesson = todayLessons.sorted { $0.end > $1.end }.first + return lastLesson.map { now > $0.end } ?? true + } + + private func findNextSchoolDay(after dayIndex: Int) -> Int? { + for day in (dayIndex + 1)...4 { + if !lessonsForDay(day).isEmpty { + return day + } + } + return nil + } + + private func findFirstSchoolDay(weekOffset: Int) -> Int { + let oldOffset = self.weekOffset + for day in 0...4 { + let lessons = lessonsForDayWithOffset(day, weekOffset: weekOffset) + if !lessons.isEmpty { + return day + } + } + return 0 + } + + private func lessonsForDayWithOffset(_ day: Int, weekOffset: Int) -> [WidgetLesson] { + guard let data = dataStore.data else { return [] } + + let allLessons: [WidgetLesson] + if let all = data.timetable.allLessons, !all.isEmpty { + allLessons = all + } else { + return [] + } + + let targetDateStr = getDateStringForDayWithOffset(day, weekOffset: weekOffset) + return allLessons.filter { $0.date == targetDateStr } + } + + private func getDateStringForDayWithOffset(_ day: Int, weekOffset: Int) -> String { + let calendar = Calendar.current + let now = Date() + + let weekday = calendar.component(.weekday, from: now) + let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday) + + guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else { + return "" + } + + let totalDaysToAdd = day + (weekOffset * 7) + guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else { + return "" + } + + return formatDate(targetDate) + } + + private func shouldShowNextWeek() -> Bool { + guard let allLessons = dataStore.data?.timetable.allLessons, !allLessons.isEmpty else { + return false + } + + let now = Date() + let calendar = Calendar.current + + let weekday = calendar.component(.weekday, from: now) + let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday) + guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now), + let friday = calendar.date(byAdding: .day, value: 4, to: monday) else { + return false + } + let fridayString = formatDate(friday) + let mondayString = formatDate(monday) + + let currentWeekLessons = allLessons.filter { lesson in + lesson.date >= mondayString && lesson.date <= fridayString + } + + guard !currentWeekLessons.isEmpty else { + return false + } + + let lastLesson = currentWeekLessons + .sorted { $0.date > $1.date || ($0.date == $1.date && $0.end > $1.end) } + .first + + guard let last = lastLesson else { + return false + } + + return now > last.end + } + + // MARK: - Day Selector + + private var daySelector: some View { + HStack(spacing: 6) { + ForEach(0..<5, id: \.self) { day in + Button(action: { selectedDay = day }) { + Text(dayLabels[day]) + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 32) + .foregroundColor(selectedDay == day ? .white : .primary) + .background(selectedDay == day ? Color.blue : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isToday(day) && selectedDay != day ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + + private func isToday(_ day: Int) -> Bool { + guard weekOffset == 0 else { return false } + let weekday = Calendar.current.component(.weekday, from: Date()) + return day == weekday - 2 + } + + // MARK: - Lessons Content + + @ViewBuilder + private var lessonsContent: some View { + let lessons = lessonsForDay(selectedDay) + + if lessons.isEmpty { + freeDayView + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(lessons) { lesson in + NavigationLink { + LessonDetailView(lesson: lesson) + } label: { + lessonRow(lesson) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + } + } + + private func lessonsForDay(_ day: Int) -> [WidgetLesson] { + guard let data = dataStore.data else { return [] } + + let allLessons: [WidgetLesson] + if let all = data.timetable.allLessons, !all.isEmpty { + allLessons = all + } else { + var combined: [WidgetLesson] = [] + combined.append(contentsOf: data.timetable.today) + combined.append(contentsOf: data.timetable.tomorrow) + if let nextSchoolDay = data.timetable.nextSchoolDay { + combined.append(contentsOf: nextSchoolDay) + } + allLessons = combined + } + + let targetDateStr = getDateStringForDay(day) + + let uniqueDates = Set(allLessons.map { $0.date }).sorted() + print("[Watch] lessonsForDay: day=\(day), weekOffset=\(weekOffset), targetDate=\(targetDateStr), lessons=\(allLessons.count)") + print("[Watch] Unique dates in lessons: \(uniqueDates)") + + if let first = allLessons.first { + let cal = Calendar.current + let comp = cal.dateComponents([.year, .month, .day, .hour, .minute], from: first.start) + print("[Watch] First lesson: date=\(first.date), start=\(comp.year!)-\(comp.month!)-\(comp.day!) \(comp.hour!):\(comp.minute!)") + } + + let filtered = allLessons.filter { $0.date == targetDateStr } + print("[Watch] Filtered lessons: \(filtered.count) for \(targetDateStr)") + + return filtered.sorted { ($0.lessonNumber ?? 0) < ($1.lessonNumber ?? 0) } + } + + private func getDateStringForDay(_ day: Int) -> String { + let calendar = Calendar.current + let now = Date() + + let weekday = calendar.component(.weekday, from: now) + let daysFromMonday = (weekday == 1) ? -6 : (2 - weekday) + + guard let monday = calendar.date(byAdding: .day, value: daysFromMonday, to: now) else { + return "" + } + + let totalDaysToAdd = day + (weekOffset * 7) + guard let targetDate = calendar.date(byAdding: .day, value: totalDaysToAdd, to: monday) else { + return "" + } + + return formatDate(targetDate) + } + + private func formatDate(_ date: Date) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } + + private var freeDayView: some View { + VStack(spacing: 12) { + Image(systemName: "sun.max.fill") + .font(.system(size: 40)) + .foregroundColor(.yellow) + + Text("free_day".localized) + .font(.headline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + // MARK: - Lesson Row + + @ViewBuilder + private func lessonRow(_ lesson: WidgetLesson) -> some View { + FirkaCard(isHighlighted: lesson.isCurrentlyActive) { + HStack(alignment: .top, spacing: 8) { + if let number = lesson.lessonNumber { + Text("\(number).") + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(.blue) + .frame(width: 24, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(lesson.displayName) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .strikethrough(lesson.isCancelled) + .opacity(lesson.isCancelled ? 0.5 : 1) + + if lesson.isSubstitution { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2) + .foregroundColor(.orange) + } + + Spacer() + + Text(lesson.start, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 4) { + if let teacher = lesson.teacher { + Text(teacher) + .lineLimit(1) + } + if let room = lesson.roomName { + Text("•") + Text(room) + } + } + .font(.caption2) + .foregroundColor(.secondary) + .opacity(lesson.isCancelled ? 0.5 : 1) + } + } + } + .opacity(lesson.isCancelled ? 0.6 : 1) + } +} + +#if DEBUG +struct TimetableView_Previews: PreviewProvider { + static var previews: some View { + TimetableView(dataStore: DataStore.shared) + } +} +#endif diff --git a/firka/ios/FirkaWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json b/firka/ios/FirkaWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/firka/ios/FirkaWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json b/firka/ios/FirkaWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/firka/ios/FirkaWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatchComplications/Assets.xcassets/Contents.json b/firka/ios/FirkaWatchComplications/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/firka/ios/FirkaWatchComplications/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json b/firka/ios/FirkaWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/firka/ios/FirkaWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/FirkaWatchComplications/FirkaComplications.swift b/firka/ios/FirkaWatchComplications/FirkaComplications.swift new file mode 100644 index 0000000..efcec27 --- /dev/null +++ b/firka/ios/FirkaWatchComplications/FirkaComplications.swift @@ -0,0 +1,369 @@ +#if os(watchOS) +import WidgetKit +import SwiftUI + +// MARK: - Complication Localization Helper + +private struct ComplicationL10n { + private static let appGroupID = "group.app.firka.firkaa" + + enum Language: String { + case hungarian = "hu" + case english = "en" + case german = "de" + } + + static var currentLanguage: Language { + guard let defaults = UserDefaults(suiteName: appGroupID) else { + return .hungarian + } + let code = defaults.string(forKey: "watch_language") ?? "hu" + return Language(rawValue: code) ?? .hungarian + } + + static func string(_ key: String) -> String { + switch currentLanguage { + case .hungarian: return hungarianStrings[key] ?? key + case .english: return englishStrings[key] ?? key + case .german: return germanStrings[key] ?? key + } + } + + private static let hungarianStrings: [String: String] = [ + "current_lesson": "Jelenlegi óra", + "next": "Következő", + "no_more_lessons": "Nincs több óra", + "average_abbrev": "Átl", + "next_lesson_title": "Következő óra", + "average_title": "Átlag", + "lesson_inline": "Óra (inline)" + ] + + private static let englishStrings: [String: String] = [ + "current_lesson": "Current Lesson", + "next": "Next", + "no_more_lessons": "No more lessons", + "average_abbrev": "Avg", + "next_lesson_title": "Next Lesson", + "average_title": "Average", + "lesson_inline": "Lesson (inline)" + ] + + private static let germanStrings: [String: String] = [ + "current_lesson": "Aktuelle Stunde", + "next": "Nächste", + "no_more_lessons": "Keine Stunden mehr", + "average_abbrev": "Ø", + "next_lesson_title": "Nächste Stunde", + "average_title": "Durchschnitt", + "lesson_inline": "Stunde (inline)" + ] +} + +// MARK: - Watch Cache Loader + +private struct WatchCacheLoader { + private static let appGroupID = "group.app.firka.firkaa" + private static let cacheFileName = "watch_data.json" + + static func loadWidgetData() -> WidgetData? { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + print("[WatchComplication] No App Group container") + return nil + } + + let fileURL = containerURL.appendingPathComponent(cacheFileName) + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + print("[WatchComplication] Cache file not found: \(fileURL.path)") + return nil + } + + guard let data = try? Data(contentsOf: fileURL) else { + print("[WatchComplication] Could not read cache file") + return nil + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + struct CachedWatchData: Codable { + let widgetData: WidgetData + let lastUpdated: Date + } + + do { + let cached = try decoder.decode(CachedWatchData.self, from: data) + print("[WatchComplication] Loaded cache from \(cached.lastUpdated)") + return cached.widgetData + } catch { + print("[WatchComplication] Failed to decode: \(error)") + return nil + } + } +} + +// MARK: - Timeline Entry + +struct FirkaTimelineEntry: TimelineEntry { + let date: Date + let data: WidgetData? +} + +// MARK: - Timeline Provider + +struct FirkaTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> FirkaTimelineEntry { + FirkaTimelineEntry(date: Date(), data: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (FirkaTimelineEntry) -> Void) { + let data = WatchCacheLoader.loadWidgetData() + completion(FirkaTimelineEntry(date: Date(), data: data)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let data = WatchCacheLoader.loadWidgetData() + let entry = FirkaTimelineEntry(date: Date(), data: data) + + let calendar = Calendar.current + let now = Date() + let hour = calendar.component(.hour, from: now) + let weekday = calendar.component(.weekday, from: now) + let isSchoolHours = (weekday >= 2 && weekday <= 6) && (hour >= 6 && hour <= 16) + + let refreshInterval: TimeInterval = isSchoolHours ? 15 * 60 : 60 * 60 + let nextRefresh = now.addingTimeInterval(refreshInterval) + + let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) + completion(timeline) + } +} + +// MARK: - Next Lesson Complication (accessoryRectangular) + +struct NextLessonComplication: Widget { + let kind = "NextLessonComplication" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in + NextLessonView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName(ComplicationL10n.string("next_lesson_title")) + .description("Shows the current or next lesson.") + .supportedFamilies([.accessoryRectangular]) + } +} + +private struct NextLessonView: View { + let entry: FirkaTimelineEntry + + private var now: Date { Date() } + + private var todayLessons: [WidgetLesson] { + (entry.data?.timetable.today ?? []).sorted { $0.start < $1.start } + } + + private var currentLesson: WidgetLesson? { + todayLessons.first { now >= $0.start && now <= $0.end } + } + + private var nextLesson: WidgetLesson? { + todayLessons.first { $0.start > now } + } + + var body: some View { + if let breakInfo = entry.data?.timetable.currentBreak { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: SeasonalIconHelper.iconName(for: breakInfo.nameKey, season: nil)) + .font(.caption) + Text(breakInfo.name) + .font(.headline) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if let lesson = currentLesson { + VStack(alignment: .leading, spacing: 2) { + Text(ComplicationL10n.string("current_lesson")) + .font(.caption2) + .foregroundStyle(.secondary) + Text(lesson.displayName) + .font(.headline) + .lineLimit(1) + HStack(spacing: 4) { + if let room = lesson.roomName { + Image(systemName: "door.right.hand.closed") + .font(.caption2) + Text(room) + .font(.caption2) + } + Text("→ \(lesson.end, formatter: Self.timeFormatter)") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if let lesson = nextLesson { + VStack(alignment: .leading, spacing: 2) { + Text(ComplicationL10n.string("next")) + .font(.caption2) + .foregroundStyle(.secondary) + Text(lesson.displayName) + .font(.headline) + .lineLimit(1) + HStack(spacing: 4) { + if let room = lesson.roomName { + Image(systemName: "door.right.hand.closed") + .font(.caption2) + Text(room) + .font(.caption2) + } + Text(lesson.start, formatter: Self.timeFormatter) + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if entry.data != nil { + VStack(alignment: .leading, spacing: 2) { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(.green) + Text(ComplicationL10n.string("no_more_lessons")) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(alignment: .leading, spacing: 2) { + Image(systemName: "book.fill") + .font(.title3) + Text("Firka") + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() +} + +// MARK: - Average Complication (accessoryCircular) + +struct AverageComplication: Widget { + let kind = "AverageComplication" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in + AverageView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName(ComplicationL10n.string("average_title")) + .description("Shows the overall grade average.") + .supportedFamilies([.accessoryCircular]) + } +} + +private struct AverageView: View { + let entry: FirkaTimelineEntry + + private var averageColor: Color { + guard let avg = entry.data?.averages.overall else { return .gray } + switch avg { + 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 + } + } + + var body: some View { + if let average = entry.data?.averages.overall { + Gauge(value: average, in: 1...5) { + Text(ComplicationL10n.string("average_abbrev")) + } currentValueLabel: { + Text(String(format: "%.1f", average)) + .font(.system(.body, design: .rounded, weight: .bold)) + } + .gaugeStyle(.accessoryCircular) + .tint(averageColor) + } else { + ZStack { + AccessoryWidgetBackground() + Text("—") + .font(.title3) + .fontWeight(.bold) + } + } + } +} + +// MARK: - Inline Complication (accessoryInline) + +struct InlineComplication: Widget { + let kind = "InlineComplication" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FirkaTimelineProvider()) { entry in + InlineView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName(ComplicationL10n.string("lesson_inline")) + .description("One-line summary of the next lesson.") + .supportedFamilies([.accessoryInline]) + } +} + +private struct InlineView: View { + let entry: FirkaTimelineEntry + + private var now: Date { Date() } + + private var todayLessons: [WidgetLesson] { + (entry.data?.timetable.today ?? []).sorted { $0.start < $1.start } + } + + private var currentOrNextLesson: WidgetLesson? { + todayLessons.first { now >= $0.start && now <= $0.end } + ?? todayLessons.first { $0.start > now } + } + + var body: some View { + if let breakInfo = entry.data?.timetable.currentBreak { + Text(breakInfo.name) + } else if let lesson = currentOrNextLesson { + Text("\(lesson.displayName) \(lesson.start, formatter: Self.timeFormatter)") + } else if entry.data != nil { + Text(ComplicationL10n.string("no_more_lessons")) + } else { + Text("Firka") + } + } + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() +} + +// MARK: - Widget Bundle + +@main +struct FirkaWatchWidgets: WidgetBundle { + var body: some Widget { + NextLessonComplication() + AverageComplication() + InlineComplication() + } +} +#endif diff --git a/firka/ios/FirkaWatchComplications/Info.plist b/firka/ios/FirkaWatchComplications/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/firka/ios/FirkaWatchComplications/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/firka/ios/FirkaWatchComplicationsExtension.entitlements b/firka/ios/FirkaWatchComplicationsExtension.entitlements new file mode 100644 index 0000000..fda7eff --- /dev/null +++ b/firka/ios/FirkaWatchComplicationsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.firka.firkaa + + + diff --git a/firka/ios/HomeWidgetsExtension/Models/Subject.swift b/firka/ios/HomeWidgetsExtension/Models/Subject.swift deleted file mode 100644 index e1dbba4..0000000 --- a/firka/ios/HomeWidgetsExtension/Models/Subject.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct WidgetSubject: Codable { - let uid: String - let name: String - let category: NameUidDesc? - let sortIndex: Int - let teacherName: String? -} - -struct NameUidDesc: Codable { - let uid: String - let name: String - let description: String? -} diff --git a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift index 599613b..6dbaf1d 100644 --- a/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift +++ b/firka/ios/HomeWidgetsExtension/Providers/TimetableProvider.swift @@ -28,6 +28,19 @@ struct TimetableProvider: AppIntentTimelineProvider { typealias Entry = TimetableEntry typealias Intent = TimetableWidgetIntent + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + return formatter + }() + + private func parseNextSchoolDayDate(_ dateString: String?) -> Date? { + guard let dateString = dateString else { return nil } + return Self.dateFormatter.date(from: dateString) + } + func placeholder(in context: Context) -> TimetableEntry { TimetableEntry( date: Date(), @@ -115,6 +128,13 @@ struct TimetableProvider: AppIntentTimelineProvider { } } minuteEntries.append(next.start) + + var nextLessonTime = next.start.addingTimeInterval(60) + while nextLessonTime <= next.end && minuteEntries.count < 180 { + minuteEntries.append(nextLessonTime) + nextLessonTime = nextLessonTime.addingTimeInterval(60) + } + minuteEntries.append(next.end.addingTimeInterval(1)) } for time in minuteEntries { @@ -133,9 +153,43 @@ struct TimetableProvider: AppIntentTimelineProvider { } } + let tomorrowLessons = data?.timetable.tomorrow ?? [] + for lesson in tomorrowLessons { + if lesson.start > now { + entries.append(createEntry(for: configuration, date: lesson.start)) + } + if lesson.end > now { + entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1))) + } + } + + let nextSchoolDayLessons = data?.timetable.nextSchoolDay ?? [] + for lesson in nextSchoolDayLessons { + if lesson.start > now { + entries.append(createEntry(for: configuration, date: lesson.start)) + } + if lesson.end > now { + entries.append(createEntry(for: configuration, date: lesson.end.addingTimeInterval(1))) + } + } + let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400)) entries.append(createEntry(for: configuration, date: midnight)) + if let nextSchoolDayDateString = data?.timetable.nextSchoolDayDate, + let nextSchoolDayDate = parseNextSchoolDayDate(nextSchoolDayDateString) { + let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate) + let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)! + + if dayBeforeNextSchoolDay > now { + entries.append(createEntry(for: configuration, date: dayBeforeNextSchoolDay)) + } + + if nextSchoolDay > now { + entries.append(createEntry(for: configuration, date: nextSchoolDay)) + } + } + let uniqueDates = Set(entries.map { $0.date }) entries = uniqueDates.map { date in entries.first { $0.date == date }! @@ -144,10 +198,10 @@ struct TimetableProvider: AppIntentTimelineProvider { if isLockScreenWidget { var refreshDate: Date - if let next = nextLesson { - refreshDate = next.start - } else if let current = currentLesson { + if let current = currentLesson { refreshDate = current.end.addingTimeInterval(1) + } else if let next = nextLesson { + refreshDate = next.end.addingTimeInterval(1) } else { refreshDate = midnight } @@ -225,6 +279,50 @@ struct TimetableProvider: AppIntentTimelineProvider { if lessons.isEmpty { if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.isEmpty { + if let nextSchoolDayDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) { + let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate) + let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)! + + if entryDay == nextSchoolDay { + let currentLesson = nextSchoolDayLessons.first { lesson in + return date >= lesson.start && date <= lesson.end + } + let nextLesson = nextSchoolDayLessons.first { $0.start > date } + + return TimetableEntry( + date: date, + configuration: configuration, + data: data, + lessons: nextSchoolDayLessons, + currentLesson: currentLesson, + nextLesson: nextLesson, + isNextDay: false, + isNextSchoolDay: false, + nextSchoolDayDateString: nil, + breakInfo: nil, + state: .normal, + debugInfo: WidgetData.lastError + ) + } + + if entryDay == dayBeforeNextSchoolDay { + return TimetableEntry( + date: date, + configuration: configuration, + data: data, + lessons: nextSchoolDayLessons, + currentLesson: nil, + nextLesson: nextSchoolDayLessons.first, + isNextDay: true, + isNextSchoolDay: false, + nextSchoolDayDateString: nil, + breakInfo: nil, + state: .normal, + debugInfo: WidgetData.lastError + ) + } + } + return TimetableEntry( date: date, configuration: configuration, diff --git a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift index dc6334f..1813a8e 100644 --- a/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift +++ b/firka/ios/HomeWidgetsExtension/Views/TimetableViews.swift @@ -57,7 +57,6 @@ struct TimetableSmallView: View { Text(lesson.displayName) .font(.subheadline) .fontWeight(.semibold) - .strikethrough(lesson.isCancelled, color: .red) .foregroundColor(lesson.isCancelled ? .red : lesson.isSubstitution ? .orange : (style == .liquidGlass ? liquidGlassPrimary : .primary)) @@ -171,8 +170,8 @@ struct TimetableMediumView: View { let nextLesson = visibleLessons[index + 1] let isInBreak = entry.date > lesson.end && entry.date < nextLesson.start if isInBreak { - let breakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(entry.date) / 60)) - BreakIndicatorRow(minutesLeft: breakMinutes, localization: localization, style: style, compact: true) + let totalBreakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(lesson.end) / 60)) + BreakIndicatorRow(minutesLeft: totalBreakMinutes, localization: localization, style: style, compact: true) } } } @@ -197,6 +196,17 @@ struct TimetableLargeView: View { return checkDate >= lesson.start && checkDate <= lesson.end } + var currentLessonIndex: Int { + let checkDate = entry.date + if let index = entry.lessons.firstIndex(where: { checkDate >= $0.start && checkDate <= $0.end }) { + return index + } + if let index = entry.lessons.firstIndex(where: { $0.start > checkDate }) { + return index + } + return max(0, entry.lessons.count - 1) + } + var hasActiveBreak: Bool { let checkDate = entry.date for i in 0.. lesson.end && entry.date < nextLesson.start if isInBreak { - let breakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(entry.date) / 60)) - BreakIndicatorRow(minutesLeft: breakMinutes, localization: localization, style: style) + let totalBreakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(lesson.end) / 60)) + BreakIndicatorRow(minutesLeft: totalBreakMinutes, localization: localization, style: style) } } } @@ -346,7 +369,6 @@ struct LessonRow: View { Text(lesson.displayName) .font(.subheadline) .fontWeight(isActive ? .semibold : .regular) - .strikethrough(lesson.isCancelled, color: .red) .foregroundColor(lessonTextColor ?? (style == .liquidGlass ? liquidGlassPrimary : .primary)) .lineLimit(1) @@ -372,7 +394,7 @@ struct LessonRow: View { } .padding(.vertical, compact ? 2 : 4) .padding(.horizontal, 8) - .currentLessonGlow(isActive: isActive && !lesson.isCancelled) + .currentLessonGlow(isActive: isActive) } } diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index 91e7f72..cf40bd1 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -3,11 +3,10 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - AA00000100000005AABBCC05 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA00000100000004AABBCC04 /* Localizable.strings */; }; 14578EED4EA309B337AB389E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A749415A687CBFC3F46FA876 /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 213F8C0F6B5418B02DE14204 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 035E9CCBCC6585D0F5639031 /* Pods_Runner.framework */; }; @@ -18,13 +17,50 @@ 4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; }; 4F30C7782E8FBF9F008BB46C /* LiveActivityWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */; }; + 4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */; }; + 4F5965FE2F2F0EAF00A3DB03 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; }; + 4F5965FF2F2F0EAF00A3DB03 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; }; + 4F59660F2F2F0F3B00A3DB03 /* SeasonalIconHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */; }; + 4F5966102F2F0F4100A3DB03 /* Average.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D02F2EC1AA00B79171 /* Average.swift */; }; + 4F5966112F2F0F4500A3DB03 /* Grade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D12F2EC1AA00B79171 /* Grade.swift */; }; + 4F5966122F2F0F4900A3DB03 /* Lesson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D22F2EC1AA00B79171 /* Lesson.swift */; }; + 4F5966132F2F0F4C00A3DB03 /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D32F2EC1AA00B79171 /* Subject.swift */; }; + 4F5966142F2F0F5100A3DB03 /* WidgetColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */; }; + 4F5966152F2F0F5500A3DB03 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */; }; + 4F5966172F2F1BBF00A3DB03 /* FirkaWatchComplicationsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4F7701D82F2EC1AA00B79171 /* Grade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D12F2EC1AA00B79171 /* Grade.swift */; }; + 4F7701D92F2EC1AA00B79171 /* WidgetColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */; }; + 4F7701DA2F2EC1AA00B79171 /* Average.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D02F2EC1AA00B79171 /* Average.swift */; }; + 4F7701DB2F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */; }; + 4F7701DC2F2EC1AA00B79171 /* Lesson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D22F2EC1AA00B79171 /* Lesson.swift */; }; + 4F7701DD2F2EC1AA00B79171 /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D32F2EC1AA00B79171 /* Subject.swift */; }; + 4F7701DE2F2EC1AA00B79171 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */; }; + 4F7701DF2F2EC1AA00B79171 /* Grade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D12F2EC1AA00B79171 /* Grade.swift */; }; + 4F7701E02F2EC1AA00B79171 /* WidgetColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */; }; + 4F7701E12F2EC1AA00B79171 /* Average.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D02F2EC1AA00B79171 /* Average.swift */; }; + 4F7701E22F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */; }; + 4F7701E32F2EC1AA00B79171 /* Lesson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D22F2EC1AA00B79171 /* Lesson.swift */; }; + 4F7701E42F2EC1AA00B79171 /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D32F2EC1AA00B79171 /* Subject.swift */; }; + 4F7701E52F2EC1AA00B79171 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */; }; + 4F7701E62F2EC1AA00B79171 /* Grade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D12F2EC1AA00B79171 /* Grade.swift */; }; + 4F7701E72F2EC1AA00B79171 /* WidgetColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */; }; + 4F7701E82F2EC1AA00B79171 /* Average.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D02F2EC1AA00B79171 /* Average.swift */; }; + 4F7701E92F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */; }; + 4F7701EA2F2EC1AA00B79171 /* Lesson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D22F2EC1AA00B79171 /* Lesson.swift */; }; + 4F7701EB2F2EC1AA00B79171 /* Subject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D32F2EC1AA00B79171 /* Subject.swift */; }; + 4F7701EC2F2EC1AA00B79171 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */; }; + 4F7701EF2F2EC2F500B79171 /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701EE2F2EC2F500B79171 /* TokenManager.swift */; }; + 4F7701F02F2EC2F500B79171 /* KretaAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */; }; + 4FCB030D2F330F3B00418E63 /* KretaAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */; }; 4FE64E342F27B07A006F9205 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; }; 4FE64E352F27B07A006F9205 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; }; 4FE64E422F27B07B006F9205 /* HomeWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4FF81B9A2F2EB4C300E95BA0 /* FirkaWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 4FF81B7A2F2EB4C100E95BA0 /* FirkaWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AA00000100000005AABBCC05 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA00000100000004AABBCC04 /* Localizable.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,6 +78,27 @@ remoteGlobalIDString = 4F30C7642E8FBF9D008BB46C; remoteInfo = TimetableWidgetExtension; }; + 4F5966182F2F1BBF00A3DB03 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4F5965FC2F2F0EAF00A3DB03; + remoteInfo = FirkaWatchComplicationsExtension; + }; + 4F59661B2F2F1BD900A3DB03 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4F5965FC2F2F0EAF00A3DB03; + remoteInfo = FirkaWatchComplicationsExtension; + }; + 4F59661D2F2F1BE700A3DB03 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4F5965FC2F2F0EAF00A3DB03; + remoteInfo = FirkaWatchComplicationsExtension; + }; 4FE64E402F27B07B006F9205 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -49,6 +106,13 @@ remoteGlobalIDString = 4FE64E322F27B079006F9205; remoteInfo = HomeWidgetsExtensionExtension; }; + 4FF81B982F2EB4C300E95BA0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4FF81B792F2EB4C100E95BA0; + remoteInfo = "FirkaWatch Watch App"; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -64,6 +128,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + 4F59661A2F2F1BBF00A3DB03 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4F5966172F2F1BBF00A3DB03 /* FirkaWatchComplicationsExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 4FF81B9B2F2EB4C300E95BA0 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 4FF81B9A2F2EB4C300E95BA0 /* FirkaWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -92,9 +178,23 @@ 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetMethodChannel.swift; sourceTree = ""; }; + 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; + 4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirkaWatchComplicationsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F5966162F2F0F6500A3DB03 /* FirkaWatchComplicationsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FirkaWatchComplicationsExtension.entitlements; sourceTree = ""; }; + 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonalIconHelper.swift; sourceTree = ""; }; + 4F7701D02F2EC1AA00B79171 /* Average.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Average.swift; sourceTree = ""; }; + 4F7701D12F2EC1AA00B79171 /* Grade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grade.swift; sourceTree = ""; }; + 4F7701D22F2EC1AA00B79171 /* Lesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lesson.swift; sourceTree = ""; }; + 4F7701D32F2EC1AA00B79171 /* Subject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subject.swift; sourceTree = ""; }; + 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetColors.swift; sourceTree = ""; }; + 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; + 4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KretaAPIClient.swift; sourceTree = ""; }; + 4F7701EE2F2EC2F500B79171 /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; 4F959B792F289CA600FF7F03 /* LiveActivityWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LiveActivityWidget.entitlements; sourceTree = ""; }; 4F959B9C2F289CA600FF7F03 /* HomeWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HomeWidgetsExtension.entitlements; sourceTree = ""; }; + 4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KretaAPIModels.swift; sourceTree = ""; }; 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HomeWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4FF81B7A2F2EB4C100E95BA0 /* FirkaWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FirkaWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -107,37 +207,44 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A749415A687CBFC3F46FA876 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - AB2E15171B6907C52E8C2B42 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - AE756C46C544099A30412EAF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - EBD040A65B2746AF6A3D5C40 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; AA00000100000001AABBCC01 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; AA00000100000002AABBCC02 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; AA00000100000003AABBCC03 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + AB2E15171B6907C52E8C2B42 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + AE756C46C544099A30412EAF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EBD040A65B2746AF6A3D5C40 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */ = { + 4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Controls/AppControls.swift, ); target = 97C146ED1CF9000F007C117D /* Runner */; }; - 4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = { + 4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( ActivityAttributes.swift, ); target = 97C146ED1CF9000F007C117D /* Runner */; }; - 4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */ = { + 4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */; + }; + 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */; }; - 4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */ = { + 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -147,32 +254,10 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 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 = ( - 4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */, - 4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = HomeWidgetsExtension; - sourceTree = ""; - }; + 4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = ""; }; + 4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = ""; }; + 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = ""; }; + 4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -185,6 +270,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4F5965FA2F2F0EAF00A3DB03 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F5965FF2F2F0EAF00A3DB03 /* SwiftUI.framework in Frameworks */, + 4F5965FE2F2F0EAF00A3DB03 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4FE64E302F27B079006F9205 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -194,6 +288,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4FF81B772F2EB4C100E95BA0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -232,6 +333,47 @@ name = Frameworks; sourceTree = ""; }; + 4F7701CD2F2EC1AA00B79171 /* API */ = { + isa = PBXGroup; + children = ( + 4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */, + 4F7701EE2F2EC2F500B79171 /* TokenManager.swift */, + 4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */, + ); + path = API; + sourceTree = ""; + }; + 4F7701CF2F2EC1AA00B79171 /* Helpers */ = { + isa = PBXGroup; + children = ( + 4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 4F7701D62F2EC1AA00B79171 /* Models */ = { + isa = PBXGroup; + children = ( + 4F7701D02F2EC1AA00B79171 /* Average.swift */, + 4F7701D12F2EC1AA00B79171 /* Grade.swift */, + 4F7701D22F2EC1AA00B79171 /* Lesson.swift */, + 4F7701D32F2EC1AA00B79171 /* Subject.swift */, + 4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */, + 4F7701D52F2EC1AA00B79171 /* WidgetData.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4F7701D72F2EC1AA00B79171 /* Shared */ = { + isa = PBXGroup; + children = ( + 4F7701CD2F2EC1AA00B79171 /* API */, + 4F7701CF2F2EC1AA00B79171 /* Helpers */, + 4F7701D62F2EC1AA00B79171 /* Models */, + ); + path = Shared; + sourceTree = SOURCE_ROOT; + }; 52B477EA0F4B63DC7CE4BA83 /* Pods */ = { isa = PBXGroup; children = ( @@ -260,12 +402,15 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 4F5966162F2F0F6500A3DB03 /* FirkaWatchComplicationsExtension.entitlements */, 4F959B792F289CA600FF7F03 /* LiveActivityWidget.entitlements */, 4F959B9C2F289CA600FF7F03 /* HomeWidgetsExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */, 4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */, + 4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */, + 4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 52B477EA0F4B63DC7CE4BA83 /* Pods */, @@ -280,6 +425,8 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */, 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */, 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */, + 4FF81B7A2F2EB4C100E95BA0 /* FirkaWatch Watch App.app */, + 4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */, ); name = Products; sourceTree = ""; @@ -287,6 +434,8 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */, + 4F7701D72F2EC1AA00B79171 /* Shared */, 4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */, AA00000100000004AABBCC04 /* Localizable.strings */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -345,6 +494,26 @@ productReference = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */; productType = "com.apple.product-type.app-extension"; }; + 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4F5966092F2F0EB100A3DB03 /* Build configuration list for PBXNativeTarget "FirkaWatchComplicationsExtension" */; + buildPhases = ( + 4F5965F92F2F0EAF00A3DB03 /* Sources */, + 4F5965FA2F2F0EAF00A3DB03 /* Frameworks */, + 4F5965FB2F2F0EAF00A3DB03 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */, + ); + name = FirkaWatchComplicationsExtension; + productName = FirkaWatchComplicationsExtension; + productReference = 4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 4FE64E322F27B079006F9205 /* HomeWidgetsExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 4FE64E462F27B07B006F9205 /* Build configuration list for PBXNativeTarget "HomeWidgetsExtension" */; @@ -365,6 +534,30 @@ productReference = 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 4FF81B792F2EB4C100E95BA0 /* FirkaWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4FF81BA52F2EB4C300E95BA0 /* Build configuration list for PBXNativeTarget "FirkaWatch Watch App" */; + buildPhases = ( + 4FF81B762F2EB4C100E95BA0 /* Sources */, + 4FF81B772F2EB4C100E95BA0 /* Frameworks */, + 4FF81B782F2EB4C100E95BA0 /* Resources */, + 4F59661A2F2F1BBF00A3DB03 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 4F5966192F2F1BBF00A3DB03 /* PBXTargetDependency */, + 4F59661C2F2F1BD900A3DB03 /* PBXTargetDependency */, + 4F59661E2F2F1BE700A3DB03 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */, + ); + name = "FirkaWatch Watch App"; + productName = "FirkaWatch Watch App"; + productReference = 4FF81B7A2F2EB4C100E95BA0 /* FirkaWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -375,6 +568,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 4F30C77E2E8FBF9F008BB46C /* Embed Foundation Extensions */, + 4FF81B9B2F2EB4C300E95BA0 /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 9705A1C41CF9048500538489 /* Embed Frameworks */, EAA586B3BBC26BBE7306869D /* [CP] Embed Pods Frameworks */, @@ -385,6 +579,7 @@ dependencies = ( 4F30C7772E8FBF9F008BB46C /* PBXTargetDependency */, 4FE64E412F27B07B006F9205 /* PBXTargetDependency */, + 4FF81B992F2EB4C300E95BA0 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -409,9 +604,15 @@ 4F30C7642E8FBF9D008BB46C = { CreatedOnToolsVersion = 26.0; }; + 4F5965FC2F2F0EAF00A3DB03 = { + CreatedOnToolsVersion = 26.2; + }; 4FE64E322F27B079006F9205 = { CreatedOnToolsVersion = 26.2; }; + 4FF81B792F2EB4C100E95BA0 = { + CreatedOnToolsVersion = 26.2; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -437,6 +638,8 @@ 331C8080294A63A400263BE5 /* RunnerTests */, 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */, 4FE64E322F27B079006F9205 /* HomeWidgetsExtension */, + 4FF81B792F2EB4C100E95BA0 /* FirkaWatch Watch App */, + 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */, ); }; /* End PBXProject section */ @@ -456,6 +659,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4F5965FB2F2F0EAF00A3DB03 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4FE64E312F27B079006F9205 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -463,6 +673,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4FF81B782F2EB4C100E95BA0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -486,10 +703,14 @@ 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"; @@ -578,10 +799,14 @@ 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"; @@ -602,6 +827,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4F7701DF2F2EC1AA00B79171 /* Grade.swift in Sources */, + 4F7701E02F2EC1AA00B79171 /* WidgetColors.swift in Sources */, + 4F7701E12F2EC1AA00B79171 /* Average.swift in Sources */, + 4F7701E22F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */, + 4F7701E32F2EC1AA00B79171 /* Lesson.swift in Sources */, + 4F7701E42F2EC1AA00B79171 /* Subject.swift in Sources */, + 4F7701E52F2EC1AA00B79171 /* WidgetData.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4F5965F92F2F0EAF00A3DB03 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F5966152F2F0F5500A3DB03 /* WidgetData.swift in Sources */, + 4F5966132F2F0F4C00A3DB03 /* Subject.swift in Sources */, + 4F5966142F2F0F5100A3DB03 /* WidgetColors.swift in Sources */, + 4F59660F2F2F0F3B00A3DB03 /* SeasonalIconHelper.swift in Sources */, + 4F5966122F2F0F4900A3DB03 /* Lesson.swift in Sources */, + 4F5966112F2F0F4500A3DB03 /* Grade.swift in Sources */, + 4F5966102F2F0F4100A3DB03 /* Average.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -609,6 +855,30 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4F7701D82F2EC1AA00B79171 /* Grade.swift in Sources */, + 4F7701D92F2EC1AA00B79171 /* WidgetColors.swift in Sources */, + 4F7701DA2F2EC1AA00B79171 /* Average.swift in Sources */, + 4F7701DB2F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */, + 4F7701DC2F2EC1AA00B79171 /* Lesson.swift in Sources */, + 4F7701DD2F2EC1AA00B79171 /* Subject.swift in Sources */, + 4F7701DE2F2EC1AA00B79171 /* WidgetData.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4FF81B762F2EB4C100E95BA0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F7701E62F2EC1AA00B79171 /* Grade.swift in Sources */, + 4F7701E72F2EC1AA00B79171 /* WidgetColors.swift in Sources */, + 4F7701E82F2EC1AA00B79171 /* Average.swift in Sources */, + 4FCB030D2F330F3B00418E63 /* KretaAPIModels.swift in Sources */, + 4F7701E92F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */, + 4F7701EA2F2EC1AA00B79171 /* Lesson.swift in Sources */, + 4F7701EB2F2EC1AA00B79171 /* Subject.swift in Sources */, + 4F7701EC2F2EC1AA00B79171 /* WidgetData.swift in Sources */, + 4F7701EF2F2EC2F500B79171 /* TokenManager.swift in Sources */, + 4F7701F02F2EC2F500B79171 /* KretaAPIClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -620,6 +890,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */, 4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */, + 4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -636,11 +907,31 @@ target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */; targetProxy = 4F30C7762E8FBF9F008BB46C /* PBXContainerItemProxy */; }; + 4F5966192F2F1BBF00A3DB03 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */; + targetProxy = 4F5966182F2F1BBF00A3DB03 /* PBXContainerItemProxy */; + }; + 4F59661C2F2F1BD900A3DB03 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */; + targetProxy = 4F59661B2F2F1BD900A3DB03 /* PBXContainerItemProxy */; + }; + 4F59661E2F2F1BE700A3DB03 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */; + targetProxy = 4F59661D2F2F1BE700A3DB03 /* PBXContainerItemProxy */; + }; 4FE64E412F27B07B006F9205 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4FE64E322F27B079006F9205 /* HomeWidgetsExtension */; targetProxy = 4FE64E402F27B07B006F9205 /* PBXContainerItemProxy */; }; + 4FF81B992F2EB4C300E95BA0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4FF81B792F2EB4C100E95BA0 /* FirkaWatch Watch App */; + targetProxy = 4FF81B982F2EB4C300E95BA0 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -962,6 +1253,159 @@ }; name = Profile; }; + 4F59660A2F2F0EB100A3DB03 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FirkaWatchComplications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatchComplications; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 4F59660B2F2F0EB100A3DB03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FirkaWatchComplications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatchComplications; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 4F59660C2F2F0EB100A3DB03 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FirkaWatchComplications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatchComplications; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Profile; + }; 4FE64E432F27B07B006F9205 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */; @@ -1107,6 +1551,159 @@ }; name = Profile; }; + 4FF81B9C2F2EB4C300E95BA0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 4FF81B9D2F2EB4C300E95BA0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 4FF81B9E2F2EB4C300E95BA0 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALID_ARCHS = "$(ARCHS_STANDARD)"; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1316,6 +1913,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4F5966092F2F0EB100A3DB03 /* Build configuration list for PBXNativeTarget "FirkaWatchComplicationsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F59660A2F2F0EB100A3DB03 /* Debug */, + 4F59660B2F2F0EB100A3DB03 /* Release */, + 4F59660C2F2F0EB100A3DB03 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 4FE64E462F27B07B006F9205 /* Build configuration list for PBXNativeTarget "HomeWidgetsExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1326,6 +1933,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4FF81BA52F2EB4C300E95BA0 /* Build configuration list for PBXNativeTarget "FirkaWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FF81B9C2F2EB4C300E95BA0 /* Debug */, + 4FF81B9D2F2EB4C300E95BA0 /* Release */, + 4FF81B9E2F2EB4C300E95BA0 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/FirkaWatch Watch App.xcscheme b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/FirkaWatch Watch App.xcscheme new file mode 100644 index 0000000..afb67be --- /dev/null +++ b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/FirkaWatch Watch App.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/HomeWidgetsExtension.xcscheme b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/HomeWidgetsExtension.xcscheme new file mode 100644 index 0000000..c030932 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/HomeWidgetsExtension.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/LiveActivityWidget.xcscheme b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/LiveActivityWidget.xcscheme new file mode 100644 index 0000000..a3d2d2e --- /dev/null +++ b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/LiveActivityWidget.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift index b4e5d18..6c25057 100644 --- a/firka/ios/Runner/AppDelegate.swift +++ b/firka/ios/Runner/AppDelegate.swift @@ -25,6 +25,7 @@ import BackgroundTasks let controller = window?.rootViewController as! FlutterViewController HomeWidgetMethodChannel.register(with: controller.binaryMessenger) + WatchSessionManager.shared.setup(with: controller.binaryMessenger) widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger) widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift new file mode 100644 index 0000000..a2b1771 --- /dev/null +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -0,0 +1,297 @@ +import Foundation +import WatchConnectivity +import Flutter + +class WatchSessionManager: NSObject, WCSessionDelegate { + static let shared = WatchSessionManager() + + private var flutterChannel: FlutterMethodChannel? + + override private init() { + super.init() + } + + func setup(with messenger: FlutterBinaryMessenger) { + flutterChannel = FlutterMethodChannel( + name: "app.firka/watch_sync", + binaryMessenger: messenger + ) + + flutterChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in + switch call.method { + case "sendTokenToWatch": + self?.handleSendTokenToWatch(arguments: call.arguments, result: result) + case "sendWidgetDataToWatch": + self?.handleSendWidgetDataToWatch(arguments: call.arguments, result: result) + case "sendLanguageToWatch": + self?.handleSendLanguageToWatch(arguments: call.arguments, result: result) + case "notifyReauthRequired": + self?.handleNotifyReauthRequired(result: result) + case "requestTokenFromWatch": + self?.handleRequestTokenFromWatch(result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + print("[WatchSessionManager] WCSession activated") + } else { + print("[WatchSessionManager] WCSession not supported on this device") + } + } + + private func handleSendTokenToWatch(arguments: Any?, result: @escaping FlutterResult) { + guard let authData = arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil)) + return + } + + guard WCSession.default.activationState == .activated else { + result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + return + } + + do { + WCSession.default.transferUserInfo([ + "id": "token_update", + "auth": authData + ]) + result(nil) + print("[WatchSessionManager] Token sent to Watch") + } catch { + result(FlutterError(code: "TRANSFER_ERROR", message: error.localizedDescription, details: nil)) + } + } + + private func handleSendWidgetDataToWatch(arguments: Any?, result: @escaping FlutterResult) { + guard let jsonString = arguments as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a JSON string", details: nil)) + return + } + + guard WCSession.default.activationState == .activated else { + result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + return + } + + do { + try WCSession.default.updateApplicationContext(["widget_data": jsonString]) + result(nil) + print("[WatchSessionManager] Widget data sent to Watch") + } catch { + result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil)) + } + } + + private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) { + guard let languageCode = arguments as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Language code must be a string", details: nil)) + return + } + + guard WCSession.default.activationState == .activated else { + result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + return + } + + WCSession.default.transferUserInfo([ + "id": "language_update", + "language": languageCode + ]) + result(nil) + print("[WatchSessionManager] Language '\(languageCode)' sent to Watch") + } + + private func handleNotifyReauthRequired(result: @escaping FlutterResult) { + guard WCSession.default.activationState == .activated else { + result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil)) + return + } + + WCSession.default.transferUserInfo([ + "id": "reauth_required" + ]) + result(nil) + print("[WatchSessionManager] Reauth notification sent to Watch") + } + + private func handleRequestTokenFromWatch(result: @escaping FlutterResult) { + guard WCSession.default.activationState == .activated else { + result(["error": "session_not_active"]) + return + } + + guard WCSession.default.isReachable else { + result(["error": "watch_not_reachable"]) + return + } + + print("[WatchSessionManager] Requesting token from Watch...") + + WCSession.default.sendMessage( + ["action": "getToken"], + replyHandler: { response in + if let tokenData = response["token"] as? [String: Any] { + print("[WatchSessionManager] Received token from Watch") + result(tokenData) + } else if let error = response["error"] as? String { + print("[WatchSessionManager] Watch returned error: \(error)") + result(["error": error]) + } else { + result(["error": "no_token"]) + } + }, + errorHandler: { error in + print("[WatchSessionManager] Failed to request token from Watch: \(error)") + result(["error": error.localizedDescription]) + } + ) + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + DispatchQueue.main.async { + if let error = error { + print("[WatchSessionManager] Activation error: \(error.localizedDescription)") + } else { + print("[WatchSessionManager] Session activated with state: \(activationState.rawValue)") + + if activationState == .activated { + let context = session.receivedApplicationContext + if let authData = context["auth"] as? [String: Any] { + print("[WatchSessionManager] Found pending auth in applicationContext") + self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + } + } + } + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + print("[WatchSessionManager] Received applicationContext from Watch") + DispatchQueue.main.async { + if let authData = applicationContext["auth"] as? [String: Any] { + print("[WatchSessionManager] Processing auth from applicationContext") + self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + } + } + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + print("[WatchSessionManager] Received message from Watch: \(message)") + + guard let action = message["action"] as? String else { + replyHandler(["error": "No action specified"]) + return + } + + switch action { + case "requestToken": + DispatchQueue.main.async { + self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in + if let tokenData = result as? [String: Any] { + if let error = tokenData["error"] as? String { + print("[WatchSessionManager] Flutter returned error: \(error)") + replyHandler(["error": error]) + } else { + print("[WatchSessionManager] Sending token to Watch") + replyHandler(["auth": tokenData]) + } + } else { + print("[WatchSessionManager] No token available from Flutter") + replyHandler(["error": "no_token"]) + } + } + } + + case "requestLanguage": + DispatchQueue.main.async { + self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in + if let languageCode = result as? String { + print("[WatchSessionManager] Sending language to Watch: \(languageCode)") + replyHandler(["language": languageCode]) + } else { + print("[WatchSessionManager] No language from Flutter, defaulting to hu") + replyHandler(["language": "hu"]) + } + } + } + + case "receiveTokenFromWatch": + guard let tokenData = message["token"] as? [String: Any] else { + replyHandler(["error": "no_token_data"]) + return + } + + print("[WatchSessionManager] Receiving token from Watch") + DispatchQueue.main.async { + self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in + if let success = result as? Bool, success { + print("[WatchSessionManager] Flutter accepted Watch token") + replyHandler(["success": true]) + } else if let resultDict = result as? [String: Any], + let success = resultDict["success"] as? Bool, success { + print("[WatchSessionManager] Flutter accepted Watch token") + replyHandler(["success": true]) + } else { + print("[WatchSessionManager] Flutter rejected Watch token") + replyHandler(["error": "rejected"]) + } + } + } + + default: + replyHandler(["error": "Unknown action: \(action)"]) + } + } + + func sessionDidBecomeInactive(_ session: WCSession) { + print("[WatchSessionManager] Session did become inactive") + } + + func sessionDidDeactivate(_ session: WCSession) { + print("[WatchSessionManager] Session did deactivate, reactivating...") + if WCSession.isSupported() { + WCSession.default.activate() + } + } + + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String : Any] = [:] + ) { + DispatchQueue.main.async { + guard let messageId = userInfo["id"] as? String else { + return + } + + if messageId == "token_update_from_watch" { + if let authData = userInfo["auth"] as? [String: Any] { + self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData) + print("[WatchSessionManager] Token received from Watch") + } + } + } + } + + func sessionWatchStateDidChange(_ session: WCSession) { + DispatchQueue.main.async { + if session.isWatchAppInstalled { + self.flutterChannel?.invokeMethod("watchAppInstalled", arguments: nil) + print("[WatchSessionManager] Watch app installed detected") + } else { + print("[WatchSessionManager] Watch app not installed") + } + } + } +} diff --git a/firka/ios/Shared/API/KretaAPIClient.swift b/firka/ios/Shared/API/KretaAPIClient.swift new file mode 100644 index 0000000..387fd56 --- /dev/null +++ b/firka/ios/Shared/API/KretaAPIClient.swift @@ -0,0 +1,295 @@ +import Foundation +#if os(watchOS) +import WatchConnectivity +#endif + +// MARK: - API Error Types + +enum APIError: Error { + case invalidURL + case requestFailed(statusCode: Int) + case decodingFailed(Error) + case unauthorized + case tokenError(TokenError) +} + +// MARK: - Kréta API Client + +class KretaAPIClient { + static let shared = KretaAPIClient() + + private let apiKey = "21ff6c25-d1da-4a68-a811-c881a6057463" + private let userAgent = "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0" + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "UTC") + return formatter + }() + + private init() {} + + // MARK: - Public API Methods + func fetchTimetable(from: Date, to: Date) async throws -> [WidgetLesson] { + let token = try await getValidToken() + let fromString = dateFormatter.string(from: from) + let toString = dateFormatter.string(from: to) + + let path = "/ellenorzo/v3/sajat/OrarendElemek" + let queryItems = [ + URLQueryItem(name: "datumTol", value: fromString), + URLQueryItem(name: "datumIg", value: toString) + ] + + let data = try await performRequest( + path: path, + queryItems: queryItems, + token: token + ) + + let kretaLessons = try decodeJSON([KretaLesson].self, from: data) + return kretaLessons.map { $0.toWidgetLesson() } + } + + func fetchGrades() async throws -> [WidgetGrade] { + let token = try await getValidToken() + let path = "/ellenorzo/v3/sajat/Ertekelesek" + + let data = try await performRequest( + path: path, + token: token + ) + + let kretaGrades = try decodeJSON([KretaGrade].self, from: data) + return kretaGrades.map { $0.toWidgetGrade() } + } + + func fetchTests() async throws -> [[String: Any]] { + let token = try await getValidToken() + let path = "/ellenorzo/v3/sajat/BejelentettSzamonkeresek" + + let data = try await performRequest( + path: path, + token: token + ) + + guard let json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw APIError.decodingFailed(DecodingError.typeMismatch( + [[String: Any]].self, + DecodingError.Context( + codingPath: [], + debugDescription: "Expected array of dictionaries" + ) + )) + } + + return json + } + + // MARK: - Token Management + + private let retryDelays: [Double] = [1, 10, 30, 60] + + func getValidToken() async throws -> WatchToken { + if !TokenManager.shared.isTokenExpired() { + guard let token = TokenManager.shared.loadToken() else { + throw APIError.tokenError(.noToken) + } + return token + } + + #if os(watchOS) + if await requestTokenFromiPhoneIfReachable() { + if let token = TokenManager.shared.loadToken(), !TokenManager.shared.isTokenExpired() { + print("[KretaAPI] Using token received from iPhone") + return token + } + } + #endif + + var lastError: TokenError = .noToken + + for (attempt, delay) in retryDelays.enumerated() { + do { + print("[KretaAPI] Token refresh attempt \(attempt + 1)/\(retryDelays.count)") + let token = try await TokenManager.shared.refreshToken() + print("[KretaAPI] Token refresh succeeded on attempt \(attempt + 1)") + return token + } catch let error as TokenError { + lastError = error + print("[KretaAPI] Token refresh failed (attempt \(attempt + 1)): \(error)") + + if error == .refreshExpired || error == .invalidGrant { + print("[KretaAPI] Permanent token error, not retrying") + throw APIError.tokenError(error) + } + + if attempt < retryDelays.count - 1 { + print("[KretaAPI] Waiting \(delay)s before next attempt...") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + print("[KretaAPI] All \(retryDelays.count) token refresh attempts failed") + throw APIError.tokenError(lastError) + } + + #if os(watchOS) + private func requestTokenFromiPhoneIfReachable() async -> Bool { + guard WCSession.default.activationState == .activated, + WCSession.default.isReachable else { + print("[KretaAPI] iPhone not reachable, will refresh locally") + return false + } + + print("[KretaAPI] Requesting fresh token from iPhone...") + + return await withCheckedContinuation { continuation in + WCSession.default.sendMessage( + ["action": "requestToken"], + replyHandler: { response in + if let authDict = response["auth"] as? [String: Any] { + do { + let jsonData = try JSONSerialization.data(withJSONObject: authDict) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let timestamp = try container.decode(Int64.self) + return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) + } + let token = try decoder.decode(WatchToken.self, from: jsonData) + try TokenManager.shared.saveToken(token) + print("[KretaAPI] Token received from iPhone and saved") + continuation.resume(returning: true) + } catch { + print("[KretaAPI] Failed to process token from iPhone: \(error)") + continuation.resume(returning: false) + } + } else { + print("[KretaAPI] iPhone didn't return a token") + continuation.resume(returning: false) + } + }, + errorHandler: { error in + print("[KretaAPI] Failed to request token from iPhone: \(error)") + continuation.resume(returning: false) + } + ) + } + } + #endif + + // MARK: - Private Helper Methods + private func performRequest( + path: String, + queryItems: [URLQueryItem] = [], + token: WatchToken + ) async throws -> Data { + let baseURLString = "https://\(token.iss).e-kreta.hu" + guard let baseURL = URL(string: baseURLString) else { + throw APIError.invalidURL + } + + var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) + if !queryItems.isEmpty { + components?.queryItems = queryItems + } + + guard let url = components?.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + request.setValue(apiKey, forHTTPHeaderField: "X-ApiKey") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.requestFailed(statusCode: -1) + } + + switch httpResponse.statusCode { + case 200: + return data + + case 401: + throw APIError.unauthorized + + case 400...599: + throw APIError.requestFailed(statusCode: httpResponse.statusCode) + + default: + throw APIError.requestFailed(statusCode: httpResponse.statusCode) + } + } catch let error as APIError { + throw error + } catch { + throw APIError.requestFailed(statusCode: -1) + } + } + + private func decodeJSON(_ type: T.Type, from data: Data) throws -> T { + let decoder = createJSONDecoder() + + do { + return try decoder.decode(type, from: data) + } catch let error as DecodingError { + throw APIError.decodingFailed(error) + } catch { + throw APIError.decodingFailed(error) + } + } + + private func createJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + + let iso8601Full = ISO8601DateFormatter() + iso8601Full.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let iso8601 = ISO8601DateFormatter() + iso8601.formatOptions = [.withInternetDateTime] + + let formatterLocal = DateFormatter() + formatterLocal.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + formatterLocal.locale = Locale(identifier: "en_US_POSIX") + formatterLocal.timeZone = TimeZone.current + + let formatterShort = DateFormatter() + formatterShort.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatterShort.locale = Locale(identifier: "en_US_POSIX") + formatterShort.timeZone = TimeZone.current + + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + if let date = iso8601Full.date(from: dateString) { + return date + } + if let date = iso8601.date(from: dateString) { + return date + } + if let date = formatterLocal.date(from: dateString) { + return date + } + if let date = formatterShort.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date: \(dateString)" + ) + } + + return decoder + } +} diff --git a/firka/ios/Shared/API/KretaAPIModels.swift b/firka/ios/Shared/API/KretaAPIModels.swift new file mode 100644 index 0000000..7046ff6 --- /dev/null +++ b/firka/ios/Shared/API/KretaAPIModels.swift @@ -0,0 +1,158 @@ +import Foundation + +// MARK: - Kréta API Response Models +struct KretaLesson: Decodable { + let uid: String + let date: String + let start: Date + let end: Date + let name: String + let lessonNumber: Int? + let teacher: String? + let subject: KretaSubject? + let theme: String? + let roomName: String? + let state: KretaNameUidDesc? + let substituteTeacher: String? + + enum CodingKeys: String, CodingKey { + case uid = "Uid" + case date = "Datum" + case start = "KezdetIdopont" + case end = "VegIdopont" + case name = "Nev" + case lessonNumber = "Oraszam" + case teacher = "TanarNeve" + case subject = "Tantargy" + case theme = "Tema" + case roomName = "TeremNeve" + case state = "Allapot" + case substituteTeacher = "HelyettesTanarNeve" + } + + func toWidgetLesson() -> WidgetLesson { + let widgetSubject = subject.map { sub in + WidgetSubject( + uid: sub.uid, + name: sub.name, + category: sub.category.map { cat in + NameUidDesc(uid: cat.uid, name: cat.name, description: cat.description) + }, + sortIndex: sub.sortIndex ?? 0, + teacherName: sub.teacherName + ) + } ?? WidgetSubject( + uid: "", + name: name, + category: nil, + sortIndex: 0, + teacherName: nil + ) + + let isCancelled = state?.name.lowercased().contains("elmarad") ?? false + + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: start) + let dateString = String(format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + + return WidgetLesson( + uid: uid, + date: dateString, + start: start, + end: end, + name: name, + lessonNumber: lessonNumber, + teacher: teacher, + substituteTeacher: substituteTeacher, + subject: widgetSubject, + theme: theme, + roomName: roomName, + isCancelled: isCancelled, + isSubstitution: substituteTeacher != nil + ) + } +} + +struct KretaSubject: Decodable { + let uid: String + let name: String + let category: KretaNameUidDesc? + let sortIndex: Int? + let teacherName: String? + + enum CodingKeys: String, CodingKey { + case uid = "Uid" + case name = "Nev" + case category = "Kategoria" + case sortIndex = "SortIndex" + case teacherName = "alkalmazottNev" + } +} + +struct KretaNameUidDesc: Decodable { + let uid: String + let name: String + let description: String? + + enum CodingKeys: String, CodingKey { + case uid = "Uid" + case name = "Nev" + case description = "Leiras" + } +} + +// MARK: - API Grade Response + +struct KretaGrade: Decodable { + let uid: String + let recordDate: Date + let subject: KretaSubject + let topic: String? + let type: KretaNameUidDesc + let numericValue: Int? + let strValue: String? + let weightPercentage: Int? + + enum CodingKeys: String, CodingKey { + case uid = "Uid" + case recordDate = "RogzitesDatuma" + case subject = "Tantargy" + case topic = "Tema" + case type = "Tipus" + case numericValue = "SzamErtek" + case strValue = "SzovegesErtek" + case weightPercentage = "SulySzazalekErteke" + } + + func toWidgetGrade() -> WidgetGrade { + let widgetSubject = WidgetSubject( + uid: subject.uid, + name: subject.name, + category: subject.category.map { cat in + NameUidDesc(uid: cat.uid, name: cat.name, description: cat.description) + }, + sortIndex: subject.sortIndex ?? 0, + teacherName: subject.teacherName + ) + + let widgetType = NameUidDesc( + uid: type.uid, + name: type.name, + description: type.description + ) + + return WidgetGrade( + uid: uid, + recordDate: recordDate, + subject: widgetSubject, + topic: topic, + type: widgetType, + numericValue: numericValue, + strValue: strValue, + weightPercentage: weightPercentage + ) + } +} diff --git a/firka/ios/Shared/API/TokenManager.swift b/firka/ios/Shared/API/TokenManager.swift new file mode 100644 index 0000000..94db3ba --- /dev/null +++ b/firka/ios/Shared/API/TokenManager.swift @@ -0,0 +1,303 @@ +import Foundation +import Security + +// MARK: - Token Structure +struct WatchToken: Codable { + let accessToken: String + let refreshToken: String + let idToken: String + let iss: String + let studentId: String + let studentIdNorm: Int64 + let expiryDate: Date + + enum CodingKeys: String, CodingKey { + case accessToken + case refreshToken + case idToken + case iss + case studentId + case studentIdNorm + case expiryDate + } +} + +// MARK: - Token Response Structure +private struct TokenRefreshResponse: Decodable { + let accessToken: String + let refreshToken: String + let idToken: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case idToken = "id_token" + case expiresIn = "expires_in" + } +} + +// MARK: - Error Types +enum TokenError: Error { + case noToken + case refreshExpired + case invalidGrant + case invalidResponse + case networkError +} + +// MARK: - Token Manager +class TokenManager { + static let shared = TokenManager() + + private let appGroupID = "group.app.firka.firkaa" + private let tokenFileName = "watch_token.json" + + private static let keychainService = "app.firka.watch.token" + private static let keychainAccount = "token" + private let tokenRefreshURL = "https://idp.e-kreta.hu/connect/token" + private let clientID = "kreta-ellenorzo-student-mobile-ios" + private let userAgent = "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0" + + private init() {} + + // MARK: - File Management + private func getTokenFilePath() -> URL? { + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { + return nil + } + return containerURL.appendingPathComponent(tokenFileName) + } + + // MARK: - Load Token + func loadToken() -> WatchToken? { + if let token = loadTokenFromKeychain() { + return token + } + + guard let filePath = getTokenFilePath() else { + return nil + } + + do { + let data = try Data(contentsOf: filePath) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let token = try decoder.decode(WatchToken.self, from: data) + + try? saveTokenToKeychain(token) + + return token + } catch { + return nil + } + } + + // MARK: - Delete Token + func deleteToken() { + deleteTokenFromKeychain() + + guard let filePath = getTokenFilePath() else { return } + try? FileManager.default.removeItem(at: filePath) + } + + // MARK: - Save Token + func saveToken(_ token: WatchToken) throws { + try saveTokenToKeychain(token) + + guard let filePath = getTokenFilePath() else { + throw TokenError.networkError + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(token) + try data.write(to: filePath) + } + + // MARK: - Keychain Methods + func saveTokenToKeychain(_ token: WatchToken) throws { + let data = try JSONEncoder().encode(token) + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + print("[TokenManager] Keychain save failed: \(status)") + throw TokenError.networkError + } + print("[TokenManager] Token saved to Keychain") + } + + func loadTokenFromKeychain() -> WatchToken? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(WatchToken.self, from: data) + } + + func deleteTokenFromKeychain() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount + ] + + SecItemDelete(query as CFDictionary) + print("[TokenManager] Token deleted from Keychain") + } + + // MARK: - Check Expiry + func isTokenExpired() -> Bool { + guard let token = loadToken() else { + return true + } + + let expiryThreshold = token.expiryDate.addingTimeInterval(-60) + return Date() >= expiryThreshold + } + + func shouldRefreshProactively() -> Bool { + guard let token = loadToken() else { + return false + } + + let proactiveThreshold = token.expiryDate.addingTimeInterval(-12 * 3600) + return Date() >= proactiveThreshold + } + + func refreshTokenProactively() async { + guard shouldRefreshProactively() else { + print("[TokenManager] Token still valid, no proactive refresh needed") + return + } + + print("[TokenManager] Proactively refreshing token...") + do { + _ = try await refreshToken() + print("[TokenManager] Proactive token refresh succeeded") + } catch { + print("[TokenManager] Proactive token refresh failed: \(error)") + } + } + + // MARK: - Refresh Token + func refreshToken() async throws -> WatchToken { + guard let currentToken = loadToken() else { + throw TokenError.noToken + } + + let response = try await performTokenRefresh( + refreshToken: currentToken.refreshToken, + instituteCode: currentToken.iss + ) + + let newToken = WatchToken( + accessToken: response.accessToken, + refreshToken: response.refreshToken, + idToken: response.idToken, + iss: currentToken.iss, + studentId: currentToken.studentId, + studentIdNorm: currentToken.studentIdNorm, + expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60) + ) + + try saveToken(newToken) + + #if os(watchOS) + WatchConnectivityManager.shared.sendTokenToiPhoneInBackground() + #endif + + return newToken + } + + // MARK: - Private Helper Methods + private func performTokenRefresh( + refreshToken: String, + instituteCode: String + ) async throws -> TokenRefreshResponse { + guard let url = URL(string: tokenRefreshURL) else { + throw TokenError.networkError + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("*/*", forHTTPHeaderField: "Accept") + + let formParameters: [String: String] = [ + "institute_code": instituteCode, + "refresh_token": refreshToken, + "grant_type": "refresh_token", + "client_id": clientID + ] + + request.httpBody = encodeFormData(formParameters).data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TokenError.networkError + } + + switch httpResponse.statusCode { + case 200: + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(TokenRefreshResponse.self, from: data) + + case 400: + throw TokenError.refreshExpired + + case 401: + throw TokenError.invalidGrant + + default: + throw TokenError.invalidResponse + } + } catch let error as TokenError { + throw error + } catch { + throw TokenError.networkError + } + } + + private func encodeFormData(_ parameters: [String: String]) -> String { + return parameters + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") + } +} diff --git a/firka/ios/LiveActivityWidget/SeasonalIconHelper.swift b/firka/ios/Shared/Helpers/SeasonalIconHelper.swift similarity index 95% rename from firka/ios/LiveActivityWidget/SeasonalIconHelper.swift rename to firka/ios/Shared/Helpers/SeasonalIconHelper.swift index 126c8c0..a753cab 100644 --- a/firka/ios/LiveActivityWidget/SeasonalIconHelper.swift +++ b/firka/ios/Shared/Helpers/SeasonalIconHelper.swift @@ -53,7 +53,7 @@ struct SeasonalIconHelper { case "newYearEve": return .purple case "newYearDay": - return .mint + return Color(red: 0.4, green: 0.9, blue: 0.8) case "seasonalBreak": return seasonColor(for: season) default: @@ -74,7 +74,7 @@ struct SeasonalIconHelper { case "autumn": return .orange case "winter": - return .cyan + return Color(red: 0.4, green: 0.8, blue: 1.0) case "other": return .blue default: diff --git a/firka/ios/HomeWidgetsExtension/Models/Average.swift b/firka/ios/Shared/Models/Average.swift similarity index 100% rename from firka/ios/HomeWidgetsExtension/Models/Average.swift rename to firka/ios/Shared/Models/Average.swift diff --git a/firka/ios/HomeWidgetsExtension/Models/Grade.swift b/firka/ios/Shared/Models/Grade.swift similarity index 52% rename from firka/ios/HomeWidgetsExtension/Models/Grade.swift rename to firka/ios/Shared/Models/Grade.swift index 8bb3640..f225fc6 100644 --- a/firka/ios/HomeWidgetsExtension/Models/Grade.swift +++ b/firka/ios/Shared/Models/Grade.swift @@ -13,6 +13,18 @@ struct WidgetGrade: Codable, Identifiable { var id: String { uid } + init(uid: String, recordDate: Date, subject: WidgetSubject, topic: String?, + type: NameUidDesc, numericValue: Int?, strValue: String?, weightPercentage: Int?) { + self.uid = uid + self.recordDate = recordDate + self.subject = subject + self.topic = topic + self.type = type + self.numericValue = numericValue + self.strValue = strValue + self.weightPercentage = weightPercentage + } + var displayValue: String { if let numeric = numericValue { return "\(numeric)" @@ -49,3 +61,20 @@ struct WidgetGrade: Codable, Identifiable { subject.teacherName } } + +extension WidgetGrade { + var displayType: String { + let typeMap: [String: String] = [ + "evkozi_jegy_ertekeles": "Órai munka", + "felevi_jegy_ertekeles": "Félévi jegy", + "evvegi_jegy_ertekeles": "Év végi jegy", + "dolgozat": "Dolgozat", + "ropdolgozat": "Röpdolgozat", + "hazi_feladat": "Házi feladat", + "osztalyzat": "Osztályzat", + "szorgalom": "Szorgalom", + "magatartas": "Magatartás" + ] + return typeMap[type.name.lowercased()] ?? type.name.replacingOccurrences(of: "_", with: " ").capitalized + } +} diff --git a/firka/ios/HomeWidgetsExtension/Models/Lesson.swift b/firka/ios/Shared/Models/Lesson.swift similarity index 51% rename from firka/ios/HomeWidgetsExtension/Models/Lesson.swift rename to firka/ios/Shared/Models/Lesson.swift index f8719d9..2b72f18 100644 --- a/firka/ios/HomeWidgetsExtension/Models/Lesson.swift +++ b/firka/ios/Shared/Models/Lesson.swift @@ -8,6 +8,7 @@ struct WidgetLesson: Codable, Identifiable { let name: String let lessonNumber: Int? let teacher: String? + let substituteTeacher: String? let subject: WidgetSubject let theme: String? let roomName: String? @@ -16,6 +17,24 @@ struct WidgetLesson: Codable, Identifiable { var id: String { uid } + init(uid: String, date: String, start: Date, end: Date, name: String, + lessonNumber: Int?, teacher: String?, substituteTeacher: String?, subject: WidgetSubject, + theme: String?, roomName: String?, isCancelled: Bool, isSubstitution: Bool) { + self.uid = uid + self.date = date + self.start = start + self.end = end + self.name = name + self.lessonNumber = lessonNumber + self.teacher = teacher + self.substituteTeacher = substituteTeacher + self.subject = subject + self.theme = theme + self.roomName = roomName + self.isCancelled = isCancelled + self.isSubstitution = isSubstitution + } + var displayName: String { subject.name } diff --git a/firka/ios/Shared/Models/Subject.swift b/firka/ios/Shared/Models/Subject.swift new file mode 100644 index 0000000..210bb4e --- /dev/null +++ b/firka/ios/Shared/Models/Subject.swift @@ -0,0 +1,29 @@ +import Foundation + +struct WidgetSubject: Codable { + let uid: String + let name: String + let category: NameUidDesc? + let sortIndex: Int? + let teacherName: String? + + init(uid: String, name: String, category: NameUidDesc?, sortIndex: Int?, teacherName: String?) { + self.uid = uid + self.name = name + self.category = category + self.sortIndex = sortIndex + self.teacherName = teacherName + } +} + +struct NameUidDesc: Codable { + let uid: String + let name: String + let description: String? + + init(uid: String, name: String, description: String?) { + self.uid = uid + self.name = name + self.description = description + } +} diff --git a/firka/ios/HomeWidgetsExtension/Models/WidgetColors.swift b/firka/ios/Shared/Models/WidgetColors.swift similarity index 100% rename from firka/ios/HomeWidgetsExtension/Models/WidgetColors.swift rename to firka/ios/Shared/Models/WidgetColors.swift diff --git a/firka/ios/HomeWidgetsExtension/Models/WidgetData.swift b/firka/ios/Shared/Models/WidgetData.swift similarity index 88% rename from firka/ios/HomeWidgetsExtension/Models/WidgetData.swift rename to firka/ios/Shared/Models/WidgetData.swift index 6e96061..3ff9d52 100644 --- a/firka/ios/HomeWidgetsExtension/Models/WidgetData.swift +++ b/firka/ios/Shared/Models/WidgetData.swift @@ -95,7 +95,7 @@ struct WidgetData: Codable { lastUpdated: nil, locale: "hu", theme: "dark", - timetable: TimetableData(today: [], tomorrow: [], nextSchoolDay: nil, nextSchoolDayDate: nil, currentBreak: nil), + timetable: TimetableData(today: [], tomorrow: [], nextSchoolDay: nil, nextSchoolDayDate: nil, currentBreak: nil, allLessons: nil), grades: [], averages: AveragesData(overall: nil, subjects: []) ) @@ -108,6 +108,17 @@ struct TimetableData: Codable { let nextSchoolDay: [WidgetLesson]? let nextSchoolDayDate: String? let currentBreak: BreakInfo? + let allLessons: [WidgetLesson]? + + init(today: [WidgetLesson], tomorrow: [WidgetLesson], nextSchoolDay: [WidgetLesson]?, + nextSchoolDayDate: String?, currentBreak: BreakInfo?, allLessons: [WidgetLesson]? = nil) { + self.today = today + self.tomorrow = tomorrow + self.nextSchoolDay = nextSchoolDay + self.nextSchoolDayDate = nextSchoolDayDate + self.currentBreak = currentBreak + self.allLessons = allLessons + } } struct BreakInfo: Codable { diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index fb16a17..eaf39b4 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -25,6 +25,12 @@ import '../model/student.dart'; import '../model/test.dart'; import '../token_grant.dart'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; + +const _watchChannel = MethodChannel('app.firka/watch_sync'); + const backoffCount = 4; const backoffMin = 100; const backoffStep = 500; @@ -58,8 +64,79 @@ class KretaClient { TokenModel model; Isar isar; + static bool needsReauth = false; + + static final ValueNotifier reauthStateNotifier = ValueNotifier(false); + + static void clearReauthFlag() { + needsReauth = false; + reauthStateNotifier.value = false; + debugPrint('[KretaClient] Reauth flag cleared'); + } + + static void _setReauthFlag() { + _setReauthFlag(); + reauthStateNotifier.value = true; + } + KretaClient(this.model, this.isar); + + Future refreshTokenProactively() async { + final now = timeNow(); + final fiveMinutesFromNow = now.add(const Duration(minutes: 5)); + + if (model.expiryDate == null || model.expiryDate!.isBefore(fiveMinutesFromNow)) { + logger.info("[Proactive] Token expired or expiring soon, refreshing proactively..."); + + try { + var extended = await extendToken(model); + var tokenModel = TokenModel.fromResp(extended); + + await isar.writeTxn(() async { + await isar.tokenModels.put(tokenModel); + }); + + logger.info("[Proactive] Token refreshed successfully. New expiry: ${tokenModel.expiryDate}"); + model = tokenModel; + + if (Platform.isIOS) { + try { + await _watchChannel.invokeMethod('sendTokenToWatch', { + 'studentId': model.studentId, + 'studentIdNorm': model.studentIdNorm, + 'iss': model.iss, + 'idToken': model.idToken, + 'accessToken': model.accessToken, + 'refreshToken': model.refreshToken, + 'expiryDate': model.expiryDate!.millisecondsSinceEpoch, + }); + } catch (e) { + debugPrint('[KretaClient] Watch token sync skipped: $e'); + } + } + + return true; + } catch (e) { + logger.warning("[Proactive] Token refresh failed: $e"); + if (_isTokenExpired(e)) { + _setReauthFlag(); + if (Platform.isIOS) { + try { + _watchChannel.invokeMethod('notifyReauthRequired'); + } catch (e) { + debugPrint('[KretaClient] Watch reauth notification skipped: $e'); + } + } + } + return false; + } + } + + logger.fine("[Proactive] Token still valid until ${model.expiryDate}, no refresh needed"); + return true; + } + Future _mutexCallback(Future Function() callback) async { while (_tokenMutex) { await Future.delayed(const Duration(milliseconds: 50)); @@ -89,6 +166,22 @@ class KretaClient { logger.info("Token refreshed successfully. New expiry: ${tokenModel.expiryDate}"); model = tokenModel; + + if (Platform.isIOS) { + try { + await _watchChannel.invokeMethod('sendTokenToWatch', { + 'studentId': model.studentId, + 'studentIdNorm': model.studentIdNorm, + 'iss': model.iss, + 'idToken': model.idToken, + 'accessToken': model.accessToken, + 'refreshToken': model.refreshToken, + 'expiryDate': model.expiryDate!.millisecondsSinceEpoch, + }); + } catch (e) { + debugPrint('[KretaClient] Watch token sync skipped: $e'); + } + } } return model.accessToken!; @@ -187,6 +280,19 @@ class KretaClient { return _cachingGet(id, url, forceCache, counter + 1); } } catch (ex) { + if (_isTokenExpired(ex)) { + _setReauthFlag(); + logger.warning("Token expired, setting needsReauth flag"); + + if (Platform.isIOS) { + try { + _watchChannel.invokeMethod('notifyReauthRequired'); + } catch (e) { + debugPrint('[KretaClient] Watch reauth notification skipped: $e'); + } + } + } + if (cache != null) { logger.finest("request failed, using cache for: $url"); return (jsonDecode(cache.cacheData!), 0, ex, true); @@ -466,6 +572,11 @@ class KretaClient { counter + 1, storeCache); } } catch (ex) { + if (_isTokenExpired(ex)) { + _setReauthFlag(); + logger.warning("Token expired in timed request, setting needsReauth flag"); + } + if (cache != null) { var items = List.empty(growable: true); for (var item in (cache as dynamic).values) { diff --git a/firka/lib/helpers/api/token_grant.dart b/firka/lib/helpers/api/token_grant.dart index cd956d2..a1456a8 100644 --- a/firka/lib/helpers/api/token_grant.dart +++ b/firka/lib/helpers/api/token_grant.dart @@ -40,6 +40,8 @@ Future getAccessToken(String code) async { } } +const _tokenRefreshRetryDelays = [1000, 3000, 5000]; + Future extendToken(TokenModel model) async { logger.info("Extending token for user: ${model.studentId}, institute: ${model.iss}"); @@ -56,27 +58,50 @@ Future extendToken(TokenModel model) async { "client_id": Constants.clientId, }; - try { - final response = await dio.post(KretaEndpoints.tokenGrantUrl, - options: Options(headers: headers), data: formData); + Exception? lastError; - switch (response.statusCode) { - case 200: - logger.info("Token extended successfully for user: ${model.studentId}"); - return TokenGrantResponse.fromJson(response.data); - case 400: - logger.warning("Token refresh failed (400) - refresh token expired for user: ${model.studentId}"); - throw TokenExpiredException(); - case 401: - logger.warning("Token refresh failed (401) - invalid grant for user: ${model.studentId}"); - throw InvalidGrantException(); - default: - logger.severe("Token refresh failed with unexpected status: ${response.statusCode} for user: ${model.studentId}"); - throw Exception( - "Failed to get access token, response code: ${response.statusCode}"); + for (int attempt = 0; attempt <= _tokenRefreshRetryDelays.length; attempt++) { + try { + if (attempt > 0) { + final delay = _tokenRefreshRetryDelays[attempt - 1]; + logger.info("Token refresh attempt ${attempt + 1}, waiting ${delay}ms..."); + await Future.delayed(Duration(milliseconds: delay)); + } + + final response = await dio.post(KretaEndpoints.tokenGrantUrl, + options: Options(headers: headers), data: formData); + + switch (response.statusCode) { + case 200: + logger.info("Token extended successfully for user: ${model.studentId}"); + return TokenGrantResponse.fromJson(response.data); + case 400: + logger.warning("Token refresh failed (400) - refresh token expired for user: ${model.studentId}"); + throw TokenExpiredException(); + case 401: + logger.warning("Token refresh failed (401) - invalid grant for user: ${model.studentId}"); + throw InvalidGrantException(); + default: + logger.warning("Token refresh failed (${response.statusCode}) for user: ${model.studentId}, attempt ${attempt + 1}"); + lastError = Exception("Failed to get access token, response code: ${response.statusCode}"); + // Continue to retry for network errors + continue; + } + } on TokenExpiredException { + rethrow; + } on InvalidGrantException { + rethrow; + } on DioException catch (e) { + logger.warning("Token refresh network error for user: ${model.studentId}, attempt ${attempt + 1}: $e"); + lastError = e; + continue; + } catch (e) { + logger.severe("Token refresh exception for user: ${model.studentId}: $e"); + lastError = e is Exception ? e : Exception(e.toString()); + continue; } - } catch (e) { - logger.severe("Token refresh exception for user: ${model.studentId}: $e"); - rethrow; } + + logger.severe("All token refresh attempts failed for user: ${model.studentId}"); + throw lastError ?? Exception("Token refresh failed after all retries"); } diff --git a/firka/lib/helpers/db/ios_widget_helper.dart b/firka/lib/helpers/db/ios_widget_helper.dart index 6bd4cc2..378a562 100644 --- a/firka/lib/helpers/db/ios_widget_helper.dart +++ b/firka/lib/helpers/db/ios_widget_helper.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; class IOSWidgetHelper { static const _channel = MethodChannel('app.firka/home_widgets'); + static const _watchChannel = MethodChannel('app.firka/watch_sync'); static Future _getAppGroupDirectory() async { if (!Platform.isIOS) return null; @@ -87,6 +88,14 @@ class IOSWidgetHelper { await reloadAllWidgets(); debugPrint('[IOSWidget] Widget reload triggered'); + + // Send data to Watch + try { + await _watchChannel.invokeMethod('sendWidgetDataToWatch', jsonString); + debugPrint('[IOSWidget] Watch data sent'); + } catch (e) { + debugPrint('[IOSWidget] Watch sync skipped: $e'); + } } /// Format DateTime with explicit timezone offset for proper Swift parsing diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart new file mode 100644 index 0000000..6c3fa40 --- /dev/null +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -0,0 +1,273 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:isar/isar.dart'; + +import '../main.dart'; +import 'api/client/kreta_client.dart'; +import 'db/models/token_model.dart'; + +/// Helper class for Watch ↔ iPhone token sync +class WatchSyncHelper { + static const _watchChannel = MethodChannel('app.firka/watch_sync'); + static bool _initialized = false; + + static void initialize() { + if (!Platform.isIOS) return; + if (_initialized) return; + _initialized = true; + + _watchChannel.setMethodCallHandler(_handleMethodCall); + debugPrint('[WatchSync] Handler initialized'); + } + + static Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'getTokenForWatch': + return _getTokenForWatch(); + case 'getLanguageForWatch': + return _getLanguageForWatch(); + case 'watchAppInstalled': + debugPrint('[WatchSync] Watch app installed detected'); + return null; + case 'onTokenFromWatch': + debugPrint('[WatchSync] Token received from Watch'); + return await _processTokenFromWatch(call.arguments); + default: + return null; + } + } + + static Map? _getTokenForWatch() { + if (!initDone || initData.tokens.isEmpty) { + debugPrint('[WatchSync] No token available'); + return {'error': 'no_token'}; + } + + final token = initData.tokens.first; + + if (token.accessToken == null || + token.refreshToken == null || + token.expiryDate == null) { + debugPrint('[WatchSync] Token incomplete'); + return {'error': 'token_incomplete'}; + } + + if (KretaClient.needsReauth) { + debugPrint('[WatchSync] iPhone needs reauth'); + return {'error': 'needsReauth'}; + } + + final tokenData = { + 'studentId': token.studentId, + 'studentIdNorm': token.studentIdNorm, + 'iss': token.iss, + 'idToken': token.idToken, + 'accessToken': token.accessToken, + 'refreshToken': token.refreshToken, + 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, + }; + + debugPrint('[WatchSync] Returning token for Watch'); + return tokenData; + } + + static Future sendTokenToWatch() async { + if (!Platform.isIOS) return; + + final tokenData = _getTokenForWatch(); + if (tokenData == null) return; + + try { + await _watchChannel.invokeMethod('sendTokenToWatch', tokenData); + debugPrint('[WatchSync] Token sent to Watch'); + } catch (e) { + debugPrint('[WatchSync] Failed to send token: $e'); + } + } + + static Future> _processTokenFromWatch(dynamic arguments) async { + if (!initDone) { + debugPrint('[WatchSync] Cannot process Watch token: app not initialized'); + return {'success': false, 'error': 'not_initialized'}; + } + + try { + final tokenData = arguments as Map; + + final watchExpiry = tokenData['expiryDate'] as int?; + if (watchExpiry == null) { + debugPrint('[WatchSync] Watch token has no expiry'); + return {'success': false, 'error': 'no_expiry'}; + } + + final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + + if (watchExpiryDate.isBefore(DateTime.now())) { + debugPrint('[WatchSync] Watch token is expired'); + return {'success': false, 'error': 'token_expired'}; + } + + debugPrint('[WatchSync] Accepting token from Watch, expiry: $watchExpiryDate'); + + final newToken = TokenModel.fromValues( + tokenData['studentIdNorm'] as int, + tokenData['studentId'] as String, + tokenData['iss'] as String, + tokenData['idToken'] as String, + tokenData['accessToken'] as String, + tokenData['refreshToken'] as String, + watchExpiry, + ); + + await initData.isar.writeTxn(() async { + await initData.isar.tokenModels.put(newToken); + }); + + initData.tokens = await initData.isar.tokenModels.where().findAll(); + + if (initData.client != null) { + initData.client!.model = newToken; + } + + KretaClient.clearReauthFlag(); + + debugPrint('[WatchSync] Token from Watch saved successfully'); + return {'success': true}; + } catch (e) { + debugPrint('[WatchSync] Failed to process Watch token: $e'); + return {'success': false, 'error': e.toString()}; + } + } + + static Future _sendTokenToWatchInternal(TokenModel token) async { + if (!Platform.isIOS) return; + + if (token.accessToken == null || + token.refreshToken == null || + token.expiryDate == null) { + debugPrint('[WatchSync] Token incomplete, not sending to Watch'); + return; + } + + final tokenData = { + 'studentId': token.studentId, + 'studentIdNorm': token.studentIdNorm, + 'iss': token.iss, + 'idToken': token.idToken, + 'accessToken': token.accessToken, + 'refreshToken': token.refreshToken, + 'expiryDate': token.expiryDate!.millisecondsSinceEpoch, + }; + + try { + await _watchChannel.invokeMethod('sendTokenToWatch', tokenData); + debugPrint('[WatchSync] iPhone token sent to Watch'); + } catch (e) { + debugPrint('[WatchSync] Failed to send token to Watch: $e'); + } + } + + static String? _getLanguageForWatch() { + if (!initDone) { + debugPrint('[WatchSync] App not initialized, returning default language'); + return 'hu'; + } + + final languageCode = initData.l10n.localeName; + debugPrint('[WatchSync] Returning language for Watch: $languageCode'); + return languageCode; + } + + static Future sendLanguageToWatch() async { + if (!Platform.isIOS) return; + + final languageCode = _getLanguageForWatch(); + if (languageCode == null) return; + + try { + await _watchChannel.invokeMethod('sendLanguageToWatch', languageCode); + debugPrint('[WatchSync] Language sent to Watch: $languageCode'); + } catch (e) { + debugPrint('[WatchSync] Failed to send language: $e'); + } + } + + static Future syncTokenFromWatch({ + Isar? isar, + List? tokens, + KretaClient? client, + }) async { + if (!Platform.isIOS) return; + + final effectiveIsar = isar ?? (initDone ? initData.isar : null); + final effectiveTokens = tokens ?? (initDone ? initData.tokens : null); + final effectiveClient = client ?? (initDone ? initData.client : null); + + if (effectiveIsar == null || effectiveTokens == null) { + debugPrint('[WatchSync] Cannot sync: no isar or tokens available'); + return; + } + + try { + debugPrint('[WatchSync] Requesting token from Watch...'); + final result = await _watchChannel.invokeMethod('requestTokenFromWatch'); + if (result == null) { + debugPrint('[WatchSync] No token from Watch'); + return; + } + + final tokenData = result as Map; + if (tokenData.containsKey('error')) { + debugPrint('[WatchSync] Watch returned error: ${tokenData['error']}'); + return; + } + + final watchExpiry = tokenData['expiryDate'] as int?; + if (watchExpiry == null) { + debugPrint('[WatchSync] Watch token has no expiry'); + return; + } + + final watchExpiryDate = DateTime.fromMillisecondsSinceEpoch(watchExpiry); + final currentToken = effectiveTokens.isNotEmpty ? effectiveTokens.first : null; + + if (currentToken?.expiryDate == null || watchExpiryDate.isAfter(currentToken!.expiryDate!)) { + debugPrint('[WatchSync] Watch has newer token, updating iPhone'); + final newToken = TokenModel.fromValues( + tokenData['studentIdNorm'] as int, + tokenData['studentId'] as String, + tokenData['iss'] as String, + tokenData['idToken'] as String, + tokenData['accessToken'] as String, + tokenData['refreshToken'] as String, + watchExpiry, + ); + + await effectiveIsar.writeTxn(() async { + await effectiveIsar.tokenModels.put(newToken); + }); + + final updatedTokens = await effectiveIsar.tokenModels.where().findAll(); + + if (initDone) { + initData.tokens = updatedTokens; + } + + if (effectiveClient != null) { + effectiveClient.model = newToken; + } + + KretaClient.clearReauthFlag(); + + debugPrint('[WatchSync] Token updated from Watch. New expiry: $watchExpiryDate'); + } else { + debugPrint('[WatchSync] iPhone token is same or newer, sending to Watch'); + await _sendTokenToWatchInternal(currentToken!); + } + } catch (e) { + debugPrint('[WatchSync] Failed to sync token from Watch: $e'); + } + } +} diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 30c0d98..1e96a37 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -36,6 +36,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'helpers/db/models/homework_cache_model.dart'; import 'helpers/update_notifier.dart'; import 'helpers/live_activity_service.dart'; +import 'helpers/watch_sync_helper.dart'; import 'l10n/app_localizations.dart'; import 'l10n/app_localizations_de.dart'; import 'l10n/app_localizations_en.dart'; @@ -153,6 +154,12 @@ Future initLang(AppInitialization data) async { } catch (e) { logger.warning('Failed to update language preference on backend: $e'); } + + try { + await WatchSyncHelper.sendLanguageToWatch(); + } catch (e) { + logger.warning('Failed to send language to Watch: $e'); + } } } @@ -207,6 +214,23 @@ Future _initData(AppInitialization init) async { logger.fine("Initializing kréta client as: ${token.studentId}"); init.client = KretaClient(token, init.isar); + // Sync token from Watch first (Watch might have fresher token) + if (Platform.isIOS) { + await Future.delayed(const Duration(milliseconds: 300)); + + await WatchSyncHelper.syncTokenFromWatch( + isar: init.isar, + tokens: init.tokens, + client: init.client, + ); + init.tokens = await init.isar.tokenModels.where().findAll(); + if (init.tokens.isNotEmpty) { + init.client.model = init.tokens.first; + } + } + + await init.client.refreshTokenProactively(); + await WidgetCacheHelper.updateWidgetCache(appStyle, init.client); if (Platform.isIOS) { @@ -461,6 +485,9 @@ class InitializationScreen extends StatelessWidget { assert(snapshot.data != null); initData = snapshot.data!; initDone = true; + + WatchSyncHelper.initialize(); + var watch = WatchConnectivity(); if (!initData.hasWatchListener) { diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index ce335a4..98617dd 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:firka/helpers/api/client/kreta_client.dart'; import 'package:firka/helpers/api/client/kreta_stream.dart'; import 'package:firka/helpers/api/exceptions/token.dart'; import 'package:firka/helpers/extensions.dart'; @@ -218,8 +219,19 @@ class _HomeScreenState extends FirkaState { await WidgetCacheHelper.refreshIOSWidgets(widget.data.client, widget.data.settings); } - if (Platform.isIOS && LiveActivityService.isTokenExpired && !_disposed) { - showReauthBottomSheet(context, widget.data, widget.data.l10n.reauth); + if (!_disposed && (LiveActivityService.isTokenExpired || KretaClient.needsReauth)) { + activeToast = ActiveToastType.reauth; + setState(() { + toast = buildReauthToast(context, widget.data, () { + if (!_disposed) { + setState(() { + activeToast = ActiveToastType.none; + toast = null; + }); + } + }); + }); + return; } } catch (e) { @@ -374,6 +386,9 @@ class _HomeScreenState extends FirkaState { if (mounted) setState(() {}); }); + // Listen for reauth state changes (e.g., when Watch sends a valid token) + KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged); + _setupNotificationListener(); _setupWidgetDeepLinkListener(); @@ -387,6 +402,18 @@ class _HomeScreenState extends FirkaState { } } + void _onReauthStateChanged() { + if (!mounted || _disposed) return; + // If reauth is no longer needed, dismiss the reauth toast + if (!KretaClient.needsReauth && activeToast == ActiveToastType.reauth) { + debugPrint('[HomeScreen] Reauth flag cleared, dismissing toast'); + setState(() { + activeToast = ActiveToastType.none; + toast = null; + }); + } + } + void settingsUpdateListener() { if (mounted) setState(() {}); } @@ -749,6 +776,9 @@ class _HomeScreenState extends FirkaState { if (mounted) setState(() {}); }); + // Remove reauth state listener + KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged); + _disposed = true; _fetching = false; _prefetched = false; diff --git a/firka/lib/ui/phone/widgets/login_webview.dart b/firka/lib/ui/phone/widgets/login_webview.dart index eb7f997..b6cf78e 100644 --- a/firka/lib/ui/phone/widgets/login_webview.dart +++ b/firka/lib/ui/phone/widgets/login_webview.dart @@ -1,9 +1,14 @@ +import 'dart:io'; + import 'package:firka/helpers/db/models/app_settings_model.dart'; +import 'package:firka/helpers/live_activity_service.dart'; import 'package:firka/main.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:isar/isar.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import '../../../helpers/api/client/kreta_client.dart'; import '../../../helpers/api/consts.dart'; import '../../../helpers/api/token_grant.dart'; import '../../../helpers/db/models/token_model.dart'; @@ -83,8 +88,30 @@ class _LoginWebviewWidgetState extends FirkaState { await accountPicker.postUpdate(); + if (Platform.isIOS) { + const watchChannel = MethodChannel('app.firka/watch_sync'); + try { + await watchChannel.invokeMethod('sendTokenToWatch', { + 'studentId': tokenModel.studentId, + 'studentIdNorm': tokenModel.studentIdNorm, + 'iss': tokenModel.iss, + 'idToken': tokenModel.idToken, + 'accessToken': tokenModel.accessToken, + 'refreshToken': tokenModel.refreshToken, + 'expiryDate': tokenModel.expiryDate!.millisecondsSinceEpoch, + }); + } catch (e) { + // Watch may not be available, ignore + } + } + if (!mounted) return NavigationDecision.prevent; + KretaClient.clearReauthFlag(); + if (Platform.isIOS) { + LiveActivityService.clearTokenExpiration(); + } + runApp(InitializationScreen()); } catch (ex) { if (ex is Error) {