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 0000000..0b50e8b Binary files /dev/null and b/firka/ios/FirkaWatch Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ 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) {