1
0
forked from firka/firka

Add Apple Watch app and watch sync support

Add a new Firka Watch app target with UI components, views and services: DataStore, BackgroundRefreshManager, WatchConnectivity/WatchSession integration, localization (WatchL10n), entitlements and assets. Move widget/shared models into ios/Shared and update Xcode project/schemes; add native Kreta API client and TokenManager for watch use. Implement watch-side caching, proactive token refresh, background scheduling, and pairing/pairing UI. Update Flutter side (watch_sync_helper, main, API/token helper changes) and tweak iOS .gitignore and project metadata to enable the watch integration and data sync between phone and watch.
This commit is contained in:
Horváth Gergely
2026-02-05 16:01:58 +01:00
committed by 4831c0
parent 874f5d4297
commit 35e1e2c6ab
60 changed files with 6396 additions and 96 deletions

View File

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

View File

@@ -33,4 +33,7 @@ Runner/GeneratedPluginRegistrant.*
!default.pbxuser
!default.perspectivev3
/.DerivedData
/.DerivedData
# Developer-specific configuration
.dev_config

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon-App-1024x1024@1x.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,33 @@
import SwiftUI
struct FirkaCard<Content: View>: 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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.firka.firkaa</string>
</array>
</dict>
</plist>

View File

@@ -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<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
switch task {
case let refreshTask as WKApplicationRefreshBackgroundTask:
Task {
await BackgroundRefreshManager.shared.handleBackgroundRefresh()
refreshTask.setTaskCompletedWithSnapshot(false)
}
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.firka.firkaa</string>
</array>
</dict>
</plist>

View File

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

View File

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

View File

@@ -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..<entry.lessons.count - 1 {
@@ -207,6 +217,21 @@ struct TimetableLargeView: View {
return false
}
var visibleLessons: [WidgetLesson] {
let totalLessons = entry.lessons.count
let maxVisible = hasActiveBreak ? 6 : 7
if totalLessons <= maxVisible {
return Array(entry.lessons)
}
var startIndex = max(0, currentLessonIndex - 1)
startIndex = min(startIndex, totalLessons - maxVisible)
let endIndex = min(startIndex + maxVisible, totalLessons)
return Array(entry.lessons[startIndex..<endIndex])
}
var headerText: String {
if entry.isNextSchoolDay {
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
@@ -228,17 +253,15 @@ struct TimetableLargeView: View {
.fontWeight(.semibold)
.widgetTextStyle(style, colors: nil)
let maxLessons = hasActiveBreak ? 6 : 7
let lessonsToShow = Array(entry.lessons.prefix(maxLessons))
ForEach(Array(lessonsToShow.enumerated()), id: \.element.id) { index, lesson in
ForEach(Array(visibleLessons.enumerated()), id: \.element.id) { index, lesson in
LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style, showRoom: true)
if index < lessonsToShow.count - 1 {
let nextLesson = lessonsToShow[index + 1]
if index < visibleLessons.count - 1 {
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)
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)
}
}

View File

@@ -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 = "<group>"; };
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
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 = "<group>"; };
4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonalIconHelper.swift; sourceTree = "<group>"; };
4F7701D02F2EC1AA00B79171 /* Average.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Average.swift; sourceTree = "<group>"; };
4F7701D12F2EC1AA00B79171 /* Grade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Grade.swift; sourceTree = "<group>"; };
4F7701D22F2EC1AA00B79171 /* Lesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lesson.swift; sourceTree = "<group>"; };
4F7701D32F2EC1AA00B79171 /* Subject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subject.swift; sourceTree = "<group>"; };
4F7701D42F2EC1AA00B79171 /* WidgetColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetColors.swift; sourceTree = "<group>"; };
4F7701D52F2EC1AA00B79171 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; };
4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KretaAPIClient.swift; sourceTree = "<group>"; };
4F7701EE2F2EC2F500B79171 /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
4F959B792F289CA600FF7F03 /* LiveActivityWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LiveActivityWidget.entitlements; sourceTree = "<group>"; };
4F959B9C2F289CA600FF7F03 /* HomeWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HomeWidgetsExtension.entitlements; sourceTree = "<group>"; };
4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KretaAPIModels.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -107,37 +207,44 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
AA00000100000001AABBCC01 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
AA00000100000002AABBCC02 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
AA00000100000003AABBCC03 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
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 = "<group>";
};
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = "<group>"; };
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = "<group>"; };
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = "<group>"; };
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = "<group>"; };
/* 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 = "<group>";
};
4F7701CD2F2EC1AA00B79171 /* API */ = {
isa = PBXGroup;
children = (
4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */,
4F7701EE2F2EC2F500B79171 /* TokenManager.swift */,
4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */,
);
path = API;
sourceTree = "<group>";
};
4F7701CF2F2EC1AA00B79171 /* Helpers */ = {
isa = PBXGroup;
children = (
4F7701CE2F2EC1AA00B79171 /* SeasonalIconHelper.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
@@ -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 = (

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FF81B792F2EB4C100E95BA0"
BuildableName = "FirkaWatch Watch App.app"
BlueprintName = "FirkaWatch Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FF81B852F2EB4C300E95BA0"
BuildableName = "FirkaWatch Watch AppTests.xctest"
BlueprintName = "FirkaWatch Watch AppTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FF81B8F2F2EB4C300E95BA0"
BuildableName = "FirkaWatch Watch AppUITests.xctest"
BlueprintName = "FirkaWatch Watch AppUITests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FF81B792F2EB4C100E95BA0"
BuildableName = "FirkaWatch Watch App.app"
BlueprintName = "FirkaWatch Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FF81B792F2EB4C100E95BA0"
BuildableName = "FirkaWatch Watch App.app"
BlueprintName = "FirkaWatch Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4FE64E322F27B079006F9205"
BuildableName = "HomeWidgetsExtension.appex"
BlueprintName = "HomeWidgetsExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4F30C7642E8FBF9D008BB46C"
BuildableName = "LiveActivityWidget.appex"
BlueprintName = "LiveActivityWidget"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

@@ -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<T: Decodable>(_ 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<bool> 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<bool> 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<T> _mutexCallback<T>(Future<T> 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<dynamic>.empty(growable: true);
for (var item in (cache as dynamic).values) {

View File

@@ -40,6 +40,8 @@ Future<TokenGrantResponse> getAccessToken(String code) async {
}
}
const _tokenRefreshRetryDelays = [1000, 3000, 5000];
Future<TokenGrantResponse> extendToken(TokenModel model) async {
logger.info("Extending token for user: ${model.studentId}, institute: ${model.iss}");
@@ -56,27 +58,50 @@ Future<TokenGrantResponse> 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");
}

View File

@@ -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<Directory?> _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

View File

@@ -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<dynamic> _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<String, dynamic>? _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<void> 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<Map<String, dynamic>> _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<dynamic, dynamic>;
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<void> _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<void> 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<void> syncTokenFromWatch({
Isar? isar,
List<TokenModel>? 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<dynamic, dynamic>;
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');
}
}
}

View File

@@ -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<void> 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<void> _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) {

View File

@@ -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<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
}
}
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<HomeScreen> {
if (mounted) setState(() {});
});
// Remove reauth state listener
KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);
_disposed = true;
_fetching = false;
_prefetched = false;

View File

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