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:
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "05db9689081f091050f01aed79f04dce0c750154"
|
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,11 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
- platform: ios
|
- platform: ios
|
||||||
create_revision: 05db9689081f091050f01aed79f04dce0c750154
|
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
base_revision: 05db9689081f091050f01aed79f04dce0c750154
|
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
5
firka/ios/.gitignore
vendored
5
firka/ios/.gitignore
vendored
@@ -33,4 +33,7 @@ Runner/GeneratedPluginRegistrant.*
|
|||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
!default.perspectivev3
|
!default.perspectivev3
|
||||||
|
|
||||||
/.DerivedData
|
/.DerivedData
|
||||||
|
|
||||||
|
# Developer-specific configuration
|
||||||
|
.dev_config
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
33
firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift
Normal file
33
firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift
Normal 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()
|
||||||
|
}
|
||||||
39
firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift
Normal file
39
firka/ios/FirkaWatch Watch App/Components/GradeBadge.swift
Normal 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()
|
||||||
|
}
|
||||||
46
firka/ios/FirkaWatch Watch App/Components/GradeRow.swift
Normal file
46
firka/ios/FirkaWatch Watch App/Components/GradeRow.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
firka/ios/FirkaWatch Watch App/Components/LessonCard.swift
Normal file
146
firka/ios/FirkaWatch Watch App/Components/LessonCard.swift
Normal 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()
|
||||||
|
}
|
||||||
68
firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift
Normal file
68
firka/ios/FirkaWatch Watch App/Components/ProgressBar.swift
Normal 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()
|
||||||
|
}
|
||||||
43
firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift
Normal file
43
firka/ios/FirkaWatch Watch App/Components/SubjectRow.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
firka/ios/FirkaWatch Watch App/ContentView.swift
Normal file
107
firka/ios/FirkaWatch Watch App/ContentView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
34
firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift
Normal file
34
firka/ios/FirkaWatch Watch App/FirkaWatchApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift
Normal file
364
firka/ios/FirkaWatch Watch App/Localization/WatchL10n.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
390
firka/ios/FirkaWatch Watch App/Services/DataStore.swift
Normal file
390
firka/ios/FirkaWatch Watch App/Services/DataStore.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift
Normal file
98
firka/ios/FirkaWatch Watch App/Views/GradeSubjectView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
firka/ios/FirkaWatch Watch App/Views/GradesView.swift
Normal file
108
firka/ios/FirkaWatch Watch App/Views/GradesView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
466
firka/ios/FirkaWatch Watch App/Views/HomeView.swift
Normal file
466
firka/ios/FirkaWatch Watch App/Views/HomeView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift
Normal file
109
firka/ios/FirkaWatch Watch App/Views/LessonDetailView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
281
firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
Normal file
281
firka/ios/FirkaWatch Watch App/Views/ReauthRequiredView.swift
Normal 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()
|
||||||
|
}
|
||||||
73
firka/ios/FirkaWatch Watch App/Views/SettingsView.swift
Normal file
73
firka/ios/FirkaWatch Watch App/Views/SettingsView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
357
firka/ios/FirkaWatch Watch App/Views/TimetableView.swift
Normal file
357
firka/ios/FirkaWatch Watch App/Views/TimetableView.swift
Normal 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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
369
firka/ios/FirkaWatchComplications/FirkaComplications.swift
Normal file
369
firka/ios/FirkaWatchComplications/FirkaComplications.swift
Normal 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
|
||||||
11
firka/ios/FirkaWatchComplications/Info.plist
Normal file
11
firka/ios/FirkaWatchComplications/Info.plist
Normal 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>
|
||||||
10
firka/ios/FirkaWatchComplicationsExtension.entitlements
Normal file
10
firka/ios/FirkaWatchComplicationsExtension.entitlements
Normal 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>
|
||||||
@@ -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?
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,19 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
|||||||
typealias Entry = TimetableEntry
|
typealias Entry = TimetableEntry
|
||||||
typealias Intent = TimetableWidgetIntent
|
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 {
|
func placeholder(in context: Context) -> TimetableEntry {
|
||||||
TimetableEntry(
|
TimetableEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
@@ -115,6 +128,13 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
minuteEntries.append(next.start)
|
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 {
|
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))
|
let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400))
|
||||||
entries.append(createEntry(for: configuration, date: midnight))
|
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 })
|
let uniqueDates = Set(entries.map { $0.date })
|
||||||
entries = uniqueDates.map { date in
|
entries = uniqueDates.map { date in
|
||||||
entries.first { $0.date == date }!
|
entries.first { $0.date == date }!
|
||||||
@@ -144,10 +198,10 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
if isLockScreenWidget {
|
if isLockScreenWidget {
|
||||||
var refreshDate: Date
|
var refreshDate: Date
|
||||||
if let next = nextLesson {
|
if let current = currentLesson {
|
||||||
refreshDate = next.start
|
|
||||||
} else if let current = currentLesson {
|
|
||||||
refreshDate = current.end.addingTimeInterval(1)
|
refreshDate = current.end.addingTimeInterval(1)
|
||||||
|
} else if let next = nextLesson {
|
||||||
|
refreshDate = next.end.addingTimeInterval(1)
|
||||||
} else {
|
} else {
|
||||||
refreshDate = midnight
|
refreshDate = midnight
|
||||||
}
|
}
|
||||||
@@ -225,6 +279,50 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
if lessons.isEmpty {
|
if lessons.isEmpty {
|
||||||
if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.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(
|
return TimetableEntry(
|
||||||
date: date,
|
date: date,
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ struct TimetableSmallView: View {
|
|||||||
Text(lesson.displayName)
|
Text(lesson.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.strikethrough(lesson.isCancelled, color: .red)
|
|
||||||
.foregroundColor(lesson.isCancelled ? .red :
|
.foregroundColor(lesson.isCancelled ? .red :
|
||||||
lesson.isSubstitution ? .orange :
|
lesson.isSubstitution ? .orange :
|
||||||
(style == .liquidGlass ? liquidGlassPrimary : .primary))
|
(style == .liquidGlass ? liquidGlassPrimary : .primary))
|
||||||
@@ -171,8 +170,8 @@ struct TimetableMediumView: View {
|
|||||||
let nextLesson = visibleLessons[index + 1]
|
let nextLesson = visibleLessons[index + 1]
|
||||||
let isInBreak = entry.date > lesson.end && entry.date < nextLesson.start
|
let isInBreak = entry.date > lesson.end && entry.date < nextLesson.start
|
||||||
if isInBreak {
|
if isInBreak {
|
||||||
let breakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(entry.date) / 60))
|
let totalBreakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(lesson.end) / 60))
|
||||||
BreakIndicatorRow(minutesLeft: breakMinutes, localization: localization, style: style, compact: true)
|
BreakIndicatorRow(minutesLeft: totalBreakMinutes, localization: localization, style: style, compact: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +196,17 @@ struct TimetableLargeView: View {
|
|||||||
return checkDate >= lesson.start && checkDate <= lesson.end
|
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 {
|
var hasActiveBreak: Bool {
|
||||||
let checkDate = entry.date
|
let checkDate = entry.date
|
||||||
for i in 0..<entry.lessons.count - 1 {
|
for i in 0..<entry.lessons.count - 1 {
|
||||||
@@ -207,6 +217,21 @@ struct TimetableLargeView: View {
|
|||||||
return false
|
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 {
|
var headerText: String {
|
||||||
if entry.isNextSchoolDay {
|
if entry.isNextSchoolDay {
|
||||||
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
|
let dateStr = WidgetLocalization.formatShortDate(entry.nextSchoolDayDateString, locale: localization.locale)
|
||||||
@@ -228,17 +253,15 @@ struct TimetableLargeView: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.widgetTextStyle(style, colors: nil)
|
.widgetTextStyle(style, colors: nil)
|
||||||
|
|
||||||
let maxLessons = hasActiveBreak ? 6 : 7
|
ForEach(Array(visibleLessons.enumerated()), id: \.element.id) { index, lesson in
|
||||||
let lessonsToShow = Array(entry.lessons.prefix(maxLessons))
|
|
||||||
ForEach(Array(lessonsToShow.enumerated()), id: \.element.id) { index, lesson in
|
|
||||||
LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style, showRoom: true)
|
LessonRow(lesson: lesson, isActive: isLessonActive(lesson), style: style, showRoom: true)
|
||||||
|
|
||||||
if index < lessonsToShow.count - 1 {
|
if index < visibleLessons.count - 1 {
|
||||||
let nextLesson = lessonsToShow[index + 1]
|
let nextLesson = visibleLessons[index + 1]
|
||||||
let isInBreak = entry.date > lesson.end && entry.date < nextLesson.start
|
let isInBreak = entry.date > lesson.end && entry.date < nextLesson.start
|
||||||
if isInBreak {
|
if isInBreak {
|
||||||
let breakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(entry.date) / 60))
|
let totalBreakMinutes = Int(ceil(nextLesson.start.timeIntervalSince(lesson.end) / 60))
|
||||||
BreakIndicatorRow(minutesLeft: breakMinutes, localization: localization, style: style)
|
BreakIndicatorRow(minutesLeft: totalBreakMinutes, localization: localization, style: style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,7 +369,6 @@ struct LessonRow: View {
|
|||||||
Text(lesson.displayName)
|
Text(lesson.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(isActive ? .semibold : .regular)
|
.fontWeight(isActive ? .semibold : .regular)
|
||||||
.strikethrough(lesson.isCancelled, color: .red)
|
|
||||||
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? liquidGlassPrimary : .primary))
|
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? liquidGlassPrimary : .primary))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
@@ -372,7 +394,7 @@ struct LessonRow: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, compact ? 2 : 4)
|
.padding(.vertical, compact ? 2 : 4)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.currentLessonGlow(isActive: isActive && !lesson.isCancelled)
|
.currentLessonGlow(isActive: isActive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
14578EED4EA309B337AB389E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A749415A687CBFC3F46FA876 /* Pods_RunnerTests.framework */; };
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
213F8C0F6B5418B02DE14204 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 035E9CCBCC6585D0F5639031 /* Pods_Runner.framework */; };
|
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 */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
4FE64E342F27B07A006F9205 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; };
|
||||||
4FE64E352F27B07A006F9205 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.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, ); }; };
|
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 */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -42,6 +78,27 @@
|
|||||||
remoteGlobalIDString = 4F30C7642E8FBF9D008BB46C;
|
remoteGlobalIDString = 4F30C7642E8FBF9D008BB46C;
|
||||||
remoteInfo = TimetableWidgetExtension;
|
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 */ = {
|
4FE64E402F27B07B006F9205 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
@@ -49,6 +106,13 @@
|
|||||||
remoteGlobalIDString = 4FE64E322F27B079006F9205;
|
remoteGlobalIDString = 4FE64E322F27B079006F9205;
|
||||||
remoteInfo = HomeWidgetsExtensionExtension;
|
remoteInfo = HomeWidgetsExtensionExtension;
|
||||||
};
|
};
|
||||||
|
4FF81B982F2EB4C300E95BA0 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 4FF81B792F2EB4C100E95BA0;
|
||||||
|
remoteInfo = "FirkaWatch Watch App";
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -64,6 +128,28 @@
|
|||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */ = {
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */ = {
|
4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Controls/AppControls.swift,
|
Controls/AppControls.swift,
|
||||||
);
|
);
|
||||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
};
|
};
|
||||||
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = {
|
4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
ActivityAttributes.swift,
|
ActivityAttributes.swift,
|
||||||
);
|
);
|
||||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
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;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
);
|
);
|
||||||
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
|
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
|
||||||
};
|
};
|
||||||
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */ = {
|
4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -147,32 +254,10 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {
|
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = "<group>"; };
|
||||||
exceptions = (
|
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = "<group>"; };
|
||||||
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */,
|
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = "<group>"; };
|
||||||
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>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -185,6 +270,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4F5965FA2F2F0EAF00A3DB03 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4F5965FF2F2F0EAF00A3DB03 /* SwiftUI.framework in Frameworks */,
|
||||||
|
4F5965FE2F2F0EAF00A3DB03 /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
4FE64E302F27B079006F9205 /* Frameworks */ = {
|
4FE64E302F27B079006F9205 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -194,6 +288,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4FF81B772F2EB4C100E95BA0 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -232,6 +333,47 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
52B477EA0F4B63DC7CE4BA83 /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -260,12 +402,15 @@
|
|||||||
97C146E51CF9000F007C117D = {
|
97C146E51CF9000F007C117D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4F5966162F2F0F6500A3DB03 /* FirkaWatchComplicationsExtension.entitlements */,
|
||||||
4F959B792F289CA600FF7F03 /* LiveActivityWidget.entitlements */,
|
4F959B792F289CA600FF7F03 /* LiveActivityWidget.entitlements */,
|
||||||
4F959B9C2F289CA600FF7F03 /* HomeWidgetsExtension.entitlements */,
|
4F959B9C2F289CA600FF7F03 /* HomeWidgetsExtension.entitlements */,
|
||||||
9740EEB11CF90186004384FC /* Flutter */,
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */,
|
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */,
|
||||||
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */,
|
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */,
|
||||||
|
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */,
|
||||||
|
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
52B477EA0F4B63DC7CE4BA83 /* Pods */,
|
52B477EA0F4B63DC7CE4BA83 /* Pods */,
|
||||||
@@ -280,6 +425,8 @@
|
|||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */,
|
4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */,
|
||||||
4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */,
|
4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */,
|
||||||
|
4FF81B7A2F2EB4C100E95BA0 /* FirkaWatch Watch App.app */,
|
||||||
|
4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -287,6 +434,8 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */,
|
||||||
|
4F7701D72F2EC1AA00B79171 /* Shared */,
|
||||||
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */,
|
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */,
|
||||||
AA00000100000004AABBCC04 /* Localizable.strings */,
|
AA00000100000004AABBCC04 /* Localizable.strings */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -345,6 +494,26 @@
|
|||||||
productReference = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */;
|
productReference = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
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 */ = {
|
4FE64E322F27B079006F9205 /* HomeWidgetsExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 4FE64E462F27B07B006F9205 /* Build configuration list for PBXNativeTarget "HomeWidgetsExtension" */;
|
buildConfigurationList = 4FE64E462F27B07B006F9205 /* Build configuration list for PBXNativeTarget "HomeWidgetsExtension" */;
|
||||||
@@ -365,6 +534,30 @@
|
|||||||
productReference = 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */;
|
productReference = 4FE64E332F27B079006F9205 /* HomeWidgetsExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
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 */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
@@ -375,6 +568,7 @@
|
|||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
4F30C77E2E8FBF9F008BB46C /* Embed Foundation Extensions */,
|
4F30C77E2E8FBF9F008BB46C /* Embed Foundation Extensions */,
|
||||||
|
4FF81B9B2F2EB4C300E95BA0 /* Embed Watch Content */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
EAA586B3BBC26BBE7306869D /* [CP] Embed Pods Frameworks */,
|
EAA586B3BBC26BBE7306869D /* [CP] Embed Pods Frameworks */,
|
||||||
@@ -385,6 +579,7 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
4F30C7772E8FBF9F008BB46C /* PBXTargetDependency */,
|
4F30C7772E8FBF9F008BB46C /* PBXTargetDependency */,
|
||||||
4FE64E412F27B07B006F9205 /* PBXTargetDependency */,
|
4FE64E412F27B07B006F9205 /* PBXTargetDependency */,
|
||||||
|
4FF81B992F2EB4C300E95BA0 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
@@ -409,9 +604,15 @@
|
|||||||
4F30C7642E8FBF9D008BB46C = {
|
4F30C7642E8FBF9D008BB46C = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
4F5965FC2F2F0EAF00A3DB03 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
4FE64E322F27B079006F9205 = {
|
4FE64E322F27B079006F9205 = {
|
||||||
CreatedOnToolsVersion = 26.2;
|
CreatedOnToolsVersion = 26.2;
|
||||||
};
|
};
|
||||||
|
4FF81B792F2EB4C100E95BA0 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
CreatedOnToolsVersion = 7.3.1;
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
@@ -437,6 +638,8 @@
|
|||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */,
|
4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */,
|
||||||
4FE64E322F27B079006F9205 /* HomeWidgetsExtension */,
|
4FE64E322F27B079006F9205 /* HomeWidgetsExtension */,
|
||||||
|
4FF81B792F2EB4C100E95BA0 /* FirkaWatch Watch App */,
|
||||||
|
4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -456,6 +659,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4F5965FB2F2F0EAF00A3DB03 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
4FE64E312F27B079006F9205 /* Resources */ = {
|
4FE64E312F27B079006F9205 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -463,6 +673,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4FF81B782F2EB4C100E95BA0 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -486,10 +703,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -578,10 +799,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -602,6 +827,27 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -609,6 +855,30 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -620,6 +890,7 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */,
|
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */,
|
||||||
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */,
|
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */,
|
||||||
|
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -636,11 +907,31 @@
|
|||||||
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
|
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
|
||||||
targetProxy = 4F30C7762E8FBF9F008BB46C /* PBXContainerItemProxy */;
|
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 */ = {
|
4FE64E412F27B07B006F9205 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 4FE64E322F27B079006F9205 /* HomeWidgetsExtension */;
|
target = 4FE64E322F27B079006F9205 /* HomeWidgetsExtension */;
|
||||||
targetProxy = 4FE64E402F27B07B006F9205 /* PBXContainerItemProxy */;
|
targetProxy = 4FE64E402F27B07B006F9205 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
4FF81B992F2EB4C300E95BA0 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 4FF81B792F2EB4C100E95BA0 /* FirkaWatch Watch App */;
|
||||||
|
targetProxy = 4FF81B982F2EB4C300E95BA0 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -962,6 +1253,159 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
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 */ = {
|
4FE64E432F27B07B006F9205 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||||
@@ -1107,6 +1551,159 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
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 */ = {
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -1316,6 +1913,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
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" */ = {
|
4FE64E462F27B07B006F9205 /* Build configuration list for PBXNativeTarget "HomeWidgetsExtension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -1326,6 +1933,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
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" */ = {
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -25,6 +25,7 @@ import BackgroundTasks
|
|||||||
let controller = window?.rootViewController as! FlutterViewController
|
let controller = window?.rootViewController as! FlutterViewController
|
||||||
|
|
||||||
HomeWidgetMethodChannel.register(with: controller.binaryMessenger)
|
HomeWidgetMethodChannel.register(with: controller.binaryMessenger)
|
||||||
|
WatchSessionManager.shared.setup(with: controller.binaryMessenger)
|
||||||
|
|
||||||
widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger)
|
widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger)
|
||||||
widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
|
widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
|
||||||
|
|||||||
297
firka/ios/Runner/WatchSessionManager.swift
Normal file
297
firka/ios/Runner/WatchSessionManager.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
295
firka/ios/Shared/API/KretaAPIClient.swift
Normal file
295
firka/ios/Shared/API/KretaAPIClient.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
158
firka/ios/Shared/API/KretaAPIModels.swift
Normal file
158
firka/ios/Shared/API/KretaAPIModels.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
303
firka/ios/Shared/API/TokenManager.swift
Normal file
303
firka/ios/Shared/API/TokenManager.swift
Normal 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: "&")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ struct SeasonalIconHelper {
|
|||||||
case "newYearEve":
|
case "newYearEve":
|
||||||
return .purple
|
return .purple
|
||||||
case "newYearDay":
|
case "newYearDay":
|
||||||
return .mint
|
return Color(red: 0.4, green: 0.9, blue: 0.8)
|
||||||
case "seasonalBreak":
|
case "seasonalBreak":
|
||||||
return seasonColor(for: season)
|
return seasonColor(for: season)
|
||||||
default:
|
default:
|
||||||
@@ -74,7 +74,7 @@ struct SeasonalIconHelper {
|
|||||||
case "autumn":
|
case "autumn":
|
||||||
return .orange
|
return .orange
|
||||||
case "winter":
|
case "winter":
|
||||||
return .cyan
|
return Color(red: 0.4, green: 0.8, blue: 1.0)
|
||||||
case "other":
|
case "other":
|
||||||
return .blue
|
return .blue
|
||||||
default:
|
default:
|
||||||
@@ -13,6 +13,18 @@ struct WidgetGrade: Codable, Identifiable {
|
|||||||
|
|
||||||
var id: String { uid }
|
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 {
|
var displayValue: String {
|
||||||
if let numeric = numericValue {
|
if let numeric = numericValue {
|
||||||
return "\(numeric)"
|
return "\(numeric)"
|
||||||
@@ -49,3 +61,20 @@ struct WidgetGrade: Codable, Identifiable {
|
|||||||
subject.teacherName
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ struct WidgetLesson: Codable, Identifiable {
|
|||||||
let name: String
|
let name: String
|
||||||
let lessonNumber: Int?
|
let lessonNumber: Int?
|
||||||
let teacher: String?
|
let teacher: String?
|
||||||
|
let substituteTeacher: String?
|
||||||
let subject: WidgetSubject
|
let subject: WidgetSubject
|
||||||
let theme: String?
|
let theme: String?
|
||||||
let roomName: String?
|
let roomName: String?
|
||||||
@@ -16,6 +17,24 @@ struct WidgetLesson: Codable, Identifiable {
|
|||||||
|
|
||||||
var id: String { uid }
|
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 {
|
var displayName: String {
|
||||||
subject.name
|
subject.name
|
||||||
}
|
}
|
||||||
29
firka/ios/Shared/Models/Subject.swift
Normal file
29
firka/ios/Shared/Models/Subject.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ struct WidgetData: Codable {
|
|||||||
lastUpdated: nil,
|
lastUpdated: nil,
|
||||||
locale: "hu",
|
locale: "hu",
|
||||||
theme: "dark",
|
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: [],
|
grades: [],
|
||||||
averages: AveragesData(overall: nil, subjects: [])
|
averages: AveragesData(overall: nil, subjects: [])
|
||||||
)
|
)
|
||||||
@@ -108,6 +108,17 @@ struct TimetableData: Codable {
|
|||||||
let nextSchoolDay: [WidgetLesson]?
|
let nextSchoolDay: [WidgetLesson]?
|
||||||
let nextSchoolDayDate: String?
|
let nextSchoolDayDate: String?
|
||||||
let currentBreak: BreakInfo?
|
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 {
|
struct BreakInfo: Codable {
|
||||||
@@ -25,6 +25,12 @@ import '../model/student.dart';
|
|||||||
import '../model/test.dart';
|
import '../model/test.dart';
|
||||||
import '../token_grant.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 backoffCount = 4;
|
||||||
const backoffMin = 100;
|
const backoffMin = 100;
|
||||||
const backoffStep = 500;
|
const backoffStep = 500;
|
||||||
@@ -58,8 +64,79 @@ class KretaClient {
|
|||||||
TokenModel model;
|
TokenModel model;
|
||||||
Isar isar;
|
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);
|
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 {
|
Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
|
||||||
while (_tokenMutex) {
|
while (_tokenMutex) {
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
@@ -89,6 +166,22 @@ class KretaClient {
|
|||||||
logger.info("Token refreshed successfully. New expiry: ${tokenModel.expiryDate}");
|
logger.info("Token refreshed successfully. New expiry: ${tokenModel.expiryDate}");
|
||||||
|
|
||||||
model = tokenModel;
|
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!;
|
return model.accessToken!;
|
||||||
@@ -187,6 +280,19 @@ class KretaClient {
|
|||||||
return _cachingGet(id, url, forceCache, counter + 1);
|
return _cachingGet(id, url, forceCache, counter + 1);
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} 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) {
|
if (cache != null) {
|
||||||
logger.finest("request failed, using cache for: $url");
|
logger.finest("request failed, using cache for: $url");
|
||||||
return (jsonDecode(cache.cacheData!), 0, ex, true);
|
return (jsonDecode(cache.cacheData!), 0, ex, true);
|
||||||
@@ -466,6 +572,11 @@ class KretaClient {
|
|||||||
counter + 1, storeCache);
|
counter + 1, storeCache);
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
if (_isTokenExpired(ex)) {
|
||||||
|
_setReauthFlag();
|
||||||
|
logger.warning("Token expired in timed request, setting needsReauth flag");
|
||||||
|
}
|
||||||
|
|
||||||
if (cache != null) {
|
if (cache != null) {
|
||||||
var items = List<dynamic>.empty(growable: true);
|
var items = List<dynamic>.empty(growable: true);
|
||||||
for (var item in (cache as dynamic).values) {
|
for (var item in (cache as dynamic).values) {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ Future<TokenGrantResponse> getAccessToken(String code) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _tokenRefreshRetryDelays = [1000, 3000, 5000];
|
||||||
|
|
||||||
Future<TokenGrantResponse> extendToken(TokenModel model) async {
|
Future<TokenGrantResponse> extendToken(TokenModel model) async {
|
||||||
logger.info("Extending token for user: ${model.studentId}, institute: ${model.iss}");
|
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,
|
"client_id": Constants.clientId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
Exception? lastError;
|
||||||
final response = await dio.post(KretaEndpoints.tokenGrantUrl,
|
|
||||||
options: Options(headers: headers), data: formData);
|
|
||||||
|
|
||||||
switch (response.statusCode) {
|
for (int attempt = 0; attempt <= _tokenRefreshRetryDelays.length; attempt++) {
|
||||||
case 200:
|
try {
|
||||||
logger.info("Token extended successfully for user: ${model.studentId}");
|
if (attempt > 0) {
|
||||||
return TokenGrantResponse.fromJson(response.data);
|
final delay = _tokenRefreshRetryDelays[attempt - 1];
|
||||||
case 400:
|
logger.info("Token refresh attempt ${attempt + 1}, waiting ${delay}ms...");
|
||||||
logger.warning("Token refresh failed (400) - refresh token expired for user: ${model.studentId}");
|
await Future.delayed(Duration(milliseconds: delay));
|
||||||
throw TokenExpiredException();
|
}
|
||||||
case 401:
|
|
||||||
logger.warning("Token refresh failed (401) - invalid grant for user: ${model.studentId}");
|
final response = await dio.post(KretaEndpoints.tokenGrantUrl,
|
||||||
throw InvalidGrantException();
|
options: Options(headers: headers), data: formData);
|
||||||
default:
|
|
||||||
logger.severe("Token refresh failed with unexpected status: ${response.statusCode} for user: ${model.studentId}");
|
switch (response.statusCode) {
|
||||||
throw Exception(
|
case 200:
|
||||||
"Failed to get access token, response code: ${response.statusCode}");
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
|
|||||||
|
|
||||||
class IOSWidgetHelper {
|
class IOSWidgetHelper {
|
||||||
static const _channel = MethodChannel('app.firka/home_widgets');
|
static const _channel = MethodChannel('app.firka/home_widgets');
|
||||||
|
static const _watchChannel = MethodChannel('app.firka/watch_sync');
|
||||||
|
|
||||||
static Future<Directory?> _getAppGroupDirectory() async {
|
static Future<Directory?> _getAppGroupDirectory() async {
|
||||||
if (!Platform.isIOS) return null;
|
if (!Platform.isIOS) return null;
|
||||||
@@ -87,6 +88,14 @@ class IOSWidgetHelper {
|
|||||||
|
|
||||||
await reloadAllWidgets();
|
await reloadAllWidgets();
|
||||||
debugPrint('[IOSWidget] Widget reload triggered');
|
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
|
/// Format DateTime with explicit timezone offset for proper Swift parsing
|
||||||
|
|||||||
273
firka/lib/helpers/watch_sync_helper.dart
Normal file
273
firka/lib/helpers/watch_sync_helper.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|||||||
import 'helpers/db/models/homework_cache_model.dart';
|
import 'helpers/db/models/homework_cache_model.dart';
|
||||||
import 'helpers/update_notifier.dart';
|
import 'helpers/update_notifier.dart';
|
||||||
import 'helpers/live_activity_service.dart';
|
import 'helpers/live_activity_service.dart';
|
||||||
|
import 'helpers/watch_sync_helper.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'l10n/app_localizations_de.dart';
|
import 'l10n/app_localizations_de.dart';
|
||||||
import 'l10n/app_localizations_en.dart';
|
import 'l10n/app_localizations_en.dart';
|
||||||
@@ -153,6 +154,12 @@ Future<void> initLang(AppInitialization data) async {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warning('Failed to update language preference on backend: $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}");
|
logger.fine("Initializing kréta client as: ${token.studentId}");
|
||||||
init.client = KretaClient(token, init.isar);
|
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);
|
await WidgetCacheHelper.updateWidgetCache(appStyle, init.client);
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
@@ -461,6 +485,9 @@ class InitializationScreen extends StatelessWidget {
|
|||||||
assert(snapshot.data != null);
|
assert(snapshot.data != null);
|
||||||
initData = snapshot.data!;
|
initData = snapshot.data!;
|
||||||
initDone = true;
|
initDone = true;
|
||||||
|
|
||||||
|
WatchSyncHelper.initialize();
|
||||||
|
|
||||||
var watch = WatchConnectivity();
|
var watch = WatchConnectivity();
|
||||||
|
|
||||||
if (!initData.hasWatchListener) {
|
if (!initData.hasWatchListener) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
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/client/kreta_stream.dart';
|
||||||
import 'package:firka/helpers/api/exceptions/token.dart';
|
import 'package:firka/helpers/api/exceptions/token.dart';
|
||||||
import 'package:firka/helpers/extensions.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);
|
await WidgetCacheHelper.refreshIOSWidgets(widget.data.client, widget.data.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isIOS && LiveActivityService.isTokenExpired && !_disposed) {
|
if (!_disposed && (LiveActivityService.isTokenExpired || KretaClient.needsReauth)) {
|
||||||
showReauthBottomSheet(context, widget.data, widget.data.l10n.reauth);
|
activeToast = ActiveToastType.reauth;
|
||||||
|
setState(() {
|
||||||
|
toast = buildReauthToast(context, widget.data, () {
|
||||||
|
if (!_disposed) {
|
||||||
|
setState(() {
|
||||||
|
activeToast = ActiveToastType.none;
|
||||||
|
toast = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -374,6 +386,9 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for reauth state changes (e.g., when Watch sends a valid token)
|
||||||
|
KretaClient.reauthStateNotifier.addListener(_onReauthStateChanged);
|
||||||
|
|
||||||
_setupNotificationListener();
|
_setupNotificationListener();
|
||||||
_setupWidgetDeepLinkListener();
|
_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() {
|
void settingsUpdateListener() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
@@ -749,6 +776,9 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove reauth state listener
|
||||||
|
KretaClient.reauthStateNotifier.removeListener(_onReauthStateChanged);
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_fetching = false;
|
_fetching = false;
|
||||||
_prefetched = false;
|
_prefetched = false;
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:firka/helpers/db/models/app_settings_model.dart';
|
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:firka/main.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../../../helpers/api/client/kreta_client.dart';
|
||||||
import '../../../helpers/api/consts.dart';
|
import '../../../helpers/api/consts.dart';
|
||||||
import '../../../helpers/api/token_grant.dart';
|
import '../../../helpers/api/token_grant.dart';
|
||||||
import '../../../helpers/db/models/token_model.dart';
|
import '../../../helpers/db/models/token_model.dart';
|
||||||
@@ -83,8 +88,30 @@ class _LoginWebviewWidgetState extends FirkaState<LoginWebviewWidget> {
|
|||||||
|
|
||||||
await accountPicker.postUpdate();
|
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;
|
if (!mounted) return NavigationDecision.prevent;
|
||||||
|
|
||||||
|
KretaClient.clearReauthFlag();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
LiveActivityService.clearTokenExpiration();
|
||||||
|
}
|
||||||
|
|
||||||
runApp(InitializationScreen());
|
runApp(InitializationScreen());
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
if (ex is Error) {
|
if (ex is Error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user